23 Commits

Author SHA1 Message Date
Timo
af2d8f1e8f feat: add subdomain management, comprehensive QR code creation/redirection, and dashboard UI with white-label support. 2026-01-07 18:33:27 +01:00
Timo
b217e50d9f feat: Add core application and marketing layouts, including navigation, user management, and a shared footer component. 2026-01-07 14:33:06 +01:00
Timo
9573a2eea9 feat: add analytics dashboard page displaying QR performance metrics, scan trends, device usage, and geographical data. 2026-01-07 13:04:23 +01:00
Timo Knuth
2a51e432e8 feat: Implement user signup API and analytics dashboard with summary API, map, and chart components, updating dependencies. 2026-01-07 11:07:55 +01:00
2a057ae3e3 feat: Implement marketing page layout, core sections, and shared UI components. 2026-01-06 12:53:57 +01:00
Timo
170c2e9c80 feat: Add new blog section, marketing landing page, dashboard, SEO documentation, and supporting image assets. 2026-01-05 22:14:49 +01:00
b628930d55 fix: prevent trailing slash redirects for API routes
Adds skipTrailingSlashRedirect to prevent HTTP 301 redirects
  on API endpoints like /api/stripe/webhook. This ensures
  webhooks and external integrations work reliably.

  🤖 Generated with Claude Code
2026-01-05 10:43:23 +01:00
Timo
0b5ea28fb6 feat: Add initial global CSS styles, including Tailwind directives, custom animations, and utility classes for common UI components. 2026-01-04 22:56:48 +01:00
Timo
50ebe599f0 feat: add new marketing layout with responsive header, navigation, and footer 2026-01-02 21:38:40 +01:00
Timo
eea8c8b33a feat: implement dashboard page for QR code listing, statistics, and management. 2026-01-02 20:38:57 +01:00
Timo
49673e84b6 feat: Implement dynamic QR code redirection with comprehensive scan tracking, device/OS detection, and geo-location. 2026-01-02 19:47:43 +01:00
Timo
d0a114c1c3 feat: add analytics summary API, dashboard page with stats grid, and blog post detail page. 2026-01-02 18:40:51 +01:00
Timo
0302821f0f feat: add newsletter broadcast system with admin login and dynamic QR code redirect service with scan tracking. 2026-01-02 18:07:18 +01:00
Timo
a15e3b67c2 feat: Implement Next.js middleware for authentication and add a new API endpoint to fetch user details. 2026-01-01 20:43:50 +01:00
Timo
c2988f1d50 chore: Update Dockerfile URLs to HTTPS and add test.md. 2026-01-01 20:28:36 +01:00
Timo
8acfb6c544 localhost change 2026-01-01 20:24:18 +01:00
Timo
91313ac7d5 footer+responsivenes 2026-01-01 20:18:45 +01:00
7afc865a3f 0 vulnerability 2026-01-01 16:17:14 +01:00
c9ebec3c2f Nextr.js 2026-01-01 15:59:08 +01:00
82ea760537 Final 2025-12-31 17:45:49 +01:00
Timo Knuth
42e8a02fde comming soon 2025-12-22 13:21:36 +01:00
Timo Knuth
6aa3267f26 Newsletter comming soon 2025-12-18 15:54:53 +01:00
Timo Knuth
f1d1f4291b Analytics 2025-12-15 20:35:50 +01:00
79 changed files with 7138 additions and 800 deletions

View File

@@ -24,7 +24,8 @@
"Bash(curl:*)",
"Bash(echo \"\n\n## CSRF Debug aktiviert!\n\nBitte teste jetzt:\n1. Browser zu http://localhost:3050/create\n2. Dynamic QR Code erstellen versuchen\n3. Server-Logs zeigen jetzt [CSRF Debug] Output\n\nIch sehe dann:\n- Ob headerToken vorhanden ist\n- Ob cookieToken vorhanden ist \n- Ob sie übereinstimmen\n\n---\n\nStripe Portal 500 Error ist separates Problem:\nhttps://dashboard.stripe.com/test/settings/billing/portal\n→ Customer Portal Configuration muss erstellt werden\n\")",
"Bash(pkill:*)",
"Skill(shadcn-ui)"
"Skill(shadcn-ui)",
"Bash(find:*)"
],
"deny": [],
"ask": []

269
DEPLOYMENT_CHECKLIST.md Normal file
View File

@@ -0,0 +1,269 @@
# 🚀 Deployment Checklist für QR Master
Diese Checkliste enthält alle notwendigen Änderungen vor dem Push nach Gitea und dem Production Deployment.
---
## ✅ 1. Environment Variables (.env)
### Basis URLs ändern
```bash
# Von:
NEXT_PUBLIC_APP_URL=http://localhost:3050
NEXTAUTH_URL=http://localhost:3050
# Zu:
NEXT_PUBLIC_APP_URL=https://www.qrmaster.net
NEXTAUTH_URL=https://www.qrmaster.net
```
### Secrets generieren (falls noch nicht geschehen)
```bash
# NEXTAUTH_SECRET (für JWT/Session Encryption)
openssl rand -base64 32
# IP_SALT (für DSGVO-konforme IP-Hashing)
openssl rand -base64 32
```
Bereits generiert:
- ✅ NEXTAUTH_SECRET: `PT8XVydC4v7QluCz/mV1yb7Y3docSFZeFDioJz4ZE98=`
- ✅ IP_SALT: `j/aluIpzsgn5Z6cbF4conM6ApK5cj4jDagkswzfgQPc=`
### Database URLs
```bash
# Development (localhost):
DATABASE_URL="postgresql://postgres:postgres@localhost:5435/qrmaster?schema=public"
DIRECT_URL="postgresql://postgres:postgres@localhost:5435/qrmaster?schema=public"
# Production (anpassen an deinen Server):
DATABASE_URL="postgresql://USER:PASSWORD@HOST:5432/qrmaster?schema=public"
DIRECT_URL="postgresql://USER:PASSWORD@HOST:5432/qrmaster?schema=public"
```
---
## 🔐 2. Google OAuth Configuration
### Redirect URIs in Google Cloud Console hinzufügen
1. Gehe zu: https://console.cloud.google.com/apis/credentials
2. Wähle deine OAuth 2.0 Client ID: `683784117141-ci1d928jo8f9g6i1isrveflmrinp92l4.apps.googleusercontent.com`
3. Füge folgende **Authorized redirect URIs** hinzu:
```
https://www.qrmaster.net/api/auth/callback/google
```
**Optional** (für Staging/Testing):
```
http://localhost:3050/api/auth/callback/google
https://staging.qrmaster.net/api/auth/callback/google
```
---
## 💳 3. Stripe Configuration
### ⚠️ WICHTIG: Von Test Mode zu Live Mode wechseln
#### Current (Test Mode):
```bash
STRIPE_SECRET_KEY=sk_test_51QYL7gP9xM...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51QYL7gP9xM...
```
#### Production (Live Mode):
1. Gehe zu: https://dashboard.stripe.com/
2. Wechsle von **Test Mode** zu **Live Mode** (Toggle oben rechts)
3. Hole dir die **Live Keys**:
- `API Keys``Secret key` (beginnt mit `sk_live_`)
- `API Keys``Publishable key` (beginnt mit `pk_live_`)
```bash
# Production Keys:
STRIPE_SECRET_KEY=sk_live_XXXXX
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_XXXXX
```
#### Webhook Secret (Production)
1. Erstelle einen neuen Webhook Endpoint: https://dashboard.stripe.com/webhooks
2. Endpoint URL: `https://www.qrmaster.net/api/webhooks/stripe`
3. Events to listen:
- `checkout.session.completed`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `invoice.payment_succeeded`
- `invoice.payment_failed`
4. Kopiere den **Signing Secret** (beginnt mit `whsec_`)
```bash
STRIPE_WEBHOOK_SECRET=whsec_XXXXX
```
#### Price IDs aktualisieren
Erstelle Produkte und Preise in **Live Mode**:
1. https://dashboard.stripe.com/products
2. Erstelle "Pro" und "Business" Pläne
3. Kopiere die Price IDs (beginnen mit `price_`)
```bash
STRIPE_PRICE_ID_PRO_MONTHLY=price_XXXXX
STRIPE_PRICE_ID_PRO_YEARLY=price_XXXXX
STRIPE_PRICE_ID_BUSINESS_MONTHLY=price_XXXXX
STRIPE_PRICE_ID_BUSINESS_YEARLY=price_XXXXX
```
---
## 📧 4. Resend Email Configuration
### Domain Verification
1. Gehe zu: https://resend.com/domains
2. Füge Domain hinzu: `qrmaster.net`
3. Konfiguriere DNS Records (SPF, DKIM, DMARC)
4. Warte auf Verification
### From Email anpassen
Aktuell verwendet alle Emails: `onboarding@resend.dev` (Resend's Test Domain)
Nach Domain Verification in `src/lib/email.ts` ändern:
```typescript
// Von:
from: 'Timo from QR Master <onboarding@resend.dev>',
// Zu:
from: 'Timo from QR Master <hello@qrmaster.net>',
// oder
from: 'Timo from QR Master <noreply@qrmaster.net>',
```
---
## 🔍 5. SEO Configuration
### Bereits korrekt konfiguriert ✅
```bash
NEXT_PUBLIC_INDEXABLE=true # ✅ Bereits gesetzt
```
### Sitemap & robots.txt prüfen
- Sitemap: `https://www.qrmaster.net/sitemap.xml`
- Robots: `https://www.qrmaster.net/robots.txt`
Nach Deployment testen!
---
## 📊 6. PostHog Analytics (Optional)
Falls du PostHog nutzt:
```bash
NEXT_PUBLIC_POSTHOG_KEY=phc_XXXXX
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
```
---
## 🐳 7. Docker Deployment
### docker-compose.yml prüfen
Stelle sicher, dass alle ENV Variables korrekt gemappt sind:
```yaml
environment:
NEXTAUTH_URL: https://www.qrmaster.net
NEXT_PUBLIC_APP_URL: https://www.qrmaster.net
# ... weitere vars
```
### Deployment Commands
```bash
# Build & Deploy
docker-compose up -d --build
# Database Migration (nach erstem Deploy)
docker-compose exec web npm run db:migrate
# Logs checken
docker-compose logs -f web
# Health Check
curl https://www.qrmaster.net
```
---
## 🔒 8. Security Checklist
- [ ] ✅ NEXTAUTH_SECRET ist gesetzt und sicher (32+ Zeichen)
- [ ] ✅ IP_SALT ist gesetzt und sicher
- [ ] ⚠️ Stripe ist auf **Live Mode** umgestellt
- [ ] ⚠️ Google OAuth Redirect URIs enthalten Production URL
- [ ] ⚠️ Resend Domain ist verifiziert
- [ ] ⚠️ Webhook Secrets sind für Production gesetzt
- [ ] ⚠️ Database URLs zeigen auf Production DB
- [ ] ⚠️ Keine Test/Dev Secrets in Production
---
## 📝 9. Vor dem Git Push
### Files prüfen
```bash
# .env sollte NICHT committet werden!
git status
# Falls .env in Git ist:
git rm --cached .env
echo ".env" >> .gitignore
```
### Sensible Daten entfernen
- [ ] Keine API Keys im Code
- [ ] Keine Secrets in Config Files
- [ ] `.env` ist in `.gitignore`
---
## 🎯 10. Nach dem Deployment testen
### Funktionen testen
1. **Google OAuth Login**: https://www.qrmaster.net/login
2. **QR Code erstellen**: https://www.qrmaster.net/create
3. **Stripe Checkout**: Testprodukt kaufen mit echten Stripe Test Cards
4. **Email Delivery**: Password Reset testen
5. **Analytics**: PostHog Events tracken
### Monitoring
```bash
# Server Logs
docker-compose logs -f
# Database Status
docker-compose exec db psql -U postgres -d qrmaster -c "SELECT COUNT(*) FROM \"User\";"
# Redis Status
docker-compose exec redis redis-cli PING
```
---
## 📞 Support Kontakte
- **Stripe Support**: https://support.stripe.com
- **Google Cloud Support**: https://support.google.com/cloud
- **Resend Support**: https://resend.com/docs
- **Next.js Docs**: https://nextjs.org/docs
---
## ✨ Deployment erfolgreich!
Nach erfolgreichem Deployment:
1. ✅ Teste alle wichtigen Features
2. ✅ Monitor Logs für Fehler
3. ✅ Prüfe Analytics Dashboard
4. ✅ Backup der Production Database erstellen
**Good luck! 🚀**

View File

@@ -27,9 +27,16 @@ COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
# Add build-time environment variables with defaults
ENV DATABASE_URL="postgresql://postgres:postgres@db:5432/qrmaster?schema=public"
ENV NEXTAUTH_URL="http://localhost:3000"
ENV NEXTAUTH_URL="https://www.qrmaster.net"
ENV NEXTAUTH_SECRET="build-time-secret"
ENV IP_SALT="build-time-salt"
ENV STRIPE_SECRET_KEY="sk_test_placeholder_for_build"
ENV RESEND_API_KEY="re_placeholder_for_build"
ENV NEXT_PUBLIC_APP_URL="https://www.qrmaster.net"
# PostHog Analytics - REQUIRED at build time for client-side bundle
ENV NEXT_PUBLIC_POSTHOG_KEY="phc_97JBJVVQlqqiZuTVRHuBnnG9HasOv3GSsdeVjossizJ"
ENV NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com"
ENV NEXT_PUBLIC_INDEXABLE="true"
RUN npx prisma generate
RUN npm run build

575
backlinks.md Normal file
View File

@@ -0,0 +1,575 @@
# QR Master - Backlink Campaign Status
**Last Updated:** 2026-01-05 | **Status:** In Progress
---
## 📊 Executive Summary
Nach initialer Analyse mit Chrome Extension:
**Wichtige Erkenntnis:** Die meisten hochwertigen Backlink-Platforms benötigen **manuelle Account-Registrierung** und können NICHT vollautomatisch submitted werden.
### ✅ Was funktioniert automatisch:
- Recherche & URL-Validierung
- Formular-Pre-Fill (Copy-Paste ready)
- Screenshot-Dokumentation
### ❌ Was manuell gemacht werden muss:
- Account-Registrierung (Email-Verifizierung)
- Captcha-Lösung
- Manuelle Review-Prozesse
- Social Login (Google/GitHub OAuth)
---
## 🎯 Session Log - 2026-01-05
### Versuchte Submissions:
**1. AlternativeTo (DA 82)**
- Status: ⏸️ Pausiert
- Problem: Website hatte 404 Errors (technische Probleme)
- Nächster Schritt: In 24h erneut versuchen
- URL: https://alternativeto.net/
**2. SaaSHub (DA 63)**
- Status: 🔐 Registrierung erforderlich
- Erkenntnis: "Submit Product" Button führt zu Login-Page
- Feature: Bietet Auto-Submit zu 110+ Directories (SEHR WERTVOLL!)
- Empfehlung: Account erstellen, dann submitten
- URL: https://www.saashub.com/submit
---
## 📝 Empfohlener Action Plan
### SOFORT (manuell, 1-2 Stunden):
1.**SaaSHub Account erstellen** - Wichtigster erster Schritt!
- Einmal submitten = 110 Directories erreicht
- ROI: 1 Stunde Arbeit = 110+ Backlinks
2. **Product Hunt Account vorbereiten**
- Draft erstellen (kein Submit!)
- Launch für nächste Woche planen
3. **Crunchbase Company Profile**
- Kostenlos, hohe DA (92)
- 10 Minuten Setup
### DIESE WOCHE (täglich 30min):
4. **HARO Daily Check** (helpareporter.com)
- Kann zu Forbes/Inc Backlinks führen
- Nur Email-Subscription nötig
5. **Medium Cross-Posts**
- 4 Blog-Artikel republizieren
- Je 15 Minuten pro Artikel
### NÄCHSTE WOCHE:
6. Product Hunt Launch
7. Reddit r/SideProject Post
8. Hacker News "Show HN"
---
Hier ist Ihr Copy-Paste Action Plan für die Claude Chrome Extension. Führen Sie diese Aufgaben nacheinander aus.
---
📋 Vorbereitung (Einmalig)
Erstellen Sie diese Info-Datei für schnelles Copy-Paste:
QR MASTER - COMPANY INFO
========================
Company Name: QR Master
Website: https://www.qrmaster.net
Tagline: Smart QR Generator & Analytics
Description (Short): Create dynamic QR codes, track scans, and scale campaigns with secure analytics.
Description (Long): QR Master is a professional QR code generator and analytics platform. Create dynamic QR codes, track scans in real-time, analyze user behavior with detailed insights, and optimize your marketing campaigns. Features include bulk generation, custom branding, API access, and enterprise-grade security.
Category: Marketing Tools, QR Code Generator, Analytics
Founded: 2026
Email: support@qrmaster.net
Twitter: @qrmaster (wenn vorhanden)
Logo URL: https://www.qrmaster.net/logo.svg
Screenshot URL: https://www.qrmaster.net/static/og-image.png (HINWEIS: Muss erstellt werden!)
Key Features:
- Dynamic QR Code Generation
- Real-time Scan Analytics
- Bulk QR Creation from CSV/Excel
- Custom Branding (Logo, Colors)
- Device & Location Tracking
- API Access
- Team Collaboration
Target Audience: Marketers, Small Business Owners, Event Managers, Developers
Pricing: Free tier, Pro at €9/month, Business at €29/month
---
🎯 WOCHE 1: Product Directories
Task 1: AlternativeTo Submission
Prompt für Claude:
Please help me submit QR Master to AlternativeTo.
1. Navigate to https://alternativeto.net/software/qr-code-monkey/
2. Click "Suggest alternative"
3. Fill out the submission form with:
- Name: QR Master
- Website: https://www.qrmaster.net
- Description: QR Master is a professional QR code generator and analytics platform. Create dynamic QR codes, track scans in real-time, analyze user behavior with detailed insights, and optimize your marketing campaigns. Features include bulk generation, custom branding, API access, and enterprise-grade security.
- Category: Marketing & SEO Tools
- Platforms: Web
- License: Freemium
4. Submit the form
5. Take a screenshot when done
---
Task 2: SaaSHub Listing
Prompt für Claude:
Please help me list QR Master on SaaSHub.
1. Go to https://www.saashub.com/
2. Click "Add Software" or "Submit a Product"
3. Fill out the form:
- Product Name: QR Master
- URL: https://www.qrmaster.net
- Short Description: Smart QR Generator & Analytics - Create dynamic QR codes and track scans
- Full Description: QR Master is a professional QR code generator and analytics platform. Create dynamic QR codes, track scans in real-time, analyze user behavior with detailed insights, and optimize your marketing campaigns. Features include bulk generation, custom branding, API access, and enterprise-grade security.
- Category: Marketing Tools
- Pricing: Freemium
- Tags: qr code, analytics, marketing, tracking, dynamic qr
4. Upload logo if requested (use logo.svg from website)
5. Submit
6. Confirm submission
---
Task 3: Betalist Submission
Prompt für Claude:
Please submit QR Master to Betalist.
1. Navigate to https://betalist.com/submit
2. Fill out the startup submission form:
- Startup Name: QR Master
- Website: https://www.qrmaster.net
- One-liner: Smart QR Generator & Analytics for Modern Marketers
- Description: QR Master helps businesses create dynamic QR codes and track their performance. Unlike static QR generators, we provide real-time analytics, device tracking, location insights, and bulk generation - perfect for marketing campaigns, events, and product packaging.
- Category: Marketing & Analytics
- Stage: Beta / Early Access
- Email: support@qrmaster.net
3. Submit the form
4. Check for confirmation email
---
🎯 WOCHE 2: Community Platforms
Task 4: Indie Hackers Profile
Prompt für Claude:
Help me set up QR Master on Indie Hackers.
1. Go to https://www.indiehackers.com/
2. Sign up or log in
3. Navigate to "Products" > "Add a Product"
4. Fill out:
- Product Name: QR Master
- Website: https://www.qrmaster.net
- Tagline: Smart QR Generator & Analytics
- Description: Professional QR code generator with real-time analytics. Track scans, analyze behavior, and optimize campaigns.
- Launch Date: January 2026
- Category: SaaS, Marketing Tools
5. Add milestones:
- "Launched QR Master v1.0"
- "First 100 users"
6. Save and publish
---
Task 5: Medium Cross-Post (Artikel 1)
Prompt für Claude:
Help me publish my blog article on Medium.
1. Go to https://medium.com/
2. Click "Write" or "New Story"
3. Copy the content from https://www.qrmaster.net/blog/qr-code-tracking-guide-2025
4. Paste into Medium editor
5. Add this at the end:
---
Want to track your QR codes with advanced analytics? Try [QR Master](https://www.qrmaster.net) - free tier available.
6. Add relevant tags: qr codes, marketing, analytics, tracking, business
7. Choose publication: "Better Marketing" or "The Startup" (if available)
8. Publish the article
9. Share the Medium URL
Wiederholen Sie für alle 4 Blog-Artikel:
- qr-code-tracking-guide-2025
- dynamic-vs-static-qr-codes
- bulk-qr-code-generator-excel
- qr-code-analytics
---
Task 6: Dev.to Tech Article
Prompt für Claude:
Help me publish a developer-focused article on Dev.to.
1. Navigate to https://dev.to/
2. Sign up or log in
3. Click "Create Post"
4. Write this article:
Title: How to Build a QR Code Tracker with Analytics API
Content:
[Schreibe einen technischen Artikel über QR-Code-Tracking mit Code-Beispielen]
Introduction:
QR codes are everywhere, but tracking them is still a challenge. In this tutorial, I'll show you how to implement QR code tracking with analytics using a modern API approach.
[Content sections...]
Conclusion:
If you don't want to build this yourself, check out QR Master (https://www.qrmaster.net) - we've built all of this plus advanced analytics.
5. Add tags: #qrcode #api #analytics #javascript #tutorial
6. Publish
---
Task 7: Quora Answer Campaign
Prompt für Claude:
Help me answer QR code questions on Quora.
1. Go to https://www.quora.com/
2. Search for "best QR code generator"
3. Find the top 3 questions with most views
4. For each question, click "Answer"
5. Write helpful answer (example):
"I've tested 10+ QR code generators, and here's what matters:
**For basic static QR codes:** Any free tool works (QR Monkey, QR Code Generator)
**For business/marketing use:** You need analytics. I'd recommend:
- QR Master (https://www.qrmaster.net) - Best analytics, tracks devices, locations, time-series data
- Bitly - Good for simple link tracking
- QR.io - Enterprise option but expensive
Key features to look for:
1. Real-time scan tracking
2. Device & location data
3. Bulk generation (CSV import)
4. Custom branding
5. API access for automation
QR Master has all of these on the free tier, which is why I switched to it for my campaigns."
6. Submit answer
7. Repeat for 5 more questions
---
🎯 WOCHE 3: Product Hunt Vorbereitung
Task 8: Product Hunt Draft
Prompt für Claude:
Help me prepare the Product Hunt submission draft (don't submit yet).
1. Go to https://www.producthunt.com/
2. Sign up or log in
3. Click "Submit" in top right
4. Start creating the product page (SAVE AS DRAFT):
Product Details:
- Name: QR Master
- Tagline: Smart QR Generator & Analytics for Modern Marketers
- Website: https://www.qrmaster.net
- Description:
QR Master is a professional QR code generator with built-in analytics. Unlike basic QR generators, we help you understand who scans your codes, when, and where.
✨ Key Features:
• Dynamic QR Codes - Update destination URLs without reprinting
• Real-time Analytics - Track scans, devices, locations, timestamps
• Bulk Generation - Upload CSV, generate 1000+ QR codes instantly
• Custom Branding - Add your logo, colors, and design
• API Access - Integrate QR generation into your workflows
• Team Collaboration - Share campaigns with your team
Perfect for:
📱 Marketing campaigns
🎫 Event management
📦 Product packaging
🏢 Business cards
📊 Campaign tracking
Pricing:
• Free: 5 dynamic QR codes
• Pro ($9/mo): 50 codes + analytics
• Business ($29/mo): 500 codes + API
We launched 4 days ago and already have [X] users creating QR codes!
5. Upload media:
- Gallery images (screenshots)
- Product demo GIF/video
- Logo
6. Add first comment draft:
"Hey Product Hunt! 👋
I'm Timo, founder of QR Master. We built this because existing QR generators either lack analytics or are too expensive.
Our goal: Make QR code tracking accessible for small businesses and marketers.
Would love your feedback on:
- Feature priorities
- Pricing
- Use cases we're missing
Happy to answer any questions!"
7. Save as DRAFT (don't publish yet!)
8. Schedule launch for next Tuesday at 00:01 AM PST
---
🎯 WOCHE 4: Reddit & Hacker News
Task 9: Reddit r/SideProject Post
Prompt für Claude:
Help me post QR Master on Reddit r/SideProject.
1. Go to https://www.reddit.com/r/SideProject/
2. Read the rules carefully
3. Click "Create Post"
4. Title: "Built QR Master - QR Generator with Real-time Analytics (launched 2 weeks ago)"
5. Post content:
Hey r/SideProject! 👋
I just launched QR Master - a QR code generator with built-in analytics.
**The Problem:**
Most QR generators are either too basic (no tracking) or too expensive ($50+/month for analytics). I needed something in between for my marketing agency.
**What I Built:**
- Dynamic QR codes (update URLs without reprinting)
- Real-time scan analytics (devices, locations, timestamps)
- Bulk generation from CSV
- Custom branding
- Free tier + affordable pro plans
**Tech Stack:**
- Next.js 14 (App Router)
- PostgreSQL + Prisma
- Stripe for payments
- PostHog for product analytics
- Docker deployment
**Current Status:**
- Launched 2 weeks ago
- [X] users signed up
- $[X] MRR
- 4 blog posts for SEO
**Lessons Learned:**
[Teile 2-3 interessante Learnings]
**What's Next:**
- Zapier integration
- API v2
- Team collaboration features
Link: https://www.qrmaster.net
Would love your feedback! Happy to answer questions about the tech stack or building a SaaS.
6. Select flair: "Launched"
7. Post
8. Monitor comments and respond quickly
---
Task 10: Hacker News Show HN
Prompt für Claude:
Help me post to Hacker News.
1. Navigate to https://news.ycombinator.com/submit
2. Fill out submission form:
- Title: "Show HN: QR Master QR Generator with Real-time Analytics"
- URL: https://www.qrmaster.net
- Text (optional):
Hi HN,
I built QR Master after struggling with QR analytics at my marketing agency. Existing tools were either too basic or prohibitively expensive ($50-200/month).
Key features:
- Dynamic QR codes (update destination without reprinting)
- Real-time analytics (scans, devices, locations, time-series)
- Bulk CSV generation
- API for automation
- Self-hostable (Docker)
Tech: Next.js 14, PostgreSQL, Prisma. Fully type-safe, deployed on Docker.
Free tier: 5 dynamic codes
Pro: $9/mo for 50 codes
The interesting technical challenge was making QR redirects fast (<50ms) while logging analytics without blocking. Used PostgreSQL with indexes + Redis caching.
Happy to answer questions about the tech or business side!
3. Submit
4. Monitor for comments (respond within 1 hour!)
---
🎯 WOCHE 5-8: Guest Posts & Outreach
Task 11: HARO Daily Check
Prompt für Claude:
Help me set up HARO (Help A Reporter Out).
1. Go to https://www.helpareporter.com/
2. Sign up for free account
3. Select categories:
- Marketing & Advertising
- Business & Finance
- Technology
- Internet & Technology
4. Confirm email subscription
Daily task (do this every morning):
1. Check HARO email digest
2. Find 2-3 queries related to:
- QR codes
- Marketing analytics
- Digital marketing tools
- Small business tools
3. Respond within 2 hours with expert answer + mention QR Master subtly
Example response template:
"As the founder of QR Master (qrmaster.net), I've helped 500+ businesses implement QR tracking. Here's what I recommend:
[Answer their question with real expertise]
The key is [insight]. That's why we built [feature] into QR Master - it solved this exact problem.
Happy to provide more details or be quoted. Contact: support@qrmaster.net"
---
Task 12: Broken Link Building
Prompt für Claude:
Help me find broken link opportunities.
1. Go to https://ahrefs.com/ (or use free alternative: https://www.seobility.net/)
2. Enter competitor: "qrcode-monkey.com"
3. Go to "Backlinks" > "Broken"
4. Export list of websites linking to broken pages
5. For top 20 sites:
- Find contact email (use Hunter.io)
- Send this email:
Subject: Broken link on [Their Site] - QR Code Resource
Hi [Name],
I was researching QR code tools and found your article: [URL]
I noticed you're linking to [broken URL] which returns a 404 error.
I run QR Master (qrmaster.net) - a similar tool with analytics features. Would you be open to updating the link? Happy to provide any info you need.
Either way, love your content on [topic]!
Best,
Timo
QR Master
https://www.qrmaster.net
6. Track responses in spreadsheet
---
🎯 BONUS: Automation Scripts
Task 13: Bulk Directory Submissions
Prompt für Claude:
Help me submit QR Master to these 10 directories in sequence:
1. https://startupstash.com/submit-startup/
2. https://www.startupbuffer.com/submit
3. https://www.startupranking.com/
4. https://www.launching.io/submit
5. https://www.f6s.com/
6. https://www.crunchbase.com/
7. https://angel.co/
8. https://www.gust.com/
9. https://www.killerstartups.com/submit-startup/
10. https://techpluto.com/submit-a-startup/
For each site:
1. Navigate to submission page
2. Fill form with company info (use prepared data)
3. Submit
4. Save confirmation screenshot
5. Move to next site
Company Info Template:
Name: QR Master
URL: https://www.qrmaster.net
Tagline: Smart QR Generator & Analytics
Category: Marketing Tools / SaaS
Description: [Use short or long version as needed]
Email: support@qrmaster.net
Founded: 2026
---
📊 Tracking Template
Erstellen Sie ein Google Sheet mit diesen Spalten:
| Platform | Status | Date | Backlink Type | DA | Traffic | Notes |
|---------------|-----------|------------|---------------|-----|---------|-------------------|
| AlternativeTo | Submitted | 2026-01-05 | Do-follow | 82 | - | Pending approval |
| Product Hunt | Draft | - | Do-follow | 91 | - | Launch 2026-01-14 |
| Medium | Published | 2026-01-06 | Do-follow | 96 | 50 | Article 1 |
---
⚡ Quick Start: First Day
Copy this prompt für Claude Chrome Extension:
Please help me get QR Master's first 5 backlinks today:
TASK 1: AlternativeTo
- Go to https://alternativeto.net/software/qr-code-monkey/
- Click "Suggest alternative"
- Submit QR Master with description: "Professional QR code generator with real-time analytics, bulk generation, and custom branding. Track scans, devices, locations. Free tier available."
TASK 2: SaaSHub
- Go to https://www.saashub.com/
- Submit product with all details
TASK 3: Betalist
- Submit to https://betalist.com/submit
TASK 4: Crunchbase
- Create company profile at https://www.crunchbase.com/
TASK 5: Medium
- Cross-post first blog article from qrmaster.net/blog
After each task, take a screenshot and tell me the result. Let's get started!
---
Möchten Sie, dass ich auch Email-Outreach-Templates für Guest Posts oder YouTube-Tutorial-Scripts erstelle?

134
claude-artifact-template.md Normal file
View File

@@ -0,0 +1,134 @@
# Claude Artifact Template for QR Master Backlinks
Use this template when creating Claude artifacts that link back to qrmaster.net.
---
## How to Use
1. Copy this template into Claude
2. Customize for your specific topic
3. Click "Publish Artifact"
4. Add `www.qrmaster.net` to allowed domains
5. Share the published link
---
## Template: Dynamic QR Codes Guide
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dynamic QR Codes: The Complete 2025 Guide</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
max-width: 800px;
margin: 0 auto;
padding: 40px 20px;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
color: #1e293b;
}
h1 { color: #0f172a; border-bottom: 3px solid #3b82f6; padding-bottom: 10px; }
h2 { color: #1e40af; margin-top: 2em; }
a { color: #2563eb; text-decoration: none; }
a:hover { text-decoration: underline; }
.cta {
background: linear-gradient(135deg, #3b82f6, #2563eb);
color: white;
padding: 20px 30px;
border-radius: 12px;
text-align: center;
margin: 30px 0;
}
.cta a { color: white; font-weight: bold; font-size: 1.1em; }
ul { margin: 1em 0; }
li { margin: 0.5em 0; }
.highlight { background: #dbeafe; padding: 15px; border-radius: 8px; margin: 20px 0; }
</style>
</head>
<body>
<h1>Dynamic QR Codes: The Complete 2025 Guide</h1>
<p>Dynamic QR codes have revolutionized how businesses connect offline and online experiences. Unlike static codes, dynamic QR codes can be edited after printing, tracked in real-time, and optimized based on performance data.</p>
<h2>What Makes Dynamic QR Codes Different?</h2>
<ul>
<li><strong>Editable:</strong> Change the destination URL anytime without reprinting</li>
<li><strong>Trackable:</strong> Monitor scans, locations, devices, and timing</li>
<li><strong>Smaller:</strong> Short redirect URLs create cleaner QR patterns</li>
<li><strong>Flexible:</strong> Perfect for campaigns where content may change</li>
</ul>
<div class="highlight">
<strong>Pro Tip:</strong> Always use dynamic QR codes for printed materials. If your URL changes after printing 10,000 flyers, you won't need to reprint them.
</div>
<h2>Best Use Cases</h2>
<ul>
<li>Restaurant menus that change seasonally</li>
<li>Event tickets with real-time updates</li>
<li>Product packaging with warranty info</li>
<li>Marketing campaigns with A/B testing</li>
<li>Business cards with contact details</li>
</ul>
<h2>How to Create Dynamic QR Codes</h2>
<p>The easiest way is using a dedicated platform like <a href="https://www.qrmaster.net" target="_blank">QR Master</a>. Here's the process:</p>
<ol>
<li>Sign up for a free account</li>
<li>Click "Create QR Code" and select "Dynamic"</li>
<li>Enter your destination URL</li>
<li>Customize colors and add your logo</li>
<li>Download in SVG or PNG format</li>
<li>Track scans in your analytics dashboard</li>
</ol>
<div class="cta">
<a href="https://www.qrmaster.net/signup" target="_blank">Create Your Free Dynamic QR Code →</a>
</div>
<h2>Tracking & Analytics</h2>
<p>With dynamic QR codes, you get access to powerful analytics:</p>
<ul>
<li>Total and unique scan counts</li>
<li>Geographic distribution (city/country)</li>
<li>Device breakdown (iOS vs Android)</li>
<li>Time-based patterns (peak hours)</li>
<li>Conversion tracking integration</li>
</ul>
<p>Learn more about <a href="https://www.qrmaster.net/qr-code-tracking" target="_blank">QR code tracking</a> and <a href="https://www.qrmaster.net/blog/qr-code-analytics" target="_blank">analytics best practices</a>.</p>
<h2>Pricing</h2>
<p>Most platforms offer tiered pricing. <a href="https://www.qrmaster.net/pricing" target="_blank">QR Master pricing</a> starts with a free tier (3 dynamic codes) and scales up for businesses needing more codes and features.</p>
<hr>
<p><em>This guide is provided by <a href="https://www.qrmaster.net" target="_blank">QR Master</a> - Free Dynamic QR Code Generator with Analytics.</em></p>
</body>
</html>
```
---
## Topic Ideas for More Artifacts
1. **"QR Codes for Restaurants: Complete Setup Guide"** - Link to /blog/qr-code-restaurant-menu
2. **"Digital Business Cards with QR Codes"** - Link to /blog/vcard-qr-code-generator
3. **"QR Code Print Size Calculator"** - Link to /blog/qr-code-print-size-guide
4. **"Small Business QR Code Marketing Playbook"** - Link to /blog/qr-code-small-business
5. **"Static vs Dynamic QR Codes Comparison"** - Link to /blog/dynamic-vs-static-qr-codes
---
## Publishing Steps
1. Create artifact in Claude with HTML content above
2. Click "Publish" button
3. In "Allowed domains" field, enter: `www.qrmaster.net, qrmaster.net`
4. Copy the embed code or share URL
5. The links inside will count as backlinks once indexed

180
claude-seo-prompts.md Normal file
View File

@@ -0,0 +1,180 @@
# Claude Artifact Prompts for Parasite SEO
**Goal:** Publish educational content on claude.ai that naturally links to qrmaster.net
**Strategy:** Informative, helpful content that does NOT look like advertising
---
## 🎯 Prompt 1: Restaurant QR Menu Guide
```
Create an informative HTML article titled "Restaurant Menu QR Codes: A Practical Guide for Restaurant Owners (2025)"
The article should:
- Be 1500-2000 words long
- Contain practical tips for restaurant owners
- Cover the following topics:
1. Why digital menus are the new standard
2. PDF vs. online menu - pros and cons
3. Optimal placement of QR codes in restaurants
4. Mistakes restaurants should avoid
5. Using tracking and analytics
Naturally incorporate these keywords:
- "restaurant menu qr code" (main keyword)
- "digital menu"
- "touchless menu"
- "qr code for restaurants"
Link ONCE naturally to https://www.qrmaster.net as "a free tool for creating restaurant QR codes"
Tone: Friendly, helpful, like an experienced consultant. NOT salesy.
HTML with clean CSS, mobile-friendly. No external dependencies.
```
---
## 🎯 Prompt 2: Dynamic vs Static QR Comparison
```
Create a technical comparison article as HTML: "Static vs Dynamic QR Codes: What's the Difference and When to Use Each"
Structure:
1. Brief explanation of what QR codes are technically
2. Static QR codes - how they work
3. Dynamic QR codes - how they work
4. Comparison table (very important!)
5. Decision guide: When to use which type
6. Realistic use cases for both
The article should be NEUTRAL and EDUCATIONAL, like a Wikipedia article but more readable.
Keywords:
- "dynamic vs static qr code" (main keyword)
- "editable qr code"
- "trackable qr code"
- "qr code types"
At the end, include a short "Tools for Creating QR Codes" section with 3-4 options. One of them is https://www.qrmaster.net - listed as an equal entry, NOT as a recommendation.
HTML with professional, minimalist design.
```
---
## 🎯 Prompt 3: Small Business Marketing Guide
```
Create a comprehensive HTML guide: "10 Ways Small Businesses Can Use QR Codes in 2025"
The article is aimed at small businesses without technical knowledge.
The 10 use cases:
1. Digital business cards (vCard)
2. Collecting Google reviews
3. Contactless payments
4. Sharing Wi-Fi access
5. Growing social media followers
6. Linking product information
7. Simplifying appointment booking
8. Discount promotions & coupons
9. Event tickets & check-in
10. Feedback & surveys
For each point: Brief explanation + concrete example + one tip.
Keywords:
- "qr code for small business"
- "qr code marketing"
- "qr code uses"
- "business qr codes"
Link ONCE naturally in the context of vCard creation to https://www.qrmaster.net/blog/vcard-qr-code-generator
Tone: Enthusiastic but not over the top. Like a helpful friend explaining technology.
```
---
## 🎯 Prompt 4: Print Size Technical Guide
```
Create a technical reference article as HTML: "QR Code Print Size Guide: Minimum Dimensions for Reliable Scanning"
This article should become THE reference for QR code print sizes.
Content:
1. The science behind QR scanning (brief)
2. The golden formula: Size = Distance ÷ 10
3. LARGE table with applications, distances, min/recommended sizes
4. Factors affecting scannability:
- Data density
- Error Correction Level
- Print quality (DPI)
- Contrast
5. Quiet zone requirements
6. File formats for printing (SVG vs PNG vs PDF)
7. Checklist before printing
Keywords:
- "qr code size for printing"
- "minimum qr code size"
- "qr code dimensions"
- "qr code print quality"
Link ONCE to https://www.qrmaster.net/blog/qr-code-print-size-guide as "detailed guide with more examples"
Tone: Technically precise, reference-style. For designers and marketers.
```
---
## 🎯 Prompt 5: QR Analytics Beginner Guide
```
Create a beginner's guide as HTML: "QR Code Analytics Explained: What You Can Track and Why It Matters"
The article is aimed at marketing beginners who have never used QR tracking before.
Structure:
1. What is QR tracking and why is it important?
2. What data can you track? (list with explanations)
- Scan count
- Geolocation
- Device types
- Timestamps
- Unique vs Total Scans
3. How does it work technically? (simplified)
4. Privacy & GDPR considerations
5. Practical application: Measuring campaign ROI
6. Common mistakes in QR tracking
Keywords:
- "qr code tracking"
- "qr code analytics"
- "track qr code scans"
- "qr code scan data"
Link ONCE naturally to https://www.qrmaster.net/blog/qr-code-analytics as an example: "For a deeper dive into analytics dashboards, see this comprehensive guide."
Tone: Friendly and explanatory, like a teacher. No jargon without explanation.
```
---
## 📋 Usage Instructions
1. **Copy prompt** → Paste into claude.ai
2. **Let it create the artifact**
3. **Click "Publish"** in Claude
4. **Allowed Domain:** Add `www.qrmaster.net, qrmaster.net`
5. **Share link** - Google indexes these!
## 💡 Tips for Maximum Effectiveness
- **Don't publish all on the same day**
- About **1 article per week** for natural growth
- Publish the **more neutral articles first** (Prompt 2 & 4)
- **Share on social media** for faster indexing
- Register the published URLs in Google Search Console

View File

@@ -52,10 +52,29 @@ services:
environment:
NODE_ENV: production
DATABASE_URL: postgresql://postgres:postgres@db:5432/qrmaster?schema=public
DIRECT_URL: postgresql://postgres:postgres@db:5432/qrmaster?schema=public
REDIS_URL: redis://redis:6379
NEXTAUTH_URL: http://localhost:3050
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3050}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-your-secret-key-change-in-production}
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3050}
IP_SALT: ${IP_SALT:-your-salt-change-in-production}
ENABLE_DEMO: ${ENABLE_DEMO:-false}
NEXT_PUBLIC_INDEXABLE: ${NEXT_PUBLIC_INDEXABLE:-true}
# Google OAuth
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
# Stripe
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY:-}
STRIPE_PRICE_ID_PRO_MONTHLY: ${STRIPE_PRICE_ID_PRO_MONTHLY:-}
STRIPE_PRICE_ID_PRO_YEARLY: ${STRIPE_PRICE_ID_PRO_YEARLY:-}
STRIPE_PRICE_ID_BUSINESS_MONTHLY: ${STRIPE_PRICE_ID_BUSINESS_MONTHLY:-}
STRIPE_PRICE_ID_BUSINESS_YEARLY: ${STRIPE_PRICE_ID_BUSINESS_YEARLY:-}
# Email & Analytics
RESEND_API_KEY: ${RESEND_API_KEY:-}
NEXT_PUBLIC_POSTHOG_KEY: ${NEXT_PUBLIC_POSTHOG_KEY:-}
NEXT_PUBLIC_POSTHOG_HOST: ${NEXT_PUBLIC_POSTHOG_HOST:-https://us.i.posthog.com}
depends_on:
db:
condition: service_healthy

View File

@@ -1,6 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
skipTrailingSlashRedirect: true,
images: {
unoptimized: false,
domains: ['www.qrmaster.net', 'qrmaster.net', 'images.qrmaster.net'],
@@ -11,6 +12,14 @@ const nextConfig = {
experimental: {
serverComponentsExternalPackages: ['@prisma/client', 'bcryptjs'],
},
// Allow build to succeed even with prerender errors
// Pages with useSearchParams() will be rendered dynamically at runtime
staticPageGenerationTimeout: 120,
onDemandEntries: {
maxInactiveAge: 25 * 1000,
pagesBufferLength: 2,
},
poweredByHeader: false,
};
export default nextConfig;

1925
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -25,19 +25,23 @@
"docker:backup": "docker compose exec db pg_dump -U postgres qrmaster > backup_$(date +%Y%m%d).sql"
},
"dependencies": {
"@auth/prisma-adapter": "^1.0.12",
"@auth/prisma-adapter": "^2.11.1",
"@edge-runtime/cookies": "^6.0.0",
"@prisma/client": "^5.7.0",
"@stripe/stripe-js": "^8.0.0",
"@types/d3-scale": "^4.0.9",
"bcryptjs": "^2.4.3",
"chart.js": "^4.4.0",
"clsx": "^2.0.0",
"d3-scale": "^4.0.2",
"dayjs": "^1.11.10",
"exceljs": "^4.4.0",
"file-saver": "^2.0.5",
"i18next": "^23.7.6",
"ioredis": "^5.3.2",
"jszip": "^3.10.1",
"next": "14.2.18",
"lucide-react": "^0.562.0",
"next": "^14.2.35",
"next-auth": "^4.24.5",
"papaparse": "^5.4.1",
"posthog-js": "^1.276.0",
@@ -49,12 +53,12 @@
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-i18next": "^13.5.0",
"react-simple-maps": "^3.0.0",
"resend": "^6.4.2",
"sharp": "^0.33.1",
"stripe": "^19.1.0",
"tailwind-merge": "^2.2.0",
"uuid": "^13.0.0",
"xlsx": "^0.18.5",
"zod": "^3.25.76"
},
"devDependencies": {
@@ -67,7 +71,7 @@
"@types/react-dom": "^18.2.18",
"autoprefixer": "^10.4.16",
"eslint": "^8.56.0",
"eslint-config-next": "14.2.18",
"eslint-config-next": "^16.1.1",
"next-sitemap": "^4.2.3",
"postcss": "^8.4.32",
"prettier": "^3.1.1",
@@ -79,4 +83,4 @@
"engines": {
"node": ">=18.0.0"
}
}
}

View File

@@ -32,6 +32,9 @@ model User {
resetPasswordToken String? @unique
resetPasswordExpires DateTime?
// White-label subdomain
subdomain String? @unique
qrCodes QRCode[]
integrations Integration[]
accounts Account[]
@@ -149,4 +152,16 @@ model Integration {
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model NewsletterSubscription {
id String @id @default(cuid())
email String @unique
source String @default("ai-coming-soon")
status String @default("subscribed")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([email])
@@index([createdAt])
}

View File

@@ -4,20 +4,22 @@ import * as bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
async function main() {
// Create demo user
const hashedPassword = await bcrypt.hash('demo123', 12);
// Create admin user for newsletter management
const hashedPassword = await bcrypt.hash('Timo.16092005', 12);
const user = await prisma.user.upsert({
where: { email: 'demo@qrmaster.net' },
update: {},
update: {
password: hashedPassword, // Update password if user exists
},
create: {
email: 'demo@qrmaster.net',
name: 'Demo User',
name: 'Admin User',
password: hashedPassword,
},
});
console.log('Created demo user:', user.email);
console.log('Created/Updated admin user:', user.email);
// Create demo QR codes
const qrCodes = [

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 668 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 706 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 741 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 759 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 720 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 577 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 626 KiB

BIN
public/landing-business.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 598 KiB

BIN
public/landing-hero-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 KiB

BIN
public/landing-hero-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

BIN
public/landing-hero-3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 537 KiB

BIN
public/landing-hero-4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 555 KiB

BIN
public/landing-qr-scan.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 657 KiB

BIN
public/landing-retail.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 639 KiB

100
seo_findings.md Normal file
View File

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

View File

@@ -1,12 +1,14 @@
'use client';
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import dynamic from 'next/dynamic';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Table } from '@/components/ui/Table';
import { useTranslation } from '@/hooks/useTranslation';
import { Line, Bar, Doughnut } from 'react-chartjs-2';
import { StatCard, Sparkline } from '@/components/analytics';
import { Line, Doughnut } from 'react-chartjs-2';
import {
Chart as ChartJS,
CategoryScale,
@@ -20,6 +22,27 @@ import {
Legend,
Filler,
} from 'chart.js';
import {
BarChart3,
Users,
Smartphone,
Globe,
Calendar,
Download,
TrendingUp,
QrCode,
HelpCircle,
} from 'lucide-react';
// Dynamically import GeoMap to avoid SSR issues with d3
const GeoMap = dynamic(() => import('@/components/analytics/GeoMap'), {
ssr: false,
loading: () => (
<div className="h-64 bg-gray-100 rounded-lg animate-pulse flex items-center justify-center">
<span className="text-gray-400">Loading map...</span>
</div>
),
});
ChartJS.register(
CategoryScale,
@@ -34,87 +57,102 @@ ChartJS.register(
Filler
);
interface QRPerformance {
id: string;
title: string;
type: string;
totalScans: number;
uniqueScans: number;
conversion: number;
trend: 'up' | 'down' | 'flat';
trendPercentage: number;
sparkline: number[];
lastScanned: string | null;
isNew?: boolean;
}
interface CountryStat {
country: string;
count: number;
percentage: number;
trend: 'up' | 'down' | 'flat';
trendPercentage: number;
isNew?: boolean;
}
interface AnalyticsData {
summary: {
totalScans: number;
uniqueScans: number;
avgScansPerQR: number;
mobilePercentage: number;
topCountry: string;
topCountryPercentage: number;
scansTrend?: { trend: 'up' | 'down' | 'flat'; percentage: number; isNew?: boolean };
avgScansTrend?: { trend: 'up' | 'down' | 'flat'; percentage: number; isNew?: boolean };
comparisonPeriod?: string;
};
deviceStats: Record<string, number>;
countryStats: CountryStat[];
dailyScans: Record<string, number>;
qrPerformance: QRPerformance[];
}
export default function AnalyticsPage() {
const { t } = useTranslation();
const [timeRange, setTimeRange] = useState('7');
const [loading, setLoading] = useState(true);
const [analyticsData, setAnalyticsData] = useState<any>(null);
const [analyticsData, setAnalyticsData] = useState<AnalyticsData | null>(null);
useEffect(() => {
fetchAnalytics();
}, [timeRange]);
const fetchAnalytics = async () => {
const fetchAnalytics = useCallback(async () => {
setLoading(true);
try {
const response = await fetch('/api/analytics/summary');
const response = await fetch(`/api/analytics/summary?range=${timeRange}`);
if (response.ok) {
const data = await response.json();
setAnalyticsData(data);
} else {
// Set empty data if not authorized
setAnalyticsData({
summary: {
totalScans: 0,
uniqueScans: 0,
avgScansPerQR: 0,
mobilePercentage: 0,
topCountry: 'N/A',
topCountryPercentage: 0,
},
deviceStats: {},
countryStats: [],
dailyScans: {},
qrPerformance: [],
});
setAnalyticsData(null);
}
} catch (error) {
console.error('Error fetching analytics:', error);
setAnalyticsData({
summary: {
totalScans: 0,
uniqueScans: 0,
avgScansPerQR: 0,
mobilePercentage: 0,
topCountry: 'N/A',
topCountryPercentage: 0,
},
deviceStats: {},
countryStats: [],
dailyScans: {},
qrPerformance: [],
});
setAnalyticsData(null);
} finally {
setLoading(false);
}
};
}, [timeRange]);
useEffect(() => {
fetchAnalytics();
}, [fetchAnalytics]);
const exportReport = () => {
// Create CSV data
if (!analyticsData) return;
const csvData = [
['QR Master Analytics Report'],
['Generated:', new Date().toLocaleString()],
['Time Range:', `Last ${timeRange} days`],
[''],
['Summary'],
['Total Scans', analyticsData?.summary.totalScans || 0],
['Unique Scans', analyticsData?.summary.uniqueScans || 0],
['Average Scans per QR', analyticsData?.summary.avgScansPerQR || 0],
['Mobile Usage %', analyticsData?.summary.mobilePercentage || 0],
['Total Scans', analyticsData.summary.totalScans],
['Unique Scans', analyticsData.summary.uniqueScans],
['Mobile Usage %', analyticsData.summary.mobilePercentage],
['Top Country', analyticsData.summary.topCountry],
[''],
['Top QR Codes'],
['Title', 'Type', 'Total Scans', 'Unique Scans', 'Conversion %'],
...(analyticsData?.qrPerformance || []).map((qr: any) => [
['Title', 'Type', 'Total Scans', 'Unique Scans', 'Conversion %', 'Last Scanned'],
...analyticsData.qrPerformance.map((qr) => [
qr.title,
qr.type,
qr.totalScans,
qr.uniqueScans,
qr.conversion,
qr.lastScanned ? new Date(qr.lastScanned).toLocaleString() : 'Never',
]),
];
// Convert to CSV string
const csv = csvData.map(row => row.join(',')).join('\n');
// Download
const csv = csvData.map((row) => row.join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -124,7 +162,7 @@ export default function AnalyticsPage() {
URL.revokeObjectURL(url);
};
// Prepare chart data based on selected time range
// Prepare chart data
const daysToShow = parseInt(timeRange);
const dateRange = Array.from({ length: daysToShow }, (_, i) => {
const date = new Date();
@@ -133,18 +171,32 @@ export default function AnalyticsPage() {
});
const scanChartData = {
labels: dateRange.map(date => {
labels: dateRange.map((date) => {
const d = new Date(date);
return d.toLocaleDateString('en', { month: 'short', day: 'numeric' });
}),
datasets: [
{
label: 'Scans',
data: dateRange.map(date => analyticsData?.dailyScans[date] || 0),
borderColor: 'rgb(37, 99, 235)',
backgroundColor: 'rgba(37, 99, 235, 0.1)',
data: dateRange.map((date) => analyticsData?.dailyScans[date] || 0),
borderColor: 'rgb(59, 130, 246)',
backgroundColor: (context: any) => {
const chart = context.chart;
const { ctx, chartArea } = chart;
if (!chartArea) return 'rgba(59, 130, 246, 0.1)';
const gradient = ctx.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
gradient.addColorStop(0, 'rgba(59, 130, 246, 0.3)');
gradient.addColorStop(1, 'rgba(59, 130, 246, 0.01)');
return gradient;
},
tension: 0.4,
fill: true,
pointRadius: 4,
pointBackgroundColor: 'rgb(59, 130, 246)',
pointBorderColor: '#fff',
pointBorderWidth: 2,
pointHoverRadius: 6,
},
],
};
@@ -159,25 +211,34 @@ export default function AnalyticsPage() {
analyticsData?.deviceStats.tablet || 0,
],
backgroundColor: [
'rgba(37, 99, 235, 0.8)',
'rgba(34, 197, 94, 0.8)',
'rgba(249, 115, 22, 0.8)',
'rgba(59, 130, 246, 0.85)',
'rgba(34, 197, 94, 0.85)',
'rgba(249, 115, 22, 0.85)',
],
borderWidth: 0,
hoverOffset: 4,
},
],
};
// Find top performing QR code
const topQR = analyticsData?.qrPerformance[0];
if (loading) {
return (
<div className="space-y-8">
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/4 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-1/2 mb-8"></div>
<div className="h-8 bg-gray-200 rounded w-1/4 mb-4" />
<div className="h-4 bg-gray-200 rounded w-1/2 mb-8" />
<div className="grid md:grid-cols-4 gap-6 mb-8">
{[1, 2, 3, 4].map(i => (
<div key={i} className="h-32 bg-gray-200 rounded"></div>
{[1, 2, 3, 4].map((i) => (
<div key={i} className="h-32 bg-gray-200 rounded-xl" />
))}
</div>
<div className="grid lg:grid-cols-2 gap-6">
<div className="h-80 bg-gray-200 rounded-xl" />
<div className="h-80 bg-gray-200 rounded-xl" />
</div>
</div>
</div>
);
@@ -186,105 +247,136 @@ export default function AnalyticsPage() {
return (
<div className="space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900">{t('analytics.title')}</h1>
<p className="text-gray-600 mt-2">{t('analytics.subtitle')}</p>
<h1 className="text-3xl font-bold text-gray-900">QR Code Analytics</h1>
<p className="text-gray-500 mt-1">Track and analyze your QR code performance</p>
</div>
<Button onClick={exportReport}>Export Report</Button>
</div>
{/* Time Range Selector */}
<div className="flex space-x-2">
{['7', '30', '90'].map((days) => (
<button
key={days}
onClick={() => setTimeRange(days)}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
timeRange === days
? 'bg-primary-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{days} Days
</button>
))}
<div className="flex items-center gap-3">
{/* Date Range Selector */}
<div className="inline-flex items-center bg-gray-100 rounded-lg p-1">
{[
{ value: '7', label: '7 Days' },
{ value: '30', label: '30 Days' },
{ value: '90', label: '90 Days' },
].map((range) => (
<button
key={range.value}
onClick={() => setTimeRange(range.value)}
className={`px-4 py-2 text-sm font-medium rounded-md transition-all ${timeRange === range.value
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
{range.label}
</button>
))}
</div>
<Button onClick={exportReport} variant="primary" className="flex items-center gap-2">
<Download className="w-4 h-4" />
Export Report
</Button>
</div>
</div>
{/* KPI Cards */}
<div className="grid md:grid-cols-4 gap-6">
<Card>
<CardContent className="p-6">
<p className="text-sm text-gray-600 mb-1">Total Scans</p>
<p className="text-2xl font-bold text-gray-900">
{analyticsData?.summary.totalScans.toLocaleString() || '0'}
</p>
<p className={`text-sm mt-2 ${analyticsData?.summary.totalScans > 0 ? 'text-green-600' : 'text-gray-500'}`}>
{analyticsData?.summary.totalScans > 0 ? '+12.5%' : 'No data'} from last period
</p>
</CardContent>
</Card>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<StatCard
title="Total Scans"
value={analyticsData?.summary.totalScans || 0}
trend={
analyticsData?.summary.scansTrend
? {
direction: analyticsData.summary.scansTrend.trend,
percentage: analyticsData.summary.scansTrend.percentage,
isNew: analyticsData.summary.scansTrend.isNew,
period: analyticsData.summary.comparisonPeriod,
}
: undefined
}
icon={<BarChart3 className="w-5 h-5 text-primary-600" />}
/>
<Card>
<CardContent className="p-6">
<p className="text-sm text-gray-600 mb-1">Avg Scans/QR</p>
<p className="text-2xl font-bold text-gray-900">
{analyticsData?.summary.avgScansPerQR || '0'}
</p>
<p className={`text-sm mt-2 ${analyticsData?.summary.avgScansPerQR > 0 ? 'text-green-600' : 'text-gray-500'}`}>
{analyticsData?.summary.avgScansPerQR > 0 ? '+8.3%' : 'No data'} from last period
</p>
</CardContent>
</Card>
<StatCard
title="Avg Scans/QR"
value={analyticsData?.summary.avgScansPerQR || 0}
trend={
analyticsData?.summary.avgScansTrend
? {
direction: analyticsData.summary.avgScansTrend.trend,
percentage: analyticsData.summary.avgScansTrend.percentage,
isNew: analyticsData.summary.avgScansTrend.isNew,
period: analyticsData.summary.comparisonPeriod,
}
: undefined
}
icon={<TrendingUp className="w-5 h-5 text-primary-600" />}
/>
<Card>
<CardContent className="p-6">
<p className="text-sm text-gray-600 mb-1">Mobile Usage</p>
<p className="text-2xl font-bold text-gray-900">
{analyticsData?.summary.mobilePercentage || '0'}%
</p>
<p className="text-sm mt-2 text-gray-500">
{analyticsData?.summary.mobilePercentage > 0 ? 'Of total scans' : 'No mobile scans'}
</p>
</CardContent>
</Card>
<StatCard
title="Mobile Usage"
value={`${analyticsData?.summary.mobilePercentage || 0}%`}
subtitle="Of total scans"
icon={<Smartphone className="w-5 h-5 text-primary-600" />}
/>
<Card>
<CardContent className="p-6">
<p className="text-sm text-gray-600 mb-1">Top Country</p>
<p className="text-2xl font-bold text-gray-900">
{analyticsData?.summary.topCountry || 'N/A'}
</p>
<p className="text-sm mt-2 text-gray-500">
{analyticsData?.summary.topCountryPercentage || '0'}% of total
</p>
</CardContent>
</Card>
<StatCard
title="Top Country"
value={analyticsData?.summary.topCountry || 'N/A'}
subtitle={`${analyticsData?.summary.topCountryPercentage || 0}% of total`}
icon={<Globe className="w-5 h-5 text-primary-600" />}
/>
</div>
{/* Charts */}
<div className="grid lg:grid-cols-2 gap-8">
{/* Scans Over Time */}
<Card>
<CardHeader>
<CardTitle>Scans Over Time</CardTitle>
{/* Main Chart Row */}
<div className="grid lg:grid-cols-3 gap-6">
{/* Scans Over Time - Takes 2 columns */}
<Card className="lg:col-span-2">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg font-semibold">Scan Trends Over Time</CardTitle>
<div className="flex items-center gap-2 text-sm text-gray-500">
<Calendar className="w-4 h-4" />
<span>{timeRange} Days</span>
</div>
</CardHeader>
<CardContent>
<div className="h-64">
<div className="h-72">
<Line
data={scanChartData}
options={{
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgba(17, 24, 39, 0.9)',
titleColor: '#fff',
bodyColor: '#fff',
padding: 12,
borderColor: 'rgba(255, 255, 255, 0.1)',
borderWidth: 1,
displayColors: false,
callbacks: {
title: (items) => items[0]?.label || '',
label: (item) => `${item.formattedValue} scans`,
},
},
},
scales: {
x: {
grid: { display: false },
ticks: { color: '#9CA3AF' },
},
y: {
beginAtZero: true,
ticks: {
precision: 0,
},
grid: { color: 'rgba(156, 163, 175, 0.1)' },
ticks: { color: '#9CA3AF', precision: 0 },
},
},
}}
@@ -293,122 +385,207 @@ export default function AnalyticsPage() {
</CardContent>
</Card>
{/* Device Types */}
{/* Device Types Donut */}
<Card>
<CardHeader>
<CardTitle>Device Types</CardTitle>
<CardTitle className="text-lg font-semibold">Device Types</CardTitle>
</CardHeader>
<CardContent>
<div className="h-64 flex items-center justify-center">
{analyticsData?.summary.totalScans > 0 ? (
{(analyticsData?.summary.totalScans || 0) > 0 ? (
<Doughnut
data={deviceChartData}
options={{
responsive: true,
maintainAspectRatio: false,
cutout: '65%',
plugins: {
legend: {
position: 'bottom',
labels: {
padding: 16,
usePointStyle: true,
pointStyle: 'circle',
},
},
},
}}
/>
) : (
<p className="text-gray-500">No scan data available</p>
<p className="text-gray-400">No scan data available</p>
)}
</div>
</CardContent>
</Card>
</div>
{/* Top Countries Table */}
<Card>
<CardHeader>
<CardTitle>Top Countries</CardTitle>
</CardHeader>
<CardContent>
{analyticsData?.countryStats.length > 0 ? (
<Table>
<thead>
<tr>
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">Country</th>
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">Scans</th>
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">Percentage</th>
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">Trend</th>
</tr>
</thead>
<tbody>
{analyticsData.countryStats.map((country: any, index: number) => (
<tr key={index} className="border-b transition-colors hover:bg-gray-50/50">
<td className="px-4 py-4 align-middle">{country.country}</td>
<td className="px-4 py-4 align-middle">{country.count.toLocaleString()}</td>
<td className="px-4 py-4 align-middle">{country.percentage}%</td>
<td className="px-4 py-4 align-middle">
<Badge variant={
country.trend === 'up' ? 'success' :
country.trend === 'down' ? 'destructive' :
'default'
}>
{country.trend === 'up' ? '↑' : country.trend === 'down' ? '↓' : '→'} {country.trendPercentage}%
</Badge>
</td>
</tr>
))}
</tbody>
</Table>
) : (
<p className="text-gray-500 text-center py-8">No country data available yet</p>
)}
</CardContent>
</Card>
{/* Geographic & Country Stats Row */}
<div className="grid lg:grid-cols-2 gap-6">
{/* Geographic Insights with Map */}
<Card>
<CardHeader>
<CardTitle className="text-lg font-semibold">Geographic Insights</CardTitle>
</CardHeader>
<CardContent>
<div className="h-64">
<GeoMap
countryStats={analyticsData?.countryStats || []}
totalScans={analyticsData?.summary.totalScans || 0}
/>
</div>
</CardContent>
</Card>
{/* QR Code Performance Table */}
<Card>
<CardHeader>
<CardTitle>QR Code Performance</CardTitle>
{/* Top Countries Table */}
<Card>
<CardHeader>
<CardTitle className="text-lg font-semibold">Top Countries</CardTitle>
</CardHeader>
<CardContent>
{(analyticsData?.countryStats?.length || 0) > 0 ? (
<div className="space-y-3">
{analyticsData!.countryStats.slice(0, 5).map((country, index) => (
<div
key={country.country}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div className="flex items-center gap-3">
<span className="text-lg font-semibold text-gray-400 w-6">
{index + 1}
</span>
<span className="font-medium text-gray-900">{country.country}</span>
</div>
<div className="flex items-center gap-4">
<span className="text-gray-600">{country.count.toLocaleString()}</span>
<span className="text-gray-400 text-sm w-12 text-right">
{country.percentage}%
</span>
<Badge
variant={
country.trend === 'up'
? 'success'
: country.trend === 'down'
? 'error'
: 'default'
}
>
{country.trend === 'up' ? '↑' : country.trend === 'down' ? '↓' : '→'}
{country.trendPercentage}%{country.isNew ? ' (new)' : ''}
</Badge>
</div>
</div>
))}
</div>
) : (
<p className="text-gray-400 text-center py-8">No country data available yet</p>
)}
</CardContent>
</Card>
</div>
{/* Top Performing QR Codes with Sparklines */}
<Card className="overflow-visible">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg font-semibold flex items-center gap-2">
<QrCode className="w-5 h-5" />
Top Performing QR Codes
</CardTitle>
</CardHeader>
<CardContent>
{analyticsData?.qrPerformance.length > 0 ? (
<Table>
<thead>
<tr>
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">QR Code</th>
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">Type</th>
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">Total Scans</th>
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">Unique Scans</th>
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">Conversion</th>
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">Trend</th>
</tr>
</thead>
<tbody>
{analyticsData.qrPerformance.map((qr: any) => (
<tr key={qr.id} className="border-b transition-colors hover:bg-gray-50/50">
<td className="px-4 py-4 align-middle font-medium">{qr.title}</td>
<td className="px-4 py-4 align-middle">
<Badge variant={qr.type === 'DYNAMIC' ? 'info' : 'default'}>
{qr.type}
</Badge>
</td>
<td className="px-4 py-4 align-middle">{qr.totalScans.toLocaleString()}</td>
<td className="px-4 py-4 align-middle">{qr.uniqueScans.toLocaleString()}</td>
<td className="px-4 py-4 align-middle">{qr.conversion}%</td>
<td className="px-4 py-4 align-middle">
<Badge variant={
qr.trend === 'up' ? 'success' :
qr.trend === 'down' ? 'destructive' :
'default'
}>
{qr.trend === 'up' ? '↑' : qr.trend === 'down' ? '↓' : '→'} {qr.trendPercentage}%
</Badge>
</td>
{(analyticsData?.qrPerformance?.length || 0) > 0 ? (
<div className="overflow-x-auto overflow-y-visible">
<Table>
<thead>
<tr className="border-b border-gray-100">
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">
QR Code
</th>
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">
Type
</th>
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">
Total Scans
</th>
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">
Unique Scans
</th>
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">
<div className="flex items-center gap-1.5">
<span>Conversions</span>
<div className="group relative inline-block">
<HelpCircle className="w-3.5 h-3.5 text-gray-400 cursor-help" />
<div className="invisible group-hover:visible absolute top-full left-1/2 -translate-x-1/2 mt-2 w-72 p-3 bg-gray-900 text-white text-xs rounded-lg shadow-xl z-[9999] pointer-events-none">
<div className="font-semibold mb-1">Conversion Rate</div>
<div className="text-gray-300">
Percentage of unique scans vs total scans. Formula: (Unique Scans / Total Scans) × 100%
</div>
<div className="absolute bottom-full left-1/2 -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-b-4 border-l-transparent border-r-transparent border-b-gray-900"></div>
</div>
</div>
</div>
</th>
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">
Trend
</th>
</tr>
))}
</tbody>
</Table>
</thead>
<tbody>
{analyticsData!.qrPerformance.map((qr) => (
<tr
key={qr.id}
className="border-b border-gray-50 transition-colors hover:bg-gray-50/50"
>
<td className="px-4 py-4 align-middle">
<span className="font-medium text-gray-900">{qr.title}</span>
</td>
<td className="px-4 py-4 align-middle">
<Badge variant={qr.type === 'DYNAMIC' ? 'info' : 'default'}>
{qr.type}
</Badge>
</td>
<td className="px-4 py-4 align-middle font-medium">
{qr.totalScans.toLocaleString()}
</td>
<td className="px-4 py-4 align-middle">{qr.uniqueScans.toLocaleString()}</td>
<td className="px-4 py-4 align-middle">{qr.conversion}%</td>
<td className="px-4 py-4 align-middle">
<div className="flex items-center gap-3">
<Sparkline
data={qr.sparkline || [0, 0, 0, 0, 0, 0, 0]}
color={
qr.trend === 'up'
? 'green'
: qr.trend === 'down'
? 'red'
: 'blue'
}
/>
<Badge
variant={
qr.trend === 'up'
? 'success'
: qr.trend === 'down'
? 'error'
: 'default'
}
>
{qr.trend === 'up' ? '↑' : qr.trend === 'down' ? '↓' : '→'}
{qr.trendPercentage}%{qr.isNew ? ' (new)' : ''}
</Badge>
</div>
</td>
</tr>
))}
</tbody>
</Table>
</div>
) : (
<p className="text-gray-500 text-center py-8">
No QR codes created yet. Create your first QR code to see analytics!
</p>
<div className="text-center py-12">
<QrCode className="w-12 h-12 text-gray-300 mx-auto mb-4" />
<p className="text-gray-500">
No QR codes created yet. Create your first QR code to see analytics!
</p>
</div>
)}
</CardContent>
</Card>

View File

@@ -3,7 +3,7 @@
import React, { useState, useCallback } from 'react';
import { useDropzone } from 'react-dropzone';
import Papa from 'papaparse';
import * as XLSX from 'xlsx';
import ExcelJS from 'exceljs';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
@@ -66,12 +66,33 @@ export default function BulkCreationPage() {
};
reader.readAsText(file);
} else if (file.name.endsWith('.xlsx') || file.name.endsWith('.xls')) {
reader.onload = (e) => {
const data = new Uint8Array(e.target?.result as ArrayBuffer);
const workbook = XLSX.read(data, { type: 'array' });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const jsonData = XLSX.utils.sheet_to_json(worksheet);
reader.onload = async (e) => {
const buffer = e.target?.result as ArrayBuffer;
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(buffer);
const worksheet = workbook.worksheets[0];
const jsonData: any[] = [];
// Get headers from first row
const headers: string[] = [];
const firstRow = worksheet.getRow(1);
firstRow.eachCell((cell, colNumber) => {
headers[colNumber - 1] = cell.value?.toString() || '';
});
// Convert rows to objects
worksheet.eachRow((row, rowNumber) => {
if (rowNumber === 1) return; // Skip header row
const rowData: any = {};
row.eachCell((cell, colNumber) => {
const header = headers[colNumber - 1];
if (header) {
rowData[header] = cell.value;
}
});
jsonData.push(rowData);
});
processData(jsonData);
};
reader.readAsArrayBuffer(file);

View File

@@ -33,6 +33,11 @@ export default function CreatePage() {
const [cornerStyle, setCornerStyle] = useState('square');
const [size, setSize] = useState(200);
// Logo state (PRO feature)
const [logo, setLogo] = useState<string>('');
const [logoSize, setLogoSize] = useState(40);
const [excavate, setExcavate] = useState(true);
// QR preview
const [qrDataUrl, setQrDataUrl] = useState('');
@@ -167,6 +172,15 @@ export default function CreatePage() {
backgroundColor: canCustomizeColors ? backgroundColor : '#FFFFFF',
cornerStyle,
size,
// Logo embedding (PRO only)
...(logo && canCustomizeColors ? {
imageSettings: {
src: logo,
height: logoSize,
width: logoSize,
excavate: excavate,
}
} : {}),
},
};
@@ -488,6 +502,95 @@ export default function CreatePage() {
</div>
</CardContent>
</Card>
{/* Logo/Icon Section (PRO Feature) */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Logo / Icon</CardTitle>
<Badge variant="info">PRO</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{!canCustomizeColors ? (
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-900">
<strong>Upgrade to PRO</strong> to add your logo or icon to QR codes.
</p>
<Link href="/pricing">
<Button variant="primary" size="sm" className="mt-2">
Upgrade Now
</Button>
</Link>
</div>
) : (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Upload Logo (PNG, JPG)
</label>
<input
type="file"
accept="image/png,image/jpeg,image/jpg"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
setLogo(reader.result as string);
};
reader.readAsDataURL(file);
}
}}
className="w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100"
/>
</div>
{logo && (
<>
<div className="flex items-center gap-4">
<img src={logo} alt="Logo preview" className="w-12 h-12 object-contain rounded border" />
<Button
variant="outline"
size="sm"
onClick={() => setLogo('')}
>
Remove
</Button>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Logo Size: {logoSize}px
</label>
<input
type="range"
min="24"
max="80"
value={logoSize}
onChange={(e) => setLogoSize(Number(e.target.value))}
className="w-full"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="excavate"
checked={excavate}
onChange={(e) => setExcavate(e.target.checked)}
className="w-4 h-4 rounded border-gray-300"
/>
<label htmlFor="excavate" className="text-sm text-gray-700">
Clear background behind logo (recommended)
</label>
</div>
</>
)}
</>
)}
</CardContent>
</Card>
</div>
{/* Right: Preview */}
@@ -505,7 +608,13 @@ export default function CreatePage() {
size={200}
fgColor={foregroundColor}
bgColor={backgroundColor}
level="M"
level={logo && canCustomizeColors ? 'H' : 'M'}
imageSettings={logo && canCustomizeColors ? {
src: logo,
height: logoSize,
width: logoSize,
excavate: excavate,
} : undefined}
/>
</div>
) : (

View File

@@ -23,6 +23,7 @@ interface QRCodeData {
createdAt: string;
scans: number;
style?: any;
status?: 'ACTIVE' | 'INACTIVE';
}
export default function DashboardPage() {
@@ -40,7 +41,10 @@ export default function DashboardPage() {
totalScans: 0,
activeQRCodes: 0,
conversionRate: 0,
uniqueScans: 0,
});
const [analyticsData, setAnalyticsData] = useState<any>(null);
const [userSubdomain, setUserSubdomain] = useState<string | null>(null);
const mockQRCodes = [
{
@@ -106,27 +110,53 @@ export default function DashboardPage() {
];
const blogPosts = [
// NEW POSTS
{
title: 'How to Create a QR Code for Restaurant Menu',
excerpt: 'Step-by-step guide to creating digital menu QR codes for your restaurant. Best practices for touchless menus.',
readTime: '12 Min',
slug: 'qr-code-restaurant-menu',
},
{
title: 'Free vCard QR Code Generator: Digital Business Cards',
excerpt: 'Create professional vCard QR codes for digital business cards. Share contact info instantly.',
readTime: '10 Min',
slug: 'vcard-qr-code-generator',
},
{
title: 'Best QR Code Generator for Small Business',
excerpt: 'Find the best QR code solution for your small business. Compare features, pricing, and use cases.',
readTime: '14 Min',
slug: 'qr-code-small-business',
},
{
title: 'QR Code Print Size Guide',
excerpt: 'Complete guide to QR code print sizes. Minimum dimensions for business cards, posters, and more.',
readTime: '8 Min',
slug: 'qr-code-print-size-guide',
},
// EXISTING POSTS
{
title: 'QR Code Tracking: Complete Guide 2025',
excerpt: 'Learn how to track QR code scans with real-time analytics. Compare free vs paid tracking tools, setup Google Analytics, and measure ROI.',
excerpt: 'Learn how to track QR code scans with real-time analytics. Compare free vs paid tracking tools.',
readTime: '12 Min',
slug: 'qr-code-tracking-guide-2025',
},
{
title: 'Dynamic vs Static QR Codes: Which Should You Use?',
excerpt: 'Understand the difference between static and dynamic QR codes. Learn when to use each type, pros/cons, and how dynamic QR codes save money.',
excerpt: 'Understand the difference between static and dynamic QR codes. Pros, cons, and when to use each.',
readTime: '10 Min',
slug: 'dynamic-vs-static-qr-codes',
},
{
title: 'How to Generate Bulk QR Codes from Excel',
excerpt: 'Generate hundreds of QR codes from Excel or CSV files in minutes. Step-by-step guide with templates, best practices, and free tools.',
excerpt: 'Generate hundreds of QR codes from Excel or CSV files in minutes. Step-by-step guide.',
readTime: '13 Min',
slug: 'bulk-qr-code-generator-excel',
},
{
title: 'QR Code Analytics: Track, Measure & Optimize Campaigns',
excerpt: 'Learn how to leverage scan analytics, campaign tracking, and dashboard insights to maximize QR code ROI.',
title: 'QR Code Analytics: Track, Measure & Optimize',
excerpt: 'Learn how to leverage scan analytics and dashboard insights to maximize QR code ROI.',
readTime: '15 Min',
slug: 'qr-code-analytics',
},
@@ -216,12 +246,15 @@ export default function DashboardPage() {
// Calculate real stats
const totalScans = data.reduce((sum: number, qr: QRCodeData) => sum + (qr.scans || 0), 0);
const activeQRCodes = data.filter((qr: QRCodeData) => qr.status === 'ACTIVE').length;
const conversionRate = activeQRCodes > 0 ? Math.round((totalScans / (activeQRCodes * 100)) * 100) : 0;
// Calculate unique scans (absolute count)
const uniqueScans = data.reduce((acc: number, qr: any) => acc + (qr.uniqueScans || 0), 0);
const conversionRate = totalScans > 0 ? Math.round((uniqueScans / totalScans) * 100) : 0;
setStats({
totalScans,
activeQRCodes,
conversionRate: Math.min(conversionRate, 100), // Cap at 100%
conversionRate,
uniqueScans,
});
} else {
// If not logged in, show zeros
@@ -230,6 +263,7 @@ export default function DashboardPage() {
totalScans: 0,
activeQRCodes: 0,
conversionRate: 0,
uniqueScans: 0,
});
}
@@ -239,6 +273,20 @@ export default function DashboardPage() {
const userData = await userResponse.json();
setUserPlan(userData.plan || 'FREE');
}
// Fetch analytics data for trends (last 30 days = month comparison)
const analyticsResponse = await fetch('/api/analytics/summary?range=30');
if (analyticsResponse.ok) {
const analytics = await analyticsResponse.json();
setAnalyticsData(analytics);
}
// Fetch user subdomain for white label display
const subdomainResponse = await fetch('/api/user/subdomain');
if (subdomainResponse.ok) {
const subdomainData = await subdomainResponse.json();
setUserSubdomain(subdomainData.subdomain || null);
}
} catch (error) {
console.error('Error fetching data:', error);
setQrCodes([]);
@@ -246,6 +294,7 @@ export default function DashboardPage() {
totalScans: 0,
activeQRCodes: 0,
conversionRate: 0,
uniqueScans: 0,
});
} finally {
setLoading(false);
@@ -307,6 +356,7 @@ export default function DashboardPage() {
totalScans: 0,
activeQRCodes: 0,
conversionRate: 0,
uniqueScans: 0,
});
showToast(`Successfully deleted ${data.deletedCount} QR code${data.deletedCount !== 1 ? 's' : ''}`, 'success');
} else {
@@ -357,7 +407,13 @@ export default function DashboardPage() {
</div>
{/* Stats Grid */}
<StatsGrid stats={stats} />
<StatsGrid
stats={stats}
trends={{
totalScans: analyticsData?.summary.scansTrend,
comparisonPeriod: analyticsData?.summary.comparisonPeriod || 'month'
}}
/>
{/* Recent QR Codes */}
<div>
@@ -401,39 +457,42 @@ export default function DashboardPage() {
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{qrCodes.map((qr) => (
<QRCodeCard
key={qr.id}
key={`${qr.id}-${userSubdomain || 'default'}`}
qr={qr}
onEdit={handleEdit}
onDelete={handleDelete}
userSubdomain={userSubdomain}
/>
))}
</div>
)}
</div>
{/* Blog & Resources */}
{/* Blog & Resources - Horizontal Scroll */}
<div>
<h2 className="text-xl font-semibold text-gray-900 mb-6">{t('dashboard.blog_resources')}</h2>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
{blogPosts.map((post) => (
<Card key={post.slug} hover>
<CardHeader>
<div className="flex items-center justify-between mb-2">
<Badge variant="info">{post.readTime}</Badge>
</div>
<CardTitle className="text-lg">{post.title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-600 text-sm">{post.excerpt}</p>
<Link
href={`/blog/${post.slug}`}
className="text-primary-600 hover:text-primary-700 text-sm font-medium mt-3 inline-block"
>
Read more
</Link>
</CardContent>
</Card>
))}
<div className="overflow-x-auto pb-4 -mx-4 px-4">
<div className="flex gap-6" style={{ minWidth: 'max-content' }}>
{blogPosts.map((post) => (
<Card key={post.slug} hover className="flex-shrink-0" style={{ width: '300px' }}>
<CardHeader>
<div className="flex items-center justify-between mb-2">
<Badge variant="info">{post.readTime}</Badge>
</div>
<CardTitle className="text-lg line-clamp-2">{post.title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-600 text-sm line-clamp-2">{post.excerpt}</p>
<Link
href={`/blog/${post.slug}`}
className="text-primary-600 hover:text-primary-700 text-sm font-medium mt-3 inline-block"
>
Read more
</Link>
</CardContent>
</Card>
))}
</div>
</div>
</div>

View File

@@ -1,12 +1,20 @@
'use client';
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { Button } from '@/components/ui/Button';
import { Dropdown, DropdownItem } from '@/components/ui/Dropdown';
import { Footer } from '@/components/ui/Footer';
import { useTranslation } from '@/hooks/useTranslation';
interface User {
id: string;
name: string | null;
email: string;
plan: string | null;
}
export default function AppLayout({
children,
}: {
@@ -16,6 +24,24 @@ export default function AppLayout({
const router = useRouter();
const { t } = useTranslation();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [user, setUser] = useState<User | null>(null);
// Fetch user data on mount
useEffect(() => {
const fetchUser = async () => {
try {
const response = await fetch('/api/user');
if (response.ok) {
const userData = await response.json();
setUser(userData);
}
} catch (error) {
console.error('Error fetching user:', error);
}
};
fetchUser();
}, []);
const handleSignOut = async () => {
// Track logout event before clearing data
@@ -37,6 +63,34 @@ export default function AppLayout({
router.push('/');
};
// Get user initials for avatar (e.g., "Timo Schmidt" -> "TS")
const getUserInitials = () => {
if (!user) return 'U';
if (user.name) {
const names = user.name.trim().split(' ');
if (names.length >= 2) {
return (names[0][0] + names[names.length - 1][0]).toUpperCase();
}
return user.name.substring(0, 2).toUpperCase();
}
// Fallback to email
return user.email.substring(0, 1).toUpperCase();
};
// Get display name (first name or full name)
const getDisplayName = () => {
if (!user) return 'User';
if (user.name) {
return user.name;
}
// Fallback to email without domain
return user.email.split('@')[0];
};
const navigation = [
{
name: t('nav.dashboard'),
@@ -107,9 +161,8 @@ export default function AppLayout({
{/* Sidebar */}
<aside
className={`fixed top-0 left-0 z-50 h-full w-64 bg-white border-r border-gray-200 transform transition-transform lg:translate-x-0 ${
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`}
className={`fixed top-0 left-0 z-50 h-full w-64 bg-white border-r border-gray-200 transform transition-transform lg:translate-x-0 ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<Link href="/" className="flex items-center space-x-2">
@@ -133,11 +186,10 @@ export default function AppLayout({
<Link
key={item.name}
href={item.href}
className={`flex items-center space-x-3 px-3 py-2 rounded-lg transition-colors ${
isActive
? 'bg-primary-50 text-primary-600'
: 'text-gray-700 hover:bg-gray-100'
}`}
className={`flex items-center space-x-3 px-3 py-2 rounded-lg transition-colors ${isActive
? 'bg-primary-50 text-primary-600'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
{item.icon}
<span className="font-medium">{item.name}</span>
@@ -169,11 +221,11 @@ export default function AppLayout({
<button className="flex items-center space-x-2 text-gray-700 hover:text-gray-900">
<div className="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center">
<span className="text-sm font-medium text-primary-600">
U
{getUserInitials()}
</span>
</div>
<span className="hidden md:block font-medium">
User
{getDisplayName()}
</span>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
@@ -193,6 +245,9 @@ export default function AppLayout({
<main className="p-6">
{children}
</main>
{/* Footer */}
<Footer variant="dashboard" />
</div>
</div>
);

View File

@@ -4,11 +4,12 @@ import React, { useState, useEffect } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Input } from '@/components/ui/Input';
import { useCsrf } from '@/hooks/useCsrf';
import { showToast } from '@/components/ui/Toast';
import ChangePasswordModal from '@/components/settings/ChangePasswordModal';
type TabType = 'profile' | 'subscription';
type TabType = 'profile' | 'subscription' | 'whitelabel';
export default function SettingsPage() {
const { fetchWithCsrf } = useCsrf();
@@ -28,6 +29,11 @@ export default function SettingsPage() {
staticUsed: 0,
});
// White Label Subdomain states
const [subdomain, setSubdomain] = useState('');
const [savedSubdomain, setSavedSubdomain] = useState<string | null>(null);
const [subdomainLoading, setSubdomainLoading] = useState(false);
// Load user data
useEffect(() => {
const fetchUserData = async () => {
@@ -53,6 +59,14 @@ export default function SettingsPage() {
const data = await statsResponse.json();
setUsageStats(data);
}
// Fetch subdomain
const subdomainResponse = await fetch('/api/user/subdomain');
if (subdomainResponse.ok) {
const data = await subdomainResponse.json();
setSavedSubdomain(data.subdomain);
setSubdomain(data.subdomain || '');
}
} catch (e) {
console.error('Failed to load user data:', e);
}
@@ -185,24 +199,31 @@ export default function SettingsPage() {
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveTab('profile')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'profile'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
className={`py-4 px-1 border-b-2 font-medium text-sm ${activeTab === 'profile'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Profile
</button>
<button
onClick={() => setActiveTab('subscription')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'subscription'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
className={`py-4 px-1 border-b-2 font-medium text-sm ${activeTab === 'subscription'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Subscription
</button>
<button
onClick={() => setActiveTab('whitelabel')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${activeTab === 'whitelabel'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
White Label
</button>
</nav>
</div>
@@ -373,6 +394,143 @@ export default function SettingsPage() {
</div>
)}
{activeTab === 'whitelabel' && (
<div className="space-y-6">
{/* White Label Subdomain */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>White Label Subdomain</CardTitle>
<Badge variant="success">FREE</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-gray-600 text-sm">
Create your own branded QR code URL. Your QR codes will be accessible via your custom subdomain.
</p>
<div className="flex items-center gap-2">
<Input
value={subdomain}
onChange={(e) => setSubdomain(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
placeholder="your-brand"
className="flex-1 max-w-xs"
/>
<span className="text-gray-600 font-medium">.qrmaster.net</span>
</div>
<div className="text-sm text-gray-500">
<ul className="list-disc list-inside space-y-1">
<li>3-30 characters</li>
<li>Only lowercase letters, numbers, and hyphens</li>
<li>Cannot start or end with a hyphen</li>
</ul>
</div>
{savedSubdomain && (
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
<p className="text-green-800 font-medium">
Your white label URL is active:
</p>
<a
href={`https://${savedSubdomain}.qrmaster.net`}
target="_blank"
rel="noopener noreferrer"
className="text-green-700 underline"
>
https://{savedSubdomain}.qrmaster.net
</a>
</div>
)}
<div className="flex gap-3">
<Button
onClick={async () => {
if (!subdomain.trim()) {
showToast('Please enter a subdomain', 'error');
return;
}
setSubdomainLoading(true);
try {
const response = await fetchWithCsrf('/api/user/subdomain', {
method: 'POST',
body: JSON.stringify({ subdomain: subdomain.trim().toLowerCase() }),
});
const data = await response.json();
if (response.ok) {
setSavedSubdomain(subdomain.trim().toLowerCase());
showToast('Subdomain saved successfully!', 'success');
} else {
showToast(data.error || 'Error saving subdomain', 'error');
}
} catch (error) {
showToast('Error saving subdomain', 'error');
} finally {
setSubdomainLoading(false);
}
}}
loading={subdomainLoading}
disabled={!subdomain.trim() || subdomain === savedSubdomain}
>
{savedSubdomain ? 'Update Subdomain' : 'Save Subdomain'}
</Button>
{savedSubdomain && (
<Button
variant="outline"
onClick={async () => {
setSubdomainLoading(true);
try {
const response = await fetchWithCsrf('/api/user/subdomain', {
method: 'DELETE',
});
if (response.ok) {
setSavedSubdomain(null);
setSubdomain('');
showToast('Subdomain removed', 'success');
}
} catch (error) {
showToast('Error removing subdomain', 'error');
} finally {
setSubdomainLoading(false);
}
}}
disabled={subdomainLoading}
>
Remove
</Button>
)}
</div>
</CardContent>
</Card>
{/* How it works */}
{savedSubdomain && (
<Card>
<CardHeader>
<CardTitle>How it works</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4 text-sm">
<div className="grid grid-cols-2 gap-4">
<div className="p-3 bg-gray-100 rounded-lg">
<p className="text-gray-500 mb-1">Before (default)</p>
<code className="text-gray-800">qrmaster.net/r/your-qr</code>
</div>
<div className="p-3 bg-primary-50 rounded-lg border border-primary-200">
<p className="text-primary-600 mb-1">After (your brand)</p>
<code className="text-primary-800">{savedSubdomain}.qrmaster.net/r/your-qr</code>
</div>
</div>
<p className="text-gray-600">
All your QR codes will work with both URLs. Share the branded version with your clients!
</p>
</div>
</CardContent>
</Card>
)}
</div>
)}
{/* Change Password Modal */}
<ChangePasswordModal
isOpen={showPasswordModal}

View File

@@ -1839,6 +1839,587 @@ const response = await fetch('https://api.qrmaster.net/v1/bulk', {
</ul>
</div>`,
},
// ============ NEW BLOG POSTS ============
'qr-code-restaurant-menu': {
slug: 'qr-code-restaurant-menu',
title: 'How to Create a QR Code for Restaurant Menu: Complete 2025 Guide',
excerpt: 'Step-by-step guide to creating digital menu QR codes for your restaurant. Learn best practices for touchless menus, placement tips, and tracking.',
date: 'January 5, 2026',
datePublished: '2026-01-05T09:00:00Z',
dateModified: '2026-01-05T09:00:00Z',
readTime: '12 Min',
category: 'Restaurant',
image: '/blog/restaurant-qr-menu.png',
imageAlt: 'Restaurant table with QR code menu card and smartphone scanning',
author: 'QR Master Team',
authorUrl: 'https://www.qrmaster.net/about',
answer: 'To create a QR code for your restaurant menu, use a dynamic QR code generator like QR Master. Upload your menu PDF or link to your online menu, customize the QR code design, print it on table tents or cards, and track scans to understand customer engagement.',
howTo: {
name: 'How to Create a Restaurant Menu QR Code',
description: 'Complete guide to setting up touchless digital menus with QR codes',
totalTime: 'PT15M',
steps: [
{
name: 'Prepare Your Digital Menu',
text: 'Create a mobile-friendly menu using PDF, Google Docs, or a dedicated menu platform. Ensure it loads quickly on smartphones.',
},
{
name: 'Generate a Dynamic QR Code',
text: 'Use QR Master to create a dynamic QR code. This allows you to update your menu URL anytime without reprinting codes.',
url: 'https://www.qrmaster.net/create',
},
{
name: 'Customize Your QR Code Design',
text: 'Add your restaurant logo, match brand colors, and ensure high contrast for easy scanning.',
},
{
name: 'Print and Place Strategically',
text: 'Print QR codes on table tents, coasters, or wall-mounted displays. Minimum size: 2x2 inches for table scanning.',
},
{
name: 'Track and Optimize',
text: 'Monitor scan analytics in your QR Master dashboard to understand peak times and popular menu items.',
url: 'https://www.qrmaster.net/analytics',
},
],
},
content: `<div class="blog-content">
<h2>Why Restaurants Need QR Code Menus in 2025</h2>
<p>Digital QR code menus have evolved from a pandemic necessity to a restaurant industry standard. In 2025, over 60% of diners prefer scanning a QR code over handling physical menus. For restaurant owners, QR menus offer significant benefits: reduced printing costs, instant menu updates, and valuable customer analytics.</p>
<p>Whether you run a fine dining establishment, casual café, or food truck, implementing a <strong>restaurant menu QR code</strong> system can streamline operations and enhance the guest experience.</p>
<h2>Step 1: Prepare Your Digital Menu</h2>
<h3>Menu Format Options</h3>
<p>Choose the right format for your digital menu:</p>
<ul>
<li><strong>PDF Menu:</strong> Simple and universal. Upload your existing menu design as a PDF for instant access.</li>
<li><strong>Website/Landing Page:</strong> Create a dedicated menu page on your website with images and descriptions.</li>
<li><strong>Menu Platform:</strong> Use services like Square, Toast, or dedicated menu apps for interactive features.</li>
<li><strong>Google Doc:</strong> Free option that allows real-time updates shared via link.</li>
</ul>
<h3>Mobile Optimization Tips</h3>
<p>Your digital menu must be mobile-friendly since 95% of scans come from smartphones:</p>
<ul>
<li>Use readable font sizes (minimum 16px)</li>
<li>Ensure fast load times (under 3 seconds)</li>
<li>Make buttons and links thumb-friendly</li>
<li>Test on both iOS and Android devices</li>
</ul>
<h2>Step 2: Create Your QR Code with QR Master</h2>
<div class="my-8">
<img src="/blog/restaurant-qr-body.png" alt="Customer scanning QR code menu at restaurant" class="rounded-lg shadow-lg w-full" />
</div>
<p>Using a <a href="/dynamic-qr-code-generator">dynamic QR code generator</a> is essential for restaurants. Unlike static codes, dynamic QR codes let you:</p>
<ul>
<li><strong>Update your menu URL anytime</strong> without reprinting QR codes</li>
<li><strong>Track scan analytics</strong> to understand customer behavior</li>
<li><strong>A/B test different landing pages</strong> for seasonal menus</li>
<li><strong>Schedule changes</strong> for lunch vs. dinner menus</li>
</ul>
<div class="bg-blue-50 border-l-4 border-blue-500 p-6 my-8 rounded-r-lg">
<h3 class="text-xl font-semibold mb-2">Pro Tip: Use Dynamic QR Codes</h3>
<p>Static QR codes encode the URL directly—if your menu URL changes, you need new codes. Dynamic codes redirect through our servers, allowing unlimited URL updates. <a href="/blog/dynamic-vs-static-qr-codes">Learn the difference</a>.</p>
</div>
<h2>Step 3: Customize Your Restaurant QR Code</h2>
<p>Branding matters. A generic black-and-white QR code looks out of place in a well-designed restaurant. Customize your code to match your brand:</p>
<ul>
<li><strong>Add your logo:</strong> Place your restaurant logo in the center of the QR code</li>
<li><strong>Match brand colors:</strong> Use your brand's color palette for foreground and background</li>
<li><strong>Choose corner styles:</strong> Rounded corners for casual vibes, square for modern/minimal</li>
<li><strong>Maintain contrast:</strong> Ensure minimum 3:1 contrast ratio for reliable scanning</li>
</ul>
<h2>Step 4: Print and Placement Best Practices</h2>
<h3>Optimal QR Code Sizes for Restaurants</h3>
<table class="w-full border-collapse my-6">
<thead>
<tr class="bg-gray-100">
<th class="border p-3 text-left">Placement</th>
<th class="border p-3 text-left">Minimum Size</th>
<th class="border p-3 text-left">Recommended Size</th>
</tr>
</thead>
<tbody>
<tr><td class="border p-3">Table tent</td><td class="border p-3">2" x 2"</td><td class="border p-3">2.5" x 2.5"</td></tr>
<tr><td class="border p-3">Coaster</td><td class="border p-3">1.5" x 1.5"</td><td class="border p-3">2" x 2"</td></tr>
<tr><td class="border p-3">Wall poster</td><td class="border p-3">4" x 4"</td><td class="border p-3">6" x 6"</td></tr>
<tr><td class="border p-3">Window decal</td><td class="border p-3">3" x 3"</td><td class="border p-3">4" x 4"</td></tr>
</tbody>
</table>
<p>Learn more about <a href="/blog/qr-code-print-size-guide">optimal QR code print sizes</a> for various materials.</p>
<h3>Strategic Placement Locations</h3>
<ul>
<li><strong>On every table:</strong> Table tents or built-in holders</li>
<li><strong>At the entrance:</strong> Allow guests to browse while waiting</li>
<li><strong>On takeout packaging:</strong> Link to your full menu or loyalty program</li>
<li><strong>At the bar:</strong> Separate drink menu access</li>
</ul>
<h2>Step 5: Track and Analyze Menu Scans</h2>
<p>With <a href="/qr-code-tracking">QR code tracking</a>, you gain valuable insights:</p>
<ul>
<li><strong>Peak scanning times:</strong> Understand when guests are viewing your menu</li>
<li><strong>Device types:</strong> Optimize for the most common devices</li>
<li><strong>Scan locations:</strong> See which tables or areas have most engagement</li>
<li><strong>Repeat scans:</strong> Identify returning customers</li>
</ul>
<h2>Common Mistakes to Avoid</h2>
<ul>
<li>❌ Using static QR codes (can't update menu URL)</li>
<li>❌ Too small print size (under 1.5 inches)</li>
<li>❌ Poor lighting near QR code placement</li>
<li>❌ Linking to non-mobile-friendly PDFs</li>
<li>❌ No call-to-action text near the code</li>
</ul>
<h2>Conclusion</h2>
<p>Creating a QR code for your restaurant menu is straightforward with the right approach. Use dynamic QR codes for flexibility, customize to match your brand, print at appropriate sizes, and track analytics to continuously improve the guest experience.</p>
<div class="bg-gradient-to-br from-primary-50 to-primary-100 p-8 rounded-2xl my-12 border border-primary-200">
<h3 class="text-2xl font-bold text-gray-900 mb-4">Create Your Restaurant Menu QR Code</h3>
<p class="text-lg text-gray-700 mb-6">Start free with QR Master—no credit card required. Update your menu anytime and track every scan.</p>
<a href="/signup" class="inline-block bg-primary-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-primary-700 transition-colors">Create Menu QR Free →</a>
</div>
<h2>Related Resources</h2>
<ul>
<li><a href="/dynamic-qr-code-generator">Dynamic QR Code Generator</a></li>
<li><a href="/blog/qr-code-print-size-guide">QR Code Print Size Guide</a></li>
<li><a href="/blog/qr-code-analytics">QR Code Analytics Guide</a></li>
<li><a href="/pricing">Pricing Plans</a></li>
</ul>
</div>`,
},
'vcard-qr-code-generator': {
slug: 'vcard-qr-code-generator',
title: 'Free vCard QR Code Generator: Digital Business Cards Made Easy',
excerpt: 'Create professional vCard QR codes for digital business cards. Share contact info instantly with a scan—includes templates and best practices.',
date: 'January 5, 2026',
datePublished: '2026-01-05T10:00:00Z',
dateModified: '2026-01-05T10:00:00Z',
readTime: '10 Min',
category: 'Business Cards',
image: '/blog/vcard-qr-code.png',
imageAlt: 'Professional business card with vCard QR code being scanned by smartphone',
author: 'QR Master Team',
authorUrl: 'https://www.qrmaster.net/about',
answer: 'A vCard QR code contains your contact information in a standardized format. When scanned, it allows the recipient to save your name, phone, email, company, and social links directly to their phone contacts with one tap.',
howTo: {
name: 'How to Create a vCard QR Code',
description: 'Step-by-step guide to creating digital business card QR codes',
totalTime: 'PT5M',
steps: [
{
name: 'Enter Your Contact Information',
text: 'Fill in your name, phone number, email, company, job title, and website URL.',
},
{
name: 'Add Social Media Links',
text: 'Include LinkedIn, Twitter, or other professional networks you want to share.',
},
{
name: 'Customize the QR Code Design',
text: 'Match your personal or company branding with custom colors and logo.',
},
{
name: 'Download and Print',
text: 'Export as SVG or high-resolution PNG for business cards, email signatures, or presentations.',
},
],
},
content: `<div class="blog-content">
<h2>What is a vCard QR Code?</h2>
<p>A vCard (Virtual Contact File) QR code contains your contact information in a standardized format (.vcf). When someone scans it with their smartphone camera, they can instantly save your details to their contacts—no typing required.</p>
<p>This technology has revolutionized professional networking. Instead of handing out paper business cards that often get lost, a <strong>vCard QR code</strong> ensures your contact information is digitally saved and accessible.</p>
<h2>Why Use a Digital Business Card QR Code?</h2>
<ul>
<li><strong>Instant Saving:</strong> Recipients add your contact with one tap</li>
<li><strong>Always Up-to-Date:</strong> With dynamic vCards, update your info without new cards</li>
<li><strong>Eco-Friendly:</strong> Reduce paper waste from traditional business cards</li>
<li><strong>Track Engagement:</strong> See who scanned and when</li>
<li><strong>Rich Information:</strong> Include social links, profile photos, and more</li>
</ul>
<div class="my-8">
<img src="/blog/vcard-qr-body.png" alt="Business professionals exchanging digital business cards" class="rounded-lg shadow-lg w-full" />
</div>
<h2>Information You Can Include in a vCard</h2>
<p>A comprehensive vCard QR code can contain:</p>
<ul>
<li><strong>Personal Info:</strong> First name, last name, prefix, suffix</li>
<li><strong>Contact Details:</strong> Mobile, work, and home phone numbers</li>
<li><strong>Email Addresses:</strong> Personal and work email</li>
<li><strong>Company Info:</strong> Company name, job title, department</li>
<li><strong>Address:</strong> Street, city, state, country, postal code</li>
<li><strong>Website:</strong> Personal or company URL</li>
<li><strong>Social Media:</strong> LinkedIn, Twitter, Instagram, Facebook</li>
<li><strong>Profile Photo:</strong> Small image encoded in the vCard</li>
<li><strong>Notes:</strong> Brief description or meeting context</li>
</ul>
<h2>Static vs Dynamic vCard QR Codes</h2>
<table class="w-full border-collapse my-6">
<thead>
<tr class="bg-gray-100">
<th class="border p-3 text-left">Feature</th>
<th class="border p-3 text-left">Static vCard</th>
<th class="border p-3 text-left">Dynamic vCard</th>
</tr>
</thead>
<tbody>
<tr><td class="border p-3">Edit after printing</td><td class="border p-3">❌ No</td><td class="border p-3">✅ Yes</td></tr>
<tr><td class="border p-3">Scan tracking</td><td class="border p-3">❌ No</td><td class="border p-3">✅ Yes</td></tr>
<tr><td class="border p-3">QR code size</td><td class="border p-3">Larger (more data)</td><td class="border p-3">Smaller (redirect URL)</td></tr>
<tr><td class="border p-3">Requires account</td><td class="border p-3">No</td><td class="border p-3">Yes (free)</td></tr>
<tr><td class="border p-3">Works offline</td><td class="border p-3">✅ Yes</td><td class="border p-3">Needs internet</td></tr>
</tbody>
</table>
<div class="bg-blue-50 border-l-4 border-blue-500 p-6 my-8 rounded-r-lg">
<h3 class="text-xl font-semibold mb-2">Recommendation: Use Dynamic vCards</h3>
<p>If you change jobs, phone numbers, or roles, dynamic vCard QR codes let you update without reprinting business cards. Learn more about <a href="/blog/dynamic-vs-static-qr-codes">dynamic vs static QR codes</a>.</p>
</div>
<h2>How to Create a vCard QR Code</h2>
<h3>Step 1: Choose Your QR Code Type</h3>
<p>Go to the <a href="/create">QR Master generator</a> and select "Contact Card" or vCard type. Choose between static (data embedded) or dynamic (editable, trackable).</p>
<h3>Step 2: Enter Your Information</h3>
<p>Fill in the contact form with your details. Required fields typically include:</p>
<ul>
<li>Full name</li>
<li>Primary phone number</li>
<li>Email address</li>
</ul>
<p>Optional but recommended: company name, job title, LinkedIn URL, and website.</p>
<h3>Step 3: Customize Design</h3>
<p>Make your vCard QR code professional:</p>
<ul>
<li>Add your company logo or headshot</li>
<li>Use brand colors</li>
<li>Ensure good contrast for scanning</li>
</ul>
<h3>Step 4: Download and Deploy</h3>
<p>Export your QR code in the right format:</p>
<ul>
<li><strong>SVG:</strong> Best for print (scalable, sharp at any size)</li>
<li><strong>PNG (300 DPI):</strong> Good for digital and print</li>
</ul>
<h2>Where to Use Your vCard QR Code</h2>
<ul>
<li><strong>Business Cards:</strong> Replace or supplement traditional cards</li>
<li><strong>Email Signatures:</strong> Let recipients save your contact instantly</li>
<li><strong>LinkedIn Profile:</strong> Add to your banner or featured section</li>
<li><strong>Conference Badges:</strong> Perfect for networking events</li>
<li><strong>Presentations:</strong> Share contact at the end of talks</li>
<li><strong>Resume/CV:</strong> Modern touch for job applications</li>
</ul>
<h2>Best Practices for Professional vCards</h2>
<ul>
<li>✅ Keep information current and accurate</li>
<li>✅ Use a professional email address (not personal Gmail)</li>
<li>✅ Include your LinkedIn profile</li>
<li>✅ Test scan before printing in bulk</li>
<li>✅ Use dynamic codes if info may change</li>
<li>❌ Don't overload with too many social links</li>
<li>❌ Avoid personal home addresses</li>
</ul>
<h2>Conclusion</h2>
<p>vCard QR codes are essential tools for modern professionals. They ensure your contact information is always accessible, up-to-date, and easy to save. Whether you're networking at conferences, meeting clients, or job hunting, a digital business card QR code makes a lasting impression.</p>
<div class="bg-gradient-to-br from-primary-50 to-primary-100 p-8 rounded-2xl my-12 border border-primary-200">
<h3 class="text-2xl font-bold text-gray-900 mb-4">Create Your Digital Business Card</h3>
<p class="text-lg text-gray-700 mb-6">Generate a free vCard QR code in seconds. Update anytime, track scans, and share professionally.</p>
<a href="/signup" class="inline-block bg-primary-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-primary-700 transition-colors">Create vCard QR Free →</a>
</div>
<h2>Related Resources</h2>
<ul>
<li><a href="/create">QR Code Generator</a></li>
<li><a href="/blog/dynamic-vs-static-qr-codes">Dynamic vs Static QR Codes</a></li>
<li><a href="/blog/qr-code-print-size-guide">QR Code Print Size Guide</a></li>
</ul>
</div>`,
},
'qr-code-small-business': {
slug: 'qr-code-small-business',
title: 'Best QR Code Generator for Small Business: 2025 Complete Guide',
excerpt: 'Find the best QR code solution for your small business. Compare features, pricing, and use cases for marketing, payments, and operations.',
date: 'January 5, 2026',
datePublished: '2026-01-05T11:00:00Z',
dateModified: '2026-01-05T11:00:00Z',
readTime: '14 Min',
category: 'Business',
image: '/blog/small-business-qr.png',
imageAlt: 'Small business owner using QR codes for customer engagement',
author: 'QR Master Team',
authorUrl: 'https://www.qrmaster.net/about',
answer: 'The best QR code generator for small business offers dynamic codes with tracking, custom branding, affordable pricing, and easy management. QR Master provides free static codes, 3 free dynamic codes, and Pro plans starting at €9/month for growing businesses.',
content: `<div class="blog-content">
<h2>Why Small Businesses Need QR Codes</h2>
<p>QR codes have become essential tools for small businesses looking to bridge the gap between physical and digital experiences. From contactless payments to customer feedback, <strong>QR codes for small business</strong> offer affordable, versatile solutions that previously required expensive custom apps.</p>
<div class="my-8">
<img src="/blog/small-business-body.png" alt="Customer scanning QR code at retail checkout" class="rounded-lg shadow-lg w-full" />
</div>
<h2>Top 10 QR Code Use Cases for Small Business</h2>
<h3>1. Digital Menus & Product Catalogs</h3>
<p>Restaurants, cafés, and retail stores use QR codes to display menus and catalogs. Customers scan to view products, reducing print costs and enabling instant updates.</p>
<p>👉 <a href="/blog/qr-code-restaurant-menu">See our restaurant menu QR guide</a></p>
<h3>2. Contactless Payments</h3>
<p>Link QR codes to payment platforms like PayPal, Venmo, or Square. Customers scan and pay without cash or card contact.</p>
<h3>3. Google Reviews & Feedback</h3>
<p>Create QR codes linking directly to your Google Business review page. Place them on receipts, tables, or follow-up emails to boost review volume.</p>
<h3>4. Business Cards & Networking</h3>
<p>Replace or enhance traditional business cards with <a href="/blog/vcard-qr-code-generator">vCard QR codes</a> that save contact info directly to phones.</p>
<h3>5. Social Media Follows</h3>
<p>QR codes linking to Instagram, Facebook, or TikTok profiles help convert in-store visitors to online followers.</p>
<h3>6. Appointment Booking</h3>
<p>Link to Calendly, Square Appointments, or your booking system. Perfect for salons, consultants, and service businesses.</p>
<h3>7. Wi-Fi Access</h3>
<p>Create Wi-Fi QR codes for your business—customers scan to connect without asking for passwords.</p>
<h3>8. Loyalty Programs</h3>
<p>QR codes can register loyalty program sign-ups or redeem points, enhancing customer retention.</p>
<h3>9. Product Information</h3>
<p>Retail and e-commerce businesses add QR codes to packaging linking to tutorials, specifications, or warranty registration.</p>
<h3>10. Event Tickets & Check-in</h3>
<p>Event businesses use QR codes as digital tickets for easy validation at entry points.</p>
<h2>What to Look for in a Small Business QR Solution</h2>
<table class="w-full border-collapse my-6">
<thead>
<tr class="bg-gray-100">
<th class="border p-3 text-left">Feature</th>
<th class="border p-3 text-left">Why It Matters</th>
</tr>
</thead>
<tbody>
<tr><td class="border p-3">Dynamic QR Codes</td><td class="border p-3">Update URLs without reprinting</td></tr>
<tr><td class="border p-3">Scan Analytics</td><td class="border p-3">Measure campaign performance</td></tr>
<tr><td class="border p-3">Custom Branding</td><td class="border p-3">Match your brand identity</td></tr>
<tr><td class="border p-3">Bulk Creation</td><td class="border p-3">Create many codes from spreadsheets</td></tr>
<tr><td class="border p-3">Affordable Pricing</td><td class="border p-3">Budget-friendly for SMBs</td></tr>
<tr><td class="border p-3">No Expiration</td><td class="border p-3">Codes work forever (with active plan)</td></tr>
</tbody>
</table>
<h2>QR Master for Small Business</h2>
<p>QR Master is designed with small businesses in mind:</p>
<ul>
<li><strong>Free Forever:</strong> Unlimited static QR codes, 3 free dynamic codes</li>
<li><strong>Pro Plan (€9/mo):</strong> 50 dynamic codes, full analytics, custom branding</li>
<li><strong>Business Plan (€29/mo):</strong> 500 codes, bulk creation, priority support</li>
</ul>
<div class="bg-blue-50 border-l-4 border-blue-500 p-6 my-8 rounded-r-lg">
<h3 class="text-xl font-semibold mb-2">Free Trial Available</h3>
<p>Start with our free plan—no credit card required. Upgrade when you need more dynamic codes or advanced features.</p>
</div>
<h2>Getting Started: Quick Setup Guide</h2>
<ol>
<li><strong>Identify Your Goal:</strong> What do you want customers to do after scanning?</li>
<li><strong>Choose Code Type:</strong> Static for permanent content, dynamic for flexibility</li>
<li><strong>Create Your QR Code:</strong> Use <a href="/create">our generator</a> to design and customize</li>
<li><strong>Print at Right Size:</strong> Follow our <a href="/blog/qr-code-print-size-guide">print size guide</a></li>
<li><strong>Track Performance:</strong> Monitor scans in your <a href="/analytics">analytics dashboard</a></li>
</ol>
<h2>Common Mistakes Small Businesses Make</h2>
<ul>
<li>❌ Using low-quality or blurry printed codes</li>
<li>❌ Linking to non-mobile-friendly pages</li>
<li>❌ Not testing codes before mass printing</li>
<li>❌ Choosing static codes when URLs might change</li>
<li>❌ Missing call-to-action near the QR code</li>
</ul>
<h2>Conclusion</h2>
<p>QR codes offer small businesses powerful, affordable tools to enhance customer experiences and streamline operations. By choosing the right generator with dynamic capabilities and analytics, you can maximize your ROI and stay competitive in 2025.</p>
<div class="bg-gradient-to-br from-primary-50 to-primary-100 p-8 rounded-2xl my-12 border border-primary-200">
<h3 class="text-2xl font-bold text-gray-900 mb-4">Start Your QR Code Strategy Today</h3>
<p class="text-lg text-gray-700 mb-6">Join thousands of small businesses using QR Master for marketing, payments, and customer engagement.</p>
<a href="/signup" class="inline-block bg-primary-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-primary-700 transition-colors">Get Started Free →</a>
</div>
<h2>Related Resources</h2>
<ul>
<li><a href="/blog/qr-code-restaurant-menu">Restaurant Menu QR Guide</a></li>
<li><a href="/blog/vcard-qr-code-generator">vCard Business Card Generator</a></li>
<li><a href="/blog/qr-code-analytics">QR Code Analytics Guide</a></li>
<li><a href="/pricing">View Pricing Plans</a></li>
</ul>
</div>`,
},
'qr-code-print-size-guide': {
slug: 'qr-code-print-size-guide',
title: 'QR Code Print Size Guide: Minimum Sizes for Every Use Case',
excerpt: 'Complete guide to QR code print sizes. Learn minimum dimensions for business cards, posters, banners, and more to ensure reliable scanning.',
date: 'January 5, 2026',
datePublished: '2026-01-05T12:00:00Z',
dateModified: '2026-01-05T12:00:00Z',
readTime: '8 Min',
category: 'Printing',
image: '/blog/qr-print-sizes.png',
imageAlt: 'Various print materials showing different QR code sizes',
author: 'QR Master Team',
authorUrl: 'https://www.qrmaster.net/about',
answer: 'The minimum QR code size depends on scanning distance. For close scanning (business cards), minimum is 0.8" x 0.8" (2cm). For 6-foot distance (posters), minimum is 6" x 6" (15cm). Rule of thumb: QR size = scanning distance ÷ 10.',
content: `<div class="blog-content">
<h2>Why QR Code Size Matters</h2>
<p>A QR code that's too small won't scan reliably, frustrating customers and wasting your printing investment. Understanding the relationship between <strong>QR code print size</strong>, scanning distance, and data density is essential for successful QR campaigns.</p>
<h2>The Scanning Distance Formula</h2>
<p>The golden rule for QR code sizing:</p>
<div class="bg-gray-100 p-6 rounded-lg my-6 text-center">
<p class="text-2xl font-bold text-gray-900">QR Code Width = Scanning Distance ÷ 10</p>
<p class="text-gray-600 mt-2">Example: 3 feet scanning distance = 3.6 inch QR code</p>
</div>
<div class="my-8">
<img src="/blog/qr-sizes-body.png" alt="Various QR code print sizes comparison" class="rounded-lg shadow-lg w-full" />
</div>
<h2>QR Code Sizes by Application</h2>
<table class="w-full border-collapse my-6">
<thead>
<tr class="bg-gray-100">
<th class="border p-3 text-left">Application</th>
<th class="border p-3 text-left">Scanning Distance</th>
<th class="border p-3 text-left">Minimum Size</th>
<th class="border p-3 text-left">Recommended</th>
</tr>
</thead>
<tbody>
<tr><td class="border p-3">Business Card</td><td class="border p-3">4-8 inches</td><td class="border p-3">0.8" (2cm)</td><td class="border p-3">1" (2.5cm)</td></tr>
<tr><td class="border p-3">Product Label</td><td class="border p-3">6-12 inches</td><td class="border p-3">0.6" (1.5cm)</td><td class="border p-3">1" (2.5cm)</td></tr>
<tr><td class="border p-3">Flyer/Brochure</td><td class="border p-3">1-2 feet</td><td class="border p-3">1.2" (3cm)</td><td class="border p-3">1.5" (4cm)</td></tr>
<tr><td class="border p-3">Table Tent</td><td class="border p-3">1-3 feet</td><td class="border p-3">2" (5cm)</td><td class="border p-3">2.5" (6cm)</td></tr>
<tr><td class="border p-3">Poster (indoor)</td><td class="border p-3">3-6 feet</td><td class="border p-3">4" (10cm)</td><td class="border p-3">6" (15cm)</td></tr>
<tr><td class="border p-3">Banner (outdoor)</td><td class="border p-3">6-15 feet</td><td class="border p-3">8" (20cm)</td><td class="border p-3">12" (30cm)</td></tr>
<tr><td class="border p-3">Billboard</td><td class="border p-3">15+ feet</td><td class="border p-3">18" (45cm)</td><td class="border p-3">24" (60cm)</td></tr>
</tbody>
</table>
<h2>Factors Affecting Scanability</h2>
<h3>1. Data Density</h3>
<p>More data = more modules = harder to scan at small sizes. Dynamic QR codes contain short redirect URLs, making them easier to scan at smaller sizes than static codes with long URLs.</p>
<h3>2. Error Correction Level</h3>
<p>QR codes have four error correction levels:</p>
<ul>
<li><strong>L (7%):</strong> Smallest codes, least damage tolerance</li>
<li><strong>M (15%):</strong> Standard, good balance</li>
<li><strong>Q (25%):</strong> Higher tolerance, larger codes</li>
<li><strong>H (30%):</strong> Maximum tolerance, largest codes (needed for logos)</li>
</ul>
<h3>3. Print Quality</h3>
<p>Low DPI printing blurs the code's modules. Recommended resolutions:</p>
<ul>
<li><strong>Minimum:</strong> 150 DPI</li>
<li><strong>Recommended:</strong> 300 DPI</li>
<li><strong>Best (small codes):</strong> 600 DPI</li>
</ul>
<h3>4. Contrast</h3>
<p>Maintain minimum 3:1 contrast ratio between foreground and background. Avoid:</p>
<ul>
<li>Light gray on white</li>
<li>Similar color tones</li>
<li>Glossy surfaces with glare</li>
</ul>
<h2>Quiet Zone Requirements</h2>
<p>The "quiet zone" is the blank margin around your QR code. Standard requirement:</p>
<div class="bg-gray-100 p-6 rounded-lg my-6 text-center">
<p class="text-xl font-bold text-gray-900">Quiet Zone = 4 × Module Size</p>
<p class="text-gray-600 mt-2">Always leave white space around your QR code</p>
</div>
<h2>File Formats for Printing</h2>
<table class="w-full border-collapse my-6">
<thead>
<tr class="bg-gray-100">
<th class="border p-3 text-left">Format</th>
<th class="border p-3 text-left">Best For</th>
<th class="border p-3 text-left">Scalability</th>
</tr>
</thead>
<tbody>
<tr><td class="border p-3">SVG</td><td class="border p-3">All print applications</td><td class="border p-3">∞ (vector)</td></tr>
<tr><td class="border p-3">PDF</td><td class="border p-3">Professional printing</td><td class="border p-3">∞ (vector)</td></tr>
<tr><td class="border p-3">PNG (300 DPI)</td><td class="border p-3">Digital and standard print</td><td class="border p-3">Limited</td></tr>
<tr><td class="border p-3">EPS</td><td class="border p-3">Professional design software</td><td class="border p-3">∞ (vector)</td></tr>
</tbody>
</table>
<div class="bg-blue-50 border-l-4 border-blue-500 p-6 my-8 rounded-r-lg">
<h3 class="text-xl font-semibold mb-2">Pro Tip: Always Use SVG</h3>
<p>Download your QR codes as SVG for infinite scalability. Scale up for billboards or down for business cards without losing quality.</p>
</div>
<h2>Testing Before Printing</h2>
<p>Always test your QR codes before bulk printing:</p>
<ol>
<li>Print a test sample at actual size</li>
<li>Scan with multiple devices (iOS, Android)</li>
<li>Test from the intended scanning distance</li>
<li>Check under actual lighting conditions</li>
<li>Verify the destination URL works correctly</li>
</ol>
<h2>Conclusion</h2>
<p>Proper QR code sizing ensures reliable scanning and protects your printing investment. Remember the distance ÷ 10 formula, always leave adequate quiet zones, and use vector formats for scalability. When in doubt, go slightly larger—a readable code is always better than a sleek but unscannable one.</p>
<div class="bg-gradient-to-br from-primary-50 to-primary-100 p-8 rounded-2xl my-12 border border-primary-200">
<h3 class="text-2xl font-bold text-gray-900 mb-4">Create Print-Ready QR Codes</h3>
<p class="text-lg text-gray-700 mb-6">Download high-resolution SVG and PNG files ready for any print application.</p>
<a href="/create" class="inline-block bg-primary-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-primary-700 transition-colors">Create QR Code →</a>
</div>
<h2>Related Resources</h2>
<ul>
<li><a href="/blog/qr-code-restaurant-menu">Restaurant Menu QR Guide</a></li>
<li><a href="/blog/bulk-qr-codes-excel">Bulk QR Code Generation</a></li>
<li><a href="/blog/dynamic-vs-static-qr-codes">Dynamic vs Static QR Codes</a></li>
</ul>
</div>`,
},
};
function truncateAtWord(text: string, maxLength: number): string {
@@ -2014,6 +2595,32 @@ export default function BlogPostPage({ params }: { params: { slug: string } }) {
<Button size="lg">Create QR Code Free</Button>
</Link>
</div>
{/* Related Articles Section */}
<div className="mt-16">
<h2 className="text-2xl font-bold text-gray-900 mb-8">Related Articles</h2>
<div className="overflow-x-auto pb-4 -mx-4 px-4">
<div className="flex gap-6" style={{ minWidth: 'max-content' }}>
{Object.values(blogPosts)
.filter((p) => p.slug !== post.slug)
.map((relatedPost) => (
<Link
key={relatedPost.slug}
href={`/blog/${relatedPost.slug}`}
className="group block bg-gray-50 rounded-xl p-6 hover:bg-gray-100 transition-colors flex-shrink-0"
style={{ width: '320px' }}
>
<Badge variant="default" className="mb-3">{relatedPost.category}</Badge>
<h3 className="font-semibold text-gray-900 group-hover:text-primary-600 transition-colors mb-2 line-clamp-2">
{relatedPost.title}
</h3>
<p className="text-sm text-gray-600 line-clamp-2">{relatedPost.excerpt}</p>
<span className="text-sm text-primary-600 mt-3 inline-block">Read more </span>
</Link>
))}
</div>
</div>
</div>
</article>
</div>
</div>

View File

@@ -46,6 +46,44 @@ export async function generateMetadata(): Promise<Metadata> {
}
const blogPosts = [
// NEW POSTS (January 2026)
{
slug: 'qr-code-restaurant-menu',
title: 'How to Create a QR Code for Restaurant Menu',
excerpt: 'Step-by-step guide to creating digital menu QR codes for your restaurant. Learn best practices for touchless menus, placement tips, and tracking.',
date: 'January 5, 2026',
readTime: '12 Min',
category: 'Restaurant',
image: '/blog/restaurant-qr-menu.png',
},
{
slug: 'vcard-qr-code-generator',
title: 'Free vCard QR Code Generator: Digital Business Cards',
excerpt: 'Create professional vCard QR codes for digital business cards. Share contact info instantly with a scan—includes templates and best practices.',
date: 'January 5, 2026',
readTime: '10 Min',
category: 'Business Cards',
image: '/blog/vcard-qr-code.png',
},
{
slug: 'qr-code-small-business',
title: 'Best QR Code Generator for Small Business: 2025 Guide',
excerpt: 'Find the best QR code solution for your small business. Compare features, pricing, and use cases for marketing, payments, and operations.',
date: 'January 5, 2026',
readTime: '14 Min',
category: 'Business',
image: '/blog/small-business-qr.png',
},
{
slug: 'qr-code-print-size-guide',
title: 'QR Code Print Size Guide: Minimum Sizes for Every Use Case',
excerpt: 'Complete guide to QR code print sizes. Learn minimum dimensions for business cards, posters, banners, and more to ensure reliable scanning.',
date: 'January 5, 2026',
readTime: '8 Min',
category: 'Printing',
image: '/blog/qr-print-sizes.png',
},
// EXISTING POSTS
{
slug: 'qr-code-tracking-guide-2025',
title: 'QR Code Tracking: Complete Guide 2025',

View File

@@ -60,10 +60,12 @@ export default function MarketingLayout({
{/* Mobile Menu Button */}
<button
className="md:hidden"
className="md:hidden text-gray-900"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-label={mobileMenuOpen ? 'Close menu' : 'Open menu'}
aria-expanded={mobileMenuOpen}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
{mobileMenuOpen ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
) : (
@@ -129,7 +131,7 @@ export default function MarketingLayout({
<div>
<h3 className="font-semibold mb-4">Resources</h3>
<ul className="space-y-2 text-gray-400">
<li><Link href="/pricing" className="hover:text-white">Full Pricing</Link></li>
<li><Link href="/#pricing" className="hover:text-white">Full Pricing</Link></li>
<li><Link href="/faq" className="hover:text-white">All Questions</Link></li>
<li><Link href="/blog" className="hover:text-white">Blog</Link></li>
<li><Link href="/signup" className="hover:text-white">Get Started</Link></li>
@@ -145,8 +147,15 @@ export default function MarketingLayout({
</div>
<div className="border-t border-gray-800 mt-8 pt-8 text-center text-gray-400">
<div className="border-t border-gray-800 mt-8 pt-8 flex items-center justify-between text-gray-400">
<Link
href="/newsletter"
className="text-[6px] text-gray-700 opacity-[0.25] hover:opacity-100 hover:text-white transition-opacity duration-300"
>
</Link>
<p>&copy; 2025 QR Master. All rights reserved.</p>
<div className="w-12"></div>
</div>
</div>
</footer>

View File

@@ -0,0 +1,367 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Mail, Users, Send, CheckCircle, AlertCircle, Loader2, Lock, LogOut } from 'lucide-react';
interface Subscriber {
email: string;
createdAt: string;
}
interface BroadcastInfo {
total: number;
recent: Subscriber[];
}
export default function NewsletterPage() {
const router = useRouter();
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isAuthenticating, setIsAuthenticating] = useState(true);
const [loginError, setLoginError] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [info, setInfo] = useState<BroadcastInfo | null>(null);
const [loading, setLoading] = useState(true);
const [broadcasting, setBroadcasting] = useState(false);
const [result, setResult] = useState<{
success: boolean;
message: string;
sent?: number;
failed?: number;
} | null>(null);
useEffect(() => {
checkAuth();
}, []);
const checkAuth = async () => {
try {
const response = await fetch('/api/newsletter/broadcast');
if (response.ok) {
setIsAuthenticated(true);
const data = await response.json();
setInfo(data);
setLoading(false);
} else {
setIsAuthenticated(false);
}
} catch (error) {
setIsAuthenticated(false);
} finally {
setIsAuthenticating(false);
}
};
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoginError('');
setIsAuthenticating(true);
try {
const response = await fetch('/api/newsletter/admin-login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password }),
});
if (response.ok) {
setIsAuthenticated(true);
await checkAuth();
} else {
const data = await response.json();
setLoginError(data.error || 'Invalid credentials');
}
} catch (error) {
setLoginError('Login failed. Please try again.');
} finally {
setIsAuthenticating(false);
}
};
const handleLogout = async () => {
await fetch('/api/auth/logout', { method: 'POST' });
router.push('/');
};
const fetchSubscriberInfo = async () => {
try {
const response = await fetch('/api/newsletter/broadcast');
if (response.ok) {
const data = await response.json();
setInfo(data);
}
} catch (error) {
console.error('Failed to fetch subscriber info:', error);
}
};
const handleBroadcast = async () => {
if (!confirm(`Are you sure you want to send the AI feature launch email to ${info?.total} subscribers?`)) {
return;
}
setBroadcasting(true);
setResult(null);
try {
const response = await fetch('/api/newsletter/broadcast', {
method: 'POST',
});
const data = await response.json();
setResult({
success: response.ok,
message: data.message || data.error,
sent: data.sent,
failed: data.failed,
});
if (response.ok) {
await fetchSubscriberInfo();
}
} catch (error) {
setResult({
success: false,
message: 'Failed to send broadcast. Please try again.',
});
} finally {
setBroadcasting(false);
}
};
// Login Screen
if (!isAuthenticated) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-purple-50 to-pink-50 dark:from-purple-950/20 dark:to-pink-950/20 px-4">
<Card className="w-full max-w-md p-8">
<div className="text-center mb-6">
<div className="w-16 h-16 bg-gradient-to-br from-purple-100 to-pink-100 dark:from-purple-900/30 dark:to-pink-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
<Lock className="w-8 h-8 text-purple-600 dark:text-purple-400" />
</div>
<h1 className="text-2xl font-bold mb-2">Newsletter Admin</h1>
<p className="text-muted-foreground text-sm">
Sign in to manage subscribers
</p>
</div>
<form onSubmit={handleLogin} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
required
className="w-full px-4 py-3 rounded-xl bg-background border border-border focus:border-purple-500 focus:ring-2 focus:ring-purple-500/20 outline-none transition-all"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
required
className="w-full px-4 py-3 rounded-xl bg-background border border-border focus:border-purple-500 focus:ring-2 focus:ring-purple-500/20 outline-none transition-all"
/>
</div>
{loginError && (
<p className="text-sm text-red-600 dark:text-red-400">{loginError}</p>
)}
<Button
type="submit"
disabled={isAuthenticating}
className="w-full bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white"
>
{isAuthenticating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Signing in...
</>
) : (
'Sign In'
)}
</Button>
</form>
<div className="mt-6 pt-6 border-t text-center">
<p className="text-xs text-muted-foreground">
Admin credentials required
</p>
</div>
</Card>
</div>
);
}
// Loading
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
);
}
// Admin Dashboard
return (
<div className="min-h-screen bg-gradient-to-br from-purple-50/30 to-pink-50/30 dark:from-purple-950/10 dark:to-pink-950/10">
<div className="container mx-auto px-4 py-8 max-w-4xl">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold mb-2">Newsletter Management</h1>
<p className="text-muted-foreground">
Manage AI feature launch notifications
</p>
</div>
<Button
onClick={handleLogout}
variant="outline"
className="flex items-center gap-2"
>
<LogOut className="w-4 h-4" />
Logout
</Button>
</div>
{/* Stats Card */}
<Card className="p-6 mb-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-purple-100 dark:bg-purple-900/20 rounded-lg flex items-center justify-center">
<Users className="w-6 h-6 text-purple-600 dark:text-purple-400" />
</div>
<div>
<h2 className="text-2xl font-bold">{info?.total || 0}</h2>
<p className="text-sm text-muted-foreground">Total Subscribers</p>
</div>
</div>
<Badge className="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
Active
</Badge>
</div>
{/* Broadcast Button */}
<div className="border-t pt-6">
<div className="mb-4">
<h3 className="font-semibold mb-2 flex items-center gap-2">
<Send className="w-4 h-4" />
Broadcast AI Feature Launch
</h3>
<p className="text-sm text-muted-foreground mb-4">
Send the AI feature launch announcement to all {info?.total} subscribers.
This will inform them that the features are now available.
</p>
</div>
<Button
onClick={handleBroadcast}
disabled={broadcasting || !info?.total}
className="w-full sm:w-auto bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white"
>
{broadcasting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Sending Emails...
</>
) : (
<>
<Mail className="w-4 h-4 mr-2" />
Send Launch Notification to All
</>
)}
</Button>
</div>
</Card>
{/* Result Message */}
{result && (
<Card
className={`p-4 mb-6 ${
result.success
? 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-900'
: 'bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-900'
}`}
>
<div className="flex items-start gap-3">
{result.success ? (
<CheckCircle className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
) : (
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
)}
<div className="flex-1">
<p
className={`font-medium ${
result.success
? 'text-green-900 dark:text-green-100'
: 'text-red-900 dark:text-red-100'
}`}
>
{result.message}
</p>
{result.sent !== undefined && (
<p className="text-sm text-muted-foreground mt-1">
Sent: {result.sent} | Failed: {result.failed}
</p>
)}
</div>
</div>
</Card>
)}
{/* Recent Subscribers */}
<Card className="p-6">
<h3 className="font-semibold mb-4">Recent Subscribers</h3>
{info?.recent && info.recent.length > 0 ? (
<div className="space-y-3">
{info.recent.map((subscriber, index) => (
<div
key={index}
className="flex items-center justify-between py-2 border-b border-border last:border-0"
>
<div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-muted-foreground" />
<span className="text-sm">{subscriber.email}</span>
</div>
<span className="text-xs text-muted-foreground">
{new Date(subscriber.createdAt).toLocaleDateString()}
</span>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No subscribers yet</p>
)}
<div className="mt-4 pt-4 border-t">
<p className="text-xs text-muted-foreground">
💡 Tip: View all subscribers in{' '}
<a
href="http://localhost:5555"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Prisma Studio
</a>
{' '}(NewsletterSubscription table)
</p>
</div>
</Card>
</div>
</div>
);
}

View File

@@ -45,6 +45,26 @@ export default function HomePage() {
return (
<>
<SeoJsonLd data={[organizationSchema(), websiteSchema()]} />
{/* Server-rendered SEO content for crawlers */}
<div className="sr-only" aria-hidden="false">
<h1>QR Master: Free Dynamic QR Code Generator with Tracking & Analytics</h1>
<p>
Create professional QR codes for your business with QR Master. Our dynamic QR code generator
lets you create trackable QR codes, edit destinations anytime, and view detailed analytics.
Perfect for restaurants, retail, events, and marketing campaigns.
</p>
<p>
Features include: Dynamic QR codes with real-time tracking, bulk QR code generation from Excel/CSV,
custom branding with colors and logos, advanced scan analytics showing device types and locations,
vCard QR codes for digital business cards, and restaurant menu QR codes.
</p>
<p>
Start free with 3 dynamic QR codes and unlimited static codes. Upgrade to Pro for 50 codes
with advanced analytics, or Business for 500 codes with bulk creation and priority support.
</p>
</div>
<HomePageClient />
</>
);

View File

@@ -2,21 +2,41 @@ import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { TrendData } from '@/types/analytics';
export const dynamic = 'force-dynamic';
// Helper function to calculate trend
function calculateTrend(current: number, previous: number): { trend: 'up' | 'down' | 'flat'; percentage: number } {
if (previous === 0) {
return current > 0 ? { trend: 'up', percentage: 100 } : { trend: 'flat', percentage: 0 };
// Helper function to calculate trend with proper edge case handling
function calculateTrend(current: number, previous: number): TrendData {
// Handle edge case: no data in either period
if (previous === 0 && current === 0) {
return { trend: 'flat', percentage: 0 };
}
const change = ((current - previous) / previous) * 100;
const percentage = Math.round(Math.abs(change));
// Handle new growth from zero - mark as "new" to distinguish from actual 100% growth
if (previous === 0 && current > 0) {
return { trend: 'up', percentage: 100, isNew: true };
}
if (change > 5) return { trend: 'up', percentage };
if (change < -5) return { trend: 'down', percentage };
return { trend: 'flat', percentage };
// Calculate actual percentage change
const change = ((current - previous) / previous) * 100;
const roundedChange = Math.round(change);
// Determine trend direction (use threshold of 5% to filter noise)
let trend: 'up' | 'down' | 'flat';
if (roundedChange > 5) {
trend = 'up';
} else if (roundedChange < -5) {
trend = 'down';
} else {
trend = 'flat';
}
return {
trend,
percentage: Math.abs(roundedChange),
isNegative: roundedChange < 0
};
}
export async function GET(request: NextRequest) {
@@ -52,14 +72,18 @@ export async function GET(request: NextRequest) {
const range = searchParams.get('range') || '30';
const daysInRange = parseInt(range, 10);
// Standardize to week (7 days) or month (30 days) for clear comparison labels
const comparisonDays = daysInRange <= 7 ? 7 : 30;
const comparisonPeriod: 'week' | 'month' = comparisonDays === 7 ? 'week' : 'month';
// Calculate current and previous period dates
const now = new Date();
const currentPeriodStart = new Date();
currentPeriodStart.setDate(now.getDate() - daysInRange);
currentPeriodStart.setDate(now.getDate() - comparisonDays);
const previousPeriodEnd = new Date(currentPeriodStart);
const previousPeriodStart = new Date(previousPeriodEnd);
previousPeriodStart.setDate(previousPeriodEnd.getDate() - daysInRange);
previousPeriodStart.setDate(previousPeriodEnd.getDate() - comparisonDays);
// Get user's QR codes with scans filtered by period
const qrCodes = await db.qRCode.findMany({
@@ -101,7 +125,32 @@ export async function GET(request: NextRequest) {
const previousUniqueScans = qrCodesWithPreviousScans.reduce((sum, qr) =>
sum + qr.scans.filter(s => s.isUnique).length, 0
);
// Calculate average scans per QR code (only count QR codes with scans)
const qrCodesWithScans = qrCodes.filter(qr => qr.scans.length > 0).length;
const avgScansPerQR = qrCodesWithScans > 0
? Math.round(totalScans / qrCodesWithScans)
: 0;
// Calculate previous period average scans per QR
const previousQrCodesWithScans = qrCodesWithPreviousScans.filter(qr => qr.scans.length > 0).length;
const previousAvgScansPerQR = previousQrCodesWithScans > 0
? Math.round(previousTotalScans / previousQrCodesWithScans)
: 0;
// Calculate trends
const scansTrend = calculateTrend(totalScans, previousTotalScans);
// New Conversion Rate Logic: (Unique Scans / Total Scans) * 100
// This represents "Engagement Efficiency" - how many scans are from fresh users
const currentConversion = totalScans > 0 ? Math.round((uniqueScans / totalScans) * 100) : 0;
const previousConversion = previousTotalScans > 0
? Math.round((previousUniqueScans / previousTotalScans) * 100)
: 0;
const avgScansTrend = calculateTrend(currentConversion, previousConversion);
// Device stats
const deviceStats = qrCodes.flatMap(qr => qr.scans)
.reduce((acc, scan) => {
@@ -109,16 +158,16 @@ export async function GET(request: NextRequest) {
acc[device] = (acc[device] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const mobileScans = (deviceStats.mobile || 0) + (deviceStats.tablet || 0);
const mobilePercentage = totalScans > 0
? Math.round((mobileScans / totalScans) * 100)
const mobilePercentage = totalScans > 0
? Math.round((mobileScans / totalScans) * 100)
: 0;
// Country stats (current period)
const countryStats = qrCodes.flatMap(qr => qr.scans)
.reduce((acc, scan) => {
const country = scan.country || 'Unknown';
const country = scan.country ?? 'Unknown Location';
acc[country] = (acc[country] || 0) + 1;
return acc;
}, {} as Record<string, number>);
@@ -126,14 +175,14 @@ export async function GET(request: NextRequest) {
// Country stats (previous period)
const previousCountryStats = qrCodesWithPreviousScans.flatMap(qr => qr.scans)
.reduce((acc, scan) => {
const country = scan.country || 'Unknown';
const country = scan.country ?? 'Unknown Location';
acc[country] = (acc[country] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const topCountry = Object.entries(countryStats)
.sort(([,a], [,b]) => b - a)[0];
.sort(([, a], [, b]) => b - a)[0];
// Daily scan counts for chart (current period)
const dailyScans = qrCodes.flatMap(qr => qr.scans).reduce((acc, scan) => {
const date = new Date(scan.ts).toISOString().split('T')[0];
@@ -141,6 +190,13 @@ export async function GET(request: NextRequest) {
return acc;
}, {} as Record<string, number>);
// Generate last 7 days for sparkline
const last7Days = Array.from({ length: 7 }, (_, i) => {
const date = new Date();
date.setDate(date.getDate() - (6 - i));
return date.toISOString().split('T')[0];
});
// QR performance (only show DYNAMIC QR codes since STATIC don't track scans)
const qrPerformance = qrCodes
.filter(qr => qr.type === 'DYNAMIC')
@@ -155,6 +211,18 @@ export async function GET(request: NextRequest) {
// Calculate trend
const trendData = calculateTrend(currentTotal, previousTotal);
// Calculate sparkline data (scans per day for last 7 days)
const sparklineData = last7Days.map(date => {
return qr.scans.filter(s =>
new Date(s.ts).toISOString().split('T')[0] === date
).length;
});
// Find last scanned date
const lastScanned = qr.scans.length > 0
? new Date(Math.max(...qr.scans.map(s => new Date(s.ts).getTime())))
: null;
return {
id: qr.id,
title: qr.title,
@@ -166,6 +234,9 @@ export async function GET(request: NextRequest) {
: 0,
trend: trendData.trend,
trendPercentage: trendData.percentage,
sparkline: sparklineData,
lastScanned: lastScanned?.toISOString() || null,
...(trendData.isNew && { isNew: true }),
};
})
.sort((a, b) => b.totalScans - a.totalScans);
@@ -174,18 +245,20 @@ export async function GET(request: NextRequest) {
summary: {
totalScans,
uniqueScans,
avgScansPerQR: qrCodes.length > 0
? Math.round(totalScans / qrCodes.length)
: 0,
avgScansPerQR: currentConversion, // Now sending Unique Rate instead of Avg per QR
mobilePercentage,
topCountry: topCountry ? topCountry[0] : 'N/A',
topCountryPercentage: topCountry && totalScans > 0
? Math.round((topCountry[1] / totalScans) * 100)
: 0,
scansTrend,
avgScansTrend,
comparisonPeriod,
comparisonDays,
},
deviceStats,
countryStats: Object.entries(countryStats)
.sort(([,a], [,b]) => b - a)
.sort(([, a], [, b]) => b - a)
.slice(0, 10)
.map(([country, count]) => {
const previousCount = previousCountryStats[country] || 0;
@@ -199,6 +272,7 @@ export async function GET(request: NextRequest) {
: 0,
trend: trendData.trend,
trendPercentage: trendData.percentage,
...(trendData.isNew && { isNew: true }),
};
}),
dailyScans,

View File

@@ -74,10 +74,8 @@ export async function POST(request: NextRequest) {
},
});
// Set cookie for auto-login after signup
cookies().set('userId', user.id, getAuthCookieOptions());
return NextResponse.json({
// Create response
const response = NextResponse.json({
success: true,
user: {
id: user.id,
@@ -86,6 +84,11 @@ export async function POST(request: NextRequest) {
plan: 'FREE',
},
});
// Set cookie for auto-login after signup
response.cookies.set('userId', user.id, getAuthCookieOptions());
return response;
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(

View File

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

View File

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

View File

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

View File

@@ -20,6 +20,10 @@ export async function GET(request: NextRequest) {
_count: {
select: { scans: true },
},
scans: {
where: { isUnique: true },
select: { id: true },
},
},
orderBy: { createdAt: 'desc' },
});
@@ -28,6 +32,7 @@ export async function GET(request: NextRequest) {
const transformed = qrCodes.map(qr => ({
...qr,
scans: qr._count.scans,
uniqueScans: qr.scans.length, // Count of scans where isUnique=true
_count: undefined,
}));
@@ -138,9 +143,9 @@ export async function POST(request: NextRequest) {
);
}
}
let enrichedContent = body.content;
// For STATIC QR codes, calculate what the QR should contain
if (isStatic) {
let qrContent = '';
@@ -180,7 +185,7 @@ END:VCARD`;
default:
qrContent = body.content.url || 'https://example.com';
}
// Add qrContent to the content object
enrichedContent = {
...body.content,

42
src/app/api/user/route.ts Normal file
View File

@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
// Force dynamic rendering (required for cookies)
export const dynamic = 'force-dynamic';
/**
* GET /api/user
* Get current user information
*/
export async function GET(request: NextRequest) {
try {
const userId = cookies().get('userId')?.value;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await db.user.findUnique({
where: { id: userId },
select: {
id: true,
name: true,
email: true,
plan: true,
},
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
return NextResponse.json(user);
} catch (error) {
console.error('Error fetching user:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,144 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
// Reserved subdomains that cannot be used
const RESERVED_SUBDOMAINS = [
'www', 'app', 'api', 'admin', 'mail', 'email',
'ftp', 'smtp', 'pop', 'imap', 'dns', 'ns1', 'ns2',
'blog', 'shop', 'store', 'help', 'support', 'dashboard',
'login', 'signup', 'auth', 'cdn', 'static', 'assets',
'dev', 'staging', 'test', 'demo', 'beta', 'alpha'
];
// Validate subdomain format
function isValidSubdomain(subdomain: string): { valid: boolean; error?: string } {
if (!subdomain) {
return { valid: false, error: 'Subdomain is required' };
}
// Must be lowercase
if (subdomain !== subdomain.toLowerCase()) {
return { valid: false, error: 'Subdomain must be lowercase' };
}
// Length check
if (subdomain.length < 3 || subdomain.length > 30) {
return { valid: false, error: 'Subdomain must be 3-30 characters' };
}
// Alphanumeric and hyphens only, no leading/trailing hyphens
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(subdomain)) {
return { valid: false, error: 'Only lowercase letters, numbers, and hyphens allowed' };
}
// No consecutive hyphens
if (/--/.test(subdomain)) {
return { valid: false, error: 'No consecutive hyphens allowed' };
}
// Check reserved
if (RESERVED_SUBDOMAINS.includes(subdomain)) {
return { valid: false, error: 'This subdomain is reserved' };
}
return { valid: true };
}
// GET /api/user/subdomain - Get current subdomain
export async function GET() {
try {
const userId = cookies().get('userId')?.value;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await db.user.findUnique({
where: { id: userId },
select: { subdomain: true },
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
return NextResponse.json({ subdomain: user.subdomain });
} catch (error) {
console.error('Error fetching subdomain:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
// POST /api/user/subdomain - Set subdomain
export async function POST(request: NextRequest) {
try {
const userId = cookies().get('userId')?.value;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const subdomain = body.subdomain?.trim().toLowerCase();
// Validate
const validation = isValidSubdomain(subdomain);
if (!validation.valid) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
// Check if already taken by another user
const existing = await db.user.findFirst({
where: {
subdomain,
NOT: { id: userId },
},
});
if (existing) {
return NextResponse.json({ error: 'This subdomain is already taken' }, { status: 409 });
}
// Update user
try {
const updatedUser = await db.user.update({
where: { id: userId },
data: { subdomain },
select: { subdomain: true } // Only select needed fields
});
return NextResponse.json({
success: true,
subdomain: updatedUser.subdomain,
url: `https://${updatedUser.subdomain}.qrmaster.net`
});
} catch (error: any) {
if (error.code === 'P2025') {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
throw error;
}
} catch (error) {
console.error('Error setting subdomain:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
// DELETE /api/user/subdomain - Remove subdomain
export async function DELETE() {
try {
const userId = cookies().get('userId')?.value;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
await db.user.update({
where: { id: userId },
data: { subdomain: null },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error removing subdomain:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -1,4 +1,5 @@
import type { Metadata } from 'next';
import { Suspense } from 'react';
import '@/styles/globals.css';
import { ToastContainer } from '@/components/ui/Toast';
import AuthProvider from '@/components/SessionProvider';
@@ -56,13 +57,15 @@ export default function RootLayout({
return (
<html lang="en">
<body className="font-sans">
<PostHogProvider>
<AuthProvider>
{children}
</AuthProvider>
<CookieBanner />
<ToastContainer />
</PostHogProvider>
<Suspense fallback={null}>
<PostHogProvider>
<AuthProvider>
{children}
</AuthProvider>
<CookieBanner />
<ToastContainer />
</PostHogProvider>
</Suspense>
</body>
</html>
);

View File

@@ -8,14 +8,21 @@ export async function GET(
) {
try {
const { slug } = await params;
// Fetch QR code by slug
const qrCode = await db.qRCode.findUnique({
where: { slug },
select: {
id: true,
title: true,
content: true,
contentType: true,
user: {
select: {
name: true,
subdomain: true,
}
}
},
});
@@ -29,7 +36,7 @@ export async function GET(
// Determine destination URL
let destination = '';
const content = qrCode.content as any;
switch (qrCode.contentType) {
case 'URL':
destination = content.url || 'https://example.com';
@@ -67,7 +74,7 @@ export async function GET(
const searchParams = request.nextUrl.searchParams;
const utmParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
const preservedParams = new URLSearchParams();
utmParams.forEach(param => {
const value = searchParams.get(param);
if (value) {
@@ -81,8 +88,94 @@ export async function GET(
destination = `${destination}${separator}${preservedParams.toString()}`;
}
// Return 307 redirect (temporary redirect that preserves method)
return NextResponse.redirect(destination, { status: 307 });
// Construct metadata
const siteName = qrCode.user?.subdomain
? `${qrCode.user.subdomain.charAt(0).toUpperCase() + qrCode.user.subdomain.slice(1)}`
: 'QR Master';
const title = qrCode.title || siteName;
const description = `Redirecting to content...`;
// Determine if we should show a preview (bots) or redirect immediately
const userAgent = request.headers.get('user-agent') || '';
const isBot = /facebookexternalhit|twitterbot|whatsapp|discordbot|telegrambot|slackbot|linkedinbot/i.test(userAgent);
// HTML response with metadata and redirect
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title}</title>
<!-- Open Graph Metadata -->
<meta property="og:title" content="${title}" />
<meta property="og:site_name" content="${siteName}" />
<meta property="og:description" content="${description}" />
<meta property="og:type" content="website" />
<meta property="og:url" content="${destination}" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="${title}" />
<meta name="twitter:description" content="${description}" />
<!-- No-cache headers to ensure fresh Redirects -->
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<!-- Fallback Redirect -->
<meta http-equiv="refresh" content="0;url=${JSON.stringify(destination).slice(1, -1)}" />
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background-color: #f9fafb;
color: #4b5563;
}
.loader {
text-align: center;
}
.spinner {
border: 3px solid #f3f3f3;
border-radius: 50%;
border-top: 3px solid #3b82f6;
width: 24px;
height: 24px;
-webkit-animation: spin 1s linear infinite; /* Safari */
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="loader">
<div class="spinner"></div>
<p>Redirecting to ${siteName}...</p>
</div>
<script>
// Immediate redirect
window.location.replace("${destination}");
</script>
</body>
</html>`;
return new NextResponse(html, {
headers: {
'Content-Type': 'text/html',
},
});
} catch (error) {
console.error('QR redirect error:', error);
return new NextResponse('Internal server error', { status: 500 });
@@ -94,8 +187,8 @@ async function trackScan(qrId: string, request: NextRequest) {
const userAgent = request.headers.get('user-agent') || '';
const referer = request.headers.get('referer') || '';
const ip = request.headers.get('x-forwarded-for') ||
request.headers.get('x-real-ip') ||
'unknown';
request.headers.get('x-real-ip') ||
'unknown';
// Check DNT header
const dnt = request.headers.get('dnt');
@@ -113,12 +206,42 @@ async function trackScan(qrId: string, request: NextRequest) {
// Hash IP for privacy
const ipHash = hashIP(ip);
// Parse user agent for device info
const isMobile = /mobile|android|iphone/i.test(userAgent);
const isTablet = /tablet|ipad/i.test(userAgent);
const device = isTablet ? 'tablet' : isMobile ? 'mobile' : 'desktop';
// Device Detection Logic:
// 1. Windows or Linux -> Always Desktop
// 2. Explicit iPad/Tablet keywords -> Tablet
// 3. Mac + Chrome browser -> Desktop (real Mac users often use Chrome)
// 4. Mac + Safari + No Referrer -> Likely iPad scanning a QR code
// 5. Mobile keywords -> Mobile
// 6. Everything else -> Desktop
const isWindows = /windows/i.test(userAgent);
const isLinux = /linux/i.test(userAgent) && !/android/i.test(userAgent);
const isExplicitTablet = /tablet|ipad|playbook|silk/i.test(userAgent);
const isAndroidTablet = /android/i.test(userAgent) && !/mobile/i.test(userAgent);
const isMacintosh = /macintosh/i.test(userAgent);
const isChrome = /chrome/i.test(userAgent);
const isSafari = /safari/i.test(userAgent) && !isChrome;
const hasReferrer = !!referer;
// iPad in desktop mode: Mac + Safari (no Chrome) + No Referrer (physical scan)
const isLikelyiPadScan = isMacintosh && isSafari && !hasReferrer;
let device: string;
if (isWindows || isLinux) {
device = 'desktop';
} else if (isExplicitTablet || isAndroidTablet || isLikelyiPadScan) {
device = 'tablet';
} else if (/mobile|iphone/i.test(userAgent)) {
device = 'mobile';
} else if (isMacintosh && isChrome) {
device = 'desktop'; // Mac with Chrome = real desktop
} else if (isMacintosh && hasReferrer) {
device = 'desktop'; // Mac with referrer = probably clicked a link on desktop
} else {
device = 'desktop'; // Default fallback
}
// Detect OS
let os = 'unknown';
if (/windows/i.test(userAgent)) os = 'Windows';
@@ -126,34 +249,39 @@ async function trackScan(qrId: string, request: NextRequest) {
else if (/linux/i.test(userAgent)) os = 'Linux';
else if (/android/i.test(userAgent)) os = 'Android';
else if (/ios|iphone|ipad/i.test(userAgent)) os = 'iOS';
// Get country from header (Vercel/Cloudflare provide this)
const country = request.headers.get('x-vercel-ip-country') ||
request.headers.get('cf-ipcountry') ||
'unknown';
request.headers.get('cf-ipcountry') ||
'unknown';
// Extract UTM parameters
const searchParams = request.nextUrl.searchParams;
const utmSource = searchParams.get('utm_source');
const utmMedium = searchParams.get('utm_medium');
const utmCampaign = searchParams.get('utm_campaign');
// Check if this is a unique scan (first scan from this IP today)
// Check if this is a unique scan (first scan from this IP + Device today)
// We include a simplified device fingerprint so different devices on same IP count as unique
const deviceFingerprint = hashIP(userAgent.substring(0, 100)); // Hash the user agent for privacy
const today = new Date();
today.setHours(0, 0, 0, 0);
const existingScan = await db.qRScan.findFirst({
where: {
qrId,
ipHash,
userAgent: {
startsWith: userAgent.substring(0, 50), // Match same device type
},
ts: {
gte: today,
},
},
});
const isUnique = !existingScan;
// Create scan record
await db.qRScan.create({
data: {

View File

@@ -0,0 +1,192 @@
'use client';
import React, { memo } from 'react';
import {
ComposableMap,
Geographies,
Geography,
ZoomableGroup,
} from 'react-simple-maps';
import { scaleLinear } from 'd3-scale';
// TopoJSON world map
const geoUrl = 'https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json';
// ISO Alpha-2 to country name mapping for common countries
const countryNameToCode: Record<string, string> = {
'United States': 'US',
'USA': 'US',
'US': 'US',
'Germany': 'DE',
'DE': 'DE',
'United Kingdom': 'GB',
'UK': 'GB',
'GB': 'GB',
'France': 'FR',
'FR': 'FR',
'Canada': 'CA',
'CA': 'CA',
'Australia': 'AU',
'AU': 'AU',
'Japan': 'JP',
'JP': 'JP',
'China': 'CN',
'CN': 'CN',
'India': 'IN',
'IN': 'IN',
'Brazil': 'BR',
'BR': 'BR',
'Spain': 'ES',
'ES': 'ES',
'Italy': 'IT',
'IT': 'IT',
'Netherlands': 'NL',
'NL': 'NL',
'Switzerland': 'CH',
'CH': 'CH',
'Austria': 'AT',
'AT': 'AT',
'Poland': 'PL',
'PL': 'PL',
'Sweden': 'SE',
'SE': 'SE',
'Norway': 'NO',
'NO': 'NO',
'Denmark': 'DK',
'DK': 'DK',
'Finland': 'FI',
'FI': 'FI',
'Belgium': 'BE',
'BE': 'BE',
'Portugal': 'PT',
'PT': 'PT',
'Ireland': 'IE',
'IE': 'IE',
'Mexico': 'MX',
'MX': 'MX',
'Argentina': 'AR',
'AR': 'AR',
'South Korea': 'KR',
'KR': 'KR',
'Singapore': 'SG',
'SG': 'SG',
'New Zealand': 'NZ',
'NZ': 'NZ',
'Russia': 'RU',
'RU': 'RU',
'South Africa': 'ZA',
'ZA': 'ZA',
'Unknown Location': 'UNKNOWN',
'unknown': 'UNKNOWN',
};
// ISO Alpha-2 to ISO Alpha-3 mapping (for matching with TopoJSON)
const alpha2ToAlpha3: Record<string, string> = {
'US': 'USA',
'DE': 'DEU',
'GB': 'GBR',
'FR': 'FRA',
'CA': 'CAN',
'AU': 'AUS',
'JP': 'JPN',
'CN': 'CHN',
'IN': 'IND',
'BR': 'BRA',
'ES': 'ESP',
'IT': 'ITA',
'NL': 'NLD',
'CH': 'CHE',
'AT': 'AUT',
'PL': 'POL',
'SE': 'SWE',
'NO': 'NOR',
'DK': 'DNK',
'FI': 'FIN',
'BE': 'BEL',
'PT': 'PRT',
'IE': 'IRL',
'MX': 'MEX',
'AR': 'ARG',
'KR': 'KOR',
'SG': 'SGP',
'NZ': 'NZL',
'RU': 'RUS',
'ZA': 'ZAF',
};
interface CountryStat {
country: string;
count: number;
percentage: number;
}
interface GeoMapProps {
countryStats: CountryStat[];
totalScans: number;
}
const GeoMap: React.FC<GeoMapProps> = ({ countryStats, totalScans }) => {
// Build a map of ISO Alpha-3 codes to scan counts
const countryData: Record<string, number> = {};
let maxCount = 0;
countryStats.forEach((stat) => {
const alpha2 = countryNameToCode[stat.country] || stat.country;
const alpha3 = alpha2ToAlpha3[alpha2];
if (alpha3) {
countryData[alpha3] = stat.count;
if (stat.count > maxCount) maxCount = stat.count;
}
});
// Color scale: light blue to dark blue based on scan count
const colorScale = scaleLinear<string>()
.domain([0, maxCount || 1])
.range(['#E0F2FE', '#1E40AF']);
return (
<div className="w-full h-full">
<ComposableMap
projection="geoMercator"
projectionConfig={{
scale: 120,
center: [0, 30],
}}
style={{ width: '100%', height: '100%' }}
>
<ZoomableGroup center={[0, 30]} zoom={1}>
<Geographies geography={geoUrl}>
{({ geographies }) =>
geographies.map((geo) => {
const isoCode = geo.properties.ISO_A3 || geo.id;
const scanCount = countryData[isoCode] || 0;
const fillColor = scanCount > 0 ? colorScale(scanCount) : '#F1F5F9';
return (
<Geography
key={geo.rsmKey}
geography={geo}
fill={fillColor}
stroke="#CBD5E1"
strokeWidth={0.5}
style={{
default: { outline: 'none' },
hover: {
fill: scanCount > 0 ? '#3B82F6' : '#E2E8F0',
outline: 'none',
cursor: 'pointer',
},
pressed: { outline: 'none' },
}}
/>
);
})
}
</Geographies>
</ZoomableGroup>
</ComposableMap>
</div>
);
};
export default memo(GeoMap);

View File

@@ -0,0 +1,86 @@
'use client';
import React from 'react';
import { Line } from 'react-chartjs-2';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Filler,
} from 'chart.js';
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Filler);
interface SparklineProps {
data: number[];
color?: 'blue' | 'green' | 'red';
width?: number;
height?: number;
}
const colorMap = {
blue: {
border: 'rgb(59, 130, 246)',
background: 'rgba(59, 130, 246, 0.1)',
},
green: {
border: 'rgb(34, 197, 94)',
background: 'rgba(34, 197, 94, 0.1)',
},
red: {
border: 'rgb(239, 68, 68)',
background: 'rgba(239, 68, 68, 0.1)',
},
};
const Sparkline: React.FC<SparklineProps> = ({
data,
color = 'blue',
width = 100,
height = 30,
}) => {
const colors = colorMap[color];
const chartData = {
labels: data.map((_, i) => i.toString()),
datasets: [
{
data,
borderColor: colors.border,
backgroundColor: colors.background,
borderWidth: 1.5,
pointRadius: 0,
tension: 0.4,
fill: true,
},
],
};
const options = {
responsive: false,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { enabled: false },
},
scales: {
x: { display: false },
y: { display: false },
},
elements: {
line: {
borderJoinStyle: 'round' as const,
},
},
};
return (
<div style={{ width, height }}>
<Line data={chartData} options={options} width={width} height={height} />
</div>
);
};
export default Sparkline;

View File

@@ -0,0 +1,103 @@
'use client';
import React from 'react';
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
interface StatCardProps {
title: string;
value: string | number;
subtitle?: string;
trend?: {
direction: 'up' | 'down' | 'flat';
percentage: number;
isNew?: boolean;
period?: string;
};
icon?: React.ReactNode;
variant?: 'default' | 'highlight';
}
const StatCard: React.FC<StatCardProps> = ({
title,
value,
subtitle,
trend,
icon,
variant = 'default',
}) => {
const getTrendColor = () => {
if (!trend) return 'text-gray-500';
if (trend.direction === 'up') return 'text-emerald-600';
if (trend.direction === 'down') return 'text-red-500';
return 'text-gray-500';
};
const getTrendIcon = () => {
if (!trend) return null;
if (trend.direction === 'up') return <TrendingUp className="w-4 h-4" />;
if (trend.direction === 'down') return <TrendingDown className="w-4 h-4" />;
return <Minus className="w-4 h-4" />;
};
return (
<div
className={`rounded-xl p-6 transition-all duration-200 ${variant === 'highlight'
? 'bg-gradient-to-br from-primary-500 to-primary-600 text-white shadow-lg shadow-primary-500/25'
: 'bg-white border border-gray-200 hover:shadow-md'
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<p
className={`text-sm font-medium ${variant === 'highlight' ? 'text-primary-100' : 'text-gray-500'
}`}
>
{title}
</p>
<p
className={`text-3xl font-bold mt-2 ${variant === 'highlight' ? 'text-white' : 'text-gray-900'
}`}
>
{typeof value === 'number' ? value.toLocaleString() : value}
</p>
{trend && (
<div className={`flex items-center gap-1 mt-3 ${getTrendColor()}`}>
{getTrendIcon()}
<span className="text-sm font-medium">
{trend.direction === 'up' ? '+' : trend.direction === 'down' ? '-' : ''}
{trend.percentage}%
{trend.isNew && ' (new)'}
</span>
{trend.period && (
<span
className={`text-sm ${variant === 'highlight' ? 'text-primary-200' : 'text-gray-400'
}`}
>
vs last {trend.period}
</span>
)}
</div>
)}
{subtitle && !trend && (
<p
className={`text-sm mt-2 ${variant === 'highlight' ? 'text-primary-200' : 'text-gray-500'
}`}
>
{subtitle}
</p>
)}
</div>
{icon && (
<div
className={`p-3 rounded-lg ${variant === 'highlight' ? 'bg-white/20' : 'bg-gray-100'
}`}
>
{icon}
</div>
)}
</div>
</div>
);
};
export default StatCard;

View File

@@ -0,0 +1,3 @@
export { default as GeoMap } from './GeoMap';
export { default as Sparkline } from './Sparkline';
export { default as StatCard } from './StatCard';

View File

@@ -21,20 +21,28 @@ interface QRCodeCardProps {
};
onEdit: (id: string) => void;
onDelete: (id: string) => void;
userSubdomain?: string | null;
}
export const QRCodeCard: React.FC<QRCodeCardProps> = ({
qr,
onEdit,
onDelete,
userSubdomain,
}) => {
// For dynamic QR codes, use the redirect URL for tracking
// For static QR codes, use the direct URL from content
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || (typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3050');
// White label: use subdomain URL if available
const mainDomain = process.env.NEXT_PUBLIC_MAIN_DOMAIN || 'qrmaster.net';
const brandedBaseUrl = userSubdomain
? `https://${userSubdomain}.${mainDomain}`
: baseUrl;
// Get the QR URL based on type
let qrUrl = '';
// SIMPLE FIX: For STATIC QR codes, ALWAYS use the direct content
if (qr.type === 'STATIC') {
// Extract the actual URL/content based on contentType
@@ -65,15 +73,17 @@ END:VCARD`;
qrUrl = qr.content.qrContent;
} else {
// Last resort fallback
qrUrl = `${baseUrl}/r/${qr.slug}`;
qrUrl = `${brandedBaseUrl}/r/${qr.slug}`;
}
console.log(`STATIC QR [${qr.title}]: ${qrUrl}`);
} else {
// DYNAMIC QR codes always use redirect for tracking
qrUrl = `${baseUrl}/r/${qr.slug}`;
console.log(`DYNAMIC QR [${qr.title}]: ${qrUrl}`);
// DYNAMIC QR codes use branded URL for white label
qrUrl = `${brandedBaseUrl}/r/${qr.slug}`;
}
// Display URL (same as qrUrl for consistency)
const displayUrl = qrUrl;
const downloadQR = (format: 'png' | 'svg') => {
const svg = document.querySelector(`#qr-${qr.id} svg`);
if (!svg) return;
@@ -171,7 +181,7 @@ END:VCARD`;
</Badge>
</div>
</div>
<Dropdown
align="right"
trigger={
@@ -196,11 +206,13 @@ END:VCARD`;
<div id={`qr-${qr.id}`} className="flex items-center justify-center bg-gray-50 rounded-lg p-4 mb-3">
<div className={qr.style?.cornerStyle === 'rounded' ? 'rounded-lg overflow-hidden' : ''}>
<QRCodeSVG
key={qrUrl}
value={qrUrl}
size={96}
fgColor={qr.style?.foregroundColor || '#000000'}
bgColor={qr.style?.backgroundColor || '#FFFFFF'}
level="M"
level={qr.style?.imageSettings ? 'H' : 'M'}
imageSettings={qr.style?.imageSettings}
/>
</div>
</div>
@@ -216,6 +228,11 @@ END:VCARD`;
<span className="text-gray-900">{qr.scans || 0}</span>
</div>
)}
{qr.type === 'DYNAMIC' && (
<div className="text-xs text-gray-400 break-all bg-gray-50 p-1 rounded border border-gray-100 mt-2">
{qrUrl}
</div>
)}
<div className="flex items-center justify-between">
<span className="text-gray-500">Created:</span>
<span className="text-gray-900">{formatDate(qr.createdAt)}</span>
@@ -223,7 +240,7 @@ END:VCARD`;
{qr.type === 'DYNAMIC' && (
<div className="pt-2 border-t">
<p className="text-xs text-gray-500">
📊 Dynamic QR: Tracks scans via {baseUrl}/r/{qr.slug}
📊 Dynamic QR: Tracks scans via {displayUrl}
</p>
</div>
)}

View File

@@ -4,26 +4,51 @@ import React from 'react';
import { Card, CardContent } from '@/components/ui/Card';
import { formatNumber } from '@/lib/utils';
import { useTranslation } from '@/hooks/useTranslation';
import { TrendData } from '@/types/analytics';
interface StatsGridProps {
stats: {
totalScans: number;
activeQRCodes: number;
conversionRate: number;
uniqueScans?: number;
};
trends?: {
totalScans?: TrendData;
comparisonPeriod?: 'week' | 'month';
};
}
export const StatsGrid: React.FC<StatsGridProps> = ({ stats }) => {
export const StatsGrid: React.FC<StatsGridProps> = ({ stats, trends }) => {
const { t } = useTranslation();
// Only show growth if there are actual scans
const showGrowth = stats.totalScans > 0;
// Build trend display text
const getTrendText = () => {
if (!trends?.totalScans) {
return 'No data yet';
}
const trend = trends.totalScans;
const sign = trend.isNegative ? '-' : '+';
const period = trends.comparisonPeriod || 'period';
const newLabel = trend.isNew ? ' (new)' : '';
return `${sign}${trend.percentage}%${newLabel} from last ${period}`;
};
const getTrendType = (): 'positive' | 'negative' | 'neutral' => {
if (!trends?.totalScans) return 'neutral';
if (trends.totalScans.trend === 'up') return 'positive';
if (trends.totalScans.trend === 'down') return 'negative';
return 'neutral';
};
const cards = [
{
title: t('dashboard.stats.total_scans'),
value: formatNumber(stats.totalScans),
change: showGrowth ? '+12%' : 'No data yet',
changeType: showGrowth ? 'positive' : 'neutral' as 'positive' | 'negative' | 'neutral',
change: getTrendText(),
changeType: getTrendType(),
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
@@ -43,13 +68,13 @@ export const StatsGrid: React.FC<StatsGridProps> = ({ stats }) => {
),
},
{
title: t('dashboard.stats.conversion_rate'),
value: `${stats.conversionRate}%`,
change: stats.totalScans > 0 ? `${stats.conversionRate}% rate` : 'No scans yet',
changeType: stats.conversionRate > 0 ? 'positive' : 'neutral' as 'positive' | 'negative' | 'neutral',
title: 'Unique Users',
value: formatNumber(stats.uniqueScans ?? 0),
change: stats.totalScans > 0 ? `${stats.uniqueScans ?? 0} unique visitors` : 'No scans yet',
changeType: (stats.uniqueScans ?? 0) > 0 ? 'positive' : 'neutral' as 'positive' | 'negative' | 'neutral',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
},
@@ -64,12 +89,11 @@ export const StatsGrid: React.FC<StatsGridProps> = ({ stats }) => {
<div>
<p className="text-sm text-gray-600 mb-1">{card.title}</p>
<p className="text-2xl font-bold text-gray-900">{card.value}</p>
<p className={`text-sm mt-2 ${
card.changeType === 'positive' ? 'text-success-600' :
card.changeType === 'negative' ? 'text-red-600' :
'text-gray-500'
}`}>
{card.changeType === 'neutral' ? card.change : `${card.change} from last month`}
<p className={`text-sm mt-2 ${card.changeType === 'positive' ? 'text-success-600' :
card.changeType === 'negative' ? 'text-red-600' :
'text-gray-500'
}`}>
{card.change}
</p>
</div>
<div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center text-primary-600">

View File

@@ -0,0 +1,197 @@
'use client';
import React, { useState } from 'react';
import { Sparkles, Brain, TrendingUp, MessageSquare, Palette, ArrowRight, Mail, CheckCircle2, Lock } from 'lucide-react';
import Link from 'next/link';
const AIComingSoonBanner = () => {
const [email, setEmail] = useState('');
const [submitted, setSubmitted] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const response = await fetch('/api/newsletter/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to subscribe');
}
setSubmitted(true);
setEmail('');
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong. Please try again.');
} finally {
setLoading(false);
}
};
const features = [
{
icon: Brain,
category: 'Smart QR Generation',
items: [
'AI-powered content optimization',
'Intelligent design suggestions',
'Auto-generate vCard from LinkedIn',
'Smart URL shortening with SEO',
],
},
{
icon: TrendingUp,
category: 'Advanced Analytics & Insights',
items: [
'AI-powered scan predictions',
'Anomaly detection',
'Natural language analytics queries',
'Automated insights reports',
],
},
{
icon: MessageSquare,
category: 'Smart Content Management',
items: [
'AI chatbot for instant support',
'Automated QR categorization',
'Smart bulk QR generation',
'Content recommendations',
],
},
{
icon: Palette,
category: 'Creative & Marketing',
items: [
'AI-generated custom QR designs',
'Marketing copy generation',
'A/B testing suggestions',
'Campaign optimization',
],
},
];
return (
<section className="relative overflow-hidden py-20 px-4 sm:px-6 lg:px-8 bg-gradient-to-br from-blue-50 via-white to-purple-50">
{/* Animated Background Orbs (matching Hero) */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-0 left-1/4 w-96 h-96 bg-blue-400/20 rounded-full blur-3xl animate-blob" />
<div className="absolute top-1/2 right-1/4 w-80 h-80 bg-purple-400/20 rounded-full blur-3xl animate-blob animation-delay-2000" />
<div className="absolute bottom-0 left-1/2 w-96 h-96 bg-cyan-400/15 rounded-full blur-3xl animate-blob animation-delay-4000" />
</div>
<div className="max-w-6xl mx-auto relative z-10">
{/* Header */}
<div className="text-center mb-12">
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-blue-100 mb-4">
<Sparkles className="w-4 h-4 text-blue-600" />
<span className="text-sm font-medium text-blue-700">
Coming Soon
</span>
</div>
<h2 className="text-3xl sm:text-4xl font-bold text-gray-900 mb-3">
The Future of QR Codes is{' '}
<span className="bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
AI-Powered
</span>
</h2>
<p className="text-gray-600 text-lg max-w-2xl mx-auto">
Revolutionary AI features to transform how you create, manage, and optimize QR codes
</p>
</div>
{/* Features Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
{features.map((feature, index) => (
<div
key={index}
className="bg-white/80 backdrop-blur rounded-xl p-6 border border-gray-100 hover:shadow-lg transition-all"
>
<div className="w-12 h-12 bg-gradient-to-br from-blue-100 to-purple-100 rounded-lg flex items-center justify-center mb-4">
<feature.icon className="w-6 h-6 text-blue-600" />
</div>
<h3 className="font-semibold text-gray-900 mb-3">
{feature.category}
</h3>
<ul className="space-y-2">
{feature.items.map((item, itemIndex) => (
<li key={itemIndex} className="flex items-start gap-2 text-sm text-gray-600">
<div className="mt-1.5 w-1.5 h-1.5 rounded-full bg-blue-500 flex-shrink-0" />
<span>{item}</span>
</li>
))}
</ul>
</div>
))}
</div>
{/* Email Capture */}
<div className="max-w-2xl mx-auto bg-gradient-to-br from-blue-50 to-purple-50 rounded-2xl p-8 border border-gray-100">
{!submitted ? (
<>
<form onSubmit={handleSubmit} className="flex flex-col sm:flex-row gap-3 mb-3">
<div className="flex-1 relative">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 pointer-events-none" />
<input
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
setError('');
}}
placeholder="your@email.com"
required
disabled={loading}
className="w-full pl-12 pr-4 py-3 rounded-xl bg-white border border-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 outline-none transition-all"
/>
</div>
<button
type="submit"
disabled={loading}
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-xl transition-all disabled:opacity-50 whitespace-nowrap flex items-center justify-center gap-2"
>
{loading ? 'Subscribing...' : (
<>
Notify Me
<ArrowRight className="w-4 h-4" />
</>
)}
</button>
</form>
{error && (
<p className="text-sm text-red-600 mb-2">{error}</p>
)}
<p className="text-xs text-gray-500 text-center">
Be the first to know when AI features launch
</p>
</>
) : (
<div className="flex items-center justify-center gap-2 text-green-600">
<CheckCircle2 className="w-5 h-5" />
<span className="font-medium">
You're on the list! We'll notify you when AI features launch.
</span>
</div>
)}
</div>
</div>
</section>
);
};
export default AIComingSoonBanner;

View File

@@ -9,7 +9,7 @@ interface FAQProps {
export const FAQ: React.FC<FAQProps> = ({ t }) => {
const [openIndex, setOpenIndex] = useState<number | null>(null);
const questions = [
'account',
'static_vs_dynamic',
@@ -40,6 +40,7 @@ export const FAQ: React.FC<FAQProps> = ({ t }) => {
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>

View File

@@ -12,7 +12,7 @@ export const Features: React.FC<FeaturesProps> = ({ t }) => {
{
key: 'analytics',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
@@ -21,7 +21,7 @@ export const Features: React.FC<FeaturesProps> = ({ t }) => {
{
key: 'customization',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
),
@@ -30,7 +30,7 @@ export const Features: React.FC<FeaturesProps> = ({ t }) => {
{
key: 'unlimited',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),

View File

@@ -86,7 +86,7 @@ export const Hero: React.FC<HeroProps> = ({ t }) => {
{templateCards.map((card, index) => (
<Card key={index} className={`${card.color} border-0 p-6 text-center hover:scale-105 transition-transform`}>
<div className="text-3xl mb-2">{card.icon}</div>
<h3 className="font-semibold text-gray-800">{card.title}</h3>
<p className="font-semibold text-gray-800">{card.title}</p>
</Card>
))}
</div>

View File

@@ -2,6 +2,7 @@
import React from 'react';
import { Hero } from '@/components/marketing/Hero';
import AIComingSoonBanner from '@/components/marketing/AIComingSoonBanner';
import { StatsStrip } from '@/components/marketing/StatsStrip';
import { TemplateCards } from '@/components/marketing/TemplateCards';
import { InstantGenerator } from '@/components/marketing/InstantGenerator';
@@ -29,6 +30,7 @@ export default function HomePageClient() {
return (
<>
<Hero t={t} />
<AIComingSoonBanner />
<InstantGenerator t={t} />
<StaticVsDynamic t={t} />
<Features t={t} />

View File

@@ -45,7 +45,7 @@ export const InstantGenerator: React.FC<InstantGeneratorProps> = ({ t }) => {
const svgData = new XMLSerializer().serializeToString(svg);
const blob = new Blob([svgData], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
img.onload = () => {
canvas.width = size;
canvas.height = size;
@@ -93,50 +93,59 @@ export const InstantGenerator: React.FC<InstantGeneratorProps> = ({ t }) => {
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label htmlFor="foreground-color" className="block text-sm font-medium text-gray-700 mb-2">
{t.generator.foreground}
</label>
<div className="flex items-center space-x-2">
<input
id="foreground-color"
type="color"
value={foregroundColor}
onChange={(e) => setForegroundColor(e.target.value)}
className="w-12 h-10 rounded border border-gray-300"
aria-label="Foreground color picker"
/>
<Input
id="foreground-color-text"
value={foregroundColor}
onChange={(e) => setForegroundColor(e.target.value)}
className="flex-1"
aria-label="Foreground color hex value"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label htmlFor="background-color" className="block text-sm font-medium text-gray-700 mb-2">
{t.generator.background}
</label>
<div className="flex items-center space-x-2">
<input
id="background-color"
type="color"
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
className="w-12 h-10 rounded border border-gray-300"
aria-label="Background color picker"
/>
<Input
id="background-color-text"
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
className="flex-1"
aria-label="Background color hex value"
/>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label htmlFor="corner-style" className="block text-sm font-medium text-gray-700 mb-2">
{t.generator.corners}
</label>
<select
id="corner-style"
value={cornerStyle}
onChange={(e) => setCornerStyle(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
@@ -147,21 +156,23 @@ export const InstantGenerator: React.FC<InstantGeneratorProps> = ({ t }) => {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label htmlFor="qr-size" className="block text-sm font-medium text-gray-700 mb-2">
{t.generator.size}
</label>
<input
id="qr-size"
type="range"
min="100"
max="400"
value={size}
onChange={(e) => setSize(Number(e.target.value))}
className="w-full"
aria-label={`QR code size: ${size} pixels`}
/>
<div className="text-sm text-gray-500 text-center mt-1">{size}px</div>
<div className="text-sm text-gray-500 text-center mt-1" aria-hidden="true">{size}px</div>
</div>
</div>
<div className="flex items-center justify-between">
<Badge variant={hasGoodContrast ? 'success' : 'warning'}>
{hasGoodContrast ? t.generator.contrast_good : 'Low contrast'}
@@ -201,7 +212,7 @@ export const InstantGenerator: React.FC<InstantGeneratorProps> = ({ t }) => {
/>
</div>
) : (
<div
<div
className="bg-gray-200 flex items-center justify-center text-gray-500"
style={{ width: 200, height: 200 }}
>

View File

@@ -0,0 +1,70 @@
import Link from 'next/link';
interface FooterProps {
variant?: 'marketing' | 'dashboard';
}
export function Footer({ variant = 'marketing' }: FooterProps) {
const isDashboard = variant === 'dashboard';
return (
<footer className={`${isDashboard ? 'bg-gray-50 text-gray-600 border-t border-gray-200 mt-12' : 'bg-gray-900 text-white mt-20'} py-12`}>
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<div className="grid md:grid-cols-4 gap-8">
<div>
<Link href="/" className="flex items-center space-x-2 mb-4 hover:opacity-80 transition-opacity">
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
<span className={`text-xl font-bold ${isDashboard ? 'text-gray-900' : ''}`}>QR Master</span>
</Link>
<p className={isDashboard ? 'text-gray-500' : 'text-gray-400'}>
Create custom QR codes in seconds with advanced tracking and analytics.
</p>
</div>
<div>
<h3 className={`font-semibold mb-4 ${isDashboard ? 'text-gray-900' : ''}`}>Product</h3>
<ul className={`space-y-2 ${isDashboard ? 'text-gray-500' : 'text-gray-400'}`}>
<li><Link href="/#features" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>Features</Link></li>
<li><Link href="/#pricing" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>Pricing</Link></li>
<li><Link href="/#faq" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>FAQ</Link></li>
<li><Link href="/blog" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>Blog</Link></li>
</ul>
</div>
<div>
<h3 className={`font-semibold mb-4 ${isDashboard ? 'text-gray-900' : ''}`}>Resources</h3>
<ul className={`space-y-2 ${isDashboard ? 'text-gray-500' : 'text-gray-400'}`}>
<li><Link href="/pricing" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>Full Pricing</Link></li>
<li><Link href="/faq" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>All Questions</Link></li>
<li><Link href="/blog" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>Blog</Link></li>
<li><Link href="/signup" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>Get Started</Link></li>
</ul>
</div>
<div>
<h3 className={`font-semibold mb-4 ${isDashboard ? 'text-gray-900' : ''}`}>Legal</h3>
<ul className={`space-y-2 ${isDashboard ? 'text-gray-500' : 'text-gray-400'}`}>
<li><Link href="/privacy" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>Privacy Policy</Link></li>
</ul>
</div>
</div>
<div className={`border-t mt-8 pt-8 flex items-center justify-between ${isDashboard ? 'border-gray-200 text-gray-500' : 'border-gray-800 text-gray-400'}`}>
{!isDashboard ? (
<Link
href="/newsletter"
className="text-[6px] text-gray-700 opacity-[0.03] hover:opacity-100 hover:text-white transition-opacity duration-300"
>
</Link>
) : (
<div></div>
)}
<p>&copy; 2025 QR Master. All rights reserved.</p>
<div className="w-12"></div>
</div>
</div>
</footer>
);
}

View File

@@ -7,7 +7,10 @@ interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
}
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, label, error, onInvalid, ...props }, ref) => {
({ className, type, label, error, onInvalid, id, ...props }, ref) => {
// Generate a unique id for accessibility if not provided
const inputId = id || (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined);
// Default English validation message
const handleInvalid = (e: React.InvalidEvent<HTMLInputElement>) => {
e.target.setCustomValidity('Please fill out this field.');
@@ -21,11 +24,12 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
return (
<div className="space-y-1">
{label && (
<label className="block text-sm font-medium text-gray-700">
<label htmlFor={inputId} className="block text-sm font-medium text-gray-700">
{label}
</label>
)}
<input
id={inputId}
type={type}
className={cn(
'flex h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',

View File

@@ -41,6 +41,7 @@ export function ScrollToTop() {
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
strokeLinecap="round"

View File

@@ -8,15 +8,19 @@ interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
}
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
({ className, label, error, options, ...props }, ref) => {
({ className, label, error, options, id, ...props }, ref) => {
// Generate a unique id for accessibility if not provided
const selectId = id || (label ? `select-${label.toLowerCase().replace(/\s+/g, '-')}` : undefined);
return (
<div className="space-y-1">
{label && (
<label className="block text-sm font-medium text-gray-700">
<label htmlFor={selectId} className="block text-sm font-medium text-gray-700">
{label}
</label>
)}
<select
id={selectId}
className={cn(
'flex h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
error && 'border-red-500 focus-visible:ring-red-500',

View File

@@ -1,76 +1,140 @@
/**
* Email Templates - CRO-Optimized
*
* What's improved (based on email marketing best practices):
*
* Subject Lines:
* - More specific with urgency/outcome ("Expires in 1 Hour")
* - Emoji for visual attention in inbox
* - Clear value proposition
*
* Design & Copy:
* - Personal sender name ("Timo from QR Master")
* - Storytelling instead of feature lists
* - Trust elements (security badges, timelines)
* - Clear CTAs with action verbs ("Reset My Password →")
* - Objection handling built-in ("Didn't request this?")
* - Mobile-optimized (shorter paragraphs, more whitespace)
*
* How to measure if this works for YOUR audience:
* 1. Track baseline metrics (open rate, click rate, conversions)
* 2. A/B test old vs new templates
* 3. Measure with real users (minimum 100 emails per variant)
*
* No guarantees - only your data will tell what works for QR Master users.
*/
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
// Use a placeholder during build time, real key at runtime
const resendKey = process.env.RESEND_API_KEY || 're_placeholder_for_build';
const resend = new Resend(resendKey);
// Rate limiter for Resend Free Tier (1 email per second)
let lastEmailSent = 0;
const MIN_EMAIL_INTERVAL = 1000; // 1 second in milliseconds
async function waitForRateLimit() {
const now = Date.now();
const timeSinceLastEmail = now - lastEmailSent;
if (timeSinceLastEmail < MIN_EMAIL_INTERVAL) {
const waitTime = MIN_EMAIL_INTERVAL - timeSinceLastEmail;
await new Promise(resolve => setTimeout(resolve, waitTime));
}
lastEmailSent = Date.now();
}
/**
* Password Reset Email - Security focused with clear urgency
*/
export async function sendPasswordResetEmail(email: string, resetToken: string) {
await waitForRateLimit();
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050';
const resetUrl = `${appUrl}/reset-password?token=${resetToken}`;
try {
await resend.emails.send({
from: 'QR Master <onboarding@resend.dev>', // Use Resend's testing domain
from: 'QR Master Security <noreply@qrmaster.net>',
replyTo: 'support@qrmaster.net',
to: email,
subject: 'Reset Your Password - QR Master',
subject: '🔐 Reset Your QR Master Password (Expires in 1 Hour)',
html: `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reset Your Password</title>
<title>Reset Your Password - QR Master</title>
</head>
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f4; padding: 20px 0;">
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f4f4f4;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f4; padding: 40px 20px;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<!-- Header -->
<!-- Main Container -->
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.08);">
<!-- Header with Security Badge -->
<tr>
<td style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 40px 30px; text-align: center;">
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: bold;">QR Master</h1>
<td style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 50px 40px; text-align: center;">
<div style="display: inline-block; background-color: rgba(255,255,255,0.2); padding: 12px 24px; border-radius: 50px; margin-bottom: 20px;">
<span style="color: #ffffff; font-size: 14px; font-weight: 600; letter-spacing: 1px;">🔒 SECURITY ALERT</span>
</div>
<h1 style="margin: 0; color: #ffffff; font-size: 32px; font-weight: bold; line-height: 1.2;">Password Reset Request</h1>
<p style="margin: 15px 0 0 0; color: rgba(255,255,255,0.9); font-size: 16px;">Someone requested to reset your password</p>
</td>
</tr>
<!-- Content -->
<!-- Content Section -->
<tr>
<td style="padding: 40px 30px;">
<h2 style="margin: 0 0 20px 0; color: #333333; font-size: 24px;">Reset Your Password</h2>
<p style="margin: 0 0 20px 0; color: #666666; font-size: 16px; line-height: 1.5;">
<td style="padding: 50px 40px;">
<p style="margin: 0 0 20px 0; color: #333333; font-size: 16px; line-height: 1.6;">
Hi there,
</p>
<p style="margin: 0 0 30px 0; color: #666666; font-size: 16px; line-height: 1.5;">
You requested to reset your password for your QR Master account. Click the button below to choose a new password:
<p style="margin: 0 0 25px 0; color: #555555; font-size: 16px; line-height: 1.7;">
We received a request to reset the password for your QR Master account. If this was you, click the button below to create a new password:
</p>
<!-- Button -->
<table width="100%" cellpadding="0" cellspacing="0">
<!-- Primary CTA Button -->
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 35px 0;">
<tr>
<td align="center" style="padding: 20px 0;">
<a href="${resetUrl}" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #ffffff; text-decoration: none; padding: 16px 40px; border-radius: 6px; font-size: 16px; font-weight: bold; display: inline-block;">
Reset Password
<td align="center">
<a href="${resetUrl}" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #ffffff; text-decoration: none; padding: 18px 50px; border-radius: 8px; font-size: 17px; font-weight: 700; display: inline-block; box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);">
🔐 Reset My Password
</a>
</td>
</tr>
</table>
<p style="margin: 30px 0 20px 0; color: #666666; font-size: 14px; line-height: 1.5;">
Or copy and paste this link into your browser:
</p>
<p style="margin: 0 0 30px 0; padding: 15px; background-color: #f8f8f8; border-radius: 4px; word-break: break-all;">
<a href="${resetUrl}" style="color: #667eea; text-decoration: none; font-size: 14px;">${resetUrl}</a>
</p>
<div style="border-top: 1px solid #eeeeee; padding-top: 20px; margin-top: 30px;">
<p style="margin: 0 0 10px 0; color: #999999; font-size: 13px; line-height: 1.5;">
<strong>This link will expire in 1 hour.</strong>
<!-- Security Info Box -->
<div style="background: linear-gradient(135deg, rgba(255, 107, 107, 0.08) 0%, rgba(255, 148, 148, 0.08) 100%); border-left: 4px solid #ff6b6b; padding: 20px 25px; margin: 35px 0; border-radius: 6px;">
<p style="margin: 0 0 12px 0; color: #d63031; font-size: 15px; font-weight: 700;">
⏱️ This link expires in 60 minutes
</p>
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6;">
For your security, this password reset link will only work once and expires after 1 hour. If you need a new link, you can request another one anytime.
</p>
</div>
<p style="margin: 0; color: #999999; font-size: 13px; line-height: 1.5;">
If you didn't request a password reset, you can safely ignore this email. Your password will not be changed.
<!-- Alternative Link -->
<p style="margin: 30px 0 15px 0; color: #888888; font-size: 14px; line-height: 1.5;">
🔗 Button not working? Copy and paste this link into your browser:
</p>
<p style="margin: 0 0 30px 0; padding: 18px 20px; background-color: #f8f9fa; border: 1px solid #e9ecef; border-radius: 6px; word-break: break-all;">
<a href="${resetUrl}" style="color: #667eea; text-decoration: none; font-size: 13px; font-family: monospace;">${resetUrl}</a>
</p>
<!-- Didn't Request This? -->
<div style="background-color: #f8f9fa; padding: 25px; border-radius: 8px; margin-top: 35px;">
<p style="margin: 0 0 12px 0; color: #333333; font-size: 15px; font-weight: 600;">
❓ Didn't request this?
</p>
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6;">
✅ You can safely ignore this email. Your password won't be changed unless you click the button above. If you're concerned about your account security, please contact us immediately at <a href="mailto:support@qrmaster.net" style="color: #667eea; text-decoration: none;">support@qrmaster.net</a>
</p>
</div>
</td>
@@ -78,15 +142,28 @@ export async function sendPasswordResetEmail(email: string, resetToken: string)
<!-- Footer -->
<tr>
<td style="background-color: #f8f8f8; padding: 30px; text-align: center; border-top: 1px solid #eeeeee;">
<p style="margin: 0 0 10px 0; color: #999999; font-size: 12px;">
© 2025 QR Master. All rights reserved.
</p>
<p style="margin: 0; color: #999999; font-size: 12px;">
This is an automated email. Please do not reply.
</p>
<td style="background-color: #f8f9fa; padding: 35px 40px; border-top: 1px solid #e9ecef;">
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center">
<p style="margin: 0 0 15px 0; color: #333333; font-size: 16px; font-weight: 600;">
QR Master
</p>
<p style="margin: 0 0 8px 0; color: #888888; font-size: 13px;">
Secure QR Code Analytics & Management
</p>
<p style="margin: 0; color: #999999; font-size: 12px;">
© 2025 QR Master. All rights reserved.
</p>
<p style="margin: 15px 0 0 0; color: #aaaaaa; font-size: 11px;">
This is an automated security email. Please do not reply.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
@@ -103,3 +180,355 @@ export async function sendPasswordResetEmail(email: string, resetToken: string)
throw new Error('Failed to send password reset email');
}
}
/**
* Newsletter Welcome Email - Value-focused with clear expectations
*/
export async function sendNewsletterWelcomeEmail(email: string) {
await waitForRateLimit();
try {
await resend.emails.send({
from: 'Timo from QR Master <timo@qrmaster.net>',
replyTo: 'support@qrmaster.net',
to: email,
subject: '🎉 You\'re In! Here\'s What Happens Next (AI QR Features)',
html: `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Welcome to QR Master AI Waitlist</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f4f4f4;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f4; padding: 40px 20px;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.08);">
<!-- Hero Header -->
<tr>
<td style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 50px 40px; text-align: center;">
<div style="display: inline-block; background-color: rgba(255,255,255,0.2); padding: 10px 20px; border-radius: 50px; margin-bottom: 20px;">
<span style="color: #ffffff; font-size: 13px; font-weight: 600; letter-spacing: 1px;">✨ EARLY ACCESS</span>
</div>
<h1 style="margin: 0; color: #ffffff; font-size: 36px; font-weight: bold; line-height: 1.2;">You're on the List! 🚀</h1>
<p style="margin: 20px 0 0 0; color: rgba(255,255,255,0.95); font-size: 18px; line-height: 1.5;">Get ready for AI-powered QR codes that work smarter, not harder</p>
</td>
</tr>
<!-- Personal Note -->
<tr>
<td style="padding: 45px 40px 35px 40px;">
<p style="margin: 0 0 20px 0; color: #333333; font-size: 16px; line-height: 1.7;">
Hey there! 👋
</p>
<p style="margin: 0 0 25px 0; color: #555555; font-size: 16px; line-height: 1.7;">
Thanks for joining the waitlist for QR Master's AI-powered features. You're among the <strong>first to know</strong> when we launch something that'll completely change how you create and manage QR codes.
</p>
<p style="margin: 0 0 35px 0; color: #555555; font-size: 16px; line-height: 1.7;">
No fluff, no spam. Just a heads-up when these features go live.
</p>
<!-- What's Coming Section -->
<div style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%); border-left: 4px solid #667eea; padding: 30px 30px; margin: 35px 0; border-radius: 8px;">
<h2 style="margin: 0 0 20px 0; color: #667eea; font-size: 20px; font-weight: 700;">
🎯 What You Can Expect
</h2>
<!-- Feature 1 -->
<div style="margin-bottom: 20px;">
<h3 style="margin: 0 0 8px 0; color: #333333; font-size: 16px; font-weight: 700;">
⚡ Smart QR Generation
</h3>
<p style="margin: 0; color: #666666; font-size: 15px; line-height: 1.6;">
AI analyzes your content and automatically suggests the optimal QR design, colors, and format for maximum scans. No more guessing what works.
</p>
</div>
<!-- Feature 2 -->
<div style="margin-bottom: 20px;">
<h3 style="margin: 0 0 8px 0; color: #333333; font-size: 16px; font-weight: 700;">
📊 Predictive Analytics
</h3>
<p style="margin: 0; color: #666666; font-size: 15px; line-height: 1.6;">
See scan forecasts, detect unusual patterns, and ask questions about your data in plain English. Your analytics become conversations, not spreadsheets.
</p>
</div>
<!-- Feature 3 -->
<div style="margin-bottom: 20px;">
<h3 style="margin: 0 0 8px 0; color: #333333; font-size: 16px; font-weight: 700;">
🤖 Auto-Organization
</h3>
<p style="margin: 0; color: #666666; font-size: 15px; line-height: 1.6;">
Your QR codes automatically get categorized, tagged, and organized. Spend less time managing, more time creating.
</p>
</div>
<!-- Feature 4 -->
<div>
<h3 style="margin: 0 0 8px 0; color: #333333; font-size: 16px; font-weight: 700;">
🎨 AI Design Studio
</h3>
<p style="margin: 0; color: #666666; font-size: 15px; line-height: 1.6;">
Generate unique, on-brand QR code designs in seconds. Just describe what you want, AI handles the rest.
</p>
</div>
</div>
<!-- Timeline -->
<div style="background-color: #fff3cd; border: 1px solid #ffc107; padding: 25px; border-radius: 8px; margin: 35px 0;">
<p style="margin: 0 0 12px 0; color: #856404; font-size: 15px; font-weight: 700;">
⏰ When Will This Happen?
</p>
<p style="margin: 0; color: #856404; font-size: 14px; line-height: 1.6;">
We're in active development and testing. You'll get an email the moment these features go live <strong>no waiting, no wondering</strong>. We respect your inbox.
</p>
</div>
<!-- Meanwhile CTA -->
<div style="background-color: #f8f9fa; padding: 30px; border-radius: 8px; margin: 35px 0; text-align: center;">
<h3 style="margin: 0 0 15px 0; color: #333333; font-size: 18px; font-weight: 700;">
💡 Want to Get Started Now?
</h3>
<p style="margin: 0 0 25px 0; color: #666666; font-size: 15px; line-height: 1.6;">
Our current platform already helps teams create, track, and manage dynamic QR codes. No AI needed just powerful tools that work today.
</p>
<a href="https://www.qrmaster.net/signup" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #ffffff; text-decoration: none; padding: 16px 40px; border-radius: 8px; font-size: 16px; font-weight: 700; display: inline-block; box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);">
🚀 Try QR Master Free →
</a>
</div>
<!-- Personal Sign-off -->
<p style="margin: 35px 0 0 0; color: #555555; font-size: 16px; line-height: 1.7;">
💌 Thanks for trusting us with your inbox. We'll make it worth it.
</p>
<p style="margin: 20px 0 0 0; color: #555555; font-size: 16px; line-height: 1.7;">
<strong>— Timo 👋</strong><br>
<span style="color: #888888; font-size: 14px;">Founder, QR Master</span>
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #f8f9fa; padding: 35px 40px; border-top: 1px solid #e9ecef;">
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center">
<p style="margin: 0 0 8px 0; color: #888888; font-size: 13px;">
<a href="https://www.qrmaster.net" style="color: #667eea; text-decoration: none;">www.qrmaster.net</a>
</p>
<p style="margin: 0; color: #999999; font-size: 12px;">
© 2025 QR Master. All rights reserved.
</p>
<p style="margin: 15px 0 0 0; color: #aaaaaa; font-size: 11px;">
You're receiving this because you signed up for AI feature notifications.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`,
});
console.log('Newsletter welcome email sent successfully to:', email);
return { success: true };
} catch (error) {
console.error('Error sending newsletter welcome email:', error);
throw new Error('Failed to send newsletter welcome email');
}
}
/**
* AI Feature Launch Email - Excitement + clear CTA
*/
export async function sendAIFeatureLaunchEmail(email: string) {
await waitForRateLimit();
try {
await resend.emails.send({
from: 'Timo from QR Master <timo@qrmaster.net>',
replyTo: 'support@qrmaster.net',
to: email,
subject: '🚀 They\'re Live! Your AI QR Features Are Ready',
html: `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Features Are Live - QR Master</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f4f4f4;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f4; padding: 40px 20px;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.08);">
<!-- Celebration Header -->
<tr>
<td style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 25px 30px; text-align: center;">
<div style="font-size: 32px; margin-bottom: 10px;">🎉</div>
<div style="display: inline-block; background-color: rgba(255,255,255,0.2); padding: 6px 14px; border-radius: 50px; margin-bottom: 12px;">
<span style="color: #ffffff; font-size: 11px; font-weight: 600; letter-spacing: 1px;">✨ NOW LIVE</span>
</div>
<h1 style="margin: 0; color: #ffffff; font-size: 24px; font-weight: bold; line-height: 1.2;">AI Features Are Here! 🚀</h1>
<p style="margin: 10px 0 0 0; color: rgba(255,255,255,0.95); font-size: 14px; line-height: 1.4;">Ready to use in your dashboard</p>
</td>
</tr>
<!-- Main Content -->
<tr>
<td style="padding: 50px 40px;">
<!-- Opening -->
<p style="margin: 0 0 25px 0; color: #333333; font-size: 17px; line-height: 1.7;">
Remember when you signed up to be notified about our AI features?
</p>
<p style="margin: 0 0 25px 0; color: #333333; font-size: 17px; line-height: 1.7; font-weight: 700;">
Well, today's that day. 🎯
</p>
<p style="margin: 0 0 35px 0; color: #555555; font-size: 16px; line-height: 1.7;">
We've been building, testing, and polishing these features for months. And they're finally ready for you to use. Right now. No beta, no waitlist, no BS.
</p>
<!-- What's New Highlight Box -->
<div style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%); border: 2px solid #667eea; padding: 35px 30px; margin: 40px 0; border-radius: 10px;">
<h2 style="margin: 0 0 25px 0; color: #667eea; font-size: 22px; font-weight: 700; text-align: center;">
✨ What's New in Your Dashboard
</h2>
<!-- Feature Cards -->
<div style="background-color: #ffffff; padding: 25px; margin-bottom: 15px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.05);">
<h3 style="margin: 0 0 10px 0; color: #333333; font-size: 17px; font-weight: 700;">
⚡ Smart QR Generation
</h3>
<p style="margin: 0; color: #666666; font-size: 15px; line-height: 1.6;">
AI analyzes your content and suggests optimal designs automatically. Just paste your link, we handle the optimization.
</p>
</div>
<div style="background-color: #ffffff; padding: 25px; margin-bottom: 15px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.05);">
<h3 style="margin: 0 0 10px 0; color: #333333; font-size: 17px; font-weight: 700;">
📊 Predictive Analytics
</h3>
<p style="margin: 0; color: #666666; font-size: 15px; line-height: 1.6;">
Ask questions about your scan data in plain English. "Which campaign performed best last month?" Done.
</p>
</div>
<div style="background-color: #ffffff; padding: 25px; margin-bottom: 15px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.05);">
<h3 style="margin: 0 0 10px 0; color: #333333; font-size: 17px; font-weight: 700;">
🤖 Auto-Organization
</h3>
<p style="margin: 0; color: #666666; font-size: 15px; line-height: 1.6;">
QR codes automatically categorized and tagged. Your library stays organized without manual work.
</p>
</div>
<div style="background-color: #ffffff; padding: 25px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.05);">
<h3 style="margin: 0 0 10px 0; color: #333333; font-size: 17px; font-weight: 700;">
🎨 AI Design Studio
</h3>
<p style="margin: 0; color: #666666; font-size: 15px; line-height: 1.6;">
Generate custom QR designs by describing what you want. "Make it modern and blue" → Done in 3 seconds.
</p>
</div>
</div>
<!-- How to Access -->
<div style="background-color: #fff3cd; border: 2px solid #ffc107; padding: 30px; border-radius: 10px; margin: 40px 0;">
<h3 style="margin: 0 0 15px 0; color: #856404; font-size: 18px; font-weight: 700;">
🎯 How to Get Started
</h3>
<ol style="margin: 0; padding-left: 20px; color: #856404; font-size: 15px; line-height: 1.8;">
<li style="margin-bottom: 10px;">🔐 Log in to your QR Master account</li>
<li style="margin-bottom: 10px;">👀 Look for the "✨ AI" badge on supported features</li>
<li style="margin-bottom: 10px;">✏️ Try creating a QR code you'll see AI suggestions automatically</li>
<li>📈 Check your Analytics tab for the new AI query interface</li>
</ol>
</div>
<!-- Primary CTA -->
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 45px 0 40px 0;">
<tr>
<td align="center">
<a href="https://www.qrmaster.net/create" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #ffffff; text-decoration: none; padding: 20px 60px; border-radius: 10px; font-size: 18px; font-weight: 700; display: inline-block; box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);">
✨ Try AI Features Now →
</a>
<p style="margin: 20px 0 0 0; color: #888888; font-size: 13px;">
✅ Available on all plans • ⚡ No extra setup required
</p>
</td>
</tr>
</table>
<!-- Personal Note -->
<div style="background-color: #f8f9fa; padding: 30px; border-radius: 8px; margin: 40px 0;">
<p style="margin: 0 0 20px 0; color: #555555; font-size: 15px; line-height: 1.7;">
💬 We're excited to see what you build with these new tools. If you have questions, ideas, or just want to share what you created <a href="mailto:support@qrmaster.net" style="color: #667eea; text-decoration: none;">hit reply</a>. I read every email.
</p>
<p style="margin: 0; color: #555555; font-size: 15px; line-height: 1.7;">
<strong>— Timo 👋</strong><br>
<span style="color: #888888; font-size: 13px;">Founder, QR Master</span>
</p>
</div>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #f8f9fa; padding: 35px 40px; border-top: 1px solid #e9ecef;">
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center">
<p style="margin: 0 0 8px 0; color: #888888; font-size: 13px;">
<a href="https://www.qrmaster.net" style="color: #667eea; text-decoration: none;">www.qrmaster.net</a> •
<a href="https://www.qrmaster.net/dashboard" style="color: #667eea; text-decoration: none;">Dashboard</a> •
<a href="https://www.qrmaster.net/faq" style="color: #667eea; text-decoration: none;">Help</a>
</p>
<p style="margin: 0; color: #999999; font-size: 12px;">
© 2025 QR Master. All rights reserved.
</p>
<p style="margin: 15px 0 0 0; color: #aaaaaa; font-size: 11px;">
You received this because you subscribed to AI feature launch notifications.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`,
});
console.log('AI feature launch email sent successfully to:', email);
return { success: true };
} catch (error) {
console.error('Error sending AI feature launch email:', error);
throw new Error('Failed to send AI feature launch email');
}
}

View File

@@ -25,10 +25,15 @@ export function parseUserAgent(userAgent: string | null): { device: string | nul
let os: string | null = null;
// Detect device
if (/Mobile|Android|iPhone|iPad/.test(userAgent)) {
device = 'mobile';
} else if (/Tablet|iPad/.test(userAgent)) {
// iPadOS 13+ sends "Macintosh" user agent.
// Without referrer info here, we fall back to checking for Safari-only Mac UAs (common for iPad)
const isIPad = /iPad/i.test(userAgent) ||
(/Macintosh/i.test(userAgent) && /Safari/i.test(userAgent) && !/Chrome/i.test(userAgent));
if (isIPad || /Tablet|PlayBook|Silk/i.test(userAgent)) {
device = 'tablet';
} else if (/Mobile|Android|iPhone/i.test(userAgent) && !isIPad) {
device = 'mobile';
} else {
device = 'desktop';
}

View File

@@ -212,6 +212,14 @@ export const RateLimits = {
windowSeconds: 60 * 60,
},
// Newsletter endpoints
// Newsletter subscribe: 5 per hour (prevent spam)
NEWSLETTER_SUBSCRIBE: {
name: 'newsletter-subscribe',
maxRequests: 5,
windowSeconds: 60 * 60,
},
// General API: 100 requests per minute
API: {
name: 'api',

View File

@@ -46,6 +46,7 @@ export function organizationSchema() {
'@type': 'Organization',
'@id': 'https://www.qrmaster.net/#organization',
name: 'QR Master',
alternateName: 'QRMaster',
url: 'https://www.qrmaster.net',
logo: {
'@type': 'ImageObject',
@@ -53,6 +54,7 @@ export function organizationSchema() {
width: 1200,
height: 630,
},
image: 'https://www.qrmaster.net/static/og-image.png',
sameAs: [
'https://twitter.com/qrmaster',
],
@@ -60,8 +62,45 @@ export function organizationSchema() {
'@type': 'ContactPoint',
contactType: 'Customer Support',
email: 'support@qrmaster.net',
availableLanguage: ['English', 'German'],
},
description: 'B2B SaaS platform for dynamic QR code generation with analytics, branding, and bulk generation for enterprise marketing campaigns.',
slogan: 'Dynamic QR codes that work smarter',
foundingDate: '2025',
areaServed: 'Worldwide',
serviceType: 'Software as a Service',
priceRange: '$0 - $29',
knowsAbout: [
'QR Code Generation',
'Marketing Analytics',
'Campaign Tracking',
'Dynamic QR Codes',
'Bulk QR Generation',
],
hasOfferCatalog: {
'@type': 'OfferCatalog',
name: 'QR Master Plans',
itemListElement: [
{
'@type': 'Offer',
itemOffered: {
'@type': 'SoftwareApplication',
name: 'QR Master Free',
applicationCategory: 'BusinessApplication',
operatingSystem: 'Web Browser',
},
},
{
'@type': 'Offer',
itemOffered: {
'@type': 'SoftwareApplication',
name: 'QR Master Pro',
applicationCategory: 'BusinessApplication',
operatingSystem: 'Web Browser',
},
},
],
},
description: 'Dynamic QR code generator with analytics, branding, and bulk generation for modern marketing campaigns.',
inLanguage: 'en',
mainEntityOfPage: 'https://www.qrmaster.net',
};

View File

@@ -1,14 +1,20 @@
import Stripe from 'stripe';
if (!process.env.STRIPE_SECRET_KEY) {
throw new Error('STRIPE_SECRET_KEY is not set');
}
// Use a placeholder during build time, real key at runtime
const stripeKey = process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder_for_build';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2025-09-30.clover',
export const stripe = new Stripe(stripeKey, {
apiVersion: '2025-10-29.clover',
typescript: true,
});
// Runtime validation (will throw when actually used in production if not set)
export function validateStripeKey() {
if (!process.env.STRIPE_SECRET_KEY) {
throw new Error('STRIPE_SECRET_KEY is not set');
}
}
export const STRIPE_PLANS = {
FREE: {
name: 'Free / Starter',

View File

@@ -140,6 +140,18 @@ export const createCheckoutSchema = z.object({
priceId: z.string().min(1, 'Price ID is required'),
});
// ==========================================
// Newsletter Schemas
// ==========================================
export const newsletterSubscribeSchema = z.object({
email: z.string()
.email('Invalid email format')
.toLowerCase()
.trim()
.max(255, 'Email must be less than 255 characters'),
});
// ==========================================
// Helper: Format Zod Errors
// ==========================================

View File

@@ -1,42 +1,78 @@
import { withAuth } from 'next-auth/middleware';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export default withAuth(
function middleware(req) {
export function middleware(req: NextRequest) {
const path = req.nextUrl.pathname;
// Public routes that don't require authentication
const publicPaths = [
'/',
'/pricing',
'/faq',
'/blog',
'/login',
'/signup',
'/privacy',
'/newsletter',
];
// Check if path is public
const isPublicPath = publicPaths.some(p => path === p || path.startsWith(p + '/'));
// Allow API routes
if (path.startsWith('/api/')) {
return NextResponse.next();
},
{
callbacks: {
authorized: ({ req, token }) => {
// Public routes that don't require authentication
const publicPaths = [
'/',
'/pricing',
'/faq',
'/blog',
'/login',
'/signup',
'/api/auth',
];
const path = req.nextUrl.pathname;
// Allow public paths
if (publicPaths.some(p => path.startsWith(p))) {
return true;
}
// Allow redirect routes
if (path.startsWith('/r/')) {
return true;
}
// Require authentication for all other routes
return !!token;
},
},
}
);
// Handle White Label Subdomains
// Check if this is a subdomain request (e.g., kunde.qrmaster.de)
const host = req.headers.get('host') || '';
const isLocalhost = host.includes('localhost') || host.includes('127.0.0.1');
const mainDomain = process.env.NEXT_PUBLIC_MAIN_DOMAIN || 'qrmaster.net';
// Extract subdomain if present (e.g., "kunde" from "kunde.qrmaster.de")
let subdomain: string | null = null;
if (!isLocalhost && host.endsWith(mainDomain) && host !== mainDomain && host !== `www.${mainDomain}`) {
const parts = host.replace(`.${mainDomain}`, '').split('.');
if (parts.length === 1 && parts[0]) {
subdomain = parts[0];
}
}
// For subdomain requests to /r/*, pass subdomain info via header
if (subdomain && path.startsWith('/r/')) {
const response = NextResponse.next();
response.headers.set('x-subdomain', subdomain);
return response;
}
// Allow redirect routes (QR code redirects)
if (path.startsWith('/r/')) {
return NextResponse.next();
}
// Allow static files
if (path.includes('.') || path.startsWith('/_next')) {
return NextResponse.next();
}
// Allow public paths
if (isPublicPath) {
return NextResponse.next();
}
// For protected routes, check for userId cookie
const userId = req.cookies.get('userId')?.value;
if (!userId) {
// Not authenticated - redirect to signup
const signupUrl = new URL('/signup', req.url);
return NextResponse.redirect(signupUrl);
}
// Authenticated - allow access
return NextResponse.next();
}
export const config = {
matcher: [

View File

@@ -3,17 +3,23 @@
@tailwind utilities;
@layer utilities {
/* Floating blob animation for hero background */
@keyframes blob {
0%, 100% {
0%,
100% {
transform: translate(0, 0) scale(1);
}
25% {
transform: translate(20px, -30px) scale(1.1);
}
50% {
transform: translate(-20px, 20px) scale(0.9);
}
75% {
transform: translate(30px, 10px) scale(1.05);
}
@@ -38,16 +44,9 @@
:root {
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-start-rgb: 255, 255, 255;
--background-end-rgb: 255, 255, 255;
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
}
color-scheme: light;
}
* {
@@ -75,12 +74,6 @@ a {
text-decoration: none;
}
@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;

50
src/types/analytics.ts Normal file
View File

@@ -0,0 +1,50 @@
export type TrendType = 'up' | 'down' | 'flat';
export interface TrendData {
trend: TrendType;
percentage: number;
isNegative?: boolean;
isNew?: boolean; // When growing from 0 previous data
}
export interface AnalyticsSummary {
totalScans: number;
uniqueScans: number;
avgScansPerQR: number;
mobilePercentage: number;
topCountry: string;
topCountryPercentage: number;
scansTrend?: TrendData;
avgScansTrend?: TrendData;
comparisonPeriod: 'week' | 'month';
comparisonDays: number;
}
export interface CountryStats {
country: string;
count: number;
percentage: number;
trend: TrendType;
trendPercentage: number;
isNew?: boolean;
}
export interface QRPerformance {
id: string;
title: string;
type: string;
totalScans: number;
uniqueScans: number;
conversion: number;
trend: TrendType;
trendPercentage: number;
isNew?: boolean;
}
export interface AnalyticsResponse {
summary: AnalyticsSummary;
deviceStats: Record<string, number>;
countryStats: CountryStats[];
dailyScans: Record<string, number>;
qrPerformance: QRPerformance[];
}

58
src/types/react-simple-maps.d.ts vendored Normal file
View File

@@ -0,0 +1,58 @@
declare module 'react-simple-maps' {
import { ComponentType, ReactNode, CSSProperties } from 'react';
export interface ComposableMapProps {
projection?: string;
projectionConfig?: {
scale?: number;
center?: [number, number];
rotate?: [number, number, number];
};
width?: number;
height?: number;
style?: CSSProperties;
children?: ReactNode;
}
export interface GeographiesProps {
geography: string | object;
children: (data: { geographies: any[] }) => ReactNode;
}
export interface GeographyProps {
geography: any;
style?: {
default?: CSSProperties;
hover?: CSSProperties;
pressed?: CSSProperties;
};
fill?: string;
stroke?: string;
strokeWidth?: number;
onClick?: (event: React.MouseEvent) => void;
onMouseEnter?: (event: React.MouseEvent) => void;
onMouseLeave?: (event: React.MouseEvent) => void;
}
export interface ZoomableGroupProps {
center?: [number, number];
zoom?: number;
minZoom?: number;
maxZoom?: number;
translateExtent?: [[number, number], [number, number]];
onMoveStart?: (event: any) => void;
onMove?: (event: any) => void;
onMoveEnd?: (event: any) => void;
children?: ReactNode;
}
export const ComposableMap: ComponentType<ComposableMapProps>;
export const Geographies: ComponentType<GeographiesProps>;
export const Geography: ComponentType<GeographyProps>;
export const ZoomableGroup: ComponentType<ZoomableGroupProps>;
export const Marker: ComponentType<any>;
export const Line: ComponentType<any>;
export const Annotation: ComponentType<any>;
export const Graticule: ComponentType<any>;
export const Sphere: ComponentType<any>;
}

1
test.md Normal file
View File

@@ -0,0 +1 @@
.

BIN
tsc_errors.txt Normal file

Binary file not shown.