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": { "permissions": {
"allow": [ "allow": [
"Bash(docker-compose:*)", "Bash(docker-compose:*)",
"Bash(docker container prune:*)", "Bash(docker container prune:*)",
"Bash(npx prisma migrate dev:*)", "Bash(npx prisma migrate dev:*)",
"Bash(npx prisma:*)", "Bash(npx prisma:*)",
"Bash(npm run dev)", "Bash(npm run dev)",
"Bash(timeout:*)", "Bash(timeout:*)",
"Bash(taskkill:*)", "Bash(taskkill:*)",
"Bash(npx kill-port:*)", "Bash(npx kill-port:*)",
"Bash(docker compose:*)", "Bash(docker compose:*)",
"Bash(curl -I https://fonts.googleapis.com)", "Bash(curl -I https://fonts.googleapis.com)",
"Bash(wsl:*)", "Bash(wsl:*)",
"Read(//c/Users/a931627/.ssh/**)", "Read(//c/Users/a931627/.ssh/**)",
"Bash(ssh-keygen:*)", "Bash(ssh-keygen:*)",
"Bash(cat:*)", "Bash(cat:*)",
"Bash(git remote add:*)", "Bash(git remote add:*)",
"Bash(git push:*)", "Bash(git push:*)",
"Bash(git remote set-url:*)", "Bash(git remote set-url:*)",
"Bash(npm install:*)", "Bash(npm install:*)",
"Bash(npm run build:*)", "Bash(npm run build:*)",
"Bash(ls:*)", "Bash(ls:*)",
"Bash(curl:*)", "Bash(curl:*)",
"Bash(echo \"\n\n## CSRF Debug aktiviert!\n\nBitte teste jetzt:\n1. Browser zu http://localhost:3050/create\n2. Dynamic QR Code erstellen versuchen\n3. Server-Logs zeigen jetzt [CSRF Debug] Output\n\nIch sehe dann:\n- Ob headerToken vorhanden ist\n- Ob cookieToken vorhanden ist \n- Ob sie übereinstimmen\n\n---\n\nStripe Portal 500 Error ist separates Problem:\nhttps://dashboard.stripe.com/test/settings/billing/portal\n→ Customer Portal Configuration muss erstellt werden\n\")", "Bash(echo \"\n\n## CSRF Debug aktiviert!\n\nBitte teste jetzt:\n1. Browser zu http://localhost:3050/create\n2. Dynamic QR Code erstellen versuchen\n3. Server-Logs zeigen jetzt [CSRF Debug] Output\n\nIch sehe dann:\n- Ob headerToken vorhanden ist\n- Ob cookieToken vorhanden ist \n- Ob sie übereinstimmen\n\n---\n\nStripe Portal 500 Error ist separates Problem:\nhttps://dashboard.stripe.com/test/settings/billing/portal\n→ Customer Portal Configuration muss erstellt werden\n\")",
"Bash(pkill:*)", "Bash(pkill:*)",
"Skill(shadcn-ui)", "Skill(shadcn-ui)",
"Bash(find:*)" "Bash(find:*)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []
} }
} }

View File

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

View File

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

View File

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

View File

@@ -1,26 +1,26 @@
name: CI name: CI
on: [push] on: [push]
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 20
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Generate Prisma Client - name: Generate Prisma Client
run: npx prisma generate run: npx prisma generate
- name: Build application - name: Build application
run: npm run build run: npm run build
- name: Run linter - name: Run linter
run: npm run lint 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 legacy-peer-deps=true

View File

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

View File

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

532
CLAUDE.md
View File

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

View File

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

40
LICENSE
View File

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

962
README.md
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,96 +1,96 @@
BEGIN:VCALENDAR BEGIN:VCALENDAR
VERSION:2.0 VERSION:2.0
PRODID:-//QR Master//Reddit Full Calendar//EN PRODID:-//QR Master//Reddit Full Calendar//EN
CALSCALE:GREGORIAN CALSCALE:GREGORIAN
METHOD:PUBLISH METHOD:PUBLISH
X-WR-CALNAME:QR Master Reddit Full X-WR-CALNAME:QR Master Reddit Full
X-WR-TIMEZONE:Europe/Berlin X-WR-TIMEZONE:Europe/Berlin
BEGIN:VEVENT BEGIN:VEVENT
UID:reddit-full-20260316-library@qrmaster.net UID:reddit-full-20260316-library@qrmaster.net
DTSTAMP:20260312T120000Z DTSTAMP:20260312T120000Z
DTSTART:20260316T100000Z DTSTART:20260316T100000Z
DTEND:20260316T103000Z DTEND:20260316T103000Z
SUMMARY:Reddit reply library - all 15 templates 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 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 END:VEVENT
BEGIN:VEVENT BEGIN:VEVENT
UID:reddit-full-20260317-post2@qrmaster.net UID:reddit-full-20260317-post2@qrmaster.net
DTSTAMP:20260312T120000Z DTSTAMP:20260312T120000Z
DTSTART:20260317T120000Z DTSTART:20260317T120000Z
DTEND:20260317T124500Z DTEND:20260317T124500Z
SUMMARY:r/startups - Post 2 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 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 END:VEVENT
BEGIN:VEVENT BEGIN:VEVENT
UID:reddit-full-20260319-post1@qrmaster.net UID:reddit-full-20260319-post1@qrmaster.net
DTSTAMP:20260312T120000Z DTSTAMP:20260312T120000Z
DTSTART:20260319T120000Z DTSTART:20260319T120000Z
DTEND:20260319T124500Z DTEND:20260319T124500Z
SUMMARY:r/SaaS - Post 1 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 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 END:VEVENT
BEGIN:VEVENT BEGIN:VEVENT
UID:reddit-full-20260324-post3@qrmaster.net UID:reddit-full-20260324-post3@qrmaster.net
DTSTAMP:20260312T120000Z DTSTAMP:20260312T120000Z
DTSTART:20260324T123000Z DTSTART:20260324T123000Z
DTEND:20260324T131500Z DTEND:20260324T131500Z
SUMMARY:r/smallbusiness - Post 3 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 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 END:VEVENT
BEGIN:VEVENT BEGIN:VEVENT
UID:reddit-full-20260326-post4@qrmaster.net UID:reddit-full-20260326-post4@qrmaster.net
DTSTAMP:20260312T120000Z DTSTAMP:20260312T120000Z
DTSTART:20260326T120000Z DTSTART:20260326T120000Z
DTEND:20260326T124500Z DTEND:20260326T124500Z
SUMMARY:r/EntrepreneurRideAlong - Post 4 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 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 END:VEVENT
BEGIN:VEVENT BEGIN:VEVENT
UID:reddit-full-20260327-post5@qrmaster.net UID:reddit-full-20260327-post5@qrmaster.net
DTSTAMP:20260312T120000Z DTSTAMP:20260312T120000Z
DTSTART:20260327T133000Z DTSTART:20260327T133000Z
DTEND:20260327T141500Z DTEND:20260327T141500Z
SUMMARY:r/SideProject - Post 5 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 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 END:VEVENT
BEGIN:VEVENT BEGIN:VEVENT
UID:reddit-full-20260331-post7@qrmaster.net UID:reddit-full-20260331-post7@qrmaster.net
DTSTAMP:20260312T120000Z DTSTAMP:20260312T120000Z
DTSTART:20260331T120000Z DTSTART:20260331T120000Z
DTEND:20260331T124500Z DTEND:20260331T124500Z
SUMMARY:r/AlphaandBetaTesters - Post 7 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 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 END:VEVENT
BEGIN:VEVENT BEGIN:VEVENT
UID:reddit-full-20260402-post8@qrmaster.net UID:reddit-full-20260402-post8@qrmaster.net
DTSTAMP:20260312T120000Z DTSTAMP:20260312T120000Z
DTSTART:20260402T120000Z DTSTART:20260402T120000Z
DTEND:20260402T124500Z DTEND:20260402T124500Z
SUMMARY:r/RoastMyStartup - Post 8 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 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 END:VEVENT
BEGIN:VEVENT BEGIN:VEVENT
UID:reddit-full-20260403-post6@qrmaster.net UID:reddit-full-20260403-post6@qrmaster.net
DTSTAMP:20260312T120000Z DTSTAMP:20260312T120000Z
DTSTART:20260403T123000Z DTSTART:20260403T123000Z
DTEND:20260403T131500Z DTEND:20260403T131500Z
SUMMARY:r/ProductMgmt - Post 6 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 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 END:VEVENT
BEGIN:VEVENT BEGIN:VEVENT
UID:reddit-full-20260407-post9@qrmaster.net UID:reddit-full-20260407-post9@qrmaster.net
DTSTAMP:20260312T120000Z DTSTAMP:20260312T120000Z
DTSTART:20260407T120000Z DTSTART:20260407T120000Z
DTEND:20260407T124500Z DTEND:20260407T124500Z
SUMMARY:r/startups - Post 9 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 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 END:VEVENT
BEGIN:VEVENT BEGIN:VEVENT
UID:reddit-full-20260409-post10@qrmaster.net UID:reddit-full-20260409-post10@qrmaster.net
DTSTAMP:20260312T120000Z DTSTAMP:20260312T120000Z
DTSTART:20260409T123000Z DTSTART:20260409T123000Z
DTEND:20260409T131500Z DTEND:20260409T131500Z
SUMMARY:r/smallbusiness - Post 10 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 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:VEVENT
END:VCALENDAR END:VCALENDAR

View File

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

View File

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

View File

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

View File

@@ -1,457 +1,457 @@
# Reddit 4-Week Calendar for QR Master # Reddit 4-Week Calendar for QR Master
Times below are in Europe/Berlin local time. Times below are in Europe/Berlin local time.
Use clean URLs only. Do not add UTM parameters to public Reddit links. Use clean URLs only. Do not add UTM parameters to public Reddit links.
## Link Map ## Link Map
- Reprint / cost angle: `https://www.qrmaster.net/reprint-calculator` - Reprint / cost angle: `https://www.qrmaster.net/reprint-calculator`
- Dynamic after print: `https://www.qrmaster.net/dynamic-qr-code-generator` - Dynamic after print: `https://www.qrmaster.net/dynamic-qr-code-generator`
- Restaurant / menu angle: `https://www.qrmaster.net/use-cases/restaurant-menu-qr-codes` - 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` - Flyer / print attribution: `https://www.qrmaster.net/use-cases/flyer-qr-codes`
- Campaign measurement: `https://www.qrmaster.net/qr-code-for-marketing-campaigns` - Campaign measurement: `https://www.qrmaster.net/qr-code-for-marketing-campaigns`
- Tracking / analytics: `https://www.qrmaster.net/qr-code-tracking` - Tracking / analytics: `https://www.qrmaster.net/qr-code-tracking`
- Bulk / packaging: `https://www.qrmaster.net/bulk-qr-code-generator` - Bulk / packaging: `https://www.qrmaster.net/bulk-qr-code-generator`
- Packaging use case: `https://www.qrmaster.net/use-cases/packaging-qr-codes` - Packaging use case: `https://www.qrmaster.net/use-cases/packaging-qr-codes`
- Privacy proof only: `https://www.qrmaster.net/privacy` - Privacy proof only: `https://www.qrmaster.net/privacy`
- Full site feedback / promo-only subs: `https://www.qrmaster.net/` - Full site feedback / promo-only subs: `https://www.qrmaster.net/`
## 2026-03-16 Monday, 14:30 ## 2026-03-16 Monday, 14:30
- Type: Comment block - Type: Comment block
- Subreddits: `r/startups`, `r/SaaS` - Subreddits: `r/startups`, `r/SaaS`
- Goal: Warm up account and build relevant karma - Goal: Warm up account and build relevant karma
- Rule: No links unless someone explicitly asks - Rule: No links unless someone explicitly asks
- Comment prompt ideas: - Comment prompt ideas:
- "The expensive part starts after print, not at QR generation." - "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." - "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." - "I care less about the QR itself and more about what happens when the destination changes later."
## 2026-03-17 Tuesday, 13:00 ## 2026-03-17 Tuesday, 13:00
- Type: Main post - Type: Main post
- Subreddit: `r/startups` - Subreddit: `r/startups`
- Title: `One URL change can ruin 500 flyers. That pain is more real than I expected.` - Title: `One URL change can ruin 500 flyers. That pain is more real than I expected.`
- Body: - Body:
```text ```text
I underestimated how annoying printed mistakes are. I underestimated how annoying printed mistakes are.
A lot of software problems are reversible. A lot of software problems are reversible.
Print problems arent. Print problems arent.
If a landing page changes after flyers, posters, inserts, or menus are already out there, someone has to: If a landing page changes after flyers, posters, inserts, or menus are already out there, someone has to:
- live with a broken flow - live with a broken flow
- reprint everything - reprint everything
- or patch it manually in a messy way - or patch it manually in a messy way
That sounds minor until you talk to people actually running campaigns or local businesses. 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? What small operational problem ended up being much more expensive than it looked at first?
``` ```
- Link if asked: `https://www.qrmaster.net/reprint-calculator` - Link if asked: `https://www.qrmaster.net/reprint-calculator`
- Possible replies: - Possible replies:
```text ```text
Yeah, thats the part I underestimated too. The QR itself is easy. The expensive part is when the destination changes after print. Yeah, thats the part I underestimated too. The QR itself is easy. The expensive part is when the destination changes after print.
``` ```
```text ```text
I built around exactly that issue, so obvious bias here: I built around exactly that issue, so obvious bias here:
https://www.qrmaster.net/reprint-calculator https://www.qrmaster.net/reprint-calculator
If links are annoying in this thread, I can just explain the workflow here. If links are annoying in this thread, I can just explain the workflow here.
``` ```
```text ```text
Static is fine if the URL is truly permanent. The pain starts when someone assumes “permanent” and the campaign changes two weeks later. 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 ## 2026-03-18 Wednesday, 14:30
- Type: Reply block - Type: Reply block
- Subreddits: `r/startups` - Subreddits: `r/startups`
- Goal: Reply to every serious comment from the Tuesday post - Goal: Reply to every serious comment from the Tuesday post
- Link rule: Only if asked directly - Link rule: Only if asked directly
- Safe reply template: - Safe reply template:
```text ```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. 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 ## 2026-03-19 Thursday, 13:00
- Type: Main post - Type: Main post
- Subreddit: `r/SaaS` - Subreddit: `r/SaaS`
- Title: `I thought QR code software was about generation. The real pain starts after print.` - Title: `I thought QR code software was about generation. The real pain starts after print.`
- Body: - Body:
```text ```text
I used to think the value was “make a QR code fast.” I used to think the value was “make a QR code fast.”
Its not. Its not.
The painful part starts after something is already printed: The painful part starts after something is already printed:
- the menu changes - the menu changes
- the event page changes - the event page changes
- the campaign URL changes - the campaign URL changes
- someone notices a typo too late - someone notices a typo too late
One small change can turn a stack of flyers into trash. One small change can turn a stack of flyers into trash.
That shifted how I think about the whole category. That shifted how I think about the whole category.
The QR itself is easy. The QR itself is easy.
The expensive part is everything around it. The expensive part is everything around it.
Anyone else building in a category where the “simple feature” isnt actually where the value is? 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` - Link if asked: `https://www.qrmaster.net/dynamic-qr-code-generator`
- Possible replies: - Possible replies:
```text ```text
Thats exactly how I see it now too. “Generate” sounds like the product, but “edit after print” is where the value starts. Thats exactly how I see it now too. “Generate” sounds like the product, but “edit after print” is where the value starts.
``` ```
```text ```text
I built a tool for that exact use case, so obvious founder bias: I built a tool for that exact use case, so obvious founder bias:
https://www.qrmaster.net/dynamic-qr-code-generator https://www.qrmaster.net/dynamic-qr-code-generator
``` ```
```text ```text
I thought analytics would be the hook. In practice, “dont make me reprint stuff” lands faster. I thought analytics would be the hook. In practice, “dont make me reprint stuff” lands faster.
``` ```
## 2026-03-20 Friday, 14:30 ## 2026-03-20 Friday, 14:30
- Type: Comment block - Type: Comment block
- Subreddits: `r/SaaS` - Subreddits: `r/SaaS`
- Goal: Extend the Thursday discussion without posting a new link - Goal: Extend the Thursday discussion without posting a new link
- If someone asks about tracking: `https://www.qrmaster.net/qr-code-tracking` - If someone asks about tracking: `https://www.qrmaster.net/qr-code-tracking`
- Safe tracking reply: - Safe tracking reply:
```text ```text
If the goal is proof instead of guesswork, tracking matters. Im building in that space too, so obvious bias: 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 https://www.qrmaster.net/qr-code-tracking
``` ```
## 2026-03-23 Monday, 14:30 ## 2026-03-23 Monday, 14:30
- Type: Comment block - Type: Comment block
- Subreddits: `r/smallbusiness`, `r/SideProject` - Subreddits: `r/smallbusiness`, `r/SideProject`
- Goal: Warm both subs before posting this week - Goal: Warm both subs before posting this week
- Rule: No link unless asked directly - Rule: No link unless asked directly
## 2026-03-24 Tuesday, 13:30 ## 2026-03-24 Tuesday, 13:30
- Type: Main post - Type: Main post
- Subreddit: `r/smallbusiness` - Subreddit: `r/smallbusiness`
- Title: `Most small businesses dont need more tools. They need fewer preventable mistakes.` - Title: `Most small businesses dont need more tools. They need fewer preventable mistakes.`
- Body: - Body:
```text ```text
I keep seeing the same pattern: I keep seeing the same pattern:
Owners usually dont want “more software.” Owners usually dont want “more software.”
They want fewer headaches. They want fewer headaches.
With QR codes, the common headaches seem to be: With QR codes, the common headaches seem to be:
- printing a code that cant be updated later - printing a code that cant be updated later
- linking to a bad mobile page - linking to a bad mobile page
- not knowing if anyone scanned it - not knowing if anyone scanned it
- having to redo materials because one URL changed - having to redo materials because one URL changed
That feels less like a marketing problem and more like an operations problem. That feels less like a marketing problem and more like an operations problem.
What low-effort process change saved you time or money recently? What low-effort process change saved you time or money recently?
``` ```
- Default link if asked: `https://www.qrmaster.net/reprint-calculator` - 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` - Restaurant/menu link if relevant: `https://www.qrmaster.net/use-cases/restaurant-menu-qr-codes`
- Possible replies: - Possible replies:
```text ```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. 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 ```text
If the menu changes regularly, I wouldnt print a static QR. I built around exactly that use case, so bias disclosed: 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 https://www.qrmaster.net/use-cases/restaurant-menu-qr-codes
``` ```
```text ```text
For a more general cost angle, this is the cleanest page to share: For a more general cost angle, this is the cleanest page to share:
https://www.qrmaster.net/reprint-calculator https://www.qrmaster.net/reprint-calculator
``` ```
## 2026-03-25 Wednesday, 14:30 ## 2026-03-25 Wednesday, 14:30
- Type: Reply block - Type: Reply block
- Subreddits: `r/smallbusiness` - Subreddits: `r/smallbusiness`
- Goal: Answer every practical question from the Tuesday post - Goal: Answer every practical question from the Tuesday post
- Rule: Only drop a link when the use case is obvious - Rule: Only drop a link when the use case is obvious
## 2026-03-26 Thursday, 13:00 ## 2026-03-26 Thursday, 13:00
- Type: Main post - Type: Main post
- Subreddit: `r/SideProject` - Subreddit: `r/SideProject`
- Title: `The weird part about building a QR product is that the technical problem isnt the interesting one` - Title: `The weird part about building a QR product is that the technical problem isnt the interesting one`
- Body: - Body:
```text ```text
Generating a QR image is trivial. Generating a QR image is trivial.
What turned out to be more interesting: What turned out to be more interesting:
- what happens after print - what happens after print
- whether someone can change the destination later - whether someone can change the destination later
- what analytics are actually useful - what analytics are actually useful
- how privacy concerns show up once tracking enters the conversation - how privacy concerns show up once tracking enters the conversation
- how bulk workflows matter way more than expected - 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. 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? 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` - 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` - Bulk link if someone asks about scale: `https://www.qrmaster.net/bulk-qr-code-generator`
- Possible replies: - Possible replies:
```text ```text
Exactly. The QR itself is not the product. The post-print control is. Exactly. The QR itself is not the product. The post-print control is.
``` ```
```text ```text
I built around that exact issue, so obvious bias: I built around that exact issue, so obvious bias:
https://www.qrmaster.net/dynamic-qr-code-generator https://www.qrmaster.net/dynamic-qr-code-generator
``` ```
```text ```text
If the interesting part for you is scale, the bulk side is here: If the interesting part for you is scale, the bulk side is here:
https://www.qrmaster.net/bulk-qr-code-generator https://www.qrmaster.net/bulk-qr-code-generator
``` ```
## 2026-03-27 Friday, 14:30 ## 2026-03-27 Friday, 14:30
- Type: Comment block - Type: Comment block
- Subreddits: `r/SideProject` - Subreddits: `r/SideProject`
- Goal: Follow up on the Thursday thread and answer bulk/packaging questions - Goal: Follow up on the Thursday thread and answer bulk/packaging questions
- Primary link if relevant: `https://www.qrmaster.net/bulk-qr-code-generator` - Primary link if relevant: `https://www.qrmaster.net/bulk-qr-code-generator`
## 2026-03-30 Monday, 15:30 ## 2026-03-30 Monday, 15:30
- Type: Comment block - Type: Comment block
- Subreddits: `r/AlphaandBetaTesters`, `r/RoastMyStartup` - Subreddits: `r/AlphaandBetaTesters`, `r/RoastMyStartup`
- Goal: Warm up both communities before feedback posts - Goal: Warm up both communities before feedback posts
- Rule: No links today - Rule: No links today
## 2026-03-31 Tuesday, 14:00 ## 2026-03-31 Tuesday, 14:00
- Type: Feedback post - Type: Feedback post
- Subreddit: `r/AlphaandBetaTesters` - Subreddit: `r/AlphaandBetaTesters`
- Title: `Looking for feedback from anyone who has used QR codes in restaurants, events, print, or packaging` - Title: `Looking for feedback from anyone who has used QR codes in restaurants, events, print, or packaging`
- Body: - Body:
```text ```text
Im trying to learn from people who use QR codes in the real world, not just in theory. Im trying to learn from people who use QR codes in the real world, not just in theory.
Especially if youve used them for: Especially if youve used them for:
- menus - menus
- flyers - flyers
- product packaging - product packaging
- event materials - event materials
- WiFi / contact sharing - WiFi / contact sharing
- agency campaigns - agency campaigns
Things Im curious about: Things Im curious about:
- what changes most often after something is printed? - what changes most often after something is printed?
- whats annoying about current tools? - whats annoying about current tools?
- do you actually care about scan analytics? - do you actually care about scan analytics?
- does privacy / GDPR affect vendor choice at all? - 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. 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 - Link placement: first comment, not the post body
- First comment link: `https://www.qrmaster.net/dynamic-qr-code-generator` - First comment link: `https://www.qrmaster.net/dynamic-qr-code-generator`
- Possible replies: - Possible replies:
```text ```text
This is the product Im testing the messaging on, so obvious bias: This is the product Im testing the messaging on, so obvious bias:
https://www.qrmaster.net/dynamic-qr-code-generator https://www.qrmaster.net/dynamic-qr-code-generator
``` ```
```text ```text
Thats useful. The thing I keep hearing too is that the problem starts once something is already printed. Thats useful. The thing I keep hearing too is that the problem starts once something is already printed.
``` ```
```text ```text
If the privacy side is the bigger concern, I can share how Im handling that specifically instead of pitching the product. 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 ## 2026-04-01 Wednesday, 15:30
- Type: Reply block - Type: Reply block
- Subreddits: `r/AlphaandBetaTesters` - Subreddits: `r/AlphaandBetaTesters`
- Goal: Answer all serious feedback and record objections - Goal: Answer all serious feedback and record objections
- Privacy proof link only if asked: `https://www.qrmaster.net/privacy` - Privacy proof link only if asked: `https://www.qrmaster.net/privacy`
## 2026-04-02 Thursday, 14:00 ## 2026-04-02 Thursday, 14:00
- Type: Roast post - Type: Roast post
- Subreddit: `r/RoastMyStartup` - Subreddit: `r/RoastMyStartup`
- Title: `Roast my positioning: is “avoid reprints and broken QR campaigns” a strong enough problem?` - Title: `Roast my positioning: is “avoid reprints and broken QR campaigns” a strong enough problem?`
- Body: - Body:
```text ```text
Im working on a product around dynamic QR codes. Im working on a product around dynamic QR codes.
The positioning Im testing is less “make QR codes” and more: The positioning Im testing is less “make QR codes” and more:
“avoid reprints, outdated links, and messy campaign management.” “avoid reprints, outdated links, and messy campaign management.”
Target users are mostly: Target users are mostly:
- small businesses - small businesses
- restaurants - restaurants
- marketers - marketers
- agencies - agencies
- event / packaging use cases - event / packaging use cases
The questions Id love roasted: The questions Id love roasted:
- does the pain feel real enough? - does the pain feel real enough?
- does this sound too niche? - does this sound too niche?
- what part sounds generic or weak? - what part sounds generic or weak?
- what would make you ignore this instantly? - what would make you ignore this instantly?
Happy to share the product if the sub is okay with it. Happy to share the product if the sub is okay with it.
``` ```
- Link placement: direct link in post is okay - Link placement: direct link in post is okay
- Link: `https://www.qrmaster.net/` - Link: `https://www.qrmaster.net/`
- Possible replies: - Possible replies:
```text ```text
Fair. The goal here is honestly sharper criticism, not a soft launch. Fair. The goal here is honestly sharper criticism, not a soft launch.
``` ```
```text ```text
Thats a good callout. If the pain still sounds too “small,” then the messaging isnt strong enough yet. Thats a good callout. If the pain still sounds too “small,” then the messaging isnt strong enough yet.
``` ```
```text ```text
Yep, thats the site: Yep, thats the site:
https://www.qrmaster.net/ https://www.qrmaster.net/
``` ```
## 2026-04-03 Friday, 15:30 ## 2026-04-03 Friday, 15:30
- Type: Objection review - Type: Objection review
- Goal: Summarize the week-3 feedback into 3 to 5 objections - Goal: Summarize the week-3 feedback into 3 to 5 objections
- Typical objection buckets: - Typical objection buckets:
- "why pay for QR codes?" - "why pay for QR codes?"
- "sounds niche" - "sounds niche"
- "privacy / GDPR?" - "privacy / GDPR?"
- "whats different from free generators?" - "whats different from free generators?"
- "who is this really for?" - "who is this really for?"
## 2026-04-06 Monday, 15:30 ## 2026-04-06 Monday, 15:30
- Type: Comment block - Type: Comment block
- Subreddits: `r/SaaS` - Subreddits: `r/SaaS`
- Goal: Re-enter with objection-informed comments before the next post - Goal: Re-enter with objection-informed comments before the next post
- Rule: No links unless asked - Rule: No links unless asked
## 2026-04-07 Tuesday, 14:00 ## 2026-04-07 Tuesday, 14:00
- Type: Main post - Type: Main post
- Subreddit: `r/SaaS` - Subreddit: `r/SaaS`
- Title: `Im starting to think “edit later” is a stronger product promise than “track scans”` - Title: `Im starting to think “edit later” is a stronger product promise than “track scans”`
- Body: - Body:
```text ```text
Interesting thing from early positioning: Interesting thing from early positioning:
I assumed analytics would be the hero feature. I assumed analytics would be the hero feature.
But “I can change the destination later” seems to click faster. But “I can change the destination later” seems to click faster.
Makes sense in hindsight. Makes sense in hindsight.
Tracking is nice. Tracking is nice.
Avoiding expensive mistakes is urgent. Avoiding expensive mistakes is urgent.
So now Im wondering if the better message is: So now Im wondering if the better message is:
- first promise control - first promise control
- then introduce analytics - then introduce analytics
- then layer in bulk / workflow / privacy - then layer in bulk / workflow / privacy
If youve sold into small businesses or marketers: If youve sold into small businesses or marketers:
what kind of promise gets attention faster, insight or control? what kind of promise gets attention faster, insight or control?
``` ```
- Default link if asked: `https://www.qrmaster.net/dynamic-qr-code-generator` - 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` - If the thread becomes measurement-heavy: `https://www.qrmaster.net/qr-code-for-marketing-campaigns`
- Possible replies: - Possible replies:
```text ```text
Thats exactly the split Im seeing too. “Insight” sounds nice, “control” feels urgent. Thats exactly the split Im seeing too. “Insight” sounds nice, “control” feels urgent.
``` ```
```text ```text
I built around that exact use case, so obvious bias: I built around that exact use case, so obvious bias:
https://www.qrmaster.net/dynamic-qr-code-generator https://www.qrmaster.net/dynamic-qr-code-generator
``` ```
```text ```text
If the attribution side is the interesting part for you, this is the more relevant page: 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 https://www.qrmaster.net/qr-code-for-marketing-campaigns
``` ```
## 2026-04-08 Wednesday, 15:30 ## 2026-04-08 Wednesday, 15:30
- Type: Reply block - Type: Reply block
- Subreddits: `r/SaaS` - Subreddits: `r/SaaS`
- Goal: Work the Tuesday thread hard for comments, not just upvotes - Goal: Work the Tuesday thread hard for comments, not just upvotes
## 2026-04-09 Thursday, 14:30 ## 2026-04-09 Thursday, 14:30
- Type: Promo post - Type: Promo post
- Subreddit: `r/Plugyourproduct` or `r/startups_promotion` - Subreddit: `r/Plugyourproduct` or `r/startups_promotion`
- Title: `QR Master: editable QR codes for print campaigns, menus, packaging, and analytics` - Title: `QR Master: editable QR codes for print campaigns, menus, packaging, and analytics`
- Body: - Body:
```text ```text
Built QR Master to solve a simple but expensive problem: Built QR Master to solve a simple but expensive problem:
people print QR codes, then the destination changes later. people print QR codes, then the destination changes later.
What it does: What it does:
- editable QR destinations after print - editable QR destinations after print
- scan tracking - scan tracking
- bulk workflows - bulk workflows
- campaign-friendly use cases for menus, flyers, events, and packaging - campaign-friendly use cases for menus, flyers, events, and packaging
Looking for honest feedback on the value prop and landing page clarity. Looking for honest feedback on the value prop and landing page clarity.
``` ```
- Link placement: direct link in post - Link placement: direct link in post
- Link: `https://www.qrmaster.net/` - Link: `https://www.qrmaster.net/`
- Possible replies: - Possible replies:
```text ```text
Appreciate it. The core promise is really “dont reprint just because the URL changed.” Appreciate it. The core promise is really “dont reprint just because the URL changed.”
``` ```
```text ```text
If you want the most direct core page instead of the homepage, this is it: If you want the most direct core page instead of the homepage, this is it:
https://www.qrmaster.net/dynamic-qr-code-generator https://www.qrmaster.net/dynamic-qr-code-generator
``` ```
```text ```text
If youre more interested in measurement than editability, this page is the better entry point: If youre more interested in measurement than editability, this page is the better entry point:
https://www.qrmaster.net/qr-code-tracking https://www.qrmaster.net/qr-code-tracking
``` ```
## 2026-04-10 Friday, 15:30 ## 2026-04-10 Friday, 15:30
- Type: Follow-up block - Type: Follow-up block
- Goal: Answer all promo-thread comments publicly and close the 4-week run - Goal: Answer all promo-thread comments publicly and close the 4-week run
- Rule: No DMs, no pressure, keep every answer in-thread - 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 # 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. 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 ## Positioning for the Month
`Dynamic QR codes for measurable offline marketing, without creepy tracking.` `Dynamic QR codes for measurable offline marketing, without creepy tracking.`
## Audience Focus ## Audience Focus
Primary audience for Days 1-15: Primary audience for Days 1-15:
`Restaurants / hospitality` `Restaurants / hospitality`
Secondary audience for Days 16-30: Secondary audience for Days 16-30:
`Agencies / offline marketers / retail operators` `Agencies / offline marketers / retail operators`
## CTA Rule for the Whole Month ## CTA Rule for the Whole Month
- Most posts: `Reply with a keyword`, `follow for more`, or `DM me` - Most posts: `Reply with a keyword`, `follow for more`, or `DM me`
- Only light link usage - Only light link usage
- Put direct product CTA mainly in replies, profile, and pinned post - Put direct product CTA mainly in replies, profile, and pinned post
## 30-Day Plan ## 30-Day Plan
### Day 1 ### Day 1
**Post type:** Founder positioning post **Post type:** Founder positioning post
**Hook:** `Most QR codes are dead the moment they get printed.` **Hook:** `Most QR codes are dead the moment they get printed.`
**Angle:** Static QR codes create reprint costs and broken customer journeys. **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.` **CTA:** `If you run offline marketing, follow this account. I'm breaking down how to fix it.`
### Day 2 ### Day 2
**Post type:** Short insight post **Post type:** Short insight post
**Hook:** `A restaurant menu should not require a reprint every time one dish changes.` **Hook:** `A restaurant menu should not require a reprint every time one dish changes.`
**Angle:** Dynamic QR codes for menus and specials. **Angle:** Dynamic QR codes for menus and specials.
**CTA:** `Reply "menu" if you want me to post the exact setup.` **CTA:** `Reply "menu" if you want me to post the exact setup.`
### Day 3 ### Day 3
**Post type:** Teardown **Post type:** Teardown
**Hook:** `3 mistakes I see on restaurant QR menus all the time:` **Hook:** `3 mistakes I see on restaurant QR menus all the time:`
**Angle:** Bad placement, no fallback page, no analytics. **Angle:** Bad placement, no fallback page, no analytics.
**CTA:** `Want me to roast your menu QR? Reply with a screenshot.` **CTA:** `Want me to roast your menu QR? Reply with a screenshot.`
### Day 4 ### Day 4
**Post type:** Thread **Post type:** Thread
**Hook:** `How restaurants can update menus without reprinting tables, flyers, or window signs:` **Hook:** `How restaurants can update menus without reprinting tables, flyers, or window signs:`
**Angle:** 5-step workflow using one dynamic QR. **Angle:** 5-step workflow using one dynamic QR.
**CTA:** `I can turn this into a checklist if people want it.` **CTA:** `I can turn this into a checklist if people want it.`
### Day 5 ### Day 5
**Post type:** Contrarian post **Post type:** Contrarian post
**Hook:** `Unpopular opinion: "free QR code generators" are expensive.` **Hook:** `Unpopular opinion: "free QR code generators" are expensive.`
**Angle:** Hidden cost is reprints, lost scans, no attribution. **Angle:** Hidden cost is reprints, lost scans, no attribution.
**CTA:** `Agree or disagree?` **CTA:** `Agree or disagree?`
### Day 6 ### Day 6
**Post type:** Demo video **Post type:** Demo video
**Hook:** `Change the destination after print. That's the whole game.` **Hook:** `Change the destination after print. That's the whole game.`
**Angle:** Quick screen recording showing edit-after-print. **Angle:** Quick screen recording showing edit-after-print.
**CTA:** `DM me "edit" and I'll send the workflow.` **CTA:** `DM me "edit" and I'll send the workflow.`
### Day 7 ### Day 7
**Post type:** Founder story **Post type:** Founder story
**Hook:** `We started building QR Master because most QR tools felt like toys.` **Hook:** `We started building QR Master because most QR tools felt like toys.`
**Angle:** Needed analytics, bulk creation, privacy-first tracking. **Angle:** Needed analytics, bulk creation, privacy-first tracking.
**CTA:** `What's one thing you hate about current QR tools?` **CTA:** `What's one thing you hate about current QR tools?`
### Day 8 ### Day 8
**Post type:** Pain-to-fix post **Post type:** Pain-to-fix post
**Hook:** `If your flyer has a QR code but no tracking, you're guessing.` **Hook:** `If your flyer has a QR code but no tracking, you're guessing.`
**Angle:** Offline campaigns need measurable scans. **Angle:** Offline campaigns need measurable scans.
**CTA:** `Reply "track" if you want a simple attribution template.` **CTA:** `Reply "track" if you want a simple attribution template.`
### Day 9 ### Day 9
**Post type:** Restaurant-specific post **Post type:** Restaurant-specific post
**Hook:** `Today's special changes. Your printed QR shouldn't.` **Hook:** `Today's special changes. Your printed QR shouldn't.`
**Angle:** Daily menu operations. **Angle:** Daily menu operations.
**CTA:** `Restaurant owners: how often do you update menus?` **CTA:** `Restaurant owners: how often do you update menus?`
### Day 10 ### Day 10
**Post type:** Roast / audit **Post type:** Roast / audit
**Hook:** `This QR code placement is killing conversions.` **Hook:** `This QR code placement is killing conversions.`
**Angle:** Explain why low-visibility placements fail. **Angle:** Explain why low-visibility placements fail.
**CTA:** `Send me your flyer/menu/poster and I'll break it down.` **CTA:** `Send me your flyer/menu/poster and I'll break it down.`
### Day 11 ### Day 11
**Post type:** Thread **Post type:** Thread
**Hook:** `5 QR code mistakes that make restaurant marketing look cheap:` **Hook:** `5 QR code mistakes that make restaurant marketing look cheap:`
**Angle:** Visual clutter, dead links, bad landing pages, no tracking, wrong CTA. **Angle:** Visual clutter, dead links, bad landing pages, no tracking, wrong CTA.
**CTA:** `I'll post 5 fixes tomorrow if this gets traction.` **CTA:** `I'll post 5 fixes tomorrow if this gets traction.`
### Day 12 ### Day 12
**Post type:** Build in public **Post type:** Build in public
**Hook:** `One thing founders underestimate: people don't want "a QR code." They want a workflow.` **Hook:** `One thing founders underestimate: people don't want "a QR code." They want a workflow.`
**Angle:** Product insight from building. **Angle:** Product insight from building.
**CTA:** `What simple tool became critical in your business?` **CTA:** `What simple tool became critical in your business?`
### Day 13 ### Day 13
**Post type:** Short proof post **Post type:** Short proof post
**Hook:** `One QR code. Multiple seasonal campaigns. Zero reprints.` **Hook:** `One QR code. Multiple seasonal campaigns. Zero reprints.`
**Angle:** Reuse same printed asset with changing destination. **Angle:** Reuse same printed asset with changing destination.
**CTA:** `This is one of the biggest underrated offline growth hacks.` **CTA:** `This is one of the biggest underrated offline growth hacks.`
### Day 14 ### Day 14
**Post type:** Demo video **Post type:** Demo video
**Hook:** `From printed table card to measurable scan funnel in under 30 seconds:` **Hook:** `From printed table card to measurable scan funnel in under 30 seconds:`
**Angle:** Show QR creation + analytics preview. **Angle:** Show QR creation + analytics preview.
**CTA:** `If you want more product breakdowns, follow.` **CTA:** `If you want more product breakdowns, follow.`
### Day 15 ### Day 15
**Post type:** Summary / recap **Post type:** Summary / recap
**Hook:** `The biggest restaurant QR lesson so far:` **Hook:** `The biggest restaurant QR lesson so far:`
**Angle:** Most businesses don't need more print, they need more flexibility. **Angle:** Most businesses don't need more print, they need more flexibility.
**CTA:** `Next week I'm switching to agencies and offline marketers.` **CTA:** `Next week I'm switching to agencies and offline marketers.`
### Day 16 ### Day 16
**Post type:** Agency-focused post **Post type:** Agency-focused post
**Hook:** `If your agency runs flyer or poster campaigns without QR attribution, you're underreporting impact.` **Hook:** `If your agency runs flyer or poster campaigns without QR attribution, you're underreporting impact.`
**Angle:** Agencies need scan data to prove ROI. **Angle:** Agencies need scan data to prove ROI.
**CTA:** `Reply "agency" if you want my offline attribution framework.` **CTA:** `Reply "agency" if you want my offline attribution framework.`
### Day 17 ### Day 17
**Post type:** Contrarian post **Post type:** Contrarian post
**Hook:** `The problem is not the QR code. The problem is the dead destination behind it.` **Hook:** `The problem is not the QR code. The problem is the dead destination behind it.`
**Angle:** Static link is the failure point. **Angle:** Static link is the failure point.
**CTA:** `This is where most campaigns quietly lose money.` **CTA:** `This is where most campaigns quietly lose money.`
### Day 18 ### Day 18
**Post type:** Thread **Post type:** Thread
**Hook:** `How to make offline campaigns actually measurable:` **Hook:** `How to make offline campaigns actually measurable:`
**Angle:** QR + UTM + landing page + analytics naming structure. **Angle:** QR + UTM + landing page + analytics naming structure.
**CTA:** `I can turn this into a swipe file.` **CTA:** `I can turn this into a swipe file.`
### Day 19 ### Day 19
**Post type:** Audit post **Post type:** Audit post
**Hook:** `3 reasons most poster QR campaigns don't convert:` **Hook:** `3 reasons most poster QR campaigns don't convert:`
**Angle:** Weak CTA, poor mobile page, no tracking structure. **Angle:** Weak CTA, poor mobile page, no tracking structure.
**CTA:** `Want a poster teardown series?` **CTA:** `Want a poster teardown series?`
### Day 20 ### Day 20
**Post type:** Demo video **Post type:** Demo video
**Hook:** `Bulk-create hundreds of QR codes from a spreadsheet.` **Hook:** `Bulk-create hundreds of QR codes from a spreadsheet.`
**Angle:** Show CSV/Excel workflow for agencies or retail. **Angle:** Show CSV/Excel workflow for agencies or retail.
**CTA:** `DM me "bulk" if that would save your team time.` **CTA:** `DM me "bulk" if that would save your team time.`
### Day 21 ### Day 21
**Post type:** Founder hot take **Post type:** Founder hot take
**Hook:** `"Just put a QR code on it" is bad marketing advice.` **Hook:** `"Just put a QR code on it" is bad marketing advice.`
**Angle:** QR is distribution, not strategy. **Angle:** QR is distribution, not strategy.
**CTA:** `What matters more: placement, offer, or landing page?` **CTA:** `What matters more: placement, offer, or landing page?`
### Day 22 ### Day 22
**Post type:** Mini case format **Post type:** Mini case format
**Hook:** `Campaign idea: one printed asset, three different destinations over 30 days.` **Hook:** `Campaign idea: one printed asset, three different destinations over 30 days.`
**Angle:** Explain how one QR can support multiple campaign phases. **Angle:** Explain how one QR can support multiple campaign phases.
**CTA:** `This is why dynamic matters more than design.` **CTA:** `This is why dynamic matters more than design.`
### Day 23 ### Day 23
**Post type:** Thread **Post type:** Thread
**Hook:** `How I'd structure QR tracking for an agency campaign with flyers, packaging, and in-store signage:` **Hook:** `How I'd structure QR tracking for an agency campaign with flyers, packaging, and in-store signage:`
**Angle:** Naming conventions, attribution logic, reporting. **Angle:** Naming conventions, attribution logic, reporting.
**CTA:** `If useful, I'll post the naming template.` **CTA:** `If useful, I'll post the naming template.`
### Day 24 ### Day 24
**Post type:** Privacy wedge post **Post type:** Privacy wedge post
**Hook:** `You can measure scans without turning people into surveillance data.` **Hook:** `You can measure scans without turning people into surveillance data.`
**Angle:** Privacy-first analytics as a business advantage. **Angle:** Privacy-first analytics as a business advantage.
**CTA:** `Too many teams think analytics has to mean creepy.` **CTA:** `Too many teams think analytics has to mean creepy.`
### Day 25 ### Day 25
**Post type:** Teardown **Post type:** Teardown
**Hook:** `This flyer has a QR code. But it still won't tell you what worked.` **Hook:** `This flyer has a QR code. But it still won't tell you what worked.`
**Angle:** Missing attribution structure. **Angle:** Missing attribution structure.
**CTA:** `Reply with "audit" and I'll post a fixed version.` **CTA:** `Reply with "audit" and I'll post a fixed version.`
### Day 26 ### Day 26
**Post type:** Retail / packaging post **Post type:** Retail / packaging post
**Hook:** `Packaging QR codes get interesting when you can change the destination later.` **Hook:** `Packaging QR codes get interesting when you can change the destination later.`
**Angle:** Product updates, campaigns, support pages, seasonal promos. **Angle:** Product updates, campaigns, support pages, seasonal promos.
**CTA:** `Retail operators: are you using QR for support, promo, or repeat purchase?` **CTA:** `Retail operators: are you using QR for support, promo, or repeat purchase?`
### Day 27 ### Day 27
**Post type:** Build in public **Post type:** Build in public
**Hook:** `One thing we keep seeing: people buy QR tools for "generation" and stay for "management."` **Hook:** `One thing we keep seeing: people buy QR tools for "generation" and stay for "management."`
**Angle:** Product-market insight. **Angle:** Product-market insight.
**CTA:** `That distinction matters more than most founders think.` **CTA:** `That distinction matters more than most founders think.`
### Day 28 ### Day 28
**Post type:** Demo video **Post type:** Demo video
**Hook:** `Here's what "measurable offline workflow" actually looks like in practice:` **Hook:** `Here's what "measurable offline workflow" actually looks like in practice:`
**Angle:** Create, edit, track, compare placements. **Angle:** Create, edit, track, compare placements.
**CTA:** `If this kind of content is useful, I'll make it a weekly series.` **CTA:** `If this kind of content is useful, I'll make it a weekly series.`
### Day 29 ### Day 29
**Post type:** Hero thread **Post type:** Hero thread
**Hook:** `Most offline marketing teams don't have a traffic problem. They have a measurement problem.` **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. **Angle:** Big thesis thread connecting restaurants, agencies, retail, and dynamic QR logic.
**CTA:** `If you work in offline marketing, this is the framework.` **CTA:** `If you work in offline marketing, this is the framework.`
### Day 30 ### Day 30
**Post type:** Month-end recap + soft CTA **Post type:** Month-end recap + soft CTA
**Hook:** `30 days of talking to people about QR workflows taught me this:` **Hook:** `30 days of talking to people about QR workflows taught me this:`
**Angle:** Summarize 5 strongest lessons from the month. **Angle:** Summarize 5 strongest lessons from the month.
**CTA:** `If you want, next I'll publish the full playbook: hooks, setup, and attribution templates.` **CTA:** `If you want, next I'll publish the full playbook: hooks, setup, and attribution templates.`
## Weekly Cadence ## Weekly Cadence
- `Mon`: strong opinion or positioning - `Mon`: strong opinion or positioning
- `Tue`: practical educational post - `Tue`: practical educational post
- `Wed`: teardown or audit - `Wed`: teardown or audit
- `Thu`: thread - `Thu`: thread
- `Fri`: product proof or demo - `Fri`: product proof or demo
- `Sat`: founder insight / build in public - `Sat`: founder insight / build in public
- `Sun`: recap or lighter conversation post - `Sun`: recap or lighter conversation post
## Content Mix ## Content Mix
- `8 threads` - `8 threads`
- `6 teardown/audit posts` - `6 teardown/audit posts`
- `5 demo videos` - `5 demo videos`
- `6 short contrarian/value posts` - `6 short contrarian/value posts`
- `5 founder/build-in-public posts` - `5 founder/build-in-public posts`
## Reply Strategy ## Reply Strategy
Every day, add: Every day, add:
- `10-15 replies` to founders, marketers, restaurant-tech, local business, retail ops, and agency accounts - `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 - Focus on posts about: offline marketing, menus, customer journeys, attribution, retail campaigns, print, local growth
- Use replies to seed your core themes: - Use replies to seed your core themes:
- reprint cost - reprint cost
- edit after print - edit after print
- measurable offline - measurable offline
- privacy-first analytics - privacy-first analytics
- bulk workflows - bulk workflows
## Optional Next Step ## Optional Next Step
If needed, this can be expanded into: If needed, this can be expanded into:
1. fully written tweets for all 30 days 1. fully written tweets for all 30 days
2. 8 full threads written out 2. 8 full threads written out
3. a Notion-style content calendar with posting times and CTAs 3. a Notion-style content calendar with posting times and CTAs

View File

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

View File

@@ -1,146 +1,146 @@
-- CreateEnum -- CreateEnum
CREATE TYPE "QRType" AS ENUM ('STATIC', 'DYNAMIC'); CREATE TYPE "QRType" AS ENUM ('STATIC', 'DYNAMIC');
-- CreateEnum -- CreateEnum
CREATE TYPE "ContentType" AS ENUM ('URL', 'WIFI', 'VCARD', 'PHONE', 'EMAIL', 'SMS', 'TEXT', 'WHATSAPP'); CREATE TYPE "ContentType" AS ENUM ('URL', 'WIFI', 'VCARD', 'PHONE', 'EMAIL', 'SMS', 'TEXT', 'WHATSAPP');
-- CreateEnum -- CreateEnum
CREATE TYPE "QRStatus" AS ENUM ('ACTIVE', 'PAUSED'); CREATE TYPE "QRStatus" AS ENUM ('ACTIVE', 'PAUSED');
-- CreateTable -- CreateTable
CREATE TABLE "User" ( CREATE TABLE "User" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"email" TEXT NOT NULL, "email" TEXT NOT NULL,
"name" TEXT, "name" TEXT,
"password" TEXT, "password" TEXT,
"image" TEXT, "image" TEXT,
"emailVerified" TIMESTAMP(3), "emailVerified" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id") CONSTRAINT "User_pkey" PRIMARY KEY ("id")
); );
-- CreateTable -- CreateTable
CREATE TABLE "Account" ( CREATE TABLE "Account" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"userId" TEXT NOT NULL, "userId" TEXT NOT NULL,
"type" TEXT NOT NULL, "type" TEXT NOT NULL,
"provider" TEXT NOT NULL, "provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL, "providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT, "refresh_token" TEXT,
"access_token" TEXT, "access_token" TEXT,
"expires_at" INTEGER, "expires_at" INTEGER,
"token_type" TEXT, "token_type" TEXT,
"scope" TEXT, "scope" TEXT,
"id_token" TEXT, "id_token" TEXT,
"session_state" TEXT, "session_state" TEXT,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id") CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
); );
-- CreateTable -- CreateTable
CREATE TABLE "Session" ( CREATE TABLE "Session" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"sessionToken" TEXT NOT NULL, "sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL, "userId" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL, "expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id") CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
); );
-- CreateTable -- CreateTable
CREATE TABLE "VerificationToken" ( CREATE TABLE "VerificationToken" (
"identifier" TEXT NOT NULL, "identifier" TEXT NOT NULL,
"token" TEXT NOT NULL, "token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL "expires" TIMESTAMP(3) NOT NULL
); );
-- CreateTable -- CreateTable
CREATE TABLE "QRCode" ( CREATE TABLE "QRCode" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"userId" TEXT NOT NULL, "userId" TEXT NOT NULL,
"title" TEXT NOT NULL, "title" TEXT NOT NULL,
"type" "QRType" NOT NULL DEFAULT 'DYNAMIC', "type" "QRType" NOT NULL DEFAULT 'DYNAMIC',
"contentType" "ContentType" NOT NULL DEFAULT 'URL', "contentType" "ContentType" NOT NULL DEFAULT 'URL',
"content" JSONB NOT NULL, "content" JSONB NOT NULL,
"tags" TEXT[], "tags" TEXT[],
"status" "QRStatus" NOT NULL DEFAULT 'ACTIVE', "status" "QRStatus" NOT NULL DEFAULT 'ACTIVE',
"style" JSONB NOT NULL, "style" JSONB NOT NULL,
"slug" TEXT NOT NULL, "slug" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "QRCode_pkey" PRIMARY KEY ("id") CONSTRAINT "QRCode_pkey" PRIMARY KEY ("id")
); );
-- CreateTable -- CreateTable
CREATE TABLE "QRScan" ( CREATE TABLE "QRScan" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"qrId" TEXT NOT NULL, "qrId" TEXT NOT NULL,
"ts" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "ts" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"ipHash" TEXT NOT NULL, "ipHash" TEXT NOT NULL,
"userAgent" TEXT, "userAgent" TEXT,
"device" TEXT, "device" TEXT,
"os" TEXT, "os" TEXT,
"country" TEXT, "country" TEXT,
"referrer" TEXT, "referrer" TEXT,
"utmSource" TEXT, "utmSource" TEXT,
"utmMedium" TEXT, "utmMedium" TEXT,
"utmCampaign" TEXT, "utmCampaign" TEXT,
"isUnique" BOOLEAN NOT NULL DEFAULT false, "isUnique" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "QRScan_pkey" PRIMARY KEY ("id") CONSTRAINT "QRScan_pkey" PRIMARY KEY ("id")
); );
-- CreateTable -- CreateTable
CREATE TABLE "Integration" ( CREATE TABLE "Integration" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"userId" TEXT NOT NULL, "userId" TEXT NOT NULL,
"provider" TEXT NOT NULL, "provider" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'inactive', "status" TEXT NOT NULL DEFAULT 'inactive',
"config" JSONB NOT NULL, "config" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Integration_pkey" PRIMARY KEY ("id") CONSTRAINT "Integration_pkey" PRIMARY KEY ("id")
); );
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId"); CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken"); CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token"); CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token"); CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "QRCode_slug_key" ON "QRCode"("slug"); CREATE UNIQUE INDEX "QRCode_slug_key" ON "QRCode"("slug");
-- CreateIndex -- CreateIndex
CREATE INDEX "QRCode_userId_createdAt_idx" ON "QRCode"("userId", "createdAt"); CREATE INDEX "QRCode_userId_createdAt_idx" ON "QRCode"("userId", "createdAt");
-- CreateIndex -- CreateIndex
CREATE INDEX "QRScan_qrId_ts_idx" ON "QRScan"("qrId", "ts"); CREATE INDEX "QRScan_qrId_ts_idx" ON "QRScan"("qrId", "ts");
-- AddForeignKey -- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE "QRCode" ADD CONSTRAINT "QRCode_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "QRCode" ADD CONSTRAINT "QRCode_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE "QRScan" ADD CONSTRAINT "QRScan_qrId_fkey" FOREIGN KEY ("qrId") REFERENCES "QRCode"("id") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "QRScan" ADD CONSTRAINT "QRScan_qrId_fkey" FOREIGN KEY ("qrId") REFERENCES "QRCode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE "Integration" ADD CONSTRAINT "Integration_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 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: 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 `[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. - A unique constraint covering the columns `[stripeSubscriptionId]` on the table `User` will be added. If there are existing duplicate values, this will fail.
*/ */
-- CreateEnum -- CreateEnum
CREATE TYPE "Plan" AS ENUM ('FREE', 'PRO', 'BUSINESS'); CREATE TYPE "Plan" AS ENUM ('FREE', 'PRO', 'BUSINESS');
-- AlterTable -- AlterTable
ALTER TABLE "User" ADD COLUMN "plan" "Plan" NOT NULL DEFAULT 'FREE', ALTER TABLE "User" ADD COLUMN "plan" "Plan" NOT NULL DEFAULT 'FREE',
ADD COLUMN "stripeCurrentPeriodEnd" TIMESTAMP(3), ADD COLUMN "stripeCurrentPeriodEnd" TIMESTAMP(3),
ADD COLUMN "stripeCustomerId" TEXT, ADD COLUMN "stripeCustomerId" TEXT,
ADD COLUMN "stripePriceId" TEXT, ADD COLUMN "stripePriceId" TEXT,
ADD COLUMN "stripeSubscriptionId" TEXT; ADD COLUMN "stripeSubscriptionId" TEXT;
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "User_stripeCustomerId_key" ON "User"("stripeCustomerId"); CREATE UNIQUE INDEX "User_stripeCustomerId_key" ON "User"("stripeCustomerId");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "User_stripeSubscriptionId_key" ON "User"("stripeSubscriptionId"); CREATE UNIQUE INDEX "User_stripeSubscriptionId_key" ON "User"("stripeSubscriptionId");

View File

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

View File

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

View File

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

View File

@@ -1,117 +1,117 @@
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcryptjs'; import * as bcrypt from 'bcryptjs';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
async function main() { async function main() {
// Create admin user for newsletter management // Create admin user for newsletter management
const hashedPassword = await bcrypt.hash('Timo.16092005', 12); const hashedPassword = await bcrypt.hash('Timo.16092005', 12);
const user = await prisma.user.upsert({ const user = await prisma.user.upsert({
where: { email: 'demo@qrmaster.net' }, where: { email: 'demo@qrmaster.net' },
update: { update: {
password: hashedPassword, // Update password if user exists password: hashedPassword, // Update password if user exists
}, },
create: { create: {
email: 'demo@qrmaster.net', email: 'demo@qrmaster.net',
name: 'Admin User', name: 'Admin User',
password: hashedPassword, password: hashedPassword,
}, },
}); });
console.log('Created/Updated admin user:', user.email); console.log('Created/Updated admin user:', user.email);
// Create demo QR codes // Create demo QR codes
const qrCodes = [ const qrCodes = [
{ {
title: 'Support Phone', title: 'Support Phone',
contentType: 'PHONE' as const, contentType: 'PHONE' as const,
content: { phone: '+1-555-0123' }, content: { phone: '+1-555-0123' },
tags: ['support', 'contact'], tags: ['support', 'contact'],
slug: 'support-phone-demo', slug: 'support-phone-demo',
}, },
{ {
title: 'Event Details', title: 'Event Details',
contentType: 'URL' as const, contentType: 'URL' as const,
content: { url: 'https://example.com/event-2025' }, content: { url: 'https://example.com/event-2025' },
tags: ['event', 'conference'], tags: ['event', 'conference'],
slug: 'event-details-demo', slug: 'event-details-demo',
}, },
{ {
title: 'Product Demo', title: 'Product Demo',
contentType: 'URL' as const, contentType: 'URL' as const,
content: { url: 'https://example.com/product-demo' }, content: { url: 'https://example.com/product-demo' },
tags: ['product', 'demo'], tags: ['product', 'demo'],
slug: 'product-demo-qr', slug: 'product-demo-qr',
}, },
{ {
title: 'Company Website', title: 'Company Website',
contentType: 'URL' as const, contentType: 'URL' as const,
content: { url: 'https://company.example.com' }, content: { url: 'https://company.example.com' },
tags: ['website', 'company'], tags: ['website', 'company'],
slug: 'company-website-qr', slug: 'company-website-qr',
}, },
{ {
title: 'Contact Card', title: 'Contact Card',
contentType: 'VCARD' as const, contentType: 'VCARD' as const,
content: { content: {
firstName: 'John', firstName: 'John',
lastName: 'Doe', lastName: 'Doe',
email: 'john@company.com', email: 'john@company.com',
phone: '+1234567890', phone: '+1234567890',
organization: 'Example Corp', organization: 'Example Corp',
title: 'CEO' title: 'CEO'
}, },
tags: ['contact', 'vcard'], tags: ['contact', 'vcard'],
slug: 'contact-card-qr', slug: 'contact-card-qr',
}, },
{ {
title: 'Event Details', title: 'Event Details',
contentType: 'URL' as const, contentType: 'URL' as const,
content: { url: 'https://example.com/event-duplicate' }, content: { url: 'https://example.com/event-duplicate' },
tags: ['event', 'duplicate'], tags: ['event', 'duplicate'],
slug: 'event-details-dup', slug: 'event-details-dup',
}, },
]; ];
const baseDate = new Date('2025-08-07T10:00:00Z'); const baseDate = new Date('2025-08-07T10:00:00Z');
for (let i = 0; i < qrCodes.length; i++) { for (let i = 0; i < qrCodes.length; i++) {
const qrData = qrCodes[i]; const qrData = qrCodes[i];
const createdAt = new Date(baseDate.getTime() + i * 60000); // 1 minute apart const createdAt = new Date(baseDate.getTime() + i * 60000); // 1 minute apart
await prisma.qRCode.upsert({ await prisma.qRCode.upsert({
where: { slug: qrData.slug }, where: { slug: qrData.slug },
update: {}, update: {},
create: { create: {
userId: user.id, userId: user.id,
title: qrData.title, title: qrData.title,
type: 'DYNAMIC', type: 'DYNAMIC',
contentType: qrData.contentType, contentType: qrData.contentType,
content: qrData.content, content: qrData.content,
tags: qrData.tags, tags: qrData.tags,
status: 'ACTIVE', status: 'ACTIVE',
style: { style: {
foregroundColor: '#000000', foregroundColor: '#000000',
backgroundColor: '#FFFFFF', backgroundColor: '#FFFFFF',
cornerStyle: 'square', cornerStyle: 'square',
size: 200, size: 200,
}, },
slug: qrData.slug, slug: qrData.slug,
createdAt, createdAt,
updatedAt: createdAt, updatedAt: createdAt,
}, },
}); });
} }
console.log('Created 6 demo QR codes'); console.log('Created 6 demo QR codes');
} }
main() main()
.catch((e) => { .catch((e) => {
console.error(e); console.error(e);
process.exit(1); process.exit(1);
}) })
.finally(async () => { .finally(async () => {
await prisma.$disconnect(); 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"> <svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Modern gradient background --> <!-- Modern gradient background -->
<defs> <defs>
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%"> <linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#3B82F6;stop-opacity:1" /> <stop offset="0%" style="stop-color:#3B82F6;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1D4ED8;stop-opacity:1" /> <stop offset="100%" style="stop-color:#1D4ED8;stop-opacity:1" />
</linearGradient> </linearGradient>
</defs> </defs>
<rect width="32" height="32" rx="7" fill="url(#bgGradient)"/> <rect width="32" height="32" rx="7" fill="url(#bgGradient)"/>
<!-- Modern QR code pattern with rounded corners --> <!-- Modern QR code pattern with rounded corners -->
<!-- Top left corner finder --> <!-- Top left corner finder -->
<rect x="5" y="5" width="10" height="10" rx="2" fill="white"/> <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"/> <rect x="7" y="7" width="6" height="6" rx="1" fill="#1D4ED8"/>
<!-- Top right corner finder --> <!-- Top right corner finder -->
<rect x="17" y="5" width="10" height="10" rx="2" fill="white"/> <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"/> <rect x="19" y="7" width="6" height="6" rx="1" fill="#1D4ED8"/>
<!-- Bottom left corner finder --> <!-- Bottom left corner finder -->
<rect x="5" y="17" width="10" height="10" rx="2" fill="white"/> <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"/> <rect x="7" y="19" width="6" height="6" rx="1" fill="#1D4ED8"/>
<!-- Modern data pattern with circles and rounded squares --> <!-- Modern data pattern with circles and rounded squares -->
<circle cx="19" cy="17" r="1.5" fill="white"/> <circle cx="19" cy="17" r="1.5" fill="white"/>
<circle cx="23" 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="19" cy="21" r="1.5" fill="white"/>
<circle cx="23" 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="19" cy="25" r="1.5" fill="white"/>
<circle cx="23" cy="25" r="1.5" fill="white"/> <circle cx="23" cy="25" r="1.5" fill="white"/>
</svg> </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
> QR Master is a B2B SaaS platform for dynamic QR codes, scan analytics, bulk generation, and privacy-conscious campaign tracking. > 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 - Primary domain: https://www.qrmaster.net
- Free static QR codes, paid dynamic QR codes with tracking and bulk workflows - Free static QR codes, paid dynamic QR codes with tracking and bulk workflows
- Main audience: marketers, restaurants, event teams, retail, and SMB operators - Main audience: marketers, restaurants, event teams, retail, and SMB operators
- Public content is optimized for citation and retrieval by AI search systems - Public content is optimized for citation and retrieval by AI search systems
## Core Product Pages ## Core Product Pages
- [Homepage](https://www.qrmaster.net): Product overview and positioning for dynamic QR codes - [Homepage](https://www.qrmaster.net): Product overview and positioning for dynamic QR codes
- [Pricing](https://www.qrmaster.net/pricing): Plans, limits, and upgrade paths - [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 - [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 - [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 - [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 - [FAQ](https://www.qrmaster.net/faq): Direct answers to product, billing, and implementation questions
## Cornerstone Guides ## 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 - [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 - [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 - [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 - [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 - [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 ## Additional Context
- [Blog Index](https://www.qrmaster.net/blog): All published QR marketing and implementation guides - [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 - [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 - [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"> <svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Modern gradient background --> <!-- Modern gradient background -->
<defs> <defs>
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%"> <linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#3B82F6;stop-opacity:1" /> <stop offset="0%" style="stop-color:#3B82F6;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1D4ED8;stop-opacity:1" /> <stop offset="100%" style="stop-color:#1D4ED8;stop-opacity:1" />
</linearGradient> </linearGradient>
</defs> </defs>
<rect width="32" height="32" rx="7" fill="url(#bgGradient)"/> <rect width="32" height="32" rx="7" fill="url(#bgGradient)"/>
<!-- Modern QR code pattern with rounded corners --> <!-- Modern QR code pattern with rounded corners -->
<!-- Top left corner finder --> <!-- Top left corner finder -->
<rect x="5" y="5" width="10" height="10" rx="2" fill="white"/> <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"/> <rect x="7" y="7" width="6" height="6" rx="1" fill="#1D4ED8"/>
<!-- Top right corner finder --> <!-- Top right corner finder -->
<rect x="17" y="5" width="10" height="10" rx="2" fill="white"/> <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"/> <rect x="19" y="7" width="6" height="6" rx="1" fill="#1D4ED8"/>
<!-- Bottom left corner finder --> <!-- Bottom left corner finder -->
<rect x="5" y="17" width="10" height="10" rx="2" fill="white"/> <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"/> <rect x="7" y="19" width="6" height="6" rx="1" fill="#1D4ED8"/>
<!-- Modern data pattern with circles and rounded squares --> <!-- Modern data pattern with circles and rounded squares -->
<circle cx="19" cy="17" r="1.5" fill="white"/> <circle cx="19" cy="17" r="1.5" fill="white"/>
<circle cx="23" 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="19" cy="21" r="1.5" fill="white"/>
<circle cx="23" 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="19" cy="25" r="1.5" fill="white"/>
<circle cx="23" cy="25" r="1.5" fill="white"/> <circle cx="23" cy="25" r="1.5" fill="white"/>
</svg> </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 # Placeholder for og-image.png
# Replace public/static/og-image.png with actual 1200×630 branded image before production # 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 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. 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 # QRMaster Growth System
Use this skill only for `qrmaster.net` work. Use this skill only for `qrmaster.net` work.
## Goal ## Goal
Turn QRMaster from a generic QR tool site into a measurable SaaS growth system built around dynamic, trackable QR workflows. Turn QRMaster from a generic QR tool site into a measurable SaaS growth system built around dynamic, trackable QR workflows.
## Default context ## Default context
- Domain: `qrmaster.net` - Domain: `qrmaster.net`
- Primary market: English - Primary market: English
- Product strengths: dynamic updates, scan analytics, bulk creation, branded QR codes, campaign attribution - Product strengths: dynamic updates, scan analytics, bulk creation, branded QR codes, campaign attribution
- Benchmark competitors: QRCode Monkey, Beaconstac, QR TIGER, QR Code Generator, Scanova - Benchmark competitors: QRCode Monkey, Beaconstac, QR TIGER, QR Code Generator, Scanova
## Core thesis ## Core thesis
- Do not optimize for generic "free QR" traffic first. - Do not optimize for generic "free QR" traffic first.
- Win where dynamic updates and tracking clearly matter. - Win where dynamic updates and tracking clearly matter.
- Treat use-case pages as acquisition assets, not blog filler. - 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. - Every page must map to a workflow pain, a commercial parent, and a tracked CTA.
## When to use this skill ## When to use this skill
- Planning QRMaster SEO, AEO, or GEO work - Planning QRMaster SEO, AEO, or GEO work
- Auditing the QRMaster content and route structure - Auditing the QRMaster content and route structure
- Building or improving `/use-cases`, hubs, or commercial landing pages - Building or improving `/use-cases`, hubs, or commercial landing pages
- Prioritizing new pages or existing content upgrades - Prioritizing new pages or existing content upgrades
- Designing internal links for QRMaster - Designing internal links for QRMaster
- Defining marketing events and SEO-to-signup measurement for QRMaster - Defining marketing events and SEO-to-signup measurement for QRMaster
## How to use this skill ## How to use this skill
1. Read `references/current-state-findings.md` if the current repo state is unknown. 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. 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. 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. 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. 5. If you are not inside the QRMaster repo, produce plans, specs, or content only. Do not mutate product files.
## Companion skills ## Companion skills
Use the smallest set needed: Use the smallest set needed:
- `agentic-saas-advisor`: SaaS wedge, workflow, monetization, 30/60/90 sequencing - `agentic-saas-advisor`: SaaS wedge, workflow, monetization, 30/60/90 sequencing
- `seo-aeo-geo-expert`: SEO/AEO/GEO detail and content shaping - `seo-aeo-geo-expert`: SEO/AEO/GEO detail and content shaping
- `keyword-research`: cluster design and SERP-driven prioritization - `keyword-research`: cluster design and SERP-driven prioritization
- `conversion`: page-level conversion flow and CTA decisions - `conversion`: page-level conversion flow and CTA decisions
- `analytics-tracking`: event taxonomy and measurement QA - `analytics-tracking`: event taxonomy and measurement QA
- `site-architecture`: hub, URL, and internal-link structure - `site-architecture`: hub, URL, and internal-link structure
## Output contract ## Output contract
Return outputs in this order when doing planning work: Return outputs in this order when doing planning work:
1. `TL;DR` 1. `TL;DR`
2. `Sub-Niche Thesis` 2. `Sub-Niche Thesis`
3. `QRMaster Baseline Audit` 3. `QRMaster Baseline Audit`
4. `Competitor Gap Analysis` 4. `Competitor Gap Analysis`
5. `SERP Pattern Summary` 5. `SERP Pattern Summary`
6. `Top 30 Page Backlog` 6. `Top 30 Page Backlog`
7. `Internal Linking Model` 7. `Internal Linking Model`
8. `Priority Scores` 8. `Priority Scores`
9. `30/60/90 Plan` 9. `30/60/90 Plan`
10. `Tracking Spec` 10. `Tracking Spec`
11. `Assumptions` 11. `Assumptions`
## Guardrails ## Guardrails
- Do not treat all QR traffic as equal; prioritize workflows with update or tracking value. - 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 add pages without a real product-fit angle.
- Do not separate SEO from signup and activation measurement. - Do not separate SEO from signup and activation measurement.
- Do not expand beyond the core wedge until the first cluster set shows repeatable ROI. - Do not expand beyond the core wedge until the first cluster set shows repeatable ROI.

View File

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

View File

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

View File

@@ -1,79 +1,79 @@
# QRMaster Top 30 Backlog # QRMaster Top 30 Backlog
This is the initial candidate backlog for the first serious QRMaster growth sprint. 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. It is not a promise to build every page immediately; it is the structured pool to score and sequence.
## 1. Commercial pages ## 1. Commercial pages
| Bucket | Page | Primary cluster | Route status | Notes | | Bucket | Page | Primary cluster | Route status | Notes |
|---|---|---|---|---| |---|---|---|---|---|
| Commercial | Dynamic QR Code Generator | dynamic qr code generator | existing | Core money page, strongest wedge fit | | 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 | 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 | 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 | 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 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 | | Commercial | QR Codes for Marketing Campaigns | qr code marketing | likely upgrade/new | Important parent for flyer/poster clusters |
## 2. Use-case pages ## 2. Use-case pages
| Cluster | Page | Primary cluster | Route status | Primary commercial parent | | 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 | 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 | | 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 | 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 | 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 | 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 | | 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 | 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 | | 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 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 | 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 | | 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 | 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 | 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 | | 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 | 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 | | 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 | | 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 | | Feedback | Feedback QR Codes | qr code for feedback collection | future | QR Code Tracking |
## 3. Support / authority pages ## 3. Support / authority pages
| Bucket | Page | Primary cluster | Route status | Notes | | Bucket | Page | Primary cluster | Route status | Notes |
|---|---|---|---|---| |---|---|---|---|---|
| Support | Use Cases Hub | qr code use cases | future | Main hub and distribution point | | 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 | 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 | 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 | 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 | 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 | | Support | Best QR Code Generator Comparison | best qr code generator 2026 | existing blog post | Comparison and alternatives support |
## Prioritization logic ## Prioritization logic
Default early `P1` pool: Default early `P1` pool:
1. Dynamic QR Code Generator 1. Dynamic QR Code Generator
2. QR Code Tracking 2. QR Code Tracking
3. Restaurant Menu QR Codes 3. Restaurant Menu QR Codes
4. Flyer QR Codes 4. Flyer QR Codes
5. Business Card QR Codes 5. Business Card QR Codes
6. Event QR Codes 6. Event QR Codes
7. Packaging QR Codes 7. Packaging QR Codes
8. Use Cases Hub 8. Use Cases Hub
Likely early `P2` pool: Likely early `P2` pool:
- Bulk QR Code Generator - Bulk QR Code Generator
- QR Code Analytics - QR Code Analytics
- vCard QR Codes - vCard QR Codes
- UTM Parameters with QR Codes - UTM Parameters with QR Codes
- Trackable QR Codes - Trackable QR Codes
- Small Business QR Codes - Small Business QR Codes
- Real Estate Sign QR Codes - Real Estate Sign QR Codes
Likely `P3` until proof improves: Likely `P3` until proof improves:
- low-intent vanity generators - low-intent vanity generators
- generic "free QR" comparison pages without wedge fit - generic "free QR" comparison pages without wedge fit
- pages with weak product proof or unclear CTA path - pages with weak product proof or unclear CTA path

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,20 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const filePath = path.join(__dirname, '../src/lib/blog-data.ts'); const filePath = path.join(__dirname, '../src/lib/blog-data.ts');
let content = fs.readFileSync(filePath, 'utf-8'); let content = fs.readFileSync(filePath, 'utf-8');
// Remove the draft note from qr-code-scan-statistics-2026 // 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 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; const originalLength = content.length;
content = content.replace(draftNotePattern, ''); content = content.replace(draftNotePattern, '');
const newLength = content.length; const newLength = content.length;
fs.writeFileSync(filePath, content, 'utf-8'); fs.writeFileSync(filePath, content, 'utf-8');
if (originalLength > newLength) { if (originalLength > newLength) {
console.log(`✅ Removed draft note from qr-code-scan-statistics-2026 (${originalLength - newLength} bytes deleted)`); console.log(`✅ Removed draft note from qr-code-scan-statistics-2026 (${originalLength - newLength} bytes deleted)`);
} else { } else {
console.log('⚠️ Draft note not found or already removed'); console.log('⚠️ Draft note not found or already removed');
} }

View File

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

View File

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

View File

@@ -1,100 +1,100 @@
# QR Master SEO Analysis Report # QR Master SEO Analysis Report
**Domain:** www.qrmaster.net **Domain:** www.qrmaster.net
**Date:** January 5, 2026 **Date:** January 5, 2026
--- ---
## Executive Summary ## Executive Summary
| Metric | Current | Target | | Metric | Current | Target |
|--------|---------|--------| |--------|---------|--------|
| Domain Rating (DR) | 0 | 20+ | | Domain Rating (DR) | 0 | 20+ |
| Backlinks | 0 | 50+ | | Backlinks | 0 | 50+ |
| OnPage Score | 67% | 90%+ | | OnPage Score | 67% | 90%+ |
| Organic Keywords | 0 | 50+ | | Organic Keywords | 0 | 50+ |
--- ---
## ✅ What's Working Well ## ✅ What's Working Well
- **Meta-Angaben:** 100% ✓ (Title, Description, Canonical) - **Meta-Angaben:** 100% ✓ (Title, Description, Canonical)
- **Mobile Optimization:** Viewport + Apple Touch Icon ✓ - **Mobile Optimization:** Viewport + Apple Touch Icon ✓
- **HTTPS:** Fully implemented ✓ - **HTTPS:** Fully implemented ✓
- **Doctype & Encoding:** Correct ✓ - **Doctype & Encoding:** Correct ✓
- **Server Configuration:** 90% ✓ (redirects, compression) - **Server Configuration:** 90% ✓ (redirects, compression)
--- ---
## 🔴 Critical Issues (Fix Immediately) ## 🔴 Critical Issues (Fix Immediately)
### 1. Missing H1 & Content ### 1. Missing H1 & Content
- **Problem:** "0 words" detected on homepage - **Problem:** "0 words" detected on homepage
- **Cause:** Client-side rendering not visible to crawlers - **Cause:** Client-side rendering not visible to crawlers
- **Status:** ✅ FIXED - Added server-side SEO content block - **Status:** ✅ FIXED - Added server-side SEO content block
### 2. No Internal Links ### 2. No Internal Links
- **Problem:** Homepage appears as landing page with few links - **Problem:** Homepage appears as landing page with few links
- **Solution:** Blog posts now include internal links to key pages - **Solution:** Blog posts now include internal links to key pages
### 3. X-Powered-By Header ### 3. X-Powered-By Header
- **Problem:** Exposes tech stack - **Problem:** Exposes tech stack
- **Status:** ✅ FIXED - Added `poweredByHeader: false` to next.config - **Status:** ✅ FIXED - Added `poweredByHeader: false` to next.config
### 4. Zero Backlinks ### 4. Zero Backlinks
- **Problem:** No external links pointing to domain - **Problem:** No external links pointing to domain
- **Solution:** Submit to directories, create Claude artifacts - **Solution:** Submit to directories, create Claude artifacts
--- ---
## Keyword Opportunities ## Keyword Opportunities
### High Priority (Low/Medium Difficulty) ### High Priority (Low/Medium Difficulty)
| Keyword | KD | Volume | Action | | Keyword | KD | Volume | Action |
|---------|-----|--------|--------| |---------|-----|--------|--------|
| qr code tracking | 4 (Easy) | ~1.7K | ✅ Existing blog post | | qr code tracking | 4 (Easy) | ~1.7K | ✅ Existing blog post |
| qr code for restaurant menu | 44 (Hard) | ~100+ | ✅ NEW blog post | | qr code for restaurant menu | 44 (Hard) | ~100+ | ✅ NEW blog post |
| vcard qr code generator | 47 (Hard) | ~100+ | ✅ NEW blog post | | vcard qr code generator | 47 (Hard) | ~100+ | ✅ NEW blog post |
| bulk qr code generator | 54 (Hard) | ~795 | ✅ Existing page | | bulk qr code generator | 54 (Hard) | ~795 | ✅ Existing page |
### Avoid (Too Competitive) ### Avoid (Too Competitive)
| Keyword | KD | Required Backlinks | | Keyword | KD | Required Backlinks |
|---------|-----|-------------------| |---------|-----|-------------------|
| qr code generator | 94 | ~1,197 | | qr code generator | 94 | ~1,197 |
| dynamic qr code generator | 85 | ~488 | | dynamic qr code generator | 85 | ~488 |
--- ---
## Competitor Analysis (Top 3) ## Competitor Analysis (Top 3)
| Rank | Domain | DR | Backlinks | Traffic | | Rank | Domain | DR | Backlinks | Traffic |
|------|--------|-----|-----------|---------| |------|--------|-----|-----------|---------|
| 1 | qr-code-generator.com | 83 | 67K | 986K | | 1 | qr-code-generator.com | 83 | 67K | 986K |
| 2 | canva.com/qr | 93 | 7.4K | 433K | | 2 | canva.com/qr | 93 | 7.4K | 433K |
| 3 | adobe.com/express/qr | 96 | 13K | 317K | | 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. **Takeaway:** Focus on long-tail keywords and niche content. Direct competition for head terms is not viable without 100+ quality backlinks.
--- ---
## Action Plan ## Action Plan
### Phase 1: Technical (Completed ✅) ### Phase 1: Technical (Completed ✅)
- [x] Add server-side H1 to homepage - [x] Add server-side H1 to homepage
- [x] Remove X-Powered-By header - [x] Remove X-Powered-By header
- [x] Add 4 new blog posts - [x] Add 4 new blog posts
### Phase 2: Backlinks (Your Action Required) ### Phase 2: Backlinks (Your Action Required)
- [ ] Submit to Product Hunt - [ ] Submit to Product Hunt
- [ ] Submit to AlternativeTo - [ ] Submit to AlternativeTo
- [ ] Submit to SaaSHub - [ ] Submit to SaaSHub
- [ ] Create Claude artifacts with backlinks - [ ] Create Claude artifacts with backlinks
### Phase 3: Monitoring ### Phase 3: Monitoring
- [ ] Re-run SEO audit in 2 weeks - [ ] Re-run SEO audit in 2 weeks
- [ ] Check GSC for indexed pages - [ ] Check GSC for indexed pages
- [ ] Monitor keyword rankings monthly - [ ] Monitor keyword rankings monthly
--- ---
## Source Data ## Source Data
Raw data from: Seobility SEO Check, Ahrefs Free Tools, SpyFu 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'; 'use client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { Dialog } from '@/components/ui/Dialog'; import { Dialog } from '@/components/ui/Dialog';
import { useTranslation } from '@/hooks/useTranslation'; import { useTranslation } from '@/hooks/useTranslation';
interface Integration { interface Integration {
id: string; id: string;
name: string; name: string;
description: string; description: string;
icon: string; icon: string;
status: 'active' | 'inactive' | 'coming_soon'; status: 'active' | 'inactive' | 'coming_soon';
category: string; category: string;
features: string[]; features: string[];
} }
export default function IntegrationsPage() { export default function IntegrationsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const [selectedIntegration, setSelectedIntegration] = useState<Integration | null>(null); const [selectedIntegration, setSelectedIntegration] = useState<Integration | null>(null);
const [showSetupDialog, setShowSetupDialog] = useState(false); const [showSetupDialog, setShowSetupDialog] = useState(false);
const [apiKey, setApiKey] = useState(''); const [apiKey, setApiKey] = useState('');
const [webhookUrl, setWebhookUrl] = useState(''); const [webhookUrl, setWebhookUrl] = useState('');
const integrations: Integration[] = [ const integrations: Integration[] = [
{ {
id: 'zapier', id: 'zapier',
name: 'Zapier', name: 'Zapier',
description: 'Connect QR Master with 5,000+ apps', description: 'Connect QR Master with 5,000+ apps',
icon: '⚡', icon: '⚡',
status: 'active', status: 'active',
category: 'Automation', category: 'Automation',
features: [ features: [
'Trigger actions when QR codes are scanned', 'Trigger actions when QR codes are scanned',
'Create QR codes from other apps', 'Create QR codes from other apps',
'Update QR destinations automatically', 'Update QR destinations automatically',
'Sync analytics to spreadsheets', 'Sync analytics to spreadsheets',
], ],
}, },
{ {
id: 'airtable', id: 'airtable',
name: 'Airtable', name: 'Airtable',
description: 'Sync QR codes with your Airtable bases', description: 'Sync QR codes with your Airtable bases',
icon: '📊', icon: '📊',
status: 'inactive', status: 'inactive',
category: 'Database', category: 'Database',
features: [ features: [
'Two-way sync with Airtable', 'Two-way sync with Airtable',
'Bulk import from bases', 'Bulk import from bases',
'Auto-update QR content', 'Auto-update QR content',
'Analytics dashboard integration', 'Analytics dashboard integration',
], ],
}, },
{ {
id: 'google-sheets', id: 'google-sheets',
name: 'Google Sheets', name: 'Google Sheets',
description: 'Manage QR codes from spreadsheets', description: 'Manage QR codes from spreadsheets',
icon: '📈', icon: '📈',
status: 'inactive', status: 'inactive',
category: 'Spreadsheet', category: 'Spreadsheet',
features: [ features: [
'Import QR codes from sheets', 'Import QR codes from sheets',
'Export analytics data', 'Export analytics data',
'Real-time sync', 'Real-time sync',
'Collaborative QR management', 'Collaborative QR management',
], ],
}, },
{ {
id: 'slack', id: 'slack',
name: 'Slack', name: 'Slack',
description: 'Get QR scan notifications in Slack', description: 'Get QR scan notifications in Slack',
icon: '💬', icon: '💬',
status: 'coming_soon', status: 'coming_soon',
category: 'Communication', category: 'Communication',
features: [ features: [
'Real-time scan notifications', 'Real-time scan notifications',
'Daily analytics summaries', 'Daily analytics summaries',
'Team collaboration', 'Team collaboration',
'Custom alert rules', 'Custom alert rules',
], ],
}, },
{ {
id: 'webhook', id: 'webhook',
name: 'Webhooks', name: 'Webhooks',
description: 'Send data to any URL', description: 'Send data to any URL',
icon: '🔗', icon: '🔗',
status: 'active', status: 'active',
category: 'Developer', category: 'Developer',
features: [ features: [
'Custom webhook endpoints', 'Custom webhook endpoints',
'Real-time event streaming', 'Real-time event streaming',
'Retry logic', 'Retry logic',
'Event filtering', 'Event filtering',
], ],
}, },
{ {
id: 'api', id: 'api',
name: 'REST API', name: 'REST API',
description: 'Full programmatic access', description: 'Full programmatic access',
icon: '🔧', icon: '🔧',
status: 'active', status: 'active',
category: 'Developer', category: 'Developer',
features: [ features: [
'Complete CRUD operations', 'Complete CRUD operations',
'Bulk operations', 'Bulk operations',
'Analytics API', 'Analytics API',
'Rate limiting: 1000 req/hour', 'Rate limiting: 1000 req/hour',
], ],
}, },
]; ];
const stats = { const stats = {
totalQRCodes: 234, totalQRCodes: 234,
activeIntegrations: 2, activeIntegrations: 2,
syncStatus: 'Synced', syncStatus: 'Synced',
availableServices: 6, availableServices: 6,
}; };
const handleActivate = (integration: Integration) => { const handleActivate = (integration: Integration) => {
setSelectedIntegration(integration); setSelectedIntegration(integration);
setShowSetupDialog(true); setShowSetupDialog(true);
}; };
const handleTestConnection = async () => { const handleTestConnection = async () => {
// Simulate API test // Simulate API test
await new Promise(resolve => setTimeout(resolve, 1000)); await new Promise(resolve => setTimeout(resolve, 1000));
alert('Connection successful!'); alert('Connection successful!');
}; };
const handleSaveIntegration = () => { const handleSaveIntegration = () => {
setShowSetupDialog(false); setShowSetupDialog(false);
// Update integration status // Update integration status
}; };
return ( return (
<div className="space-y-8"> <div className="space-y-8">
{/* Header */} {/* Header */}
<div> <div>
<h1 className="text-3xl font-bold text-gray-900">{t('integrations.title')}</h1> <h1 className="text-3xl font-bold text-gray-900">{t('integrations.title')}</h1>
<p className="text-gray-600 mt-2">{t('integrations.subtitle')}</p> <p className="text-gray-600 mt-2">{t('integrations.subtitle')}</p>
</div> </div>
{/* Stats */} {/* Stats */}
<div className="grid md:grid-cols-4 gap-6"> <div className="grid md:grid-cols-4 gap-6">
<Card> <Card>
<CardContent className="p-6"> <CardContent className="p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-gray-600 mb-1">QR Codes Total</p> <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> <p className="text-2xl font-bold text-gray-900">{stats.totalQRCodes}</p>
</div> </div>
<div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center"> <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"> <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" /> <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> </svg>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="p-6"> <CardContent className="p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-gray-600 mb-1">Active Integrations</p> <p className="text-sm text-gray-600 mb-1">Active Integrations</p>
<p className="text-2xl font-bold text-gray-900">{stats.activeIntegrations}</p> <p className="text-2xl font-bold text-gray-900">{stats.activeIntegrations}</p>
</div> </div>
<div className="w-12 h-12 bg-success-100 rounded-lg flex items-center justify-center"> <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"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg> </svg>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="p-6"> <CardContent className="p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-gray-600 mb-1">Sync Status</p> <p className="text-sm text-gray-600 mb-1">Sync Status</p>
<p className="text-2xl font-bold text-gray-900">{stats.syncStatus}</p> <p className="text-2xl font-bold text-gray-900">{stats.syncStatus}</p>
</div> </div>
<div className="w-12 h-12 bg-info-100 rounded-lg flex items-center justify-center"> <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"> <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" /> <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> </svg>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="p-6"> <CardContent className="p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-gray-600 mb-1">Available Services</p> <p className="text-sm text-gray-600 mb-1">Available Services</p>
<p className="text-2xl font-bold text-gray-900">{stats.availableServices}</p> <p className="text-2xl font-bold text-gray-900">{stats.availableServices}</p>
</div> </div>
<div className="w-12 h-12 bg-warning-100 rounded-lg flex items-center justify-center"> <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"> <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" /> <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> </svg>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Integration Cards */} {/* Integration Cards */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{integrations.map((integration) => ( {integrations.map((integration) => (
<Card key={integration.id} className="hover:shadow-lg transition-shadow"> <Card key={integration.id} className="hover:shadow-lg transition-shadow">
<CardHeader> <CardHeader>
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="text-3xl">{integration.icon}</div> <div className="text-3xl">{integration.icon}</div>
<div> <div>
<CardTitle className="text-lg">{integration.name}</CardTitle> <CardTitle className="text-lg">{integration.name}</CardTitle>
<Badge <Badge
variant={ variant={
integration.status === 'active' ? 'success' : integration.status === 'active' ? 'success' :
integration.status === 'coming_soon' ? 'warning' : integration.status === 'coming_soon' ? 'warning' :
'default' 'default'
} }
className="mt-1" className="mt-1"
> >
{integration.status === 'active' ? 'Active' : {integration.status === 'active' ? 'Active' :
integration.status === 'coming_soon' ? 'Coming Soon' : integration.status === 'coming_soon' ? 'Coming Soon' :
'Inactive'} 'Inactive'}
</Badge> </Badge>
</div> </div>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm text-gray-600 mb-4">{integration.description}</p> <p className="text-sm text-gray-600 mb-4">{integration.description}</p>
<div className="space-y-2 mb-4"> <div className="space-y-2 mb-4">
{integration.features.slice(0, 3).map((feature, index) => ( {integration.features.slice(0, 3).map((feature, index) => (
<div key={index} className="flex items-start space-x-2"> <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"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg> </svg>
<span className="text-sm text-gray-700">{feature}</span> <span className="text-sm text-gray-700">{feature}</span>
</div> </div>
))} ))}
</div> </div>
{integration.status === 'active' ? ( {integration.status === 'active' ? (
<Button variant="outline" className="w-full"> <Button variant="outline" className="w-full">
Configure Configure
</Button> </Button>
) : integration.status === 'coming_soon' ? ( ) : integration.status === 'coming_soon' ? (
<Button variant="outline" className="w-full" disabled> <Button variant="outline" className="w-full" disabled>
Coming Soon Coming Soon
</Button> </Button>
) : ( ) : (
<Button className="w-full" onClick={() => handleActivate(integration)}> <Button className="w-full" onClick={() => handleActivate(integration)}>
Activate & Configure Activate & Configure
</Button> </Button>
)} )}
</CardContent> </CardContent>
</Card> </Card>
))} ))}
</div> </div>
{/* Setup Dialog */} {/* Setup Dialog */}
{showSetupDialog && selectedIntegration && ( {showSetupDialog && selectedIntegration && (
<Dialog <Dialog
open={showSetupDialog} open={showSetupDialog}
onOpenChange={setShowSetupDialog} onOpenChange={setShowSetupDialog}
> >
<div className="bg-white rounded-xl shadow-lg border border-gray-200 p-6 max-w-lg mx-auto"> <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> <h2 className="text-lg font-semibold mb-4">Setup {selectedIntegration.name}</h2>
<div className="space-y-4"> <div className="space-y-4">
{selectedIntegration.id === 'zapier' && ( {selectedIntegration.id === 'zapier' && (
<> <>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Webhook URL Webhook URL
</label> </label>
<Input <Input
value="https://hooks.zapier.com/hooks/catch/123456/abcdef/" value="https://hooks.zapier.com/hooks/catch/123456/abcdef/"
readOnly readOnly
className="font-mono text-sm" className="font-mono text-sm"
/> />
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-gray-500 mt-1">
Copy this URL to your Zapier trigger Copy this URL to your Zapier trigger
</p> </p>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Events to Send Events to Send
</label> </label>
<div className="space-y-2"> <div className="space-y-2">
<label className="flex items-center"> <label className="flex items-center">
<input type="checkbox" className="mr-2" defaultChecked /> <input type="checkbox" className="mr-2" defaultChecked />
<span className="text-sm">QR Code Scanned</span> <span className="text-sm">QR Code Scanned</span>
</label> </label>
<label className="flex items-center"> <label className="flex items-center">
<input type="checkbox" className="mr-2" defaultChecked /> <input type="checkbox" className="mr-2" defaultChecked />
<span className="text-sm">QR Code Created</span> <span className="text-sm">QR Code Created</span>
</label> </label>
<label className="flex items-center"> <label className="flex items-center">
<input type="checkbox" className="mr-2" /> <input type="checkbox" className="mr-2" />
<span className="text-sm">QR Code Updated</span> <span className="text-sm">QR Code Updated</span>
</label> </label>
</div> </div>
</div> </div>
<div className="p-4 bg-gray-50 rounded-lg"> <div className="p-4 bg-gray-50 rounded-lg">
<h4 className="font-medium text-gray-900 mb-2">Sample Payload</h4> <h4 className="font-medium text-gray-900 mb-2">Sample Payload</h4>
<pre className="text-xs text-gray-600 overflow-x-auto"> <pre className="text-xs text-gray-600 overflow-x-auto">
{`{ {`{
"event": "qr_scanned", "event": "qr_scanned",
"qr_id": "abc123", "qr_id": "abc123",
"title": "Product Page", "title": "Product Page",
"timestamp": "2025-01-01T12:00:00Z", "timestamp": "2025-01-01T12:00:00Z",
"location": "United States", "location": "United States",
"device": "mobile" "device": "mobile"
}`} }`}
</pre> </pre>
</div> </div>
</> </>
)} )}
{selectedIntegration.id === 'airtable' && ( {selectedIntegration.id === 'airtable' && (
<> <>
<Input <Input
label="API Key" label="API Key"
type="password" type="password"
value={apiKey} value={apiKey}
onChange={(e) => setApiKey(e.target.value)} onChange={(e) => setApiKey(e.target.value)}
placeholder="key..." placeholder="key..."
/> />
<Input <Input
label="Base ID" label="Base ID"
value="" value=""
placeholder="app..." placeholder="app..."
/> />
<Input <Input
label="Table Name" label="Table Name"
value="" value=""
placeholder="QR Codes" placeholder="QR Codes"
/> />
<Button variant="outline" onClick={handleTestConnection}> <Button variant="outline" onClick={handleTestConnection}>
Test Connection Test Connection
</Button> </Button>
</> </>
)} )}
{selectedIntegration.id === 'google-sheets' && ( {selectedIntegration.id === 'google-sheets' && (
<> <>
<div className="text-center p-6"> <div className="text-center p-6">
<Button> <Button>
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24"> <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="#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="#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="#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" /> <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> </svg>
Connect Google Account Connect Google Account
</Button> </Button>
</div> </div>
<Input <Input
label="Spreadsheet URL" label="Spreadsheet URL"
value="" value=""
placeholder="https://docs.google.com/spreadsheets/..." placeholder="https://docs.google.com/spreadsheets/..."
/> />
</> </>
)} )}
<div className="flex justify-end space-x-3 pt-4"> <div className="flex justify-end space-x-3 pt-4">
<Button variant="outline" onClick={() => setShowSetupDialog(false)}> <Button variant="outline" onClick={() => setShowSetupDialog(false)}>
Cancel Cancel
</Button> </Button>
<Button onClick={handleSaveIntegration}> <Button onClick={handleSaveIntegration}>
Save Integration Save Integration
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
</Dialog> </Dialog>
)} )}
</div> </div>
); );
} }

View File

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

View File

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

View File

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

View File

@@ -1,145 +1,145 @@
import { getPublishedPostBySlug } from '@/lib/content'; import { getPublishedPostBySlug } from '@/lib/content';
import sanitizeHtml from 'sanitize-html'; import sanitizeHtml from 'sanitize-html';
const RAW_ENABLED_SLUGS = new Set([ const RAW_ENABLED_SLUGS = new Set([
'dynamic-vs-static-qr-codes', 'dynamic-vs-static-qr-codes',
'qr-code-small-business', 'qr-code-small-business',
'qr-code-tracking-guide-2025', 'qr-code-tracking-guide-2025',
'utm-parameter-qr-codes', 'utm-parameter-qr-codes',
'trackable-qr-codes', 'trackable-qr-codes',
]); ]);
function decodeHtmlEntities(text: string): string { function decodeHtmlEntities(text: string): string {
return text return text
.replace(/&nbsp;/g, ' ') .replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&') .replace(/&amp;/g, '&')
.replace(/&quot;/g, '"') .replace(/&quot;/g, '"')
.replace(/&#39;/g, "'") .replace(/&#39;/g, "'")
.replace(/&lt;/g, '<') .replace(/&lt;/g, '<')
.replace(/&gt;/g, '>') .replace(/&gt;/g, '>')
.replace(/&mdash;/g, '--') .replace(/&mdash;/g, '--')
.replace(/&ndash;/g, '-') .replace(/&ndash;/g, '-')
.replace(/&hellip;/g, '...') .replace(/&hellip;/g, '...')
.replace(/&#x27;/g, "'") .replace(/&#x27;/g, "'")
.replace(/&#x2F;/g, '/') .replace(/&#x2F;/g, '/')
.replace(/&#(\d+);/g, (_, code) => { .replace(/&#(\d+);/g, (_, code) => {
const value = Number.parseInt(code, 10); const value = Number.parseInt(code, 10);
return Number.isNaN(value) ? '' : String.fromCharCode(value); return Number.isNaN(value) ? '' : String.fromCharCode(value);
}); });
} }
function cleanHtmlToText(html: string): string { function cleanHtmlToText(html: string): string {
const normalized = html const normalized = html
.replace(/<div\b[^>]*class=(['"])[^'"]*post-metadata[^'"]*\1[^>]*>[\s\S]*?<\/div>/gi, '') .replace(/<div\b[^>]*class=(['"])[^'"]*post-metadata[^'"]*\1[^>]*>[\s\S]*?<\/div>/gi, '')
.replace(/<div\b[^>]*class=(['"])[^'"]*blog-content[^'"]*\1[^>]*>/gi, '') .replace(/<div\b[^>]*class=(['"])[^'"]*blog-content[^'"]*\1[^>]*>/gi, '')
.replace(/<\/div>\s*$/i, ''); .replace(/<\/div>\s*$/i, '');
const withLinks = normalized.replace( const withLinks = normalized.replace(
/<a\b[^>]*href=(['"])(.*?)\1[^>]*>([\s\S]*?)<\/a>/gi, /<a\b[^>]*href=(['"])(.*?)\1[^>]*>([\s\S]*?)<\/a>/gi,
(_, __, href: string, text: string) => `[${cleanHtmlToText(text)}](${href})`, (_, __, href: string, text: string) => `[${cleanHtmlToText(text)}](${href})`,
); );
const structured = withLinks const structured = withLinks
.replace(/<br\s*\/?>/gi, '\n') .replace(/<br\s*\/?>/gi, '\n')
.replace(/<li\b[^>]*>/gi, '- ') .replace(/<li\b[^>]*>/gi, '- ')
.replace(/<\/li>/gi, '\n') .replace(/<\/li>/gi, '\n')
.replace(/<h([1-6])\b[^>]*>/gi, (_, level: string) => `${'#'.repeat(Number.parseInt(level, 10))} `) .replace(/<h([1-6])\b[^>]*>/gi, (_, level: string) => `${'#'.repeat(Number.parseInt(level, 10))} `)
.replace(/<\/h[1-6]>/gi, '\n\n') .replace(/<\/h[1-6]>/gi, '\n\n')
.replace(/<\/p>/gi, '\n\n') .replace(/<\/p>/gi, '\n\n')
.replace(/<\/div>/gi, '\n\n') .replace(/<\/div>/gi, '\n\n')
.replace(/<\/section>/gi, '\n\n') .replace(/<\/section>/gi, '\n\n')
.replace(/<\/ul>/gi, '\n') .replace(/<\/ul>/gi, '\n')
.replace(/<\/ol>/gi, '\n'); .replace(/<\/ol>/gi, '\n');
const stripped = sanitizeHtml(structured, { const stripped = sanitizeHtml(structured, {
allowedTags: [], allowedTags: [],
allowedAttributes: {}, allowedAttributes: {},
}); });
return decodeHtmlEntities(stripped) return decodeHtmlEntities(stripped)
.replace(/\r\n/g, '\n') .replace(/\r\n/g, '\n')
.replace(/\n{3,}/g, '\n\n') .replace(/\n{3,}/g, '\n\n')
.replace(/[ \t]+\n/g, '\n') .replace(/[ \t]+\n/g, '\n')
.replace(/\n[ \t]+/g, '\n') .replace(/\n[ \t]+/g, '\n')
.trim(); .trim();
} }
function renderRawPost(slug: string): string | null { function renderRawPost(slug: string): string | null {
if (!RAW_ENABLED_SLUGS.has(slug)) { if (!RAW_ENABLED_SLUGS.has(slug)) {
return null; return null;
} }
const post = getPublishedPostBySlug(slug); const post = getPublishedPostBySlug(slug);
if (!post) { if (!post) {
return null; return null;
} }
const sections: string[] = [ const sections: string[] = [
`# ${post.title}`, `# ${post.title}`,
'', '',
post.description, post.description,
'', '',
`Canonical URL: https://www.qrmaster.net/blog/${post.slug}`, `Canonical URL: https://www.qrmaster.net/blog/${post.slug}`,
`Published: ${post.datePublished}`, `Published: ${post.datePublished}`,
`Updated: ${post.dateModified || post.updatedAt || post.datePublished}`, `Updated: ${post.dateModified || post.updatedAt || post.datePublished}`,
]; ];
if (post.quickAnswer) { if (post.quickAnswer) {
sections.push('', '## Quick Answer', '', cleanHtmlToText(post.quickAnswer)); sections.push('', '## Quick Answer', '', cleanHtmlToText(post.quickAnswer));
} }
if (post.keySteps?.length) { if (post.keySteps?.length) {
sections.push('', '## Steps', '', ...post.keySteps.map((step, index) => `${index + 1}. ${step}`)); sections.push('', '## Steps', '', ...post.keySteps.map((step, index) => `${index + 1}. ${step}`));
} }
const mainText = cleanHtmlToText(post.content); const mainText = cleanHtmlToText(post.content);
if (mainText) { if (mainText) {
sections.push('', '## Article', '', mainText); sections.push('', '## Article', '', mainText);
} }
if (post.faq?.length) { if (post.faq?.length) {
sections.push('', '## FAQ', ''); sections.push('', '## FAQ', '');
for (const item of post.faq) { for (const item of post.faq) {
sections.push(`Q: ${cleanHtmlToText(item.question)}`); sections.push(`Q: ${cleanHtmlToText(item.question)}`);
sections.push(`A: ${cleanHtmlToText(item.answer)}`, ''); sections.push(`A: ${cleanHtmlToText(item.answer)}`, '');
} }
if (sections[sections.length - 1] === '') { if (sections[sections.length - 1] === '') {
sections.pop(); sections.pop();
} }
} }
if (post.sources?.length) { if (post.sources?.length) {
sections.push('', '## Sources', ''); sections.push('', '## Sources', '');
for (const source of post.sources) { for (const source of post.sources) {
const accessDate = source.accessDate ? ` (accessed ${source.accessDate})` : ''; const accessDate = source.accessDate ? ` (accessed ${source.accessDate})` : '';
sections.push(`- ${source.name}: ${source.url}${accessDate}`); sections.push(`- ${source.name}: ${source.url}${accessDate}`);
} }
} }
return `${sections.join('\n').trim()}\n`; return `${sections.join('\n').trim()}\n`;
} }
export async function GET( export async function GET(
_request: Request, _request: Request,
{ params }: { params: { slug: string } }, { params }: { params: { slug: string } },
) { ) {
const content = renderRawPost(params.slug); const content = renderRawPost(params.slug);
if (!content) { if (!content) {
return new Response('Not Found', { return new Response('Not Found', {
status: 404, status: 404,
headers: { headers: {
'Content-Type': 'text/plain; charset=utf-8', 'Content-Type': 'text/plain; charset=utf-8',
'X-Robots-Tag': 'noindex, nofollow', 'X-Robots-Tag': 'noindex, nofollow',
}, },
}); });
} }
return new Response(content, { return new Response(content, {
headers: { headers: {
'Content-Type': 'text/markdown; charset=utf-8', 'Content-Type': 'text/markdown; charset=utf-8',
'X-Robots-Tag': 'noindex, nofollow', 'X-Robots-Tag': 'noindex, nofollow',
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400', '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 React from 'react';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import SeoJsonLd from '@/components/SeoJsonLd'; import SeoJsonLd from '@/components/SeoJsonLd';
import { faqPageSchema } from '@/lib/schema'; import { faqPageSchema } from '@/lib/schema';
import { Card, CardContent } from '@/components/ui/Card'; import { Card, CardContent } from '@/components/ui/Card';
import { ObfuscatedMailto } from '@/components/ui/ObfuscatedMailto'; import { ObfuscatedMailto } from '@/components/ui/ObfuscatedMailto';
function truncateAtWord(text: string, maxLength: number): string { function truncateAtWord(text: string, maxLength: number): string {
if (text.length <= maxLength) return text; if (text.length <= maxLength) return text;
const truncated = text.slice(0, maxLength); const truncated = text.slice(0, maxLength);
const lastSpace = truncated.lastIndexOf(' '); const lastSpace = truncated.lastIndexOf(' ');
return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated; return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated;
} }
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const title = truncateAtWord('QR Master FAQ: Dynamic, Tracking, Bulk, and Print', 60); const title = truncateAtWord('QR Master FAQ: Dynamic, Tracking, Bulk, and Print', 60);
const description = truncateAtWord( const description = truncateAtWord(
'Answers about dynamic QR codes, scan tracking, privacy, bulk creation, and print setup.', 'Answers about dynamic QR codes, scan tracking, privacy, bulk creation, and print setup.',
160 160
); );
return { return {
title, title,
description, description,
alternates: { alternates: {
canonical: 'https://www.qrmaster.net/faq', canonical: 'https://www.qrmaster.net/faq',
languages: { languages: {
'x-default': 'https://www.qrmaster.net/faq', 'x-default': 'https://www.qrmaster.net/faq',
en: 'https://www.qrmaster.net/faq', en: 'https://www.qrmaster.net/faq',
}, },
}, },
openGraph: { openGraph: {
title, title,
description, description,
url: 'https://www.qrmaster.net/faq', url: 'https://www.qrmaster.net/faq',
type: 'website', type: 'website',
images: [ images: [
{ {
url: 'https://www.qrmaster.net/og-image.png', url: 'https://www.qrmaster.net/og-image.png',
width: 1200, width: 1200,
height: 630, height: 630,
alt: 'QR Master FAQ', alt: 'QR Master FAQ',
}, },
], ],
}, },
twitter: { twitter: {
title, title,
description, description,
}, },
}; };
} }
type FAQItemWithRichText = { type FAQItemWithRichText = {
question: string; question: string;
answer: string; answer: string;
answerRich?: React.ReactNode; answerRich?: React.ReactNode;
}; };
const faqs: FAQItemWithRichText[] = [ const faqs: FAQItemWithRichText[] = [
{ {
question: 'What is a dynamic QR code?', question: 'What is a dynamic QR code?',
answer: answer:
'A dynamic QR code points to a redirect URL, so you can change the final destination later without replacing the printed QR image.', 'A dynamic QR code points to a redirect URL, so you can change the final destination later without replacing the printed QR image.',
answerRich: ( answerRich: (
<> <>
A dynamic QR code points to a redirect URL, so you can change the final destination later without replacing the printed QR image. 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 />
<br /> <br />
<strong>Why teams use it:</strong> <strong>Why teams use it:</strong>
<ul className="mt-2 list-disc space-y-1 pl-5"> <ul className="mt-2 list-disc space-y-1 pl-5">
<li>Update the destination after print</li> <li>Update the destination after print</li>
<li>Review scan analytics later</li> <li>Review scan analytics later</li>
<li>Keep one printed QR in use across changing campaigns or content</li> <li>Keep one printed QR in use across changing campaigns or content</li>
</ul> </ul>
</> </>
), ),
}, },
{ {
question: 'How do I track QR scans?', question: 'How do I track QR scans?',
answer: 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.', '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: ( answerRich: (
<> <>
QR Master tracks scans through the dynamic QR redirect step. QR Master tracks scans through the dynamic QR redirect step.
<br /> <br />
<br /> <br />
<strong>Current analytics context:</strong> <strong>Current analytics context:</strong>
<ul className="mt-2 list-disc space-y-1 pl-5"> <ul className="mt-2 list-disc space-y-1 pl-5">
<li>Total and unique scan reporting</li> <li>Total and unique scan reporting</li>
<li>Device type</li> <li>Device type</li>
<li>Location context</li> <li>Location context</li>
<li>Time-based scan activity</li> <li>Time-based scan activity</li>
</ul> </ul>
<br /> <br />
<Link href="/qr-code-tracking" className="font-medium text-blue-600 hover:underline"> <Link href="/qr-code-tracking" className="font-medium text-blue-600 hover:underline">
Learn more about tracking Learn more about tracking
</Link> </Link>
</> </>
), ),
}, },
{ {
question: 'What security measures are in place?', question: 'What security measures are in place?',
answer: answer:
'QR Master uses HTTPS/TLS, CSRF protection for relevant write actions, and rate limiting on API routes.', 'QR Master uses HTTPS/TLS, CSRF protection for relevant write actions, and rate limiting on API routes.',
answerRich: ( answerRich: (
<> <>
QR Master uses standard protective controls that are visible in the current codebase. QR Master uses standard protective controls that are visible in the current codebase.
<br /> <br />
<br /> <br />
<strong>Security-related controls:</strong> <strong>Security-related controls:</strong>
<ul className="mt-2 list-disc space-y-1 pl-5"> <ul className="mt-2 list-disc space-y-1 pl-5">
<li>HTTPS/TLS encryption for all connections</li> <li>HTTPS/TLS encryption for all connections</li>
<li>CSRF protection for relevant write actions</li> <li>CSRF protection for relevant write actions</li>
<li>Rate limiting on API routes</li> <li>Rate limiting on API routes</li>
</ul> </ul>
</> </>
), ),
}, },
{ {
question: 'How does bulk QR creation work today?', question: 'How does bulk QR creation work today?',
answer: 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.', '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: ( answerRich: (
<> <>
QR Master currently supports bulk QR creation through spreadsheet upload in the Business plan. QR Master currently supports bulk QR creation through spreadsheet upload in the Business plan.
<br /> <br />
<br /> <br />
<strong>Current bulk flow facts:</strong> <strong>Current bulk flow facts:</strong>
<ul className="mt-2 list-disc space-y-1 pl-5"> <ul className="mt-2 list-disc space-y-1 pl-5">
<li>CSV, XLS, and XLSX uploads are supported</li> <li>CSV, XLS, and XLSX uploads are supported</li>
<li>Up to 1,000 rows per upload</li> <li>Up to 1,000 rows per upload</li>
<li>Output is static QR codes, not dynamic tracking batches</li> <li>Output is static QR codes, not dynamic tracking batches</li>
</ul> </ul>
<br /> <br />
<Link href="/bulk-qr-code-generator" className="font-medium text-blue-600 hover:underline"> <Link href="/bulk-qr-code-generator" className="font-medium text-blue-600 hover:underline">
See the bulk QR workflow See the bulk QR workflow
</Link> </Link>
</> </>
), ),
}, },
{ {
question: 'What are the best practices for printing QR codes?', question: 'What are the best practices for printing QR codes?',
answer: 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.', '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: ( answerRich: (
<> <>
For reliable scanning, follow these print-first basics: For reliable scanning, follow these print-first basics:
<br /> <br />
<br /> <br />
<ul className="mt-2 list-disc space-y-1 pl-5"> <ul className="mt-2 list-disc space-y-1 pl-5">
<li>Minimum size around 2x2 cm for close-range scans</li> <li>Minimum size around 2x2 cm for close-range scans</li>
<li>Dark foreground on a light background</li> <li>Dark foreground on a light background</li>
<li>Leave a quiet zone around the QR code</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> <li>Use SVG or a high-resolution PNG depending on the print workflow</li>
</ul> </ul>
</> </>
), ),
}, },
{ {
question: 'Is the service privacy-conscious?', question: 'Is the service privacy-conscious?',
answer: 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.', '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: ( answerRich: (
<> <>
QR Master is built around minimal scanner data collection. QR Master is built around minimal scanner data collection.
<br /> <br />
<br /> <br />
<strong>Privacy-related measures:</strong> <strong>Privacy-related measures:</strong>
<ul className="mt-2 list-disc space-y-1 pl-5"> <ul className="mt-2 list-disc space-y-1 pl-5">
<li>IP addresses are anonymized or hashed</li> <li>IP addresses are anonymized or hashed</li>
<li>No scanner PII storage</li> <li>No scanner PII storage</li>
</ul> </ul>
<br /> <br />
<Link href="/privacy" className="font-medium text-blue-600 hover:underline"> <Link href="/privacy" className="font-medium text-blue-600 hover:underline">
Read the privacy policy Read the privacy policy
</Link> </Link>
</> </>
), ),
}, },
{ {
question: 'What is the difference between static and dynamic QR codes?', question: 'What is the difference between static and dynamic QR codes?',
answer: 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.', '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: ( answerRich: (
<> <>
Static QR codes store the destination directly in the image and stay fixed. 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. Dynamic QR codes route through QR Master so the destination can be changed later and scan analytics can be reviewed.
<br /> <br />
<br /> <br />
<Link href="/dynamic-qr-code-generator" className="font-medium text-blue-600 hover:underline"> <Link href="/dynamic-qr-code-generator" className="font-medium text-blue-600 hover:underline">
Create a dynamic QR code Create a dynamic QR code
</Link> </Link>
</> </>
), ),
}, },
]; ];
export default function FAQPage() { export default function FAQPage() {
return ( return (
<> <>
<SeoJsonLd data={faqPageSchema(faqs.map(({ question, answer }) => ({ question, answer })))} /> <SeoJsonLd data={faqPageSchema(faqs.map(({ question, answer }) => ({ question, answer })))} />
<div className="bg-gradient-to-b from-gray-50 to-white py-20"> <div className="bg-gradient-to-b from-gray-50 to-white py-20">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<div className="mx-auto max-w-4xl"> <div className="mx-auto max-w-4xl">
<div className="mb-16 text-center"> <div className="mb-16 text-center">
<h1 className="mb-6 text-4xl font-bold text-gray-900 lg:text-5xl"> <h1 className="mb-6 text-4xl font-bold text-gray-900 lg:text-5xl">
Frequently Asked Questions Frequently Asked Questions
</h1> </h1>
<p className="mb-4 text-xl text-gray-600"> <p className="mb-4 text-xl text-gray-600">
Answers about dynamic QR codes, scan tracking, privacy, bulk creation, and print setup. Answers about dynamic QR codes, scan tracking, privacy, bulk creation, and print setup.
</p> </p>
<p className="text-sm text-gray-500">Last updated: March 12, 2026</p> <p className="text-sm text-gray-500">Last updated: March 12, 2026</p>
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
{faqs.map((faq) => ( {faqs.map((faq) => (
<Card key={faq.question} className="border-l-4 border-blue-500"> <Card key={faq.question} className="border-l-4 border-blue-500">
<CardContent className="p-8"> <CardContent className="p-8">
<h2 className="mb-4 text-2xl font-semibold text-gray-900">{faq.question}</h2> <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> <div className="text-lg leading-relaxed text-gray-700">{faq.answerRich || faq.answer}</div>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
</div> </div>
<div className="mt-16 rounded-r-lg border-l-4 border-blue-500 bg-blue-50 p-8"> <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> <h2 className="mb-4 text-2xl font-bold text-gray-900">Still have questions?</h2>
<p className="text-lg leading-relaxed text-gray-700"> <p className="text-lg leading-relaxed text-gray-700">
Our support team is here to help. Contact us at{' '} Our support team is here to help. Contact us at{' '}
<ObfuscatedMailto <ObfuscatedMailto
email="support@qrmaster.net" email="support@qrmaster.net"
className="font-semibold text-blue-600 hover:text-blue-700" className="font-semibold text-blue-600 hover:text-blue-700"
/>{' '} />{' '}
and include the workflow you are trying to build. and include the workflow you are trying to build.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</> </>
); );
} }

View File

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

View File

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

View File

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

View File

@@ -1,112 +1,112 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { import {
buildUseCaseMetadata, buildUseCaseMetadata,
UseCasePageTemplate, UseCasePageTemplate,
} from "@/components/marketing/UseCasePageTemplate"; } from "@/components/marketing/UseCasePageTemplate";
export const metadata: Metadata = buildUseCaseMetadata({ export const metadata: Metadata = buildUseCaseMetadata({
title: "QR Codes for Marketing Campaigns", title: "QR Codes for Marketing Campaigns",
description: description:
"Plan QR codes for marketing campaigns around placement tracking, changing destinations, and offline-to-online attribution.", "Plan QR codes for marketing campaigns around placement tracking, changing destinations, and offline-to-online attribution.",
canonicalPath: "/qr-code-for-marketing-campaigns", canonicalPath: "/qr-code-for-marketing-campaigns",
}); });
export default function QRCodeForMarketingCampaignsPage() { export default function QRCodeForMarketingCampaignsPage() {
return ( return (
<UseCasePageTemplate <UseCasePageTemplate
title="QR Codes for Marketing Campaigns" title="QR Codes for Marketing Campaigns"
description="Plan QR codes for marketing campaigns around placement tracking, changing destinations, and offline-to-online attribution." description="Plan QR codes for marketing campaigns around placement tracking, changing destinations, and offline-to-online attribution."
eyebrow="Campaign Workflows" 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." 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" pageType="commercial"
cluster="marketing-campaigns" cluster="marketing-campaigns"
useCase="marketing-campaigns" useCase="marketing-campaigns"
breadcrumbs={[ breadcrumbs={[
{ name: "Home", url: "/" }, { name: "Home", url: "/" },
{ {
name: "QR Codes for Marketing Campaigns", name: "QR Codes for Marketing Campaigns",
url: "/qr-code-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." 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={[ whenToUse={[
"You run flyers, posters, packaging inserts, or event signage with campaign-specific CTA copy.", "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.", "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.", "Your destination may change during the life of the printed campaign.",
]} ]}
comparisonItems={[ comparisonItems={[
{ label: "Offer updates", text: "New print required", value: true }, { label: "Offer updates", text: "New print required", value: true },
{ label: "Placement attribution", text: "Often manual", value: true }, { label: "Placement attribution", text: "Often manual", value: true },
{ label: "Creative testing", text: "Hard to manage", value: true }, { label: "Creative testing", text: "Hard to manage", value: true },
]} ]}
howToSteps={[ howToSteps={[
"Create campaign QR flows around one clear action and one named placement context.", "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.", "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.", "Measure scans with a clean CTA path into signup, lead capture, or campaign landing pages.",
]} ]}
primaryCta={{ primaryCta={{
href: "/dynamic-qr-code-generator", href: "/dynamic-qr-code-generator",
label: "Create a trackable campaign QR", label: "Create a trackable campaign QR",
}} }}
secondaryCta={{ secondaryCta={{
href: "/use-cases", href: "/use-cases",
label: "Browse use-case workflows", label: "Browse use-case workflows",
}} }}
workflowTitle="What strong campaign QR workflows look like" 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." 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={[ workflowCards={[
{ {
title: "Placement-aware routing", title: "Placement-aware routing",
description: "Keep banner, flyer, packaging, and in-store placements comparable by using distinct destinations or campaign tags.", description: "Keep banner, flyer, packaging, and in-store placements comparable by using distinct destinations or campaign tags.",
}, },
{ {
title: "Post-print flexibility", 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.", 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", 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.", 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" checklistTitle="Campaign QR checklist"
checklist={[ checklist={[
"Match each QR code to one campaign purpose and one primary CTA.", "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.", "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.", "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.", "Link the campaign flow back to a measured CTA path instead of stopping at raw scan counts.",
]} ]}
supportLinks={[ supportLinks={[
{ {
href: "/qr-code-tracking", href: "/qr-code-tracking",
title: "QR Code Tracking", title: "QR Code Tracking",
description: "Use when the real priority is measuring placement and scanner context.", description: "Use when the real priority is measuring placement and scanner context.",
}, },
{ {
href: "/custom-qr-code-generator", href: "/custom-qr-code-generator",
title: "Custom QR Code Generator", title: "Custom QR Code Generator",
description: "Useful when brand fit and print creative need more control.", description: "Useful when brand fit and print creative need more control.",
}, },
{ {
href: "/blog/utm-parameter-qr-codes", href: "/blog/utm-parameter-qr-codes",
title: "UTM Parameters with QR Codes", title: "UTM Parameters with QR Codes",
description: "Support article for placement naming and campaign attribution strategy.", description: "Support article for placement naming and campaign attribution strategy.",
}, },
]} ]}
faq={[ faq={[
{ {
question: "Why use QR codes in marketing campaigns?", 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.", 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?", 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.", 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?", 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.", 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 React from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Metadata } from 'next'; import { Metadata } from 'next';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import SeoJsonLd from '@/components/SeoJsonLd'; import SeoJsonLd from '@/components/SeoJsonLd';
import { organizationSchema, reviewSchema, aggregateRatingSchema } from '@/lib/schema'; import { organizationSchema, reviewSchema, aggregateRatingSchema } from '@/lib/schema';
import { testimonials, getAggregateRating } from '@/lib/testimonial-data'; import { testimonials, getAggregateRating } from '@/lib/testimonial-data';
import { Testimonials } from '@/components/marketing/Testimonials'; import { Testimonials } from '@/components/marketing/Testimonials';
import { Star } from 'lucide-react'; import { Star } from 'lucide-react';
function truncateAtWord(text: string, maxLength: number): string { function truncateAtWord(text: string, maxLength: number): string {
if (text.length <= maxLength) return text; if (text.length <= maxLength) return text;
const truncated = text.slice(0, maxLength); const truncated = text.slice(0, maxLength);
const lastSpace = truncated.lastIndexOf(' '); const lastSpace = truncated.lastIndexOf(' ');
return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated; return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated;
} }
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const title = truncateAtWord('Customer Testimonials | QR Master Reviews', 60); const title = truncateAtWord('Customer Testimonials | QR Master Reviews', 60);
const description = truncateAtWord( 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.', 'Read what our customers say about QR Master. Real reviews from businesses using dynamic QR codes for restaurants, pottery, retail, events, and more.',
160 160
); );
return { return {
title, title,
description, description,
keywords: ['qr master reviews', 'qr code testimonials', 'customer reviews', 'qr code generator reviews', 'dynamic qr code reviews'], keywords: ['qr master reviews', 'qr code testimonials', 'customer reviews', 'qr code generator reviews', 'dynamic qr code reviews'],
alternates: { alternates: {
canonical: 'https://www.qrmaster.net/testimonials', canonical: 'https://www.qrmaster.net/testimonials',
}, },
openGraph: { openGraph: {
title, title,
description, description,
url: 'https://www.qrmaster.net/testimonials', url: 'https://www.qrmaster.net/testimonials',
type: 'website', type: 'website',
images: [ images: [
{ {
url: 'https://www.qrmaster.net/og-image.png', url: 'https://www.qrmaster.net/og-image.png',
width: 1200, width: 1200,
height: 630, height: 630,
alt: 'QR Master Customer Testimonials', alt: 'QR Master Customer Testimonials',
}, },
], ],
}, },
twitter: { twitter: {
title, title,
description, description,
}, },
}; };
} }
export default function TestimonialsPage() { export default function TestimonialsPage() {
const aggregateRating = getAggregateRating(); const aggregateRating = getAggregateRating();
const reviewSchemas = testimonials.map(t => reviewSchema(t)); const reviewSchemas = testimonials.map(t => reviewSchema(t));
return ( return (
<> <>
<SeoJsonLd data={[ <SeoJsonLd data={[
organizationSchema(), organizationSchema(),
aggregateRatingSchema(aggregateRating), aggregateRatingSchema(aggregateRating),
...reviewSchemas ...reviewSchemas
]} /> ]} />
<div className="bg-white"> <div className="bg-white">
{/* Hero Section with Aggregate Rating */} {/* 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"> <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"> <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"> <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> Customer <span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-600 to-purple-600">Testimonials</span>
</h1> </h1>
<p className="text-xl text-gray-600 max-w-2xl mx-auto mb-8 leading-relaxed"> <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 Real experiences from businesses using QR Master to create dynamic QR codes
</p> </p>
{/* Aggregate Rating Display */} {/* Aggregate Rating Display */}
<div className="flex flex-col items-center justify-center gap-3 mb-10"> <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`}> <div className="flex gap-1" aria-label={`${aggregateRating.ratingValue} out of 5 stars`}>
{[...Array(5)].map((_, index) => ( {[...Array(5)].map((_, index) => (
<Star <Star
key={index} key={index}
className={`w-8 h-8 ${index < aggregateRating.ratingValue className={`w-8 h-8 ${index < aggregateRating.ratingValue
? 'fill-yellow-400 text-yellow-400' ? 'fill-yellow-400 text-yellow-400'
: 'fill-gray-200 text-gray-200' : 'fill-gray-200 text-gray-200'
}`} }`}
/> />
))} ))}
</div> </div>
<p className="text-lg text-gray-700"> <p className="text-lg text-gray-700">
<span className="font-bold text-2xl">{aggregateRating.ratingValue}</span> out of 5 stars <span className="font-bold text-2xl">{aggregateRating.ratingValue}</span> out of 5 stars
</p> </p>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
Based on {aggregateRating.reviewCount} {aggregateRating.reviewCount === 1 ? 'review' : 'reviews'} Based on {aggregateRating.reviewCount} {aggregateRating.reviewCount === 1 ? 'review' : 'reviews'}
</p> </p>
</div> </div>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center"> <div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
<Link href="/signup"> <Link href="/signup">
<Button size="lg" className="text-lg px-8 py-6 shadow-lg shadow-blue-500/25"> <Button size="lg" className="text-lg px-8 py-6 shadow-lg shadow-blue-500/25">
Get Started Free Get Started Free
</Button> </Button>
</Link> </Link>
</div> </div>
</div> </div>
</section> </section>
{/* Testimonials Grid */} {/* Testimonials Grid */}
<Testimonials <Testimonials
testimonials={testimonials} testimonials={testimonials}
showAll={true} showAll={true}
title="What Our Customers Are Saying" title="What Our Customers Are Saying"
subtitle="Discover how businesses use QR Master for their unique needs" subtitle="Discover how businesses use QR Master for their unique needs"
/> />
{/* CTA Section */} {/* CTA Section */}
<section className="py-20 bg-gradient-to-br from-blue-50 via-white to-purple-50"> <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"> <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"> <h2 className="text-3xl sm:text-4xl font-bold text-gray-900 mb-6">
Ready to create your own QR codes? Ready to create your own QR codes?
</h2> </h2>
<p className="text-xl text-gray-600 mb-10 max-w-2xl mx-auto"> <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. Join businesses using QR Master to create dynamic, trackable QR codes for their products, menus, events, and campaigns.
</p> </p>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center"> <div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
<Link href="/signup"> <Link href="/signup">
<Button size="lg" className="text-lg px-8 py-6"> <Button size="lg" className="text-lg px-8 py-6">
Start Free Today Start Free Today
</Button> </Button>
</Link> </Link>
<Link href="/pricing"> <Link href="/pricing">
<Button variant="outline" size="lg" className="text-lg px-8 py-6"> <Button variant="outline" size="lg" className="text-lg px-8 py-6">
View Pricing View Pricing
</Button> </Button>
</Link> </Link>
</div> </div>
</div> </div>
</section> </section>
</div> </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 Link from 'next/link';
import { FAQSection } from '@/components/aeo/FAQSection';
import { BarcodeFormatPicker } from './BarcodeFormatPicker';
export function BarcodeGuide() { export function BarcodeGuide() {
return ( 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. Different barcode formats are used for different purposes. Choosing the right one is important for compatibility and scanning accuracy.
</p> </p>
<div className="grid md:grid-cols-2 gap-6 not-prose my-8"> <div className="not-prose overflow-x-auto my-8">
{/* EAN-13 Card */} <table className="w-full text-sm border-collapse rounded-xl overflow-hidden border border-slate-200">
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-shadow"> <thead>
<div className="flex items-center gap-3 mb-3"> <tr className="bg-slate-900 text-white">
<Tag className="w-5 h-5 text-blue-500" /> <th className="text-left p-3 font-semibold">Format</th>
<h4 className="text-lg font-bold text-slate-900 m-0">EAN-13</h4> <th className="text-left p-3 font-semibold">Use Case</th>
</div> <th className="text-left p-3 font-semibold">Digits / Chars</th>
<div className="text-xs font-mono bg-slate-100 inline-block px-2 py-1 rounded text-slate-500 mb-3">Retail Europe</div> <th className="text-left p-3 font-semibold">Region</th>
<div className="mb-3 bg-slate-50 rounded border border-slate-100 p-2 flex justify-center"> </tr>
<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" /> </thead>
</div> <tbody>
<p className="text-sm text-slate-600 m-0"> <tr className="border-t border-slate-100 bg-white">
EAN-13 is widely used in retail, especially in Europe. It is designed for consumer products sold in stores and supermarkets. <td className="p-3 font-bold text-blue-700">EAN-13</td>
</p> <td className="p-3 text-slate-600">Retail products, supermarkets</td>
</div> <td className="p-3 text-slate-500 font-mono text-xs">13 numeric</td>
<td className="p-3 text-slate-600">Europe / Global</td>
{/* UPC-A Card */} </tr>
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-shadow"> <tr className="border-t border-slate-100 bg-slate-50">
<div className="flex items-center gap-3 mb-3"> <td className="p-3 font-bold text-indigo-700">UPC-A</td>
<ShoppingCart className="w-5 h-5 text-indigo-500" /> <td className="p-3 text-slate-600">Retail products (North America)</td>
<h4 className="text-lg font-bold text-slate-900 m-0">UPC-A</h4> <td className="p-3 text-slate-500 font-mono text-xs">12 numeric</td>
</div> <td className="p-3 text-slate-600">USA / Canada</td>
<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> </tr>
<div className="mb-3 bg-slate-50 rounded border border-slate-100 p-2 flex justify-center"> <tr className="border-t border-slate-100 bg-white">
<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" /> <td className="p-3 font-bold text-emerald-700">Code 128</td>
</div> <td className="p-3 text-slate-600">Shipping, logistics, inventory</td>
<p className="text-sm text-slate-600 m-0"> <td className="p-3 text-slate-500 font-mono text-xs">Variable alphanumeric</td>
UPC-A is similar to EAN-13 but is mainly used in the United States and Canada for retail products. <td className="p-3 text-slate-600">Universal</td>
</p> </tr>
</div> <tr className="border-t border-slate-100 bg-slate-50">
<td className="p-3 font-bold text-orange-700">Code 39</td>
{/* Code 128 Card */} <td className="p-3 text-slate-600">Industrial, automotive, defense</td>
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-shadow"> <td className="p-3 text-slate-500 font-mono text-xs">Variable alphanumeric</td>
<div className="flex items-center gap-3 mb-3"> <td className="p-3 text-slate-600">Industrial</td>
<Settings className="w-5 h-5 text-emerald-500" /> </tr>
<h4 className="text-lg font-bold text-slate-900 m-0">Code 128</h4> <tr className="border-t border-slate-100 bg-white">
</div> <td className="p-3 font-bold text-purple-700">MSI</td>
<div className="text-xs font-mono bg-slate-100 inline-block px-2 py-1 rounded text-slate-500 mb-3">Logistics Universal</div> <td className="p-3 text-slate-600">Shelf / bin labeling, warehouse</td>
<div className="mb-3 bg-slate-50 rounded border border-slate-100 p-2 flex justify-center"> <td className="p-3 text-slate-500 font-mono text-xs">Variable numeric</td>
<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" /> <td className="p-3 text-slate-600">Retail / Warehouse</td>
</div> </tr>
<p className="text-sm text-slate-600 m-0"> <tr className="border-t border-slate-100 bg-slate-50">
Code 128 is a flexible barcode format that supports letters and numbers. It is commonly used in logistics, shipping, and internal tracking systems. <td className="p-3 font-bold text-red-700">Pharmacode</td>
</p> <td className="p-3 text-slate-600">Pharmaceutical packaging</td>
</div> <td className="p-3 text-slate-500 font-mono text-xs">3131071 numeric</td>
<td className="p-3 text-slate-600">Pharma</td>
{/* Code 39 Card */} </tr>
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-shadow"> </tbody>
<div className="flex items-center gap-3 mb-3"> </table>
<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> </div>
<BarcodeFormatPicker />
<h2>Why Use a Barcode Generator?</h2> <h2>Why Use a Barcode Generator?</h2>
<p>Using a Barcode Generator offers several advantages:</p> <p>Using a Barcode Generator offers several advantages:</p>
<div className="not-prose grid gap-4 mb-8"> <div className="not-prose grid gap-4 mb-8">
@@ -244,8 +205,20 @@ export function BarcodeGuide() {
</ul> </ul>
<p> <p>
A reliable <strong>Barcode Generator</strong> helps streamline these processes and improves efficiency. 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> </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> <h2>Understanding Check Digits</h2>
<p> <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. 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" /> <hr className="my-12 border-slate-200" />
<div className="flex items-center gap-3 mb-6 not-prose"> <div className="not-prose">
<HelpCircle className="w-6 h-6 text-blue-500" /> <FAQSection
<h2 className="text-2xl font-bold text-slate-900 m-0">Frequently Asked Questions (FAQ)</h2> title="Frequently Asked Questions"
</div> items={[
{
<div className="not-prose space-y-8"> question: 'What is a Barcode Generator?',
<div> 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.',
<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> question: 'Is this barcode generator free to use?',
<div> 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.',
<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> question: 'Which barcode format should I use?',
<div> 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.',
<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 /> question: 'Can I download barcodes in vector format (SVG)?',
<strong>UPC-A:</strong> Standard for retail products in USA/Canada.<br /> 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.',
<strong>Code 128:</strong> Best for logistics, shipping, and internal tracking (supports letters & numbers). },
</p> {
</div> question: 'How do I generate a barcode online?',
<div> 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.',
<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> question: 'Are generated barcodes scannable?',
<div> answer: 'Yes. We generate standard-compliant barcodes that are readable by any standard optical or laser barcode scanner, including smartphone camera apps.',
<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> question: 'Can I use these barcodes for Amazon (EAN/UPC)?',
<div> 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.',
<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> question: 'What is the difference between a barcode and a QR code?',
<div> 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.',
<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> </div>
<div className="mt-12 p-6 bg-slate-900 rounded-xl text-white not-prose"> <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 // SEO Optimized Metadata
export const metadata: Metadata = { export const metadata: Metadata = {
title: { 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'], keywords: ['barcode generator', 'online barcode maker', 'create barcode free', 'ean-13 generator', 'upc-a generator', 'code 128 generator', 'barcode creator', 'printable barcodes'],
alternates: { alternates: {
canonical: 'https://www.qrmaster.net/tools/barcode-generator', canonical: 'https://www.qrmaster.net/tools/barcode-generator',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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