1 Commits

231 changed files with 23124 additions and 49347 deletions

0
..md Normal file
View File

100
.gitignore vendored
View File

@@ -1,51 +1,51 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies # dependencies
/node_modules /node_modules
/.pnp /.pnp
.pnp.js .pnp.js
# testing # testing
/coverage /coverage
# next.js # next.js
/.next/ /.next/
/out/ /out/
# production # production
/build /build
# misc # misc
.DS_Store .DS_Store
*.pem *.pem
# debug # debug
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
# local env files # local env files
.env*.local .env*.local
.env .env
# vercel # vercel
.vercel .vercel
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# prisma # prisma
/prisma/migrations/ /prisma/migrations/
# docker # docker
docker-compose.override.yml docker-compose.override.yml
*.sql *.sql
/backups/ /backups/
# logs # logs
logs logs
*.log *.log
# local dev script # local dev script
dev-server.js dev-server.js

View File

@@ -1,87 +0,0 @@
# Indexing Setup & Usage Guide
This guide explains how to fast-track your content indexing on **Google** and **Bing/Yandex** using the provided scripts.
> [!IMPORTANT]
> **WAIT UNTIL LIVE:** Do not run these scripts until your new URLs are live and returning a `200 OK` status. If you submit a `404` URL, it may negatively impact your crawling budget or cause errors.
---
## 1. Google Indexing API
The Google Indexing API allows you to notify Google when pages are added or removed. It is faster than waiting for the Googlebot to crawl your sitemap.
### Prerequisites: `service_account.json`
To use the script `scripts/trigger-indexing.js`, you need a **Service Account Key** from Google Cloud.
1. **Go to Google Cloud Console:** [https://console.cloud.google.com/](https://console.cloud.google.com/)
2. **Create a Project:** (e.g., "QR Master Indexing").
3. **Enable API:** Search for "Web Search Indexing API" and enable it.
4. **Create Service Account:**
* Go to "IAM & Admin" > "Service Accounts".
* Click "Create Service Account".
* Name it (e.g., "indexer").
* Grant it the "Owner" role (simplest for this) or a custom role with Indexing permissions.
5. **Create Key:**
* Click on the newly created service account email.
* Go to "Keys" tab -> "Add Key" -> "Create new key" -> **JSON**.
* This will download a JSON file.
6. **Save Key:**
* Rename the file to `service_account.json`.
* Place it in the **root** of your project (same folder as `package.json`).
* **NOTE:** This file is ignored by git for security (`.gitignore`), so you must copy it manually if you switch laptops.
7. **Authorize in Search Console:**
* Open the JSON file and copy the `client_email` address.
* Go to **Google Search Console** property for `qrmaster.net`.
* Go to "Settings" > "Users and permissions".
* **Add User:** Paste the service account email and give it **"Owner"** permission. (This is required for the API to work).
### How to Run
1. **Run the script:**
```bash
npm run trigger:indexing
```
*(Or manually: `npx tsx scripts/trigger-indexing.ts`)*
2. The script will automatically fetch ALL active URLs from the project (including tools and blog posts) and submit them to Google. You should see a "Success" message for each URL.
---
## 2. IndexNow (Bing, Yandex, etc.)
IndexNow is a protocol used by Bing and others. It's much simpler than Google's API.
### Prerequisites: API Key
1. **Get Key:** Go to [Bing Webmaster Tools](https://www.bing.com/webmasters) or generate one at [indexnow.org](https://www.indexnow.org/).
2. **Verify Setup:**
* The key is typically a long random string (e.g., `abc123...`).
* Ensure you have a text file named after the key (e.g., `abc123....txt`) containing the key itself inside your `public/` folder so it's accessible at `https://www.qrmaster.net/abc123....txt`.
* Alternatively, set the environment variable in your `.env` file:
```
INDEXNOW_KEY=your_key_here
```
### How to Run
This script (`scripts/submit-indexnow.ts`) automatically gathers all meaningful URLs from your project (tools, blog posts, main pages) and submits them.
1. Run the script:
```bash
npm run submit:indexnow
```
*(Or manually: `npx tsx scripts/submit-indexnow.ts`)*
2. It will output which URLs were submitted.
---
## Summary Checklist
- [ ] New page is published and live.
- [ ] `service_account.json` is in the project root.
- [ ] Service Account email is added as Owner in Google Search Console.
- [ ] Run `npm run trigger:indexing` (for Google).
- [ ] Run `npm run submit:indexnow` (for Bing/Yandex).

View File

@@ -1,291 +0,0 @@
# 🚀 Side Project Marketing Strategy
> **"Engineering as Marketing"** Kostenlose Micro-Tools bauen, um SEO-Traffic abzufangen und in zahlende Kunden zu konvertieren.
**Status:** Planung abgeschlossen, bereit für Implementierung
**Autor:** QR Master Team
**Letzte Aktualisierung:** 2026-01-08
---
## Executive Summary
Wir nutzen die bewiesene "Engineering as Marketing" Strategie (bekannt von HubSpot's Website Grader, Ahrefs' Free Tools, Shopify's Business Tools), um organischen Traffic über spezialisierte, kostenlose QR-Generatoren zu gewinnen.
### Das Konzept in einem Satz
> Anstatt gegen "QR Code Generator" (DA 90+ Konkurrenz) zu kämpfen, bauen wir 10 spezialisierte Tools für Long-Tail-Keywords wie "WiFi QR Code erstellen" oder "VCard QR Generator".
### Warum das funktioniert
1. **Weniger Konkurrenz:** "WiFi QR Code Generator" hat 1/10 der Konkurrenz von "QR Code Generator"
2. **Höhere Kaufabsicht:** Wer "Restaurant Menu QR Code" sucht, ist bereit für ein Premium-Tool
3. **Natürliche Backlinks:** Leute teilen nützliche Tools ("Hier, dieser Generator ist kostenlos")
4. **Zero Marginal Cost:** Client-Side Generierung = 0€ Serverkosten pro User
---
## ROI Projektion (Konservativ)
| Metrik | Monat 3 | Monat 6 | Monat 12 |
|--------|---------|---------|----------|
| Organischer Traffic (alle Tools) | 2.000 | 10.000 | 25.000 |
| Free Signups (20% Conv.) | 400 | 2.000 | 5.000 |
| Paid Customers (3% der Signups) | 12 | 60 | 150 |
| **Zusätzlicher MRR** | **108€** | **540€** | **1.350€** |
> **Benchmarks verwendet:** 2-3% Free-to-Paid Conversion (Industry Standard), 20% Tool-to-Signup (optimistisch, aber erreichbar mit gutem UX).
---
## Die Tools-Roadmap
### Phase 1: Quick Wins (Woche 1-2)
Fokus auf **hohes Suchvolumen + geringe Komplexität**.
| Tool | URL | Geschätztes SV | Implementierungs-Aufwand |
|------|-----|----------------|-------------------------|
| **WiFi QR Generator** | `/tools/wifi-qr-code` | 40.000/Monat | 4h |
| **VCard QR Generator** | `/tools/vcard-qr-code` | 15.000/Monat | 4h |
| **WhatsApp QR Generator** | `/tools/whatsapp-qr-code` | 20.000/Monat | 3h |
### Phase 2: Monetization Focus (Woche 3-4)
Fokus auf **hohe Conversion-Wahrscheinlichkeit** (B2B Use Cases).
| Tool | URL | Geschätztes SV | Upsell-Hook |
|------|-----|----------------|-------------|
| **App Store Link QR** | `/tools/app-store-qr-code` | 5.000/Monat | Smart Routing (iOS/Android) |
| **PDF to QR** | `/tools/pdf-qr-code` | 15.000/Monat | PDF Hosting (benötigt Account) |
| **Menu QR Generator** | `/tools/menu-qr-code` | 8.000/Monat | Multi-Sprache, Analytics |
### Phase 3: Differenzierung (Monat 2+)
Fokus auf **Unique Features** die Konkurrenten nicht haben.
| Tool | URL | Differenzierung |
|------|-----|-----------------|
| **Barcode Generator** | `/tools/barcode-generator` | EAN/UPC/ISBN Unterstützung |
| **Bitcoin/Crypto QR** | `/tools/bitcoin-qr-code` | Multi-Wallet Format |
| **AI Art QR (Viral)** | `/tools/ai-qr-code` | Stable Diffusion Integration |
## Geplantes Portfolio: Kostenlose Statische Generatoren (15 Typen)
Wir werden die folgenden 15 statischen QR-Code-Typen anbieten. Diese sind **dauerhaft kostenlos** und erfordern keine Server-Infrastruktur für Redirects (im Gegensatz zu dynamischen Codes).
> **Wichtig:** Alle diese Generatoren stehen sowohl **öffentlich als SEO-Landingpages** zur Verfügung (zur Neukundengewinnung), als auch im **eingeloggten Bereich** für registrierte Nutzer (für Komfort und Zentralisierung).
1. **URL / Link**: Der Standard. Öffnet eine Webseite.
2. **Text**: Zeigt reinen Text an (bis zu 300 Zeichen).
3. **WiFi**: Verbindet direkt mit einem WLAN-Netzwerk (WPA/WEP/Open).
4. **VCard / Kontakt**: Speichert einen Kontakt direkt im Adressbuch.
5. **WhatsApp**: Startet einen Chat mit einer Nummer (und optionalem Text).
6. **E-Mail**: Öffnet das E-Mail-Programm mit Empfänger, Betreff und Body.
7. **SMS**: Bereitet eine SMS an eine Nummer vor.
8. **Anruf / Tel**: Startet einen Anruf an eine Nummer.
9. **Event / Kalender**: Fügt einen Termin zum Kalender hinzu (.ics).
10. **Geo / Maps**: Öffnet einen Standort in Google Maps/Apple Maps.
11. **Facebook**: Öffnet ein Profil oder eine Seite.
12. **Instagram**: Öffnet ein Instagram-Profil.
13. **Twitter / X**: Öffnet ein Profil oder erstellt einen Tweet.
14. **YouTube**: Öffnet ein Video oder einen Kanal.
15. **TikTok**: Öffnet ein TikTok-Profil.
Diese Breite deckt 99% der "Everyday Use Cases" ab und maximiert die SEO-Angriffsfläche.
---
## Technische Architektur
### Warum Client-Side Generierung?
```
┌─────────────────────────────────────────────────────────────┐
│ USER BROWSER │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────┐ │
│ │ Form Input │ -> │ qrcode.js │ -> │ Canvas/SVG │ │
│ │ (SSID, PW) │ │ (generation) │ │ (download) │ │
│ └─────────────┘ └──────────────┘ └────────────────┘ │
│ │
│ KEINE Server-Calls! │
└─────────────────────────────────────────────────────────────┘
```
**Vorteile:**
- **Privatsphäre:** Passwörter verlassen nie den Browser
- **Speed:** Instant Generation (kein Network Latency)
- **Kosten:** 0€ pro generiertem Code
- **Scale:** Kein Backend-Limit
### Datei-Struktur (Next.js)
```
src/app/(marketing)/tools/
├── wifi-qr-code/
│ ├── page.tsx # Server Component (SEO)
│ └── WiFiGenerator.tsx # Client Component (Interaktion)
├── vcard-qr-code/
│ ├── page.tsx
│ └── VCardGenerator.tsx
└── [weitere tools]/
```
### Shared Components
```typescript
// src/components/tools/QRDownloadButtons.tsx
// Wiederverwendbare Download-Buttons für alle Tools
// src/components/tools/UpgradePrompt.tsx
// "Willst du Scans tracken?" CTA Box
```
---
## SEO-Strategie pro Tool-Page
Jede Seite folgt dem gleichen bewährten Muster:
### 1. Above the Fold: Sofort nutzbar
```
┌────────────────────────────────────────┐
│ H1: Free WiFi QR Code Generator │
│ Subline: Teile dein WLAN in Sekunden │
│ │
│ ┌─────────────────────────────────┐ │
│ │ [SSID] [Password] [WPA▼] │ │
│ │ │ │
│ │ [ Generate QR Code ] │ │
│ └─────────────────────────────────┘ │
└────────────────────────────────────────┘
```
**Regel:** Der User muss SOFORT interagieren können. Kein langer Intro-Text.
### 2. Schema Markup (Pflicht!)
```json
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "WiFi QR Code Generator",
"applicationCategory": "UtilitiesApplication",
"operatingSystem": "Web Browser",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "EUR"
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "4.8",
"ratingCount": "1247"
}
}
```
### 3. FAQ Section (Long-Tail Keywords)
```markdown
## Häufig gestellte Fragen
### Wie funktioniert ein WiFi QR Code?
Der QR Code enthält deine WLAN-Daten im Format...
### Ist es sicher, mein WiFi Passwort in einem QR Code zu speichern?
Ja, der QR Code wird nur lokal in deinem Browser generiert...
### Kann ich den QR Code später bearbeiten?
Dieser Generator erstellt statische Codes. Für editierbare...
```
### 4. Conversion Prompt (Der Hook)
```
┌─────────────────────────────────────────────────────────┐
│ ✅ QR Code erfolgreich erstellt! │
│ │
│ ⚠️ Hinweis: Dies ist ein statischer Code. │
│ Wenn du dein Passwort änderst, musst du neu drucken. │
│ │
│ → Erstelle einen dynamischen Code (jederzeit änderbar) │
│ │
│ Bonus: Sieh wer deinen Code scannt (Datum, Standort) │
│ │
│ [ Kostenlos registrieren ] │
└─────────────────────────────────────────────────────────┘
```
---
## Conversion Optimierung
### Die "Limitation Awareness" Methode
Jedes Tool zeigt nach der Generierung **sanft** die Limitierungen auf:
| Tool | Statische Limitation | Upsell-Feature |
|------|---------------------|----------------|
| WiFi | Passwort-Änderung = Neudruck | Dynamischer Code (editierbar) |
| VCard | Kontakt-Update = Neudruck | Immer aktuelle Visitenkarte |
| Menu | Neue Speisekarte = Neudruck | PDF-Hosting + Analytics |
| App Store | Nur ein Store-Link | Smart Device Detection |
### Email Capture vor Download
**Optional (A/B testen):**
```
"Gib deine Email ein, um den QR als hochauflösende PNG zu erhalten"
```
→ Baut Email-Liste, auch wenn User nicht sofort konvertiert.
---
## Erfolgsmetriken (KPIs)
| KPI | Tool | Ziel (Monat 3) |
|-----|------|----------------|
| **Organic Sessions** | Google Analytics | 2.000/Monat |
| **QR Generations** | PostHog Event | 500/Monat |
| **Signup Clicks** | PostHog Event | 100/Monat |
| **Actual Signups** | DB Query | 50/Monat |
| **Paid Conversion** | Stripe | 5/Monat |
### Tracking Events implementieren
```typescript
// Auf jeder Tool-Page
posthog.capture('tool_qr_generated', {
tool: 'wifi',
format: 'png'
});
posthog.capture('tool_signup_cta_clicked', {
tool: 'wifi'
});
```
---
## Nächste Schritte
1. [ ] **Heute:** WiFi QR Generator implementieren (`/tools/wifi-qr-code`)
2. [ ] **Diese Woche:** VCard + WhatsApp Generator
3. [ ] **Nächste Woche:** Google Search Console monitoren für erste Impressions
4. [ ] **Monat 2:** A/B Test Email-Capture vs. Direct Download
5. [ ] **Monat 3:** Phase 2 Tools (App Store, PDF, Menu)
---
## Referenzen & Inspiration
- [HubSpot Website Grader](https://website.grader.com/) Das Original "Engineering as Marketing"
- [Ahrefs Free Tools](https://ahrefs.com/free-seo-tools) 12+ Free Tools als Lead Magnets
- [Shopify Business Tools](https://www.shopify.com/tools) Logo Maker, Invoice Generator, etc.
---
*Dieses Dokument wird regelmäßig aktualisiert basierend auf Traffic-Daten und Conversion-Rates.*

View File

@@ -1,29 +0,0 @@
# Ahrefs SEO Findings & Status
## Critical Issues (Priority: High)
- [RESOLVED] **Page has no outgoing links**
- Found on: `privacy`, `newsletter`, `faq`, `/`, `qr-code-erstellen`
- *Status:* Verified `MarketingLayout` provides navigation. Added specific back-links to `newsletter` (admin), `login`, and `signup`.
- [RESOLVED] **Newsletter Page Misconfiguration**
- Found: `/newsletter` page has "Admin Dashboard" title.
- *Status:* Confirmed as internal Admin tool. Added "Back to Home" link to satisfy link checkers.
- [FIXED] **3XX Redirects & Links to Redirects**
- *Fixed in:* `blog/page.tsx` (links updated) and `blog/[slug]/page.tsx` (301s added).
- [FIXED] **Duplicate Metadata**
- *Fixed in:* `pricing`, `login`, `signup`, `qr-code-erstellen`.
## Warnings (Priority: Medium)
- [VERIFIED] **Hreflang and HTML lang mismatch**
- Found on: `1 page`.
- *Status:* Verified `src/app/(marketing)/layout.tsx` has `lang="en"` and `(marketing-de)/layout.tsx` has `lang="de"`. Correct.
- [FIXED] **Image file size too large**
- *Fixed:* Swapped `1-boy.png` & `2-body.png` for WebP versions as requested.
- [FIXED] **H1 tag missing or empty**
- *Status:* Verified `sr-only` H1s exist on core pages. `faq` and `privacy` have visible H1s.
## Notices (Priority: Low)
- [VERIFIED] **Low word count / Thin content**
- Found on: `login`, `signup`.
- *Status:* Expected behavior for functional auth pages.
- [VERIFIED] **Meta description too short**
- *Status:* Descriptions are concise and functional. No critical SEO impact.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

View File

@@ -1,464 +0,0 @@
Here is Claude's plan:
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
Restaurant Menu QR Code - Dashboard Integration Plan
Executive Summary
Add "Restaurant Menu" as a new QR code content type in the dashboard at /create.
Users can upload PDF menus, store them in Cloudflare R2, and generate dynamic QR
codes that redirect to the PDF. This integrates with the existing QRCode model and
tracking system.
Key Change: This is a dashboard feature (authenticated), not a public marketing
tool.
Architecture Overview
Integration Approach
- New ContentType: Add MENU to existing ContentType enum
- Existing Models: Use existing QRCode and QRScan models (no new tables)
- PDF Storage: Cloudflare R2 (S3-compatible, zero egress fees)
- URL Structure: Use existing /r/[slug] redirect (not new route)
- Authentication: Required (dashboard feature for logged-in users)
Data Flow
1. User logs in → Goes to /create → Selects "Restaurant Menu" type
2. Uploads PDF → Validate → Upload to R2 → Get public URL
3. Creates QR code with content: { pdfUrl: "...", restaurantName: "...", menuTitle:
"..." }
4. QR code redirects to: /r/[slug] → Redirect to PDF URL
5. Scans tracked in existing QRScan table
Database Schema Changes
Update Existing Enum
Modify /prisma/schema.prisma:
enum ContentType {
URL
VCARD
GEO
PHONE
SMS
TEXT
WHATSAPP
MENU // NEW: Restaurant menu PDFs
}
Migration Command: npx prisma migrate dev --name add_menu_content_type
No New Models Needed
The existing models handle everything:
QRCode model (already exists):
- contentType: MENU (new enum value)
- content: Json stores: { pdfUrl: string, restaurantName?: string, menuTitle?:
string }
- userId: String (owner of QR code)
- slug: String (for /r/[slug] redirect)
QRScan model (already exists):
- Tracks all scans regardless of content type
Environment Configuration
New Environment Variables
Add to .env and production:
# Cloudflare R2 (S3-compatible API)
R2_ACCOUNT_ID=your-cloudflare-account-id
R2_ACCESS_KEY_ID=your-r2-access-key
R2_SECRET_ACCESS_KEY=your-r2-secret-key
R2_BUCKET_NAME=qrmaster-menus
R2_PUBLIC_URL=https://pub-xxxxx.r2.dev # Or custom domain
# Menu upload limits
MAX_MENU_FILE_SIZE=10485760 # 10MB in bytes
Update env.ts
Add to /src/lib/env.ts schema:
const envSchema = z.object({
// ... existing fields ...
R2_ACCOUNT_ID: z.string().optional(),
R2_ACCESS_KEY_ID: z.string().optional(),
R2_SECRET_ACCESS_KEY: z.string().optional(),
R2_BUCKET_NAME: z.string().default('qrmaster-menus'),
R2_PUBLIC_URL: z.string().optional(),
MAX_MENU_FILE_SIZE: z.string().default('10485760'),
});
Critical Files to Modify/Create
1. R2 Client Library
File: /src/lib/r2.ts (NEW)
Purpose: Handle PDF uploads to Cloudflare R2
import { S3Client, PutObjectCommand, DeleteObjectCommand } from
'@aws-sdk/client-s3';
import { env } from './env';
const r2Client = new S3Client({
region: 'auto',
endpoint: `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: env.R2_ACCESS_KEY_ID!,
secretAccessKey: env.R2_SECRET_ACCESS_KEY!,
},
});
export async function uploadMenuToR2(
file: Buffer,
filename: string,
shortId: string
): Promise<string> {
const key = `menus/${shortId}.pdf`;
await r2Client.send(
new PutObjectCommand({
Bucket: env.R2_BUCKET_NAME,
Key: key,
Body: file,
ContentType: 'application/pdf',
ContentDisposition: `inline; filename="${filename}"`,
CacheControl: 'public, max-age=31536000',
})
);
return `${env.R2_PUBLIC_URL}/${key}`;
}
export async function deleteMenuFromR2(r2Key: string): Promise<void> {
await r2Client.send(
new DeleteObjectCommand({
Bucket: env.R2_BUCKET_NAME,
Key: r2Key,
})
);
}
export function generateUniqueFilename(originalFilename: string): string {
const timestamp = Date.now();
const random = crypto.randomBytes(4).toString('hex');
const ext = originalFilename.split('.').pop();
return `menu_${timestamp}_${random}.${ext}`;
}
2. Upload API Endpoint
File: /src/app/api/menu/upload/route.ts (NEW)
Purpose: Handle PDF uploads from the create page
Responsibilities:
- Accept multipart/form-data PDF upload
- Validate file type (PDF magic bytes), size (max 10MB)
- Rate limit: 10 uploads per minute per user (authenticated)
- Upload to R2 with unique filename
- Return R2 public URL
Request: FormData { file: File }
Response:
{
"success": true,
"pdfUrl": "https://pub-xxxxx.r2.dev/menus/menu_1234567890_abcd.pdf"
}
Key Implementation Details:
- Use request.formData() to parse upload
- Check PDF magic bytes: %PDF- at file start
- Verify authentication (userId from cookies)
- Rate limit by userId (not IP, since authenticated)
- Error handling: 401 (not authenticated), 413 (too large), 415 (wrong type), 429
(rate limit)
3. Update Redirect Route
File: /src/app/r/[slug]/route.ts (MODIFY)
Add MENU case to the switch statement (around line 33-64):
case 'MENU':
destination = content.pdfUrl || 'https://example.com';
break;
Explanation: When a dynamic MENU QR code is scanned, redirect directly to the PDF
URL stored in content.pdfUrl
4. Update Validation Schema
File: /src/lib/validationSchemas.ts (MODIFY)
Line 28: Update contentType enum to include MENU:
contentType: z.enum(['URL', 'VCARD', 'GEO', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT',
'MENU'], {
errorMap: () => ({ message: 'Invalid content type' })
}),
Line 63: Update bulk QR schema as well:
contentType: z.enum(['URL', 'VCARD', 'GEO', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT',
'MENU']),
5. Update Create Page - Add MENU Type
File: /src/app/(app)/create/page.tsx (MODIFY)
Multiple changes needed:
A. Add MENU to contentTypes array (around line 104-109):
const contentTypes = [
{ value: 'URL', label: 'URL / Website' },
{ value: 'VCARD', label: 'Contact Card' },
{ value: 'GEO', label: 'Location/Maps' },
{ value: 'PHONE', label: 'Phone Number' },
{ value: 'MENU', label: 'Restaurant Menu' }, // NEW
];
B. Add MENU case to getQRContent() (around line 112-134):
case 'MENU':
return content.pdfUrl || 'https://example.com/menu.pdf';
C. Add MENU frame options in getFrameOptionsForContentType() (around line 19-40):
case 'MENU':
return [...baseOptions, { id: 'menu', label: 'Menu' }, { id: 'order', label:
'Order Here' }, { id: 'viewmenu', label: 'View Menu' }];
D. Add MENU-specific form fields in renderContentFields() function (needs to be
added):
This will be a new section after the URL/VCARD/GEO/PHONE sections that renders:
- File upload dropzone (react-dropzone)
- Upload button with loading state
- Optional: Restaurant name input
- Optional: Menu title input
After upload success, store pdfUrl in content state:
setContent({ pdfUrl: response.pdfUrl, restaurantName: '', menuTitle: '' });
6. Update Rate Limiting
File: /src/lib/rateLimit.ts (MODIFY)
Add to RateLimits object (after line 229):
// Menu PDF upload: 10 per minute (authenticated users)
MENU_UPLOAD: {
name: 'menu-upload',
maxRequests: 10,
windowSeconds: 60,
},
Implementation Steps
Phase 1: Backend Setup (Day 1)
1. Install Dependencies
npm install @aws-sdk/client-s3 react-dropzone
2. Configure Cloudflare R2
- Create R2 bucket: "qrmaster-menus" via Cloudflare dashboard
- Generate API credentials (Access Key ID + Secret)
- Add credentials to .env and production environment
- Set bucket to public (for PDF access)
3. Database Migration
- Add MENU to ContentType enum in prisma/schema.prisma
- Run: npx prisma migrate dev --name add_menu_content_type
- Verify migration: npx prisma studio
4. Environment Configuration
- Update src/lib/env.ts with R2 variables
- Update src/lib/rateLimit.ts with MENU_UPLOAD config
5. Create R2 Client
- Create src/lib/r2.ts with upload function
- Test in development: upload sample PDF
Phase 2: API & Validation (Day 1-2)
6. Update Validation Schema (/src/lib/validationSchemas.ts)
- Add MENU to contentType enums (line 28 and 63)
- Verify no other changes needed
7. Create Upload API (/src/app/api/menu/upload/route.ts)
- Parse multipart/form-data
- Validate PDF (magic bytes, size)
- Verify authentication (userId from cookies)
- Rate limit by userId (10/minute)
- Upload to R2
- Return pdfUrl
8. Update Redirect Route (/src/app/r/[slug]/route.ts)
- Add MENU case to switch statement (line 33-64)
- Redirect to content.pdfUrl
Phase 3: Dashboard Integration (Day 2-3)
9. Update Create Page (/src/app/(app)/create/page.tsx)
- Add MENU to contentTypes array (line 104-109)
- Add MENU case in getQRContent() (line 112-134)
- Add MENU frame options in getFrameOptionsForContentType() (line 19-40)
- Add renderContentFields() for MENU type:
- File upload dropzone (react-dropzone)
- Upload button + loading state
- Optional restaurant name input
- Optional menu title input
- Handle file upload:
- POST to /api/menu/upload
- Update content state with pdfUrl
- Show success message
Phase 4: Testing & Polish (Day 3-4)
10. Functional Testing
- Login to dashboard → Go to /create
- Select "Restaurant Menu" content type
- Upload various PDF sizes (1MB, 5MB, 10MB, 11MB - should reject)
- Test non-PDF files (should reject)
- Test rate limiting (11th upload in minute should fail)
- Create dynamic QR code with restaurant name
- Test QR code redirect (/r/[slug] → PDF URL)
- Test scan tracking (verify QRScan record created)
- Test on mobile (scan QR with phone camera, PDF opens)
11. Error Handling
- Not authenticated: 401 error
- File too large: "File too large. Maximum size: 10MB"
- Invalid file type: "Please upload a PDF file"
- Upload failed: "Upload failed, please try again"
- R2 upload error: Handle gracefully with toast message
12. UI Polish
- Loading states during PDF upload
- Upload progress indicator
- Success message after upload
- Preview QR code with PDF link
- Responsive design (mobile, tablet, desktop)
- Accessibility (ARIA labels, keyboard nav)
Phase 5: Deployment (Day 4)
13. Production Setup
- Add R2 credentials to Cloudflare Pages environment variables
- Run database migration: npx prisma migrate deploy
- Verify R2 bucket is public (for PDF access)
14. Deploy to Production
- Deploy to Cloudflare Pages
- Test upload in production dashboard
- Create test QR code, verify redirect works
- Monitor logs for errors
15. Documentation
- Update user docs (if any) about new MENU content type
- Add tooltips/help text in create page for menu upload
Edge Cases & Solutions
File Validation
- Problem: User uploads 50MB PDF or .exe file
- Solution:
- Client-side validation (check file.size and file.type before upload)
- Server-side validation (PDF magic bytes: %PDF-, 10MB limit)
- Error: "File too large. Maximum size: 10MB" or "Please upload a PDF file"
Rate Limiting
- Problem: User uploads many PDFs quickly
- Solution:
- Rate limit by userId: 10 uploads per minute (authenticated)
- Show toast error: "Too many uploads. Please wait a moment."
- More generous than anonymous (since authenticated)
PDF Deletion/Management
- Problem: User deletes QR code, but PDF stays in R2
- Solution (Phase 1): Leave PDFs in R2 (simple, safe)
- Future Enhancement: Add cleanup job to delete unused PDFs
- Check QRCode records, delete orphaned R2 files
- Run monthly via cron job
Large PDF Files
- Problem: 10MB limit might be too small for some menus
- Solution (Phase 1): Start with 10MB limit
- Future: Increase to 20MB if users request it
- Best Practice: Recommend users optimize PDFs (compress images)
PDF URL Stored in JSON
- Problem: If R2 URL changes, need to update all QRCode records
- Solution: Use consistent R2 bucket URL (won't change)
- Migration: If R2 URL ever changes, run SQL update on content JSON field
Verification & Testing
End-to-End Test Scenario
1. Authentication Test
- Log in to dashboard at /login
- Navigate to /create
- Verify "Restaurant Menu" appears in content type dropdown
2. Upload Test
- Select "Restaurant Menu" content type
- Upload sample restaurant menu PDF (2MB)
- Enter restaurant name: "Test Restaurant"
- Enter menu title: "Dinner Menu"
- Verify success message and pdfUrl returned
3. QR Code Creation Test
- Enter title: "My Restaurant Menu QR"
- Select Dynamic QR type
- Customize QR color (change to blue)
- Select frame: "Menu"
- Click "Create QR Code"
- Verify success redirect to dashboard
4. Scan Test
- From dashboard, copy QR code URL: qrmaster.net/r/[slug]
- Open URL in browser
- Verify 307 redirect to R2 PDF URL
- PDF opens in browser correctly
5. Analytics Test
- Go to dashboard, click on created menu QR
- View analytics page
- Verify scan count = 1 (from previous test)
- Check device type is recorded correctly
6. Mobile Test
- Download QR code as PNG
- Display on screen
- Scan with phone camera
- Verify phone opens PDF directly
- Check dashboard - scan count should increment
7. Rate Limit Test
- Upload 10 PDFs in quick succession (should succeed)
- Upload 11th PDF within same minute (should fail with 429)
- Wait 1 minute, verify uploads work again
Success Metrics
- MENU content type available in dashboard /create page
- Users can upload PDFs and create QR codes successfully
- PDFs stored in R2 and accessible via public URLs
- Dynamic QR codes redirect correctly: /r/[slug] → PDF
- Scan tracking works (QRScan records created)
- Rate limiting prevents abuse (10/minute per user)
- Existing QR code functionality unaffected
- No breaking changes to other content types
Critical File Paths
Modified Files:
1. /prisma/schema.prisma - Add MENU to ContentType enum
2. /src/lib/validationSchemas.ts - Add MENU to contentType enums (lines 28, 63)
3. /src/app/(app)/create/page.tsx - Add MENU UI and logic
4. /src/app/r/[slug]/route.ts - Add MENU redirect case
5. /src/lib/env.ts - Add R2 environment variables
6. /src/lib/rateLimit.ts - Add MENU_UPLOAD rate limit
New Files:
7. /src/lib/r2.ts - R2 client library for PDF uploads
8. /src/app/api/menu/upload/route.ts - PDF upload API endpoint

View File

@@ -1,89 +0,0 @@
# Final SEO & Technical Fix Report
**Datum:** 13.01.2026
**Status:** Ready for Deployment
Hier ist die detaillierte Aufschlüsselung aller Ahrefs-Punkte und die konkreten Maßnahmen, die wir umgesetzt haben.
## 1. Kritische Fehler (Die "29"er Gruppe)
Diese Fehler traten alle 29-mal auf. Ursache war derselbe zugrundeliegende Fehler: Die Blog-Posts waren durch falsche Redirects nicht erreichbar.
| Ahrefs Meldung | Anzahl | Was wir gemacht haben (Fix) |
| :--- | :--- | :--- |
| **Page has no outgoing links** | 29 | **Fix:** Redirects für Blog-Posts entfernt.<br>_Erklärung:_ Da die Seite vorher nicht lud (Redirect/404), fand Ahrefs keine Links auf der Seite. Jetzt, wo sie lädt, sind die Links sichtbar. |
| **H1 tag missing or empty** | 29 | **Fix:** Blog-Post-Ansicht repariert.<br>_Erklärung:_ Die vorige Fehlerseite hatte keine H1. Die echten Blog-Artikel haben korrekte H1-Tags. |
| **Low word count** | 29 | **Fix:** Inhalt wiederhergestellt.<br>_Erklärung:_ Die leeren Redirect-Seiten hatten 0 Wörter. Die echten Artikel haben >1000 Wörter. |
| **Indexable page not in sitemap** | 29 | **Fix:** `sitemap.ts` aktualisiert.<br>_Erklärung:_ Wir haben Code hinzugefügt, der alle Blog-Slugs automatisch in die Sitemap schreibt. |
## 2. Redirects & Links
Fehlerhafte Weiterleitungen, die Nutzer und Crawler verwirrten.
| Ahrefs Meldung | Anzahl | Was wir gemacht haben (Fix) |
| :--- | :--- | :--- |
| **Page has links to redirect** | 5 | **Fix:** Hardcoded Links in `blog/page.tsx` entfernt.<br>_Erklärung:_ Einige Blog-Teaser verlinkten fälschlicherweise auf `/tools/*` oder `/signup`. Jetzt verlinken sie korrekt auf `/blog/[slug]`. |
| **3XX redirect** | 5 | **Fix:** `next.config.mjs` bereinigt.<br>_Erklärung:_ Wir haben 5 veraltete Redirect-Regeln gelöscht (z.B. den, der `/analytics` blockierte). |
| **HTTP to HTTPS redirect** | 1 | **Prüfung:** Next.js erledigt dies automatisch. Sollte durch Cloudflare/Vercel (Deployment) forciert werden. |
## 3. Bilder & Performance
| Ahrefs Meldung | Anzahl | Was wir gemacht haben (Fix) |
| :--- | :--- | :--- |
| **Image file size too large** | 3 | **Fix:** Bilder komprimiert.<br>_Details:_ `qr-code-analytics-dashboard.png` (5.7MB) -> 327KB. `static-vs-dynamic-qr-codes-*.png` ebenfalls massiv verkleinert. |
## 4. Social Media / Open Graph
| Ahrefs Meldung | Anzahl | Was wir gemacht haben (Fix) |
| :--- | :--- | :--- |
| **Open Graph tags incomplete** | 6 | **Fix:** `layout.tsx` korrigiert.<br>_Erklärung:_ Der Pfad zum OG-Image war `/static/og-image.png`. Wir haben ihn zu `/og-image.png` korrigiert, damit Facebook/LinkedIn das Bild finden. |
| **Open Graph tags missing** | 2 | **Fix:** Metadaten zur deutschen Seite (`marketing-de`) und Homepage hinzugefügt.<br>_Erklärung:_ Der deutschen Seite fehlten die OG-Tags komplett. Jetzt sind sie synchron mit der englischen Version. |
## 5. Strukturierte Daten (Schema)
| Ahrefs Meldung | Anzahl | Was wir gemacht haben (Fix) |
| :--- | :--- | :--- |
| **Structured data validation error** | 34 | **Fix:** Seiten repariert -> Schema repariert.<br>_Erklärung:_ Das Schema (JSON-LD) braucht Daten wie "Autor", "Bild", "URL". Wenn die Seite kaputt ist (wie bei den 29 oben), fehlen diese Daten und das Schema ist ungültig. Da die Seiten jetzt gehen, ist auch das Schema valide. |
## 6. Absichtliche "Fehler" (Kein Fix nötig)
Diese Punkte sind korrekt so und müssen nicht behoben werden.
| Ahrefs Meldung | Anzahl | Status |
| :--- | :--- | :--- |
| **Noindex page** | 2 | **Korrekt.** Das sind Seiten wie `/newsletter` oder `/404`, die Google nicht indexieren soll (über `robots.ts` gesteuert). |
| **Pages to submit to IndexNow** | 30 | **Info.** Das ist nur ein Vorschlag von Ahrefs, Bing manuell anzupingen. Kein Fehler. |
## 7. Indexability Issues (CRITICAL & Review)
Prüfung der gemeldeten Indexierungsprobleme.
| Ahrefs Meldung | Status | Analyse / Maßnahmen |
| :--- | :--- | :--- |
| **Indexable page became non-indexable (4)** | **Verifiziert** | Dies betrifft Admin- und Dashboard-Routen (`/dashboard`, `/create`, etc.), die in `robots.ts` nun explizit auf `disallow` gesetzt sind. **Dies ist korrekt und gewollt.** Die Seiten waren vorher evtl. indexierbar, sollten es aber nicht sein. |
| **Nofollow page** | **Verifiziert** | Bezieht sich meist auf Login/Signup oder externe Links. Im Code wurden keine ungewollten `nofollow` Tags gefunden. |
| **Noindex and nofollow page** | **Verifiziert** | Korrekt für `/admin` oder `/private` Rounten. |
## 8. Content-Feinschliff
Optimierung von Titeln und Inhalten.
| Maßnahme | Details | Status |
| :--- | :--- | :--- |
| **Title kürzen** | `WiFiGenerator.tsx` | **Gefixed.** <br>Titel gekürzt von ~64 auf 54 Zeichen: _"Free WiFi QR Code Generator \| WLAN QR Code \| QR Master"_ |
| **Not-indexable-Seiten prüfen** | Blog / Redirects | **Gefixed.** Siehe Punkt 1. Die Seiten haben nun Content und ausgehende Links. |
| **Meta description changes** | Diverse Seiten | **Info.** Änderungen wurden durch die neuen Metadata-Funktionen übernommen und sind valide. |
## 9. Twitter/X Cards
Integration von Social Cards.
| Ahrefs Meldung | Anzahl | Was wir gemacht haben (Fix) |
| :--- | :--- | :--- |
| **X (Twitter) card missing** | 2 | **Fix:** `layout.tsx` (Global & DE)<br>_Erklärung:_ Twitter Card Metadaten (`summary_large_image`) wurden global im Root-Layout und im deutschen Layout (`marketing-de`) ergänzt. Alle Seiten erben nun automatisch diese Tags. |
---
**Zusammenfassung:**
Wir haben 100% der technischen Fehler behoben, einschließlich der kritischen Indexierungsfehler bei den Blogs und der fehlenden Social Tags. Der nächste Ahrefs-Crawl sollte einen **Health Score >90** bestätigen.
## 10. Kleinere Content & OG-Fixes
Die letzten verbleibenden "Missing Issues" wurden ebenfalls behoben:
| Ahrefs Meldung | Status | Fix |
| :--- | :--- | :--- |
| **Noindex follow page (1)** | **Verifiziert** | `(auth)/layout.tsx`: Login/Signup-Seiten sind nun explizit auf `index: false, follow: true` gesetzt. |
| **Meta description too short (2)** | **Fixed** | `(auth)` & `(app)` Layouts: Descriptions auf 130-160 Zeichen erweitert, um SEO-Standards zu erfüllen. |
| **OG URL ≠ canonical (1)** | **Fixed** | `layout.tsx`: `og:url` wurde entfernt, damit Next.js automatisch die korrekte Canonical/Current URL verwendet. |

View File

@@ -1,14 +0,0 @@
{
"mcpServers": {
"firecrawl": {
"command": "npx",
"args": [
"-y",
"firecrawl-mcp"
],
"env": {
"FIRECRAWL_API_KEY": "fc-268826f038ad4bf0a38c48690ba9c1fa"
}
}
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1,41 +0,0 @@
🚀 Neue Content-Typen
Feature Beschreibung
WiFi QR SSID, Passwort, Verschlüsselungstyp perfekt für Cafés/Hotels
Event (VEVENT) Kalendereinträge direkt ins Handy importieren
App Store Links Smart-Links die iOS/Android erkennen
PayPal/Bitcoin Zahlungsaufforderungen per QR
WhatsApp/Telegram Direkt-Chat mit vordefinierter Nachricht
📊 Analytics-Erweiterungen
Feature Beschreibung
UTM-Parameter Automatische Kampagnen-Tags für Google Analytics
Conversion Tracking Ziel-URLs definieren und Conversion messen
A/B Testing Zwei Ziel-URLs testen, welche besser performt
Scheduled Reports Wöchentliche/monatliche E-Mail-Reports
Export (CSV/PDF) Analytics-Daten exportieren
🎨 QR Design & Styling
Feature Beschreibung
Design Templates Vorgefertigte Farb-/Logo-Kombinationen
Frames & CTA "Scan me!" Rahmen um den QR Code
Dot Styles Runde Punkte, Diamanten, etc.
Eye Shapes Custom Corner-Marker Designs
Gradient Colors Farbverläufe statt Vollfarben
🗂️ Organisation & Teamwork
Feature Beschreibung
Folders/Projekte QR Codes in Ordner organisieren
Tags & Filter Flexibles Tagging-System
Team Workspaces Mehrere User pro Account (BUSINESS)
Activity Log Wer hat was wann geändert
QR Code Archiv Soft-Delete statt Löschen
⚙️ Pro Features
Feature Beschreibung
Passwortschutz QR führt zu Passwort-geschützter Seite
Ablaufdatum QR Code deaktiviert sich automatisch
Scan-Limit Max. X Scans erlauben
Geo-Targeting Verschiedene URLs je nach Standort
Device Detection Desktop vs. Mobile unterschiedliche URLs
🔌 Integrationen
Feature Beschreibung
Zapier/Make Webhooks bei Scans triggern
Google Sheets Scan-Daten automatisch exportieren
Slack Notifications Benachrichtigung bei X Scans
API für Entwickler Public API mit Token-Auth

View File

@@ -1,25 +1,25 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
output: 'standalone', output: 'standalone',
skipTrailingSlashRedirect: true, skipTrailingSlashRedirect: true,
images: { images: {
unoptimized: false, unoptimized: false,
domains: ['www.qrmaster.net', 'qrmaster.net', 'images.qrmaster.net'], domains: ['www.qrmaster.net', 'qrmaster.net', 'images.qrmaster.net'],
formats: ['image/webp', 'image/avif'], formats: ['image/webp', 'image/avif'],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
}, },
experimental: { experimental: {
serverComponentsExternalPackages: ['@prisma/client', 'bcryptjs'], serverComponentsExternalPackages: ['@prisma/client', 'bcryptjs'],
}, },
// Allow build to succeed even with prerender errors // Allow build to succeed even with prerender errors
// Pages with useSearchParams() will be rendered dynamically at runtime // Pages with useSearchParams() will be rendered dynamically at runtime
staticPageGenerationTimeout: 120, staticPageGenerationTimeout: 120,
onDemandEntries: { onDemandEntries: {
maxInactiveAge: 25 * 1000, maxInactiveAge: 25 * 1000,
pagesBufferLength: 2, pagesBufferLength: 2,
}, },
poweredByHeader: false, poweredByHeader: false,
}; };
export default nextConfig; export default nextConfig;

View File

@@ -1,68 +0,0 @@
# SEO Setup (Copy these into the tool)
**Focus Keyword:** Best QR Code Generator 2026
**Page Title:** Best QR Code Generator 2026: Ultimate Guide (Dynamic & AI)
**Meta Description:** Discover standards for the best QR code generator in 2026. Learn why dynamic QR codes, AI analytics, and unlimited scans are essential for your business growth.
**Related Keywords:**
1. free dynamic qr code generator
2. qr code tracking analytics
3. edit qr code after printing
4. unlimited scan qr code
5. vector qr code svg
6. custom brand qr code
7. bulk qr code generator
8. gdpr compliant qr code
---
# Article Content
# Best QR Code Generator 2026: The Ultimate Guide
The digital landscape has transformed, and finding the **Best QR Code Generator 2026** is critical for businesses connecting with customers. The humble QR code has evolved into a sophisticated marketing instrument. To stay competitive, your chosen platform must offer more than just links—it must unlock data, flexibility, and brand engagement.
![Best QR Code Generator 2026 Analytics Dashboard](/blog/best-qr-code-generator-2026-dashboard.jpg)
In this guide, we explore why static codes are dead and why top-tier tools now rely entirely on dynamic technology.
## Why Dynamic QR Codes Are Non-Negotiable
If you are not using a modern solution, you might still be stuck with static codes. The industry standard has shifted entirely to **dynamic QR codes** for critical reasons:
1. **Editability**: Printed 5,000 brochures with the wrong link? A dynamic platform lets you update the destination URL in seconds.
2. **Tracking & Analytics**: You need to know *who* scanned and *when*.
3. **Retargeting**: Integration with [Google Analytics](https://www.qrmaster.net/analytics) allows you to build audiences.
### Static vs. Dynamic: The 2026 Verdict
| Feature | Static QR Code | Best QR Code Generator 2026 (Dynamic) |
| :--- | :--- | :--- |
| **Editing** | Impossible | Instant updates anytime |
| **Analytics** | None | Real-time AI Data |
| **Lifespan** | Until link breaks | Indefinite |
## Top Trends Defining the Market
### 1. AI-Driven Scan Prediction
Leading platforms integrates Artificial Intelligence to predict peak scan times. By analyzing historical data, platforms like [QR Master](https://www.qrmaster.net/) suggest optimal placement.
### 2. Augmented Reality (AR) Integration
New codes trigger immersive AR experiences. The **Best QR Code Generator 2026** supports these next-gen formats natively, allowing customers to visualize products immediately.
### 3. Hyper-Personalization
Contextual redirects are a hallmark of advanced generators. Redirect users in Berlin to German pages and New York users to US pages automatically, ensuring the highest possible conversion rate.
## How to Choose the Right Tool
With many tools available, how do you verify which is the right one for you?
* **No Scan Limits**: Many services cap you at 100 scans. Ensure your provider offers [unlimited scans](https://www.qrmaster.net/pricing).
* **Vector Formats**: Essential for professional printing (SVG/EPS).
* **GDPR Compliance**: Data privacy is paramount.
## Conclusion: Future-Proof Your Marketing
As we move through the year, selecting the **Best QR Code Generator 2026** is the highest ROI decision you can make. Don't settle for temporary solutions. Choose a platform that scales with your ambition.
*Ready to upgrade? Start creating with the industry leader today: [Sign Up Free](https://www.qrmaster.net/signup).*

22428
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,99 +1,86 @@
{ {
"name": "qr-master", "name": "qr-master",
"version": "1.0.0", "version": "1.0.0",
"description": "Create custom QR codes in seconds", "description": "Create custom QR codes in seconds",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3050", "dev": "next dev -p 3050",
"build": "prisma generate && next build", "build": "prisma generate && next build",
"trigger:indexing": "tsx scripts/trigger-indexing.ts", "start": "next start",
"submit:indexnow": "tsx scripts/submit-indexnow.ts", "lint": "next lint",
"start": "next start", "db:generate": "prisma generate",
"lint": "next lint", "db:migrate": "prisma migrate dev",
"indexnow": "tsx scripts/submit-indexnow.ts", "db:deploy": "prisma migrate deploy",
"db:generate": "prisma generate", "db:seed": "tsx prisma/seed.ts",
"db:migrate": "prisma migrate dev", "db:studio": "prisma studio",
"db:deploy": "prisma migrate deploy", "postinstall": "prisma generate",
"db:seed": "tsx prisma/seed.ts", "docker:dev": "docker compose -f docker-compose.dev.yml up -d",
"db:studio": "prisma studio", "docker:dev:stop": "docker compose -f docker-compose.dev.yml down",
"postinstall": "prisma generate", "docker:dev:clean": "docker compose -f docker-compose.dev.yml down --remove-orphans && docker container prune -f",
"docker:dev": "docker compose -f docker-compose.dev.yml up -d", "docker:prod": "docker compose up -d --build",
"docker:dev:stop": "docker compose -f docker-compose.dev.yml down", "docker:stop": "docker compose down",
"docker:dev:clean": "docker compose -f docker-compose.dev.yml down --remove-orphans && docker container prune -f", "docker:logs": "docker compose logs -f",
"docker:prod": "docker compose up -d --build", "docker:db": "docker compose exec db psql -U postgres -d qrmaster",
"docker:stop": "docker compose down", "docker:redis": "docker compose exec redis redis-cli",
"docker:logs": "docker compose logs -f", "docker:backup": "docker compose exec db pg_dump -U postgres qrmaster > backup_$(date +%Y%m%d).sql"
"docker:db": "docker compose exec db psql -U postgres -d qrmaster", },
"docker:redis": "docker compose exec redis redis-cli", "dependencies": {
"docker:backup": "docker compose exec db pg_dump -U postgres qrmaster > backup_$(date +%Y%m%d).sql" "@auth/prisma-adapter": "^2.11.1",
}, "@edge-runtime/cookies": "^6.0.0",
"dependencies": { "@prisma/client": "^5.7.0",
"@auth/prisma-adapter": "^2.11.1", "@stripe/stripe-js": "^8.0.0",
"@aws-sdk/client-s3": "^3.972.0", "@types/d3-scale": "^4.0.9",
"@aws-sdk/s3-request-presigner": "^3.972.0", "bcryptjs": "^2.4.3",
"@edge-runtime/cookies": "^6.0.0", "chart.js": "^4.4.0",
"@prisma/client": "^5.7.0", "clsx": "^2.0.0",
"@stripe/stripe-js": "^8.0.0", "d3-scale": "^4.0.2",
"@types/d3-scale": "^4.0.9", "dayjs": "^1.11.10",
"axios": "^1.13.2", "exceljs": "^4.4.0",
"bcryptjs": "^2.4.3", "file-saver": "^2.0.5",
"chart.js": "^4.4.0", "i18next": "^23.7.6",
"clsx": "^2.0.0", "ioredis": "^5.3.2",
"copy-image-clipboard": "^2.1.2", "jszip": "^3.10.1",
"d3-scale": "^4.0.2", "lucide-react": "^0.562.0",
"dayjs": "^1.11.10", "next": "^14.2.35",
"dotenv": "^17.2.3", "next-auth": "^4.24.5",
"exceljs": "^4.4.0", "papaparse": "^5.4.1",
"file-saver": "^2.0.5", "posthog-js": "^1.276.0",
"framer-motion": "^12.24.10", "qr-code-styling": "^1.9.2",
"googleapis": "^170.1.0", "qrcode": "^1.5.3",
"html-to-image": "^1.11.13", "qrcode.react": "^3.1.0",
"i18next": "^23.7.6", "react": "^18.2.0",
"ioredis": "^5.3.2", "react-chartjs-2": "^5.2.0",
"jspdf": "^4.0.0", "react-dom": "^18.2.0",
"jszip": "^3.10.1", "react-dropzone": "^14.2.3",
"lucide-react": "^0.562.0", "react-i18next": "^13.5.0",
"next": "^14.2.35", "react-simple-maps": "^3.0.0",
"next-auth": "^4.24.5", "resend": "^6.4.2",
"papaparse": "^5.4.1", "sharp": "^0.33.1",
"posthog-js": "^1.332.0", "stripe": "^19.1.0",
"qr-code-styling": "^1.9.2", "tailwind-merge": "^2.2.0",
"qrcode": "^1.5.3", "uuid": "^13.0.0",
"qrcode.react": "^3.1.0", "zod": "^3.25.76"
"react": "^18.2.0", },
"react-barcode": "^1.6.1", "devDependencies": {
"react-chartjs-2": "^5.2.0", "@types/bcryptjs": "^2.4.6",
"react-dom": "^18.2.0", "@types/file-saver": "^2.0.7",
"react-dropzone": "^14.2.3", "@types/node": "^20.10.5",
"react-i18next": "^13.5.0", "@types/papaparse": "^5.3.14",
"react-simple-maps": "^3.0.0", "@types/qrcode": "^1.5.5",
"resend": "^6.4.2", "@types/react": "^18.2.45",
"stripe": "^19.1.0", "@types/react-dom": "^18.2.18",
"tailwind-merge": "^2.2.0", "autoprefixer": "^10.4.16",
"uuid": "^13.0.0", "eslint": "^8.56.0",
"zod": "^3.25.76" "eslint-config-next": "^16.1.1",
}, "next-sitemap": "^4.2.3",
"devDependencies": { "postcss": "^8.4.32",
"@types/bcryptjs": "^2.4.6", "prettier": "^3.1.1",
"@types/file-saver": "^2.0.7", "prisma": "^5.7.0",
"@types/node": "^20.10.5", "tailwindcss": "^3.3.6",
"@types/papaparse": "^5.3.14", "tsx": "^4.7.0",
"@types/qrcode": "^1.5.5", "typescript": "^5.3.3"
"@types/react": "^18.2.45", },
"@types/react-dom": "^18.2.18", "engines": {
"autoprefixer": "^10.4.16", "node": ">=18.0.0"
"eslint": "^8.56.0", }
"eslint-config-next": "^16.1.1",
"next-sitemap": "^4.2.3",
"postcss": "^8.4.32",
"prettier": "^3.1.1",
"prisma": "^5.7.0",
"sharp": "^0.34.5",
"tailwindcss": "^3.3.6",
"tsx": "^4.7.0",
"typescript": "^5.3.3"
},
"engines": {
"node": ">=18.0.0"
}
} }

View File

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

View File

@@ -1,168 +1,167 @@
// This is your Prisma schema file, // This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema // learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-3.0.x"] binaryTargets = ["native", "debian-openssl-3.0.x"]
} }
datasource db { datasource db {
provider = "postgresql" provider = "postgresql"
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
email String @unique email String @unique
name String? name String?
password String? password String?
image String? image String?
emailVerified DateTime? emailVerified DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
// Stripe subscription fields // Stripe subscription fields
stripeCustomerId String? @unique stripeCustomerId String? @unique
stripeSubscriptionId String? @unique stripeSubscriptionId String? @unique
stripePriceId String? stripePriceId String?
stripeCurrentPeriodEnd DateTime? stripeCurrentPeriodEnd DateTime?
plan Plan @default(FREE) plan Plan @default(FREE)
// Password reset fields // Password reset fields
resetPasswordToken String? @unique resetPasswordToken String? @unique
resetPasswordExpires DateTime? resetPasswordExpires DateTime?
qrCodes QRCode[] // White-label subdomain
integrations Integration[] subdomain String? @unique
accounts Account[]
sessions Session[] qrCodes QRCode[]
} integrations Integration[]
accounts Account[]
enum Plan { sessions Session[]
FREE }
PRO
BUSINESS enum Plan {
} FREE
PRO
model Account { BUSINESS
id String @id @default(cuid()) }
userId String
type String model Account {
provider String id String @id @default(cuid())
providerAccountId String userId String
refresh_token String? @db.Text type String
access_token String? @db.Text provider String
expires_at Int? providerAccountId String
token_type String? refresh_token String? @db.Text
scope String? access_token String? @db.Text
id_token String? @db.Text expires_at Int?
session_state String? token_type String?
scope String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade) id_token String? @db.Text
session_state String?
@@unique([provider, providerAccountId])
} user User @relation(fields: [userId], references: [id], onDelete: Cascade)
model Session { @@unique([provider, providerAccountId])
id String @id @default(cuid()) }
sessionToken String @unique
userId String model Session {
expires DateTime id String @id @default(cuid())
user User @relation(fields: [userId], references: [id], onDelete: Cascade) sessionToken String @unique
} userId String
expires DateTime
model VerificationToken { user User @relation(fields: [userId], references: [id], onDelete: Cascade)
identifier String }
token String @unique
expires DateTime model VerificationToken {
identifier String
@@unique([identifier, token]) token String @unique
} expires DateTime
model QRCode { @@unique([identifier, token])
id String @id @default(cuid()) }
userId String
title String model QRCode {
type QRType @default(DYNAMIC) id String @id @default(cuid())
contentType ContentType @default(URL) userId String
content Json title String
tags String[] type QRType @default(DYNAMIC)
status QRStatus @default(ACTIVE) contentType ContentType @default(URL)
style Json content Json
slug String @unique tags String[]
createdAt DateTime @default(now()) status QRStatus @default(ACTIVE)
updatedAt DateTime @updatedAt style Json
slug String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade) createdAt DateTime @default(now())
scans QRScan[] updatedAt DateTime @updatedAt
@@index([userId, createdAt]) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
} scans QRScan[]
enum QRType { @@index([userId, createdAt])
STATIC }
DYNAMIC
} enum QRType {
STATIC
enum ContentType { DYNAMIC
URL }
VCARD
GEO enum ContentType {
PHONE URL
SMS VCARD
TEXT GEO
WHATSAPP PHONE
PDF SMS
APP TEXT
COUPON WHATSAPP
FEEDBACK }
}
enum QRStatus {
enum QRStatus { ACTIVE
ACTIVE PAUSED
PAUSED }
}
model QRScan {
model QRScan { id String @id @default(cuid())
id String @id @default(cuid()) qrId String
qrId String ts DateTime @default(now())
ts DateTime @default(now()) ipHash String
ipHash String userAgent String?
userAgent String? device String?
device String? os String?
os String? country String?
country String? referrer String?
referrer String? utmSource String?
utmSource String? utmMedium String?
utmMedium String? utmCampaign String?
utmCampaign String? isUnique Boolean @default(false)
isUnique Boolean @default(false)
qr QRCode @relation(fields: [qrId], references: [id], onDelete: Cascade)
qr QRCode @relation(fields: [qrId], references: [id], onDelete: Cascade)
@@index([qrId, ts])
@@index([qrId, ts]) }
}
model Integration {
model Integration { id String @id @default(cuid())
id String @id @default(cuid()) userId String
userId String provider String
provider String status String @default("inactive")
status String @default("inactive") config Json
config Json createdAt DateTime @default(now())
createdAt DateTime @default(now()) updatedAt DateTime @updatedAt
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) }
}
model NewsletterSubscription {
model NewsletterSubscription { id String @id @default(cuid())
id String @id @default(cuid()) email String @unique
email String @unique source String @default("ai-coming-soon")
source String @default("ai-coming-soon") status String @default("subscribed")
status String @default("subscribed") createdAt DateTime @default(now())
createdAt DateTime @default(now()) updatedAt DateTime @updatedAt
updatedAt DateTime @updatedAt
@@index([email])
@@index([email]) @@index([createdAt])
@@index([createdAt])
} }

View File

@@ -1,4 +0,0 @@
Contact: mailto:security@qrmaster.net
Expires: 2027-01-01T00:00:00.000Z
Strategies: https://www.qrmaster.net/.well-known/security.txt
Preferred-Languages: en, de

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 398 KiB

View File

@@ -1 +0,0 @@
bb6dfaacf1ed41a880281c426c54ed7c

BIN
public/blog/1-boy.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

BIN
public/blog/2-body.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

BIN
public/blog/3-body.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 MiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 MiB

BIN
public/blog/4-body.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 MiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 737 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 804 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 860 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 630 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 863 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 646 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 699 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 726 KiB

View File

@@ -1 +0,0 @@
google-site-verification: googleccd5315437d68a49.html

Binary file not shown.

Before

Width:  |  Height:  |  Size: 518 KiB

View File

@@ -1,13 +0,0 @@
/* TEAM */
Founder: Timo Knuth
Site: https://qrmaster.net
Twitter: @qrmaster
/* THANKS */
Thanks to: Next.js, Vercel, Tailwind CSS, Stripe, Supabase
/* SITE */
Last update: 2026/01/12
Language: English, German
Doctype: HTML5
IDE: VS Code

View File

@@ -1,48 +0,0 @@
# QR Master
> QR Master is a B2B SaaS platform for creating dynamic QR codes with real-time analytics, custom branding, and bulk generation. Free tools available for URL, WiFi, vCard, WhatsApp, Instagram, and 15+ other QR code types.
- Primary domain: https://www.qrmaster.net
- Free static QR codes, paid dynamic QR codes with tracking
- German landing page available at /qr-code-erstellen
- Enterprise features: Bulk generation, API access, team management
## Free Tools
- [URL QR Generator](https://www.qrmaster.net/tools/url-qr-code): Create QR codes for any website link
- [WiFi QR Generator](https://www.qrmaster.net/tools/wifi-qr-code): Share WiFi credentials via QR code
- [vCard QR Generator](https://www.qrmaster.net/tools/vcard-qr-code): Digital business card QR codes
- [Text QR Generator](https://www.qrmaster.net/tools/text-qr-code): Encode plain text in QR codes
- [Email QR Generator](https://www.qrmaster.net/tools/email-qr-code): Pre-filled email QR codes
- [SMS QR Generator](https://www.qrmaster.net/tools/sms-qr-code): Send SMS messages via QR
- [Phone QR Generator](https://www.qrmaster.net/tools/phone-qr-code): One-tap phone call QR codes
- [WhatsApp QR Generator](https://www.qrmaster.net/tools/whatsapp-qr-code): Start WhatsApp chats instantly
- [Instagram QR Generator](https://www.qrmaster.net/tools/instagram-qr-code): Grow Instagram followers
- [TikTok QR Generator](https://www.qrmaster.net/tools/tiktok-qr-code): Link to TikTok profiles
- [Twitter QR Generator](https://www.qrmaster.net/tools/twitter-qr-code): Share Twitter/X profiles
- [YouTube QR Generator](https://www.qrmaster.net/tools/youtube-qr-code): Link to videos and channels
- [Facebook QR Generator](https://www.qrmaster.net/tools/facebook-qr-code): Share Facebook pages
- [PayPal QR Generator](https://www.qrmaster.net/tools/paypal-qr-code): Accept PayPal payments
- [Crypto QR Generator](https://www.qrmaster.net/tools/crypto-qr-code): Bitcoin and crypto wallet QR codes
- [Event QR Generator](https://www.qrmaster.net/tools/event-qr-code): Calendar event QR codes
- [Geolocation QR Generator](https://www.qrmaster.net/tools/geolocation-qr-code): Share map locations
- [Zoom QR Generator](https://www.qrmaster.net/tools/zoom-qr-code): Join Zoom meetings instantly
- [Teams QR Generator](https://www.qrmaster.net/tools/teams-qr-code): Join Microsoft Teams meetings
## Premium Features
- [Dynamic QR Codes](https://www.qrmaster.net/dynamic-qr-code-generator): Editable QR codes with real-time tracking
- [Bulk QR Generator](https://www.qrmaster.net/bulk-qr-code-generator): Generate hundreds of QR codes from CSV/Excel
- [QR Code Tracking](https://www.qrmaster.net/qr-code-tracking): Analytics dashboard with scan statistics
## Information
- [Homepage](https://www.qrmaster.net): Main landing page
- [Pricing](https://www.qrmaster.net/pricing): Free, Pro, and Business plans
- [FAQ](https://www.qrmaster.net/faq): Frequently asked questions
- [Blog](https://www.qrmaster.net/blog): Tips and guides for QR code marketing
- [Privacy Policy](https://www.qrmaster.net/privacy): Data privacy information
## Localized Pages
- [German Landing Page](https://www.qrmaster.net/qr-code-erstellen): QR Code Generator auf Deutsch

Binary file not shown.

Before

Width:  |  Height:  |  Size: 531 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 593 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 583 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 511 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 464 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 511 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 486 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 535 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 555 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 600 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 551 KiB

View File

@@ -1,49 +0,0 @@
const sharp = require('sharp');
const path = require('path');
const fs = require('fs');
const imagesToConvert = [
'2-body.png',
'2-hero.png',
'qr-code-analytics-hero.png',
'1-hero.png'
];
const blogDir = path.join(__dirname, '../public/blog');
async function compressImages() {
console.log('🖼️ Starting image compression...\n');
for (const imageName of imagesToConvert) {
const inputPath = path.join(blogDir, imageName);
const outputName = imageName.replace('.png', '.webp');
const outputPath = path.join(blogDir, outputName);
if (!fs.existsSync(inputPath)) {
console.log(`⚠️ Skipping ${imageName} - file not found`);
continue;
}
const originalSize = fs.statSync(inputPath).size;
try {
await sharp(inputPath)
.webp({ quality: 85 })
.toFile(outputPath);
const newSize = fs.statSync(outputPath).size;
const savings = ((1 - newSize / originalSize) * 100).toFixed(1);
console.log(`${imageName}`);
console.log(` Original: ${(originalSize / 1024 / 1024).toFixed(2)} MB`);
console.log(` WebP: ${(newSize / 1024 / 1024).toFixed(2)} MB`);
console.log(` Savings: ${savings}%\n`);
} catch (err) {
console.error(`❌ Failed to convert ${imageName}:`, err.message);
}
}
console.log('Done! Remember to update image references in blog-data.ts');
}
compressImages();

View File

@@ -1,23 +0,0 @@
// Helper script to run IndexNow submission
// Run with: npm run submit:indexnow
import { getAllIndexableUrls, submitToIndexNow } from '../src/lib/indexnow';
async function main() {
console.log('🚀 Starting IndexNow Submission Script...');
console.log(' Gathering URLs for IndexNow submission...');
const urls = getAllIndexableUrls();
console.log(` Found ${urls.length} indexable URLs.`);
// Basic validation of key presence (logic can be improved)
if (!process.env.INDEXNOW_KEY) {
console.warn('⚠️ WARNING: INDEXNOW_KEY environment variable is not set.');
console.warn(' The submission might fail if the key is not hardcoded in src/lib/indexnow.ts');
}
await submitToIndexNow(urls);
console.log('\n✨ IndexNow submission process completed.');
}
main().catch(console.error);

View File

@@ -1,64 +0,0 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('🔄 Starting Database Diagnostics...');
try {
// 1. Test Connection
console.log('1⃣ Testing basic connection...');
await prisma.$connect();
console.log('✅ Connected to database successfully.');
// 2. Test Lead Table Existence
console.log('2⃣ Testing Lead table access...');
try {
const count = await prisma.lead.count();
console.log(`✅ Lead table found. Current count: ${count}`);
} catch (e: any) {
console.error('❌ FAILED to access Lead table.');
if (e.code === 'P2021') {
console.error(' 👉 Error P2021: The table "Lead" does not exist in the current database.');
console.error(' 👉 SOLUTION: Run "npx prisma migrate deploy"');
} else {
console.error(' 👉 Error:', e.message);
}
throw e; // rethrow to stop
}
// 3. Test Writing a dummy lead (optional, rolling back transaction)
console.log('3⃣ Testing write permission...');
await prisma.$transaction(async (tx) => {
const lead = await tx.lead.create({
data: {
email: 'test_diagnostic_script@example.com',
source: 'diagnostic-script',
reprintCost: 0,
updatesPerYear: 0,
annualSavings: 0
}
});
console.log('✅ Successfully created test lead with ID:', lead.id);
// We purposefully throw an error to rollback this transaction so we don't dirty the DB
throw new Error('ROLLBACK_TEST');
}).catch((e) => {
if (e.message === 'ROLLBACK_TEST') {
console.log('✅ Transaction rollback successful (cleaning up test data).');
} else {
throw e;
}
});
console.log('\n🎉 ALL CHECKS PASSED! The database is effectively readable and writable.');
} catch (error) {
console.error('\n💥 DIAGNOSTICS FAILED');
console.error(error);
process.exit(1);
} finally {
await prisma.$disconnect();
}
}
main();

View File

@@ -1,81 +0,0 @@
import { google } from 'googleapis';
import fs from 'fs';
import path from 'path';
import { getAllIndexableUrls } from '../src/lib/indexnow';
// ==========================================
// CONFIGURATION
// ==========================================
// Path to your Service Account Key (JSON file)
const KEY_FILE = path.join(__dirname, '../service_account.json');
// Urls are now fetched dynamically from src/lib/indexnow.ts
// ==========================================
async function runUsingServiceAccount() {
console.log('🚀 Starting Google Indexing Script (All Pages)...');
if (!fs.existsSync(KEY_FILE)) {
console.error('\n❌ ERROR: Service Account Key not found!');
console.error(` Expected path: ${KEY_FILE}`);
console.error(' Please follow the instructions in INDEXING_GUIDE.md to create and save the key.');
return;
}
console.log(`🔑 Authenticating with key file: ${path.basename(KEY_FILE)}...`);
const auth = new google.auth.GoogleAuth({
keyFile: KEY_FILE,
scopes: ['https://www.googleapis.com/auth/indexing'],
});
try {
const client = await auth.getClient();
console.log('✅ Authentication successful.');
console.log(' Gathering URLs to index...');
const allUrls = getAllIndexableUrls();
console.log(` Found ${allUrls.length} URLs to index.`);
for (const url of allUrls) {
console.log(`\n📄 Processing: ${url}`);
try {
const result = await google.indexing('v3').urlNotifications.publish({
auth: client,
requestBody: {
url: url,
type: 'URL_UPDATED'
}
});
console.log(` 👉 Status: ${result.status} ${result.statusText}`);
// Optional: Log more details from result.data if needed
} catch (innerError: any) {
console.error(` ❌ Failed to index ${url}`);
if (innerError.response) {
console.error(` Reason: ${innerError.response.status} - ${JSON.stringify(innerError.response.data)}`);
// 429 = Quota exceeded
// 403 = Permission denied (check service account owner status)
} else {
console.error(` Reason: ${innerError.message}`);
}
}
// Optional: Add a small delay to avoid hitting rate limits too fast if you have hundreds of URLs
// await new Promise(resolve => setTimeout(resolve, 500));
}
console.log('\n✨ Done! All requests processed.');
console.log(' Note: Check Google Search Console for actual indexing status over time.');
} catch (error: any) {
console.error('\n❌ Fatal error occurred:');
console.error(error.message);
}
}
runUsingServiceAccount();

View File

@@ -1,34 +0,0 @@
import { db } from '../src/lib/db';
async function main() {
try {
console.log('Verifying Lead model...');
// Type assertion to bypass potential type generation issues locally if they exist
const leadCount = await (db as any).lead.count();
console.log(`Current lead count: ${leadCount}`);
const testLead = await (db as any).lead.create({
data: {
email: 'test_verify@example.com',
source: 'verification-script',
reprintCost: 100,
updatesPerYear: 12,
annualSavings: 1200,
},
});
console.log('Successfully created test lead:', testLead.id);
// Clean up
await (db as any).lead.delete({
where: { id: testLead.id }
});
console.log('Successfully deleted test lead');
} catch (error) {
console.error('Verification failed:', error);
process.exit(1);
}
}
main();

View File

@@ -1,742 +0,0 @@
Overview: qr code generator
View Cached Page
Create Report
370,000
Monthly Volume
337,000
Estimated Clicks
Clicked any result
Low
91%
High
Mobile vs Desktop
Mobile
Desktop
Not Enough Data
Paid clicks
Low
03%
12%
High
13%+
Difficulty
73
Google Provided Data
Expand
Cost Per Click
$0.51
Monthly Cost
$3,072
Search Volume
90,500
Advertisers
15
Homepages
6
Fresh SV
918,000
Universal search in SERP
8,191
Similar keywords
qr code generator
370,000
qr code generator free
43,300
free qr code generator
34,400
generate qr code
10,800
google qr code generator
8,000
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
View All
655
Questions
How to generate a qr code
1,700
How to generate qr code
630
How to generate qr code for url
270
What is the best qr code generator?
220
How to generate bank qr code without edge
200
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
View All
19,120,108
Also ranks for
qr code generator free
43,300
qr code maker
52,000
free qr code generator
34,400
qr generator
25,300
create qr code
29,500
Overview: barcode generator
View Cached Page
Create Report
58,300
Monthly Volume
51,000
Estimated Clicks
Clicked any result
Low
87%
High
Mobile vs Desktop
Mobile
Desktop
Low Mobile
Paid clicks
Low
03%
1%
High
13%+
Difficulty
22
Google Provided Data
Expand
Cost Per Click
$1.68
Monthly Cost
$5,316
Search Volume
110,000
Advertisers
5
Homepages
21
Fresh SV
72,800
Universal search in SERP
5,381
Similar keywords
barcode generator
58,300
free barcode generator
4,000
upc barcode generator
3,200
2d barcode generator
1,300
generate barcode
1,300
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
View All
326
Questions
How to store barcodes generated into a folder in linux python
250
How to generate barcodes
180
How to generate barcodes in excel
180
How to generate barcodes for products
135
How to generate a third party barcode for j1 waiver
110
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
View All
27,933,196
Also ranks for
free barcode generator
4,000
barcode maker
6,100
upc code generator
3,600
upc generator
4,500
2d barcode generator
1,300
Overview: qr code maker
View Cached Page
Create Report
52,000
Monthly Volume
48,200
Estimated Clicks
Clicked any result
Low
93%
High
Mobile vs Desktop
Mobile
Desktop
Not Enough Data
Paid clicks
Low
03%
12%
High
13%+
Difficulty
47
Google Provided Data
Expand
Cost Per Click
$0.37
Monthly Cost
$209
Search Volume
18,100
Advertisers
11
Homepages
32
Fresh SV
71,300
Universal search in SERP
601
Similar keywords
qr code maker
52,000
animal crossing qr code maker
2,000
free qr code maker
2,000
qr code maker free
1,900
mini qr code maker
380
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
View All
13
Questions
How to maker qr code for cia
70
How to make qr codes with brother label maker
70
How to make a qr code qr code maker
50
How to post qr codes online mii maker
40
How to get qr code watch maker
28
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
View All
84,638,180
Also ranks for
qr code generator free
43,300
free qr code generator
34,400
create a qr code
17,100
create qr code
29,500
qr generator
25,300
Overview: google qr code generator
View Cached Page
Create Report
8,000
Monthly Volume
5,100
Estimated Clicks
Clicked any result
Low
64%
High
Mobile vs Desktop
Mobile
Desktop
Low Mobile
Paid clicks
Low
03%
2%
High
13%+
Difficulty
52
Google Provided Data
Expand
Cost Per Click
$3.53
Monthly Cost
$0.00
Search Volume
2,900
Advertisers
9
Homepages
20
Fresh SV
11,500
Universal search in SERP
336
Similar keywords
google qr code generator
8,000
qr code generator google
4,800
free qr code generator google
720
qr code generator google form
440
google form qr code generator
320
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
View All
59
Questions
Does google have a qr code generator?
135
How to generate qr code for google authenticator
100
Does google have a qr code generator for contact info?
90
How to generate qr code for google form
90
How to generate a qr code for a google form
90
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
View All
27,918,916
Also ranks for
qr code generator free
43,300
qr code maker
52,000
create qr code
29,500
qr generator
25,300
free qr code generator
34,400
Overview: create qr code
View Cached Page
Create Report
29,500
Monthly Volume
26,400
Estimated Clicks
Clicked any result
Low
89%
High
Mobile vs Desktop
Mobile
Desktop
Not Enough Data
Paid clicks
Low
03%
16%
High
13%+
Difficulty
52
Google Provided Data
Expand
Cost Per Click
$3.32
Monthly Cost
$1,406
Search Volume
14,800
Advertisers
15
Homepages
25
Fresh SV
50,000
Universal search in SERP
3,223
Similar keywords
create qr code
29,500
create a qr code
17,100
How to create a qr code
9,200
create qr code free
5,500
How to create a qr code free
1,400
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
View All
1,110
Questions
How to create a qr code
9,200
How to create a qr code free
1,400
How to create qr codes
1,300
How to create qr code
1,300
How to create a qr code for a google form
1,100
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
View All
18,733,729
Also ranks for
qr code generator free
43,300
qr code maker
52,000
create a qr code
17,100
free qr code generator
34,400
qr generator
25,300
Overview: qr code with logo
View Cached Page
Create Report
1,600
Monthly Volume
1,300
Estimated Clicks
Clicked any result
Low
81%
High
Mobile vs Desktop
Mobile
Desktop
Low Mobile
Paid clicks
Low
03%
8%
High
13%+
Difficulty
48
Google Provided Data
Expand
Cost Per Click
$0.00
Monthly Cost
$0.00
Search Volume
-
Advertisers
1
Homepages
25
Fresh SV
2,900
Universal search in SERP
291
Similar keywords
qr code generator with logo
4,100
qr code with logo
1,600
create qr code with logo
440
qr code generator with logo free
400
android studio qr code generator with logo
300
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
View All
17
Questions
How to make qr code with logo
40
How to design qr code with logo
40
How to create qr code with logo
28
How to make own qr code with logo
24
How to create your own qr code with logo
24
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
View All
37,452,720
Also ranks for
qr code maker
52,000
qr code generator free
43,300
create qr code
29,500
free qr code generator
34,400
create a qr code
17,100
Overview: spotify code generator
View Cached Page
Create Report
840
Monthly Volume
630
Estimated Clicks
Clicked any result
Low
76%
High
Mobile vs Desktop
Mobile
Desktop
Not Enough Data
Paid clicks
Not Enough Data
Difficulty
21
Google Provided Data
Expand
Cost Per Click
$0.00
Monthly Cost
$0.00
Search Volume
90
Advertisers
0
Homepages
5
Fresh SV
2,400
Universal search in SERP
106
Similar keywords
spotify code generator
840
spotify premium code generator no survey
420
spotify premium codes generator
300
spotify premium code generator no survey 2017
290
spotify code generator 2019
290
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
View All
5
Questions
How to generate spotify code
90
How to get spotify premium code free generator 2018
70
How to get code for spotify premium spotify premium free code generator
24
Where is spotify pin code generator?
12
How to generate a spotify code
-
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
Log in to see all results
--
View All
82,799,750
Also ranks for
qr code maker
52,000
spotify codes
7,100
spotify code
5,700
qrcode
11,900
create qr code
29,500

View File

@@ -1,506 +0,0 @@
## A) Executive summary (max 12 bullets)
* **Win fast (060 days)** by launching a *“wedge” set* of low-KD, high-intent tool pages (WhatsApp / Instagram / vCard / Bulk / PDF) + one differentiated feature hub (**QR Code Analytics + Tracking**) that every tool page upsells into.
* **Build an intent ladder**: *Free generator → Dynamic QR → Tracking/Analytics → Bulk/API/Teams → Custom domains + integrations* (this mirrors how category leaders gate value). ([qr-code-generator.com][1])
* **Exploit SERP splits**: head terms (“qr code generator”) are crowded with generalist tools (Canva/Adobe) + legacy generators, while **dynamic/tracking** queries skew toward SaaS platforms—your product sweet spot. ([qr-code-generator.com][1])
* **Turn “Google QR Code Generator” into a capture page**: Google/Chrome already generates a basic QR for a URL; your angle is *“Chrome is static-only → heres dynamic + analytics + UTM + campaign dashboards.”* ([Google Hilfe][2])
* **Programmatic SEO (pSEO) is mandatory** in this space: competitors scale with templated “solutions” pages by QR type (vCard, WiFi, Spotify, Instagram, etc.). ([qr-code-generator.com][3])
* **Avoid pSEO index bloat** with strict canonical + noindex rules and *minimum content thresholds* per template (examples below).
* **Differentiate on trust**: QR scams (“quishing”) are rising; bake “safe redirect + link preview + scan security” into product messaging and content. ([Der Guardian][4])
* **Make “Barcode Generator” a top-of-funnel traffic engine** (58k SV / KD 22 in your data) but route conversions toward QR analytics + dynamic capabilities; barcode SERPs are full of embed-only utilities and hardware vendors. ([Free Online Barcode Generator by TEC-IT][5])
* **Ship IA early**: a scalable sitemap with `/tools/`, `/features/`, `/integrations/`, `/compare/`, `/learn/`, and `/templates/` prevents cannibalization and makes internal linking deterministic.
* **Measure leading indicators**: indexation coverage, impressions, tool-page CVR to signup, activation (QR created), and upgrades (dynamic/tracking enabled).
* **Link acquisition**: win with embed widgets, UTM/GA4 tracking guides, open-source SDKs, and directory placements (10 angles below).
* **Assumptions used** (adjustable): **EN**, **Global/US focus**, **Freemium SaaS → subscription**, primary conversion **signup → generate → enable tracking**.
---
## B) Competitor landscape (top competitors + what they do best + weaknesses)
Below is a **SERP-driven** view of recurring domains across “QR code generator”, “dynamic QR”, “tracking/analytics”, and “type” queries (vCard/Instagram/Spotify/etc.):
### 1) QR Code Generator (Bitly) — `qr-code-generator.com`
**Best at**
* Clear **feature ladder + gating** (static free → dynamic/analytics → bulk/API/teams). ([qr-code-generator.com][1])
* Massive **“solutions” library** (SEO scale by QR type). ([qr-code-generator.com][3])
**Weaknesses to exploit**
* Heavy gating/upsell can frustrate “free” intent.
* Many “solution” pages trend toward **marketing copy**—opportunity for deeper “how-to + templates + examples + tracking instrumentation”.
### 2) QRCode Monkey — `qrcode-monkey.com`
**Best at**
* “Free + design/customization” positioning; vectors/print talk resonates. ([QRCode Monkey][6])
* Has an **API pitch** (some scaling). ([QRCode Monkey][7])
**Weaknesses**
* Less credible on analytics-first workflows; your advantage is *campaign measurement + dashboards*.
### 3) The QR Code Generator (TQRCG) — `the-qrcode-generator.com`
**Best at**
* Trust messaging: “free means free” + warns about expiring codes. ([the-qrcode-generator.com][8])
**Weaknesses**
* Content often “how-to guide” oriented; you can outrank with **better tools + richer templates + integrations**.
### 4) Hovercode — `hovercode.com`
**Best at**
* Product-led pages (“create now”) + “trackable QR codes” positioning. ([Hovercode][9])
* pSEO via many generator variants (logo, circle, etc.). ([Hovercode][10])
**Weaknesses**
* Opportunity to beat them with **comparison pages + GA4 instrumentation + bulk workflows**.
### 5) Scanova — `scanova.io`
**Best at**
* Strong **feature pages**: dynamic, tracking, security, landing pages (good enterprise pitch). ([Scanova][11])
**Weaknesses**
* Many blogs are long; you can win snippets with **structured templates + FAQs + exact steps + schema**.
### 6) Flowcode — `flowcode.com`
**Best at**
* Owns “offline conversions + analytics” narrative (enterprise). ([flowcode.com][12])
**Weaknesses**
* Often skewed to demos; you can capture SMB/free intent and upgrade later.
### 7) QRCodeChimp — `qrcodechimp.com`
**Best at**
* Huge template catalog (menus, forms, cards, etc.) + GA integration content. ([QR Code Chimp][13])
**Weaknesses**
* Template sprawl risks thin pages—beat them on **quality thresholds + tighter topical clusters**.
### 8) ME-QR — `me-qr.com`
**Best at**
* Aggressive pSEO for types (PDF/Instagram/WhatsApp/Spotify). ([me-qr.com][14])
**Weaknesses**
* Many pages feel commodity; you can differentiate with **better UX + security + analytics clarity**.
### 9) Canva / Adobe Express (generalists)
* Canva and Adobe rank on “free QR code generator” intent via ecosystem pull. ([Canva][15])
**Your play**: dont “out-brand” them—**out-specialize** on dynamic/tracking/bulk/API and win long-tail + mid-tail.
### 10) Barcode generators (for your “Barcode Generator” gold mine)
* TEC-IT (embed + backlink requirement) and Barcodes Inc (hardware upsell). ([Free Online Barcode Generator by TEC-IT][5])
**Your play**: best-in-class UX + formats + bulk + API docs + “barcode vs QR” education to route users into QR analytics.
---
## C) Keyword clusters + priority order (explain why)
### Intent model (how to cluster)
* **Do / Generate (tool intent)**: “X QR code generator”, “bulk”, “PDF to QR”, “WiFi QR”, “Instagram QR”, “WhatsApp QR”.
* **Decide (commercial investigation)**: “dynamic vs static”, “trackable QR codes”, “best QR code generator”, “QR code analytics”.
* **Implement (technical)**: “QR code API”, “track QR codes in GA4”, “UTM QR code”, “bulk QR from CSV / Sheets”.
* **Navigate (platform-native)**: “Google QR code generator”, “Spotify code generator”, “Instagram QR code”.
### Priority ladder (P0 → P2)
**P0 (launch first; fastest to rank + high upsell value)**
1. **WhatsApp QR Code Generator** (SV 180 / KD 17 in your list) → high intent + low KD + SMB conversion path.
2. **Instagram QR Code Generator** (SV 440 / KD 23) → same logic + add “IG has native QR; heres branded + tracked campaigns”. ([Instagram Hilfe][16])
3. **vCard QR Code Generator** (SV 180 / KD 24) → business use case; great signup driver.
4. **QR Code Analytics** (SV 135 / KD 24) → *your core differentiator*; becomes the internal-link destination from every tool page.
5. **Trackable QR Codes** (SV 135 / KD 0) → perfect wedge term; map to a commercial page that demonstrates tracking dashboard and “dynamic”.
6. **Barcode Generator** (58k / KD 22) → big traffic engine; route to QR features + analytics.
**P1 (build authority + revenue features)**
* **Bulk QR Code Generator** (SV 360 / KD 33)
* **QR Code Tracking** (SV 320 / KD 37) (map carefully vs “analytics”)
* **WiFi QR Code Generator** (SV 1,400 / KD 34)
* **PDF to QR Code Generator** (SV ~260 / KD 36, CPC high)
* **Google QR Code Generator** (SV 8k) (capture via “Chrome static QR” + upsell). ([Google Hilfe][2])
**P2 (long-term mid/high competition)**
* **Dynamic QR Code Generator** (SV 1,200 / KD 43)
* **Free QR Code Generator** (SV 34,400 / KD 34)
* **QR code maker** (SV 52k / KD 47)
* **QR Code Generator** (SV 370k) — pillar target supported by everything above.
### Cannibalization rule (critical)
* **One primary intent per page.** Example mapping:
* `/features/qr-code-analytics/` = “qr code analytics” (feature/commercial)
* `/learn/qr-code-tracking/` = “qr code tracking” (educational/how it works + GA4)
* `/tools/trackable-qr-code-generator/` = “trackable qr codes” (tool + demo dashboard)
---
## D) Recommended sitemap / IA (with URL examples)
### Core structure (scalable + pSEO-safe)
**1) Tools (transactional)**
* `/qr-code-generator/` (core tool hub, not a blog post)
* `/tools/vcard-qr-code-generator/`
* `/tools/whatsapp-qr-code-generator/`
* `/tools/instagram-qr-code-generator/`
* `/tools/wifi-qr-code-generator/`
* `/tools/pdf-to-qr-code-generator/`
* `/tools/bulk-qr-code-generator/`
* `/barcode-generator/` (separate category; include QR/2D + 1D)
**2) Features (commercial)**
* `/features/dynamic-qr-codes/`
* `/features/qr-code-analytics/`
* `/features/qr-code-campaigns/` (folders, tags, exports)
* `/features/custom-domain/`
* `/features/teams-roles/`
* `/features/security-anti-phishing/` (trust wedge; see “quishing”). ([Der Guardian][4])
**3) Integrations (high-intent + linkable)**
* `/integrations/google-analytics-4/`
* `/integrations/hubspot/`
* `/integrations/zapier/`
* `/integrations/shopify/`
(Ship GA4 first; it supports your “tracking” narrative.)
**4) Learn Hub (educational; supports rankings + conversions)**
* `/learn/dynamic-vs-static-qr-codes/`
* `/learn/how-to-track-qr-codes-in-ga4/`
* `/learn/qr-code-size-guide/`
* `/learn/qr-code-error-correction/`
* `/learn/google-qr-code-generator/` (Chromes built-in QR + limitations). ([Google Hilfe][2])
* `/learn/spotify-code-generator/` (Spotify Codes explainer + CTA to your tool). ([SpotifyCodes][17])
**5) Templates / Use cases (pSEO with guardrails)**
* `/templates/restaurant-menu-qr/`
* `/templates/business-card-qr/`
* `/templates/event-check-in-qr/`
Each template must include: examples, copy/paste CTAs, recommended QR type, tracking setup, and links to the tool.
### Breadcrumb + internal linking rules (hub-and-spoke)
* **Tool pages** link up to:
* `/features/qr-code-analytics/`
* `/features/dynamic-qr-codes/`
* `/learn/dynamic-vs-static-qr-codes/`
* the **closest** templates + GA4 integration (where relevant)
* **Learn pages** link down to:
* the *single best-matching tool page* (primary CTA)
* 24 related learn pages (cluster reinforcement)
* **Integrations** link to:
* analytics feature + tracking learn guide + relevant tool pages
---
## E) “Wedge” plan: what to launch first to rank within 3060 days
### Launch set (minimum viable topical authority)
**Week 13 shipping goal: 8 pages that create a ranking flywheel**
**Tool pages (P0)**
1. `/tools/whatsapp-qr-code-generator/` (KD 17)
2. `/tools/instagram-qr-code-generator/` (KD 23)
3. `/tools/vcard-qr-code-generator/` (KD 24)
4. `/tools/trackable-qr-code-generator/` (KD 0 term → commercial wedge)
5. `/barcode-generator/` (traffic engine)
**Feature + Learn pages (conversion + trust)**
6) `/features/qr-code-analytics/` (your core differentiator)
7) `/learn/dynamic-vs-static-qr-codes/` (decision content)
8) `/learn/google-qr-code-generator/` (steal “Google/Chrome” demand; Chrome is static URL sharing). ([Google Hilfe][2])
### Why this ranks fast on a new domain
* Low-KD type terms are less “brand dominated” than head terms.
* Every tool page naturally links to analytics + dynamic, so **internal PageRank concentrates** on your money features.
* “Google QR code generator” content can win featured snippets because its step-based and grounded in official Chrome documentation. ([Google Hilfe][2])
---
## F) 90-day execution roadmap (week-by-week)
### Weeks 12: Foundations (technical + tracking + SEO hygiene)
* **Tech SEO**
* Set up GSC + GA4 (or PostHog) + server-side event pipeline for “QR created / downloaded / scan events”.
* Define **indexation policy**: which templates get indexed, which are noindex.
* Implement: XML sitemaps by type (`/sitemap-tools.xml`, `/sitemap-learn.xml`), robots, canonicals, hreflang plan (even if EN-only now).
* **Schema baseline**
* Organization, WebSite, BreadcrumbList sitewide.
* SoftwareApplication/WebApplication on core tool hub.
* **Information architecture**
* Ship nav for Tools / Features / Learn / Pricing / API.
### Week 3: Ship the wedge tool pages (P0)
* Publish WhatsApp / Instagram / vCard / Trackable tool pages.
* Each ships with: FAQ, examples, “Static vs Dynamic” block, “Enable analytics” CTA, and internal links to `/features/qr-code-analytics/`.
### Week 4: Ship the analytics feature hub + dynamic feature hub
* `/features/qr-code-analytics/` + `/features/dynamic-qr-codes/`
* Add product screenshots/GIFs and a simple “How tracking works” diagram (dynamic redirect → logging → dashboard).
### Week 5: Learn cluster for decision + “Google QR”
* `/learn/dynamic-vs-static-qr-codes/`
* `/learn/google-qr-code-generator/` (include “Chrome creates QR for a page” and limitations). ([Google Hilfe][2])
### Week 6: Barcode Generator tool + “Barcode vs QR” guide
* Launch `/barcode-generator/` + `/learn/barcode-vs-qr-code/` to route barcode traffic into QR use cases.
* Add bulk export formats and “print quality” section to compete with incumbents. ([Free Online Barcode Generator by TEC-IT][5])
### Week 7: Bulk + PDF tools (P1)
* `/tools/bulk-qr-code-generator/` (CSV upload; align with SERP expectations like “download ZIP”). ([quickchart.io][18])
* `/tools/pdf-to-qr-code-generator/` (CPC-heavy query → strong conversion)
### Week 8: GA4 integration page (linkable asset)
* `/integrations/google-analytics-4/`
* Companion guide: `/learn/how-to-track-qr-codes-in-ga4/` (UTMs, events, attribution).
### Week 9: Authority pieces (start the pillar support)
Publish 2 of these 5 (see section below):
* “QR Code Size Guide”
* “QR Code Error Correction Explained”
* “UTM Builder for QR Campaigns”
* “QR Code Security / Quishing Prevention”
* “QR Code Analytics Benchmarks”
### Week 10: pSEO expansion (controlled)
* Add 1020 additional `/tools/{type}/` pages (WiFi, email, SMS, etc.) only if they meet your thin-content threshold.
* Add 1020 `/templates/` pages tied to real use cases.
### Week 11: Comparisons (conversion-focused)
* `/compare/qr-code-generator-vs-canva/`
* `/compare/qr-code-generator-vs-qrcode-monkey/`
* `/compare/dynamic-qr-code-generators/` (listicle with your wedge terms)
### Week 1213: Iterate based on GSC data
* Optimize pages with impressions but low CTR (titles/meta).
* Expand FAQs to match PAA.
* Strengthen internal links from high-impression pages to money pages.
---
## G) Page briefs for the top 5 money pages (H1, sections, schema, CTA, internal links)
### 1) Dynamic QR Code Generator
**URL:** `/features/dynamic-qr-codes/` (feature) + optional `/tools/dynamic-qr-code-generator/` (tool demo)
**Primary keyword:** dynamic qr code generator
**H1:** Dynamic QR Code Generator (Editable + Trackable)
**Sections (order matters)**
* What is a dynamic QR code? (vs static)
* Edit destination after printing (URL, file, page)
* Tracking/analytics overview (scans, time, location, device)
* Use cases (menus, flyers, events, packaging)
* How it works (redirect + logging)
* Pricing preview + free tier
* FAQ (Do they expire? Can I change the URL? Can I export data?)
**Schema**
* FAQPage
* SoftwareApplication (or WebApplication)
* BreadcrumbList
**Primary CTA**
* “Create a dynamic QR code” (signup)
**Internal links**
* To `/features/qr-code-analytics/`, `/learn/dynamic-vs-static-qr-codes/`, `/integrations/google-analytics-4/`
> Competitor pattern to beat: strong gating + feature ladder is common. ([qr-code-generator.com][1])
---
### 2) QR Code Analytics
**URL:** `/features/qr-code-analytics/`
**Primary keyword:** qr code analytics
**H1:** QR Code Analytics: Track Scans, Measure Campaign ROI
**Sections**
* What you can measure (total/unique scans, geo, device, time)
* Campaign organization (folders/tags, UTM conventions)
* Export + integrations (GA4 first)
* Dashboards (examples: restaurant menu, event check-in, retail)
* Data accuracy & privacy notes
* FAQ (“Can I track a static QR?” → explain dynamic requirement)
**Schema**
* FAQPage
* SoftwareApplication
* BreadcrumbList
**CTA**
* “Enable analytics on your QR code” (upgrade nudges)
**Internal links**
* From **every tool page** (sticky sidebar “Track scans with Analytics”)
* To `/learn/how-to-track-qr-codes-in-ga4/`
> This is exactly what SaaS competitors highlight for upsell. ([flowcode.com][12])
---
### 3) Bulk QR Code Generator
**URL:** `/tools/bulk-qr-code-generator/`
**Primary keyword:** bulk qr code generator
**H1:** Bulk QR Code Generator (CSV Upload → Download ZIP)
**Sections**
* Upload CSV / paste data / Google Sheets import (later)
* Output formats (PNG/SVG/PDF), naming conventions
* Dynamic vs static toggle per row (upsell!)
* Common workflows: inventory labels, invites, coupons
* QA: scan testing, error correction, print sizing
* FAQ
**Schema**
* FAQPage
* HowTo (only if you include step-by-step with images)
**CTA**
* “Generate bulk QR codes” + secondary “Enable tracking for all”
**Internal links**
* To `/features/qr-code-analytics/` + `/features/dynamic-qr-codes/`
> SERPs often expect “free bulk + zip”; match that intent. ([QR Explore][19])
---
### 4) vCard QR Code Generator
**URL:** `/tools/vcard-qr-code-generator/`
**Primary keyword:** vCard qr code generator
**H1:** vCard QR Code Generator (Digital Business Card)
**Sections**
* vCard fields + preview (VCF standard)
* iOS/Android compatibility + best practices
* Static vs dynamic vCard (edit contact later)
* Examples: sales reps, events, storefront QR
* CTA: “Add scan tracking to your business cards”
* FAQ (works on Android/iOS; does it expire; can I add photo; etc.)
**Schema**
* FAQPage
* SoftwareApplication
**CTA**
* “Create vCard QR” + upsell “Track scans / update later”
**Internal links**
* To `/learn/dynamic-vs-static-qr-codes/` + analytics feature
---
### 5) QR Code API (developer money page)
**URL:** `/features/qr-code-api/` + `/docs/api/`
**Primary keyword:** qr code api, qr code generator api
**H1:** QR Code API (Generate QR Codes at Scale)
**Sections**
* Authentication, endpoints, rate limits
* Generate static/dynamic, bulk endpoints, webhooks (scan events)
* Code samples (JS/Python/cURL)
* Compliance + uptime
* Pricing tiers
**Schema**
* SoftwareApplication (feature page)
* TechArticle (docs pages)
**CTA**
* “Get API key” / “Start trial”
**Internal links**
* From bulk generator + analytics pages
---
## H) Risks + mitigation (cannibalization, programmatic pitfalls, E-E-A-T, index bloat)
### 1) Keyword cannibalization (very likely in this niche)
**Risk:** “qr code tracking”, “trackable qr codes”, “qr code analytics” collapse into the same intent.
**Mitigation:** hard-map intents:
* Analytics = feature/commercial
* Tracking = learn/how-to + GA4
* Trackable QR = tool landing with demo dashboard
### 2) Programmatic SEO thin pages / index bloat
**Risk:** hundreds of near-identical “{type} QR generator” pages get ignored/deindexed.
**Mitigation (hard rules)**
* Index only pages that include **unique elements**:
* type-specific fields + validation (real tool)
* 23 examples
* type-specific FAQs
* type-specific tracking use case
* **Noindex**: parameter pages, empty states, duplicate locale stubs, search/filter pages.
### 3) Trust & QR scam concerns (reputation risk, but also opportunity)
**Risk:** Users fear scanning QR codes; Google may reward safety content.
**Mitigation:** ship “Security” feature page + learn content about safe scanning and link previews, referencing real-world scam patterns. ([Der Guardian][4])
### 4) Over-reliance on “Google QR Code Generator” traffic
**Risk:** users only want Chromes built-in static QR and bounce.
**Mitigation:** page structure: “How to do it in Chrome” (satisfy intent) → “When you need dynamic + analytics” (convert). ([Google Hilfe][2])
### 5) E-E-A-T gap vs incumbents
**Risk:** new domain lacks credibility.
**Mitigation**
* Publish 23 “benchmarks / research” assets with original data (even small): scan-rate benchmarks, print-size testing, or campaign case studies.
* Add transparent pricing, uptime, privacy policy, and author/editor pages for Learn content.
---
If you tell me your **target market (US vs DACH vs global), language (EN/DE), and monetization (freemium vs trials)**, I can *tighten the sitemap + 90-day calendar* so it perfectly matches your rollout (especially internationalization + URL strategy).
[1]: https://www.qr-code-generator.com/?utm_source=chatgpt.com "QR Code Generator | Create Your Free QR Codes"
[2]: https://support.google.com/chrome/answer/10051760?co=GENIE.Platform%3DDesktop&hl=en&utm_source=chatgpt.com "Share pages in Chrome - Computer"
[3]: https://www.qr-code-generator.com/solutions/?utm_source=chatgpt.com "QR Code Solution for Every Purpose"
[4]: https://www.theguardian.com/money/2025/may/25/qr-code-scam-what-is-quishing-drivers-app-phone-parking-payment?utm_source=chatgpt.com "'Pay here': the QR code 'quishing' scam targeting drivers"
[5]: https://barcode.tec-it.com/en?utm_source=chatgpt.com "Free Online Barcode Generator: Create Barcodes for Free!"
[6]: https://www.qrcode-monkey.com/?utm_source=chatgpt.com "QRCode Monkey - The free QR Code Generator to create ..."
[7]: https://www.qrcode-monkey.com/de/qr-code-service/?utm_source=chatgpt.com "QR Code API for Static Codes"
[8]: https://www.the-qrcode-generator.com/?utm_source=chatgpt.com "The QR Code Generator (TQRCG): Create Free QR Codes"
[9]: https://hovercode.com/?utm_source=chatgpt.com "QR Code Generator | Create Free Dynamic QR Codes"
[10]: https://hovercode.com/circle-qr-code-generator/?utm_source=chatgpt.com "Generate circle QR codes (no sign up required)"
[11]: https://scanova.io/features/?utm_source=chatgpt.com "Powerful features for all QR Code use cases"
[12]: https://www.flowcode.com/product/analytics?utm_source=chatgpt.com "Gain insight into your offline marketing with in-depth Analytics"
[13]: https://www.qrcodechimp.com/qr-code-analytics-guide/?utm_source=chatgpt.com "QR Code Analytics: Track, Analyze & Optimize Your ..."
[14]: https://me-qr.com/qr-code-generator/pdf?srsltid=AfmBOooK1o7kkjaSizlEOWcEcYcDWfKhZuuM3XvrJGQlm2xdiTbw1exS&utm_source=chatgpt.com "Create QR Code For PDF FREE"
[15]: https://www.canva.com/qr-code-generator/?utm_source=chatgpt.com "Free QR Code Generator - Create QR codes with ease"
[16]: https://help.instagram.com/925529167647849/?utm_source=chatgpt.com "Find and customize the QR code of your Instagram profile"
[17]: https://www.spotifycodes.com/?utm_source=chatgpt.com "Spotify Codes"
[18]: https://quickchart.io/bulk-qr-code-generator/?utm_source=chatgpt.com "Bulk QR Code Generator | Custom colors and logo, free"
[19]: https://qrexplore.com/generate/?utm_source=chatgpt.com "Bulk QR Code Generator"

View File

@@ -1,156 +0,0 @@
SEO Opportunity Report & Implementation Plan (Jan 2026)
1. Executive Summary
An analysis of the provided Google Keyword Planner data (Jan 22, 2026) reveals significant low-competition, high-volume traffic opportunities that were previously untapped. We have immediately capitalized on the Barcode opportunity and have a clear path to capture Custom QR intent.
2. Key Data Findings ("Hidden Gems")
We identified three specific clusters where search volume is high but competition is exceptionally low.
A. The "QR Barcode" Anomaly (Gold Mine) 🏆
Users are confused about the terminology, searching for "qr barcode" or "bar code generator" instead of just "barcode".
Keywords: qr barcode, bar code generator, scan code generator
Volume: 10k 100k (High)
Competition: Low / Medium
Opportunity: Most competitors optimize for "Barcode Generator". By targeting the "wrong" terms users actually type, we can win easy traffic.
B. The "Free" Intent
High volume, but users are specifically looking for "free" and "no signup".
Keyword: free qr code generator (100k 1M)
Keyword: qr code generator free (100k 1M)
Opportunity: Aggressive targeting of these exact match phrases on the homepage metadata.
C. The "Custom" Gap
Users want customization but don't always use the term "design".
Keyword: custom qr code generator
Volume: 1k 10k
Competition: Low
Current Status: MISSING. We do not have a dedicated landing page for this high-intent cluster.
3. Actions Already Implemented ✅
We have immediately updated the metadata to capture the traffic identified in findings A and B.
1. Barcode Generator Optimization
File:
src/app/(marketing)/tools/barcode-generator/page.tsx
Action: Updated <title> and meta description.
New Target: "QR Barcode" and "Bar Code Generator".
Why: To capture the 100k+ users searching for these specific variants.
2. Homepage Optimization
File:
src/app/(marketing)/page.tsx
Action: Injected high-volume keyword tags.
New Target: qr generator, free qr code generator, custom qr code generator.
Why: To signal relevance to Google for the broadest "head terms".
4. Implementation Plan: "Custom QR Code" Landing Page 🚀
To capture the 1k10k/month users searching for "custom qr code generator" (Finding C), we need a dedicated landing page. This page will focus on design features (colors, logos, frames) rather than just "generating" a code.
Phase 1: Page Structure (New File)
Path: src/app/(marketing)/tools/custom-qr-code-generator/page.tsx
Content Strategy:
H1: "Free Custom QR Code Generator with Logo & Colors"
Hero: Visual emphasis on beautiful codes, not black-and-white ones.
Live Editor Demo: (Reuse existing QRCodeGeneratorClient but pre-opened "Design" tab).
Features Section:
"Add Your Logo"
"Custom Colors & Gradients"
"Unique Frames & Shapes"
FAQ: Targeting specific "custom" questions (vector formats, scanning reliability).
Phase 2: Internal Linking
Add link to /tools/custom-qr-code-generator in the Footer and Main Navigation under "Tools".
Link from Homepage "Features" section.
Phase 3: Metadata Strategy
title: 'Custom QR Code Generator Add Logo, Colors & Frames',
description: 'Create unique custom QR codes with your logo, colors, and frames. Free online customizer with high-quality vector download (SVG/PNG).',
keywords: ['custom qr code generator', 'qr code with logo', 'qr code design', 'creative qr code', 'branded qr code']
5. Next Steps
Approval: Confirm this plan to build the custom-qr-code-generator page.
Execution: I will create the directory and page file.
Verify: Check that the new page renders and is linked correctly.
new findings
Das ist super, dass du die Dateien hochgeladen hast! Denn: Du hast die Daten bereits.
Ich habe deine Excel-Listen analysiert und das Problem gefunden: In deiner Ansicht im Tool hieß die Spalte vielleicht anders oder war versteckt, aber in der Datei heißt sie Avg. monthly searches. Das ist exakt das Suchvolumen, das du gesucht hast.
Hier sind die echten "Schätze" direkt aus deiner Datei, gefiltert nach meiner Strategie:
1. Die "Low Hanging Fruits" (Viel Traffic, fast 0 Konkurrenz)
Das sind die Keywords, bei denen du SOFORT ranken kannst, weil die großen Player schlafen.
call qr code
Suchvolumen: 50.000 (!)
Wettbewerb-Index: 0 (Das ist extrem selten! Absolut gar keine Konkurrenz auf Google Ads).
Idee: Mach eine Seite "Telefon-QR-Code erstellen". Leute wollen, dass beim Scannen direkt eine Nummer gewählt wird. Das ist eine riesige Nische.
generate website qr code
Suchvolumen: 50.000
Wettbewerb-Index: 17 (Sehr gering).
CPC: 6,16 € (Das ist der Jackpot: Viel Traffic, wenig Konkurrenz UND hoher Wert).
bar code generator
Suchvolumen: 500.000
Wettbewerb-Index: 3 (Fast nichts).
Bestätigt meine frühere Analyse: Das ist dein Traffic-Bringer.
2. Die "Goldminen" (Hoher Klickpreis / High Ticket)
Hier zahlen Firmen richtig Geld. Wenn du hier rankst, sind die Nutzer sehr wertvoll.
manage qr codes
CPC (Gebot): 18,77 €
Das suchen Firmen, die viele Codes verwalten müssen (Agenturen, Großkunden). Perfekt für dein "Pro"-Abo.
dynamic code generator
CPC: 15,62 €
Das Kern-Keyword für dein Business-Modell.
qr tracking
CPC: 12,51 €
Wettbewerb ist hier höher, aber der Umsatz pro Kunde auch.
3. Der Trend-Tipp (+900% Wachstum)
free bulk qr code generator (Massen-Erstellung)
Trend: +900%
Viele Leute suchen nach einer Möglichkeit, 100 Codes auf einmal zu erstellen (z.B. für Mitarbeiterausweise oder Inventar). Wenn du so ein Feature ("Bulk Upload") hast oder baust, bist du Marktführer.
Was du jetzt tun musst (in Excel oder Google Sheets):
Du brauchst das Tool gar nicht mehr zwingend. Du kannst einfach in deiner Datei filtern:
Such die Spalte Avg. monthly searches -> Das ist dein Suchvolumen.
Such die Spalte Competition (indexed value) -> Das ist die Härte (0-100).
Filter: Avg. monthly searches > 1000 UND Competition (indexed value) < 20.
Damit findest du genau die oben genannten Chancen. Soll ich dir für eines dieser Keywords (z.B. den "Call QR Code" mit Index 0) eine Gliederung schreiben?

View File

@@ -1,743 +0,0 @@
Issues
/
Multiple H1 tags
Why and how to fix
Submit to IndexNow
Create new issue
All URLs
Pages
Resources
Content
Links
Redirects
Indexability
Sitemaps
Ahrefs metrics
Word or phrase
URL
Advanced filter
Crawl history
Hide chart
12 Jan
13 Jan
13 Jan
14 Jan
14 Jan
0
1
2
3
4
All filter results
All filter results
3
Lost from filter results
0
Lost
0
Patches
Changes: Don't show
Columns
Export
PR
URL
Organic traffic
HTTP status code
Depth
H1
H1 length
No. of H1
Is indexable page
40
html
QR Master: Dynamic QR Generator
https://www.qrmaster.net/
0
200
0
QR Master: Dynamic QR Code Generator with Analytics
Create QR Codes That Work Everywhere
51
36
2
Yes
38
html
Pricing Plans | QR Master
https://www.qrmaster.net/pricing
0
200
0
QR Master Pricing Choose Your QR Code Plan
Choose Your Plan
44
16
2
Yes
38
html
QR Code Erstellen Kostenlos | QR Master
https://www.qrmaster.net/qr-code-erstellen
0
200
0
QR Code Erstellen Kostenloser QR Code Generator mit Tracking
Erstellen Sie QR-Codes, die überall funktionieren
62
49
2
Yes
Showing 3 of 3
Issues
/
Open Graph tags missing
Why and how to fix
Submit to IndexNow
Create new issue
All URLs
Pages
Resources
Content
Links
Redirects
Indexability
Sitemaps
Ahrefs metrics
Word or phrase
URL
Advanced filter
Crawl history
Hide chart
12 Jan
13 Jan
13 Jan
14 Jan
14 Jan
0
1
2
3
4
All filter results
All filter results
2
Lost from filter results
0
Lost
0
Patches
Changes: Don't show
Columns
Export
PR
URL
Organic traffic
Is valid Open graph
Open graph attributes
Open graph values
Depth
Is indexable page
No. of all inlinks
39
html
Login to QR Master | Access Your Dashboard
https://www.qrmaster.net/login
0
0
Yes
38
38
html
Create Free Account | QR Master
https://www.qrmaster.net/signup
0
0
Yes
37
Showing 2 of 2
Issues
/
X (Twitter) card missing
Why and how to fix
Submit to IndexNow
Create new issue
All URLs
Pages
Resources
Content
Links
Redirects
Indexability
Sitemaps
Ahrefs metrics
Word or phrase
URL
Advanced filter
Crawl history
Hide chart
12 Jan
13 Jan
13 Jan
14 Jan
14 Jan
0
1
2
3
4
All filter results
All filter results
2
Lost from filter results
0
Lost
0
Patches
Changes: Don't show
Columns
Export
PR
URL
Organic traffic
Is valid X (Twitter) card
X (Twitter) card attributes
X (Twitter) card values
Depth
Is indexable page
No. of all inlinks
39
html
Login to QR Master | Access Your Dashboard
https://www.qrmaster.net/login
0
0
Yes
38
38
html
Create Free Account | QR Master
https://www.qrmaster.net/signup
0
0
Yes
37
Showing 2 of 2
Issues
/
Slow page
Why and how to fix
Submit to IndexNow
Create new issue
All URLs
Pages
Resources
Content
Links
Redirects
Indexability
Sitemaps
Ahrefs metrics
Word or phrase
URL
Advanced filter
Crawl history
Hide chart
12 Jan
13 Jan
13 Jan
14 Jan
14 Jan
0
2
4
6
8
All filter results
All filter results
8
Lost from filter results
0
Lost
0
Patches
Changes: Don't show
Columns
Export
PR
URL
Organic traffic
HTTP status code
Size (bytes)
Time to first byte (ms)
Loading time (ms)
Depth
Is indexable page
No. of all inlinks
First found at
39
html
QR Master FAQ: Dynamic & Bulk QR | QR Master
https://www.qrmaster.net/faq
0
200
9,957
3,291
3,295
0
Yes
38
38
html
Free WhatsApp QR Code Generator | Start Chats Instantly | QR Master
https://www.qrmaster.net/tools/whatsapp-qr-code
0
200
17,196
22,105
22,108
0
Yes
36
38
html
QR Insights: Latest QR Strategies | QR Master
https://www.qrmaster.net/blog
0
200
9,739
23,152
23,153
0
Yes
36
38
html
Free PayPal QR Code Generator | Accept Payments Instantly | QR Master
https://www.qrmaster.net/tools/paypal-qr-code
0
200
17,661
16,253
16,254
0
Yes
36
38
html
Free vCard QR Code Generator | QR Master
https://www.qrmaster.net/tools/vcard-qr-code
0
200
19,120
17,305
17,328
0
Yes
36
38
html
Free Text QR Code Generator | Text zu QR Code | QR Master
https://www.qrmaster.net/tools/text-qr-code
0
200
17,089
27,995
28,036
0
Yes
36
38
html
Free Crypto QR Code Generator | Krypto QR Code Erstellen | QR Master
https://www.qrmaster.net/tools/crypto-qr-code
0
200
17,093
10,033
10,069
0
Yes
36
18
html
Newsletter Admin | QR Master | QR Master
https://www.qrmaster.net/newsletter
0
200
7,334
11,826
11,830
1
No
36
https://www.qrmaster.net/
Showing 8 of 8
Issues
/
Structured data has schema.org validation error
Why and how to fix
Submit to IndexNow
Create new issue
All URLs
Pages
Resources
Content
Links
Redirects
Indexability
Sitemaps
Ahrefs metrics
Word or phrase
URL
Advanced filter
Crawl history
Hide chart
12 Jan
13 Jan
13 Jan
14 Jan
14 Jan
0
10
20
30
40
All filter results
All filter results
12
Lost from filter results
25
Lost
1
Patches
Changes: Don't show
Columns
Export
PR
URL
Organic traffic
Schema items
Structured data issues
Is indexable page
38
html
QR Insights: Latest QR Strategies | QR Master
https://www.qrmaster.net/blog
0
BreadcrumbList
Organization
WebSite
Schema.org validation error
View issues
Yes
38
html
QR Code Tracking & Analytics - Track Scans | QR Master | QR Master
https://www.qrmaster.net/qr-code-tracking
0
BreadcrumbList
HowTo
Organization
SoftwareApplication
WebSite
Schema.org validation error
View issues
Yes
38
html
Bulk QR Code Generator | Create from Excel | QR Master | QR Master
https://www.qrmaster.net/bulk-qr-code-generator
0
BreadcrumbList
FAQPage
HowTo
Organization
SoftwareApplication
All 6
Schema.org validation error
View issues
Yes
24
html
Free vCard QR Generator: Digital Cards | QR Master
https://www.qrmaster.net/blog/vcard-qr-code-generator
0
BlogPosting
BreadcrumbList
HowTo
Organization
WebSite
Schema.org validation error
View issues
Yes
24
html
Restaurant Menu QR Codes: 2025 Guide | QR Master
https://www.qrmaster.net/blog/qr-code-restaurant-menu
0
BlogPosting
BreadcrumbList
HowTo
Organization
WebSite
Schema.org validation error
View issues
Yes
24
html
QR Code Analytics: The Complete Guide | QR Master
https://www.qrmaster.net/blog/qr-code-analytics
0
BlogPosting
BreadcrumbList
HowTo
Organization
WebSite
Schema.org validation error
View issues
Yes
24
html
Dynamic vs Static QR Codes: The Ultimate Comparison | QR Master
https://www.qrmaster.net/blog/dynamic-vs-static-qr-codes
0
BlogPosting
BreadcrumbList
Organization
WebSite
Schema.org validation error
View issues
Yes
24
html
How to Generate Bulk QR Codes from Excel | QR Master
https://www.qrmaster.net/blog/bulk-qr-code-generator-excel
0
BlogPosting
BreadcrumbList
HowTo
Organization
WebSite
Schema.org validation error
View issues
Yes
24
html
QR Code Print Size Guide: Minimum Sizes for Every Use Case | QR Master
https://www.qrmaster.net/blog/qr-code-print-size-guide
0
BlogPosting
BreadcrumbList
Organization
WebSite
Schema.org validation error
View issues
Yes
24
html
Best QR Code Generator for Small Business 2025 | QR Master
https://www.qrmaster.net/blog/qr-code-small-business
0
BlogPosting
BreadcrumbList
Organization
WebSite
Schema.org validation error
View issues
Yes
24
html
QR Code Tracking: Complete Guide 2025 | QR Master
https://www.qrmaster.net/blog/qr-code-tracking-guide-2025
0
BlogPosting
BreadcrumbList
HowTo
Organization
WebSite
Schema.org validation error
View issues
Yes
21
html
Dynamic QR Code Generator | Edit & Track QR | QR Master | QR Master
https://www.qrmaster.net/dynamic-qr-code-generator
0
BreadcrumbList
FAQPage
HowTo
Organization
SoftwareApplication
All 6
Schema.org validation error
View issues
Yes
Showing 12 of 12

View File

@@ -1,68 +0,0 @@
# SEO Remaining Tasks
This document contains a list of all SEO issues identified in the Ahrefs and Seobility reports that still need to be addressed in the codebase.
## 1. Content & Metadata Issues
- [ ] **Fix Missing H1 Tags on Core Pages**
- Affected Pages: `/`, `/pricing`, `/login`, `/signup`, `/faq`, `/privacy`, `/newsletter`, `/create`.
- **Issue:** These pages are Client Side Rendered (CSR) or lack a server-side `<h1>` tag in the initial HTML payload.
- **Action:** Add an `<h1>` (visible or `sr-only`) to the Server Component or ensure the Client Component renders it immediately.
- [ ] **Fix Low Word Count / Thin Content**
- Affected Pages: `/`, `/pricing`, `/login`, `/signup`, `/faq`, `/privacy`.
- **Issue:** Crawlers see 0 words on these pages because the content is rendered via JavaScript (`use client`).
- **Action:** Implement Server Side Rendering (SSR) for the main content or add `sr-only` semantic fallbacks for crawlers.
- [ ] **Expand Meta Descriptions**
- Affected Pages: `/`, `/pricing`, `/login`, `/signup`, `/newsletter`, `/privacy`, `/faq`, `/qr-code-erstellen`, Blog entries.
- **Issue:** Meta descriptions are too short (< 80 characters) or duplicates.
- **Action:** Update `generateMetadata` in `page.tsx` files to have descriptions between 110-160 characters.
- [ ] **Fix Page Titles**
- Affected Pages: `/qr-code-erstellen`, Blog posts.
- **Issue:** Titles are too long (> 60-70 characters) or have keyword stuffing/repetition.
- **Action:** Shorten titles to be concise and click-worthy, avoiding simple concatenation of keywords.
- [ ] **Fix Duplicate Content & Titles**
- Affected Pages: `/pricing`, `/newsletter`, `/login`, `/signup`.
- **Issue:** These pages likely share the same metadata or layout without unique content in the crawler's eyes.
- **Action:** Ensure each page has unique `title` and `description` in `generateMetadata`.
## 2. Technical SEO
- [ ] **Fix 307 Redirects to 301**
- **Issue:** Blog posts and legacy URLs are redirecting with status `307` (Temporary) instead of `301` (Permanent).
- **Affected Paths:**
- `/blog/vcard-qr-code-generator` -> `/create`
- `/blog/qr-code-restaurant-menu` -> `/dynamic-qr-code-generator`
- `/blog/bulk-qr-code-generator` -> `/bulk-qr-code-generator`
- **Action:** Locate these redirects (likely in `next.config.js` or `middleware.ts` or component logic) and change status to 301.
- [ ] **Fix Indexing of Protected/Private Pages**
- **Issue:** Ahrefs is flagging `/pricing` as "Indexable" but likely encountering issues. Verify if `/pricing` should be indexed.
- **Action:** Ensure public pages like Pricing are NOT in `(app)` group which has `noindex` in layout, or override the `robots` meta in `pricing/page.tsx`.
- [ ] **Fix "No Outgoing Links"**
- **Issue:** Crawlers see pages as dead ends because links are injected via JS.
- **Action:** Ensure standard `<a>` or `Link` tags are present in the initial HTML.
## 3. Link Profile
- [ ] **Improve Internal Link Texts**
- **Issue:** "Click here" or full URL used as anchor text.
- **Action:** Use descriptive keywords for links (e.g., "See our pricing" instead of "Click here").
- [ ] **Fix Alternate Links (hreflang)**
- **Issue:** Mismatch in `hreflang` or missing self-referencing canonicals.
- **Action:** Verify `alternates` configuration in `layout.tsx` or `page.tsx` matches the actual URL structure.
## 4. Performance & Images
- [ ] **Optimize Large Images**
- **Files:** `/blog/1-boy.png`, `/blog/2-body.png` (~4MB each).
- **Action:** Convert to WebP/AVIF and resize to < 500KB.
- [ ] **Improve Page Speed**
- **Issue:** Response time for `/qr-code-erstellen` is slow.
- **Action:** Check for expensive server-side operations or optimize database queries.

View File

@@ -1,22 +0,0 @@
# Seobility SEO Findings & Status
## Structure & Internal Linking
- [FIXED] **Improve Internal Link Texts**
- *Status:* Replaced "Read more" with "Read Article" in `blog/page.tsx`.
- [VERIFIED] **Pages with few internal links (9 pages)**
- *Status:* Core pages. `MarketingLayout` ensures Footer/Nav links exist on all these pages. Design choice.
## Onpage & Content
- [PARTIAL] **Problems with Page Titles (13 pages)**
- *Fixed:* Word repetition (Duplication).
- *Remaining:* "Too long" titles (e.g. `QR Code Analytics: Track...`).
- [VERIFIED] **Keywords not in text**
- *Action:* Content reviewed. Titles match page intent. Modern SEO prefers natural language over exact keyword stuffing.
- [RESOLVED] **Identical HTML Pages**
- *Status:* `privacy`, `faq`, `newsletter`. Verified as False Positives (Unique content found) or Admin Page confusion (`newsletter`).
## Technical
- [VERIFIED] **H1 Headings**
- *Status:* **False Positive in Report**. Code review confirms `<h1 className="sr-only">` tags are present on all core pages (Login, Signup, etc.). Crawlers can read this.
- [FIXED] **Duplicate Meta Descriptions**
- *Status:* Addressed by fixing metadata on core pages.

View File

@@ -1,13 +0,0 @@
{
"type": "service_account",
"project_id": "gen-lang-client-0595806638",
"private_key_id": "e44bc1717f1cf413521149de272bf13bfa89a336",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC0xJkozHODpcpD\nu3dTHPfprZk6eKiOT05h+uG8Clm8i8LLaS/eHT+B02qxFYMBX0VH9O2GvPp/VnfC\nB/Clc7bofN5VDpQMjVUiPDqMbUVEAiQHNOTp9pkfJltaHAl/J5Cc/DccCaOn89xT\nFD5b7dTn29suuBZHTqsaFDlydnU2xJAwcrWBm7/A0JZM85d76yhY0Jxcg9w8XlpE\n+TWN8OxSUIfubaac0mfI40RH2EfugmA7M45t7B3sEbmHk5tVQSItvncz2ls9fUE4\neB6u4foMFp4Z9k5Ejs7y4N3Yft0JWS+RjI0bcvvvQ/wcnDfcwCdDFFn2Y+hflKMm\nS9+ZRnmBAgMBAAECggEAAztAeo3JifZD3nzEUcDte9cHgN7AMtlJ3Wvc7va5Sw50\nizkCmSlwPoc4/0MvoMo0+701JVxbenXveMpEb3fZMoszkdU9U9iPZCfzB4wQErOa\nppuprbbOXtO9JzZVinWzflPSIUVK16lUVvYVrmfpHYou1G/dIMIXQkVsD7NR9t/B\nafD0w/q1nwwyPB08BjSemKXDQo6NF0cE/TIvaMj8vtxuouAL+fea0n/XxMQNoIoJ\nF+pJtPQ1hkQrpayzuj3smQ11PFpYuvsZHuS3dG9j4gPjGClezK3Sflt7vwNywIRc\ntJ0Qx58on0dy0YnppMWrHh/nykraVLusvMI04joqwQKBgQDlE1Mbi8dpeKn7zkV9\nLS/O6S5Ql2k2G6KxI8GHn3qxB5yfU8G2xqk64r04YB6SMCXscIQu1Tmro8kDMTZk\n5b/issH3+7uqGcJMYhZczWsjax3S1ugepXt29dF26VnbyfvD7h9qleKLhIq32z9P\nxzZGhptTCa0swypi7prNE0MhZwKBgQDKA75g8UhVULA6q3hFEG+24ICd3Gekdz1y\nmaDrPjSJmeMSUlDl4QhGRbZBSJcAfcFKk4+Nme3sTYvjMMz6per4a5TC/+IlSufm\nOSL+CSVijvVYwCMyLyiAcm5Pqcjw16S6enHIidnOYP8e8OM0H2aNKfFTKq30B3ww\nAF8ipa+01wKBgQC24JaYhx7LtOj/fc08AbcJGF9BN59m8ukPQdxeyZLJgaooCFW9\n9RtlR16IgzPkwUuFVs4wFUnVHQx83+zs3/4wnUT9FJrdUXMsR6JStCu0Ou+0Qp1M\n2g+XCOgQZnq2XKoB4ThzfvU9LLMR1JbWudM6unuF71OxSJ2uHY636YjOQQKBgBs6\n+fSTUY6+e6LM7j9RAd4C0RN2XDodIJlMABb1oZtStPsJQYJbHQRr7S9Lm58jVGS7\nE0ShFSMfKNYNA/RdXRjzV3AZkeA5Ap1T4lWf4fwxDP1TmOrw1GLMCfaPClj8mGXS\nj3farRNWm80N53JlMSuiFbeCL0SPpbvKsQg4kUCtAoGAUORyhW70nhZJ1BbmvyRf\n17fcwenK/3GmWgqsrzN7/ucPwjqIzLGVoAXd2euxpE49/VW2xYpJjyHJHuoXDc66\n+AUog0bsxcKpM5tL3VelQl3SkUlCG7jYe20rMm01y35uM2REvQv3/r9F7Bbaq/9n\nSCwu/45QobgLCUx0B7wDqWA=\n-----END PRIVATE KEY-----\n",
"client_email": "indexer@gen-lang-client-0595806638.iam.gserviceaccount.com",
"client_id": "111279247752160222047",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/indexer%40gen-lang-client-0595806638.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}

View File

@@ -1,254 +0,0 @@
'use client';
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,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
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
try {
const { trackEvent, resetUser } = await import('@/components/PostHogProvider');
trackEvent('user_logout');
resetUser(); // Reset PostHog user session
} catch (error) {
console.error('PostHog tracking error:', error);
}
// Clear all cookies
document.cookie.split(";").forEach(c => {
document.cookie = c.replace(/^ +/, "").replace(/=.*/, "=;expires=" + new Date().toUTCString() + ";path=/");
});
// Clear localStorage
localStorage.clear();
// Redirect to home
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'),
href: '/dashboard',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
),
},
{
name: t('nav.create_qr'),
href: '/create',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
),
},
{
name: t('nav.bulk_creation'),
href: '/bulk-creation',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
),
},
{
name: t('nav.analytics'),
href: '/analytics',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
),
},
{
name: t('nav.pricing'),
href: '/pricing',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
},
{
name: t('nav.settings'),
href: '/settings',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
},
];
return (
<div className="min-h-screen bg-gray-50">
{/* Mobile sidebar backdrop */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* 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'
}`}
>
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<Link href="/" className="flex items-center space-x-2">
<img src="/logo.svg" alt="QR Master" className="w-8 h-8" />
<span className="text-xl font-bold text-gray-900">QR Master</span>
</Link>
<button
className="lg:hidden"
onClick={() => setSidebarOpen(false)}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<nav className="p-4 space-y-1">
{navigation.map((item) => {
const isActive = pathname === item.href;
return (
<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'
}`}
>
{item.icon}
<span className="font-medium">{item.name}</span>
</Link>
);
})}
</nav>
</aside>
{/* Main content */}
<div className="lg:ml-64">
{/* Top bar */}
<header className="bg-white border-b border-gray-200">
<div className="flex items-center justify-between px-4 py-3">
<button
className="lg:hidden"
onClick={() => setSidebarOpen(true)}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<div className="flex items-center space-x-4 ml-auto">
{/* User Menu */}
<Dropdown
align="right"
trigger={
<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">
{getUserInitials()}
</span>
</div>
<span className="hidden md:block font-medium">
{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" />
</svg>
</button>
}
>
<DropdownItem onClick={handleSignOut}>
Sign Out
</DropdownItem>
</Dropdown>
</div>
</div>
</header>
{/* Page content */}
<main className="p-6">
{children}
</main>
{/* Footer */}
<Footer variant="dashboard" />
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -44,6 +44,7 @@ export default function DashboardPage() {
uniqueScans: 0, uniqueScans: 0,
}); });
const [analyticsData, setAnalyticsData] = useState<any>(null); const [analyticsData, setAnalyticsData] = useState<any>(null);
const [userSubdomain, setUserSubdomain] = useState<string | null>(null);
const mockQRCodes = [ const mockQRCodes = [
{ {
@@ -279,6 +280,13 @@ export default function DashboardPage() {
const analytics = await analyticsResponse.json(); const analytics = await analyticsResponse.json();
setAnalyticsData(analytics); 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) { } catch (error) {
console.error('Error fetching data:', error); console.error('Error fetching data:', error);
setQrCodes([]); setQrCodes([]);
@@ -449,10 +457,11 @@ export default function DashboardPage() {
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{qrCodes.map((qr) => ( {qrCodes.map((qr) => (
<QRCodeCard <QRCodeCard
key={qr.id} key={`${qr.id}-${userSubdomain || 'default'}`}
qr={qr} qr={qr}
onEdit={handleEdit} onEdit={handleEdit}
onDelete={handleDelete} onDelete={handleDelete}
userSubdomain={userSubdomain}
/> />
))} ))}
</div> </div>

View File

@@ -1,38 +1,254 @@
import type { Metadata } from 'next'; 'use client';
import '@/styles/globals.css';
import { Suspense } from 'react'; import React, { useState, useEffect } from 'react';
import { Providers } from '@/components/Providers'; import Link from 'next/link';
import AppLayout from './AppLayout'; import { usePathname, useRouter } from 'next/navigation';
import { Button } from '@/components/ui/Button';
export const metadata: Metadata = { import { Dropdown, DropdownItem } from '@/components/ui/Dropdown';
title: 'Dashboard | QR Master', import { Footer } from '@/components/ui/Footer';
description: 'Manage your QR Master dashboard. Create dynamic QR codes, view real-time scan analytics, and configure your account settings in one secure place.', import { useTranslation } from '@/hooks/useTranslation';
robots: { index: false, follow: false },
icons: { interface User {
icon: [ id: string;
{ url: '/favicon.svg', type: 'image/svg+xml' }, name: string | null;
{ url: '/logo.svg', type: 'image/svg+xml' }, email: string;
], plan: string | null;
apple: '/logo.svg', }
},
}; export default function AppLayout({
children,
export default function RootAppLayout({ }: {
children, children: React.ReactNode;
}: { }) {
children: React.ReactNode; const pathname = usePathname();
}) { const router = useRouter();
return ( const { t } = useTranslation();
<html lang="en"> const [sidebarOpen, setSidebarOpen] = useState(false);
<body className="font-sans"> const [user, setUser] = useState<User | null>(null);
<Providers>
<Suspense fallback={null}> // Fetch user data on mount
<AppLayout> useEffect(() => {
{children} const fetchUser = async () => {
</AppLayout> try {
</Suspense> const response = await fetch('/api/user');
</Providers> if (response.ok) {
</body> const userData = await response.json();
</html> setUser(userData);
); }
} } catch (error) {
console.error('Error fetching user:', error);
}
};
fetchUser();
}, []);
const handleSignOut = async () => {
// Track logout event before clearing data
try {
const { trackEvent, resetUser } = await import('@/components/PostHogProvider');
trackEvent('user_logout');
resetUser(); // Reset PostHog user session
} catch (error) {
console.error('PostHog tracking error:', error);
}
// Clear all cookies
document.cookie.split(";").forEach(c => {
document.cookie = c.replace(/^ +/, "").replace(/=.*/, "=;expires=" + new Date().toUTCString() + ";path=/");
});
// Clear localStorage
localStorage.clear();
// Redirect to home
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'),
href: '/dashboard',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
),
},
{
name: t('nav.create_qr'),
href: '/create',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
),
},
{
name: t('nav.bulk_creation'),
href: '/bulk-creation',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
),
},
{
name: t('nav.analytics'),
href: '/analytics',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
),
},
{
name: t('nav.pricing'),
href: '/pricing',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
},
{
name: t('nav.settings'),
href: '/settings',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
},
];
return (
<div className="min-h-screen bg-gray-50">
{/* Mobile sidebar backdrop */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* 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'
}`}
>
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<Link href="/" className="flex items-center space-x-2">
<img src="/logo.svg" alt="QR Master" className="w-8 h-8" />
<span className="text-xl font-bold text-gray-900">QR Master</span>
</Link>
<button
className="lg:hidden"
onClick={() => setSidebarOpen(false)}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<nav className="p-4 space-y-1">
{navigation.map((item) => {
const isActive = pathname === item.href;
return (
<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'
}`}
>
{item.icon}
<span className="font-medium">{item.name}</span>
</Link>
);
})}
</nav>
</aside>
{/* Main content */}
<div className="lg:ml-64">
{/* Top bar */}
<header className="bg-white border-b border-gray-200">
<div className="flex items-center justify-between px-4 py-3">
<button
className="lg:hidden"
onClick={() => setSidebarOpen(true)}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<div className="flex items-center space-x-4 ml-auto">
{/* User Menu */}
<Dropdown
align="right"
trigger={
<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">
{getUserInitials()}
</span>
</div>
<span className="hidden md:block font-medium">
{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" />
</svg>
</button>
}
>
<DropdownItem onClick={handleSignOut}>
Sign Out
</DropdownItem>
</Dropdown>
</div>
</div>
</header>
{/* Page content */}
<main className="p-6">
{children}
</main>
{/* Footer */}
<Footer variant="dashboard" />
</div>
</div>
);
}

View File

@@ -1,268 +1,268 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { showToast } from '@/components/ui/Toast'; import { showToast } from '@/components/ui/Toast';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { BillingToggle } from '@/components/ui/BillingToggle'; import { BillingToggle } from '@/components/ui/BillingToggle';
export default function PricingPage() { export default function PricingPage() {
const router = useRouter(); const router = useRouter();
const [loading, setLoading] = useState<string | null>(null); const [loading, setLoading] = useState<string | null>(null);
const [currentPlan, setCurrentPlan] = useState<string>('FREE'); const [currentPlan, setCurrentPlan] = useState<string>('FREE');
const [currentInterval, setCurrentInterval] = useState<'month' | 'year' | null>(null); const [currentInterval, setCurrentInterval] = useState<'month' | 'year' | null>(null);
const [billingPeriod, setBillingPeriod] = useState<'month' | 'year'>('month'); const [billingPeriod, setBillingPeriod] = useState<'month' | 'year'>('month');
useEffect(() => { useEffect(() => {
// Fetch current user plan // Fetch current user plan
const fetchUserPlan = async () => { const fetchUserPlan = async () => {
try { try {
const response = await fetch('/api/user/plan'); const response = await fetch('/api/user/plan');
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setCurrentPlan(data.plan || 'FREE'); setCurrentPlan(data.plan || 'FREE');
setCurrentInterval(data.interval || null); setCurrentInterval(data.interval || null);
} }
} catch (error) { } catch (error) {
console.error('Error fetching user plan:', error); console.error('Error fetching user plan:', error);
} }
}; };
fetchUserPlan(); fetchUserPlan();
}, []); }, []);
const handleUpgrade = async (plan: 'PRO' | 'BUSINESS') => { const handleUpgrade = async (plan: 'PRO' | 'BUSINESS') => {
setLoading(plan); setLoading(plan);
try { try {
const response = await fetch('/api/stripe/create-checkout-session', { const response = await fetch('/api/stripe/create-checkout-session', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
plan, plan,
billingInterval: billingPeriod === 'month' ? 'month' : 'year', billingInterval: billingPeriod === 'month' ? 'month' : 'year',
}), }),
}); });
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to create checkout session'); throw new Error('Failed to create checkout session');
} }
const { url } = await response.json(); const { url } = await response.json();
window.location.href = url; window.location.href = url;
} catch (error) { } catch (error) {
console.error('Error creating checkout session:', error); console.error('Error creating checkout session:', error);
showToast('Failed to start checkout. Please try again.', 'error'); showToast('Failed to start checkout. Please try again.', 'error');
setLoading(null); setLoading(null);
} }
}; };
const handleDowngrade = async () => { const handleDowngrade = async () => {
// Show confirmation dialog // Show confirmation dialog
const confirmed = window.confirm( const confirmed = window.confirm(
'Are you sure you want to downgrade to the Free plan? Your subscription will be canceled immediately and you will lose access to premium features.' 'Are you sure you want to downgrade to the Free plan? Your subscription will be canceled immediately and you will lose access to premium features.'
); );
if (!confirmed) { if (!confirmed) {
return; return;
} }
setLoading('FREE'); setLoading('FREE');
try { try {
const response = await fetch('/api/stripe/cancel-subscription', { const response = await fetch('/api/stripe/cancel-subscription', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}); });
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
throw new Error(error.error || 'Failed to cancel subscription'); throw new Error(error.error || 'Failed to cancel subscription');
} }
showToast('Successfully downgraded to Free plan', 'success'); showToast('Successfully downgraded to Free plan', 'success');
// Refresh to update the plan // Refresh to update the plan
setTimeout(() => { setTimeout(() => {
window.location.reload(); window.location.reload();
}, 1500); }, 1500);
} catch (error: any) { } catch (error: any) {
console.error('Error canceling subscription:', error); console.error('Error canceling subscription:', error);
showToast(error.message || 'Failed to downgrade. Please try again.', 'error'); showToast(error.message || 'Failed to downgrade. Please try again.', 'error');
setLoading(null); setLoading(null);
} }
}; };
// Helper function to check if this is the user's exact current plan (plan + interval) // Helper function to check if this is the user's exact current plan (plan + interval)
const isCurrentPlanWithInterval = (planType: string, interval: 'month' | 'year') => { const isCurrentPlanWithInterval = (planType: string, interval: 'month' | 'year') => {
return currentPlan === planType && currentInterval === interval; return currentPlan === planType && currentInterval === interval;
}; };
// Helper function to check if user has this plan but different interval // Helper function to check if user has this plan but different interval
const hasPlanDifferentInterval = (planType: string) => { const hasPlanDifferentInterval = (planType: string) => {
return currentPlan === planType && currentInterval && currentInterval !== billingPeriod; return currentPlan === planType && currentInterval && currentInterval !== billingPeriod;
}; };
const selectedInterval = billingPeriod === 'month' ? 'month' : 'year'; const selectedInterval = billingPeriod === 'month' ? 'month' : 'year';
const plans = [ const plans = [
{ {
key: 'free', key: 'free',
name: 'Free', name: 'Free',
price: '€0', price: '€0',
period: 'forever', period: 'forever',
showDiscount: false, showDiscount: false,
features: [ features: [
'3 dynamic QR codes', '3 dynamic QR codes',
'Unlimited static QR codes', 'Unlimited static QR codes',
'Basic scan tracking', 'Basic scan tracking',
'Standard QR design templates', 'Standard QR design templates',
'Download as SVG/PNG', 'Download as SVG/PNG',
], ],
buttonText: currentPlan === 'FREE' ? 'Current Plan' : 'Downgrade to Free', buttonText: currentPlan === 'FREE' ? 'Current Plan' : 'Downgrade to Free',
buttonVariant: 'outline' as const, buttonVariant: 'outline' as const,
disabled: currentPlan === 'FREE', disabled: currentPlan === 'FREE',
popular: false, popular: false,
onDowngrade: handleDowngrade, onDowngrade: handleDowngrade,
}, },
{ {
key: 'pro', key: 'pro',
name: 'Pro', name: 'Pro',
price: billingPeriod === 'month' ? '€9' : '€90', price: billingPeriod === 'month' ? '€9' : '€90',
period: billingPeriod === 'month' ? 'per month' : 'per year', period: billingPeriod === 'month' ? 'per month' : 'per year',
showDiscount: billingPeriod === 'year', showDiscount: billingPeriod === 'year',
features: [ features: [
'50 dynamic QR codes', '50 dynamic QR codes',
'Unlimited static QR codes', 'Unlimited static QR codes',
'Advanced analytics (scans, devices, locations)', 'Advanced analytics (scans, devices, locations)',
'Custom branding (colors & logos)', 'Custom branding (colors)',
], ],
buttonText: isCurrentPlanWithInterval('PRO', selectedInterval) buttonText: isCurrentPlanWithInterval('PRO', selectedInterval)
? 'Current Plan' ? 'Current Plan'
: hasPlanDifferentInterval('PRO') : hasPlanDifferentInterval('PRO')
? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}` ? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}`
: 'Upgrade to Pro', : 'Upgrade to Pro',
buttonVariant: 'primary' as const, buttonVariant: 'primary' as const,
disabled: isCurrentPlanWithInterval('PRO', selectedInterval), disabled: isCurrentPlanWithInterval('PRO', selectedInterval),
popular: true, popular: true,
onUpgrade: () => handleUpgrade('PRO'), onUpgrade: () => handleUpgrade('PRO'),
}, },
{ {
key: 'business', key: 'business',
name: 'Business', name: 'Business',
price: billingPeriod === 'month' ? '€29' : '€290', price: billingPeriod === 'month' ? '€29' : '€290',
period: billingPeriod === 'month' ? 'per month' : 'per year', period: billingPeriod === 'month' ? 'per month' : 'per year',
showDiscount: billingPeriod === 'year', showDiscount: billingPeriod === 'year',
features: [ features: [
'500 dynamic QR codes', '500 dynamic QR codes',
'Unlimited static QR codes', 'Unlimited static QR codes',
'Everything from Pro', 'Everything from Pro',
'Bulk QR Creation (up to 1,000)', 'Bulk QR Creation (up to 1,000)',
'Priority email support', 'Priority email support',
'Advanced tracking & insights', 'Advanced tracking & insights',
], ],
buttonText: isCurrentPlanWithInterval('BUSINESS', selectedInterval) buttonText: isCurrentPlanWithInterval('BUSINESS', selectedInterval)
? 'Current Plan' ? 'Current Plan'
: hasPlanDifferentInterval('BUSINESS') : hasPlanDifferentInterval('BUSINESS')
? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}` ? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}`
: 'Upgrade to Business', : 'Upgrade to Business',
buttonVariant: 'primary' as const, buttonVariant: 'primary' as const,
disabled: isCurrentPlanWithInterval('BUSINESS', selectedInterval), disabled: isCurrentPlanWithInterval('BUSINESS', selectedInterval),
popular: false, popular: false,
onUpgrade: () => handleUpgrade('BUSINESS'), onUpgrade: () => handleUpgrade('BUSINESS'),
}, },
]; ];
return ( return (
<div className="container mx-auto px-4 py-12"> <div className="container mx-auto px-4 py-12">
<div className="text-center mb-12"> <div className="text-center mb-12">
<h1 className="text-4xl font-bold text-gray-900 mb-4"> <h1 className="text-4xl font-bold text-gray-900 mb-4">
Choose Your Plan Choose Your Plan
</h1> </h1>
<p className="text-xl text-gray-600"> <p className="text-xl text-gray-600">
Select the perfect plan for your QR code needs Select the perfect plan for your QR code needs
</p> </p>
</div> </div>
<div className="flex justify-center mb-8"> <div className="flex justify-center mb-8">
<BillingToggle value={billingPeriod} onChange={setBillingPeriod} /> <BillingToggle value={billingPeriod} onChange={setBillingPeriod} />
</div> </div>
<div className="grid md:grid-cols-3 gap-8 max-w-6xl mx-auto"> <div className="grid md:grid-cols-3 gap-8 max-w-6xl mx-auto">
{plans.map((plan) => ( {plans.map((plan) => (
<Card <Card
key={plan.key} key={plan.key}
className={plan.popular ? 'border-primary-500 shadow-xl relative' : ''} className={plan.popular ? 'border-primary-500 shadow-xl relative' : ''}
> >
{plan.popular && ( {plan.popular && (
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2"> <div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
<Badge variant="info" className="px-3 py-1"> <Badge variant="info" className="px-3 py-1">
Most Popular Most Popular
</Badge> </Badge>
</div> </div>
)} )}
<CardHeader className="text-center pb-8"> <CardHeader className="text-center pb-8">
<CardTitle className="text-2xl mb-4"> <CardTitle className="text-2xl mb-4">
{plan.name} {plan.name}
</CardTitle> </CardTitle>
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div className="flex items-baseline justify-center"> <div className="flex items-baseline justify-center">
<span className="text-4xl font-bold"> <span className="text-4xl font-bold">
{plan.price} {plan.price}
</span> </span>
<span className="text-gray-600 ml-2"> <span className="text-gray-600 ml-2">
{plan.period} {plan.period}
</span> </span>
</div> </div>
{plan.showDiscount && ( {plan.showDiscount && (
<Badge variant="success" className="mt-2"> <Badge variant="success" className="mt-2">
Save 16% Save 16%
</Badge> </Badge>
)} )}
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<ul className="space-y-3"> <ul className="space-y-3">
{plan.features.map((feature: string, index: number) => ( {plan.features.map((feature: string, index: number) => (
<li key={index} className="flex items-start space-x-3"> <li key={index} className="flex items-start space-x-3">
<svg className="w-5 h-5 text-success-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-5 h-5 text-success-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" /> <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg> </svg>
<span className="text-gray-700">{feature}</span> <span className="text-gray-700">{feature}</span>
</li> </li>
))} ))}
</ul> </ul>
<Button <Button
variant={plan.buttonVariant} variant={plan.buttonVariant}
className="w-full" className="w-full"
size="lg" size="lg"
disabled={plan.disabled || loading === plan.key.toUpperCase()} disabled={plan.disabled || loading === plan.key.toUpperCase()}
onClick={plan.key === 'free' ? (plan as any).onDowngrade : (plan as any).onUpgrade} onClick={plan.key === 'free' ? (plan as any).onDowngrade : (plan as any).onUpgrade}
> >
{loading === plan.key.toUpperCase() ? 'Processing...' : plan.buttonText} {loading === plan.key.toUpperCase() ? 'Processing...' : plan.buttonText}
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
</div> </div>
<div className="text-center mt-12"> <div className="text-center mt-12">
<p className="text-gray-600"> <p className="text-gray-600">
All plans include unlimited static QR codes and basic customization. All plans include unlimited static QR codes and basic customization.
</p> </p>
<p className="text-gray-600 mt-2"> <p className="text-gray-600 mt-2">
Need help choosing? <a href="mailto:support@qrmaster.net" className="text-primary-600 hover:text-primary-700 underline">Contact our team</a> Need help choosing? <a href="mailto:support@qrmaster.net" className="text-primary-600 hover:text-primary-700 underline">Contact our team</a>
</p> </p>
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,459 +1,264 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation'; import { useRouter, useParams } from 'next/navigation';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { showToast } from '@/components/ui/Toast'; import { showToast } from '@/components/ui/Toast';
import { useCsrf } from '@/hooks/useCsrf'; import { useCsrf } from '@/hooks/useCsrf';
import { Upload, FileText, HelpCircle } from 'lucide-react';
export default function EditQRPage() {
// Tooltip component for form field help const router = useRouter();
const Tooltip = ({ text }: { text: string }) => ( const params = useParams();
<div className="group relative inline-block ml-1"> const qrId = params.id as string;
<HelpCircle className="w-4 h-4 text-gray-400 cursor-help" /> const { fetchWithCsrf, loading: csrfLoading } = useCsrf();
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-gray-900 text-white text-xs rounded-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50 w-48 text-center">
{text} const [loading, setLoading] = useState(true);
<div className="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div> const [saving, setSaving] = useState(false);
</div> const [qrCode, setQrCode] = useState<any>(null);
</div> const [title, setTitle] = useState('');
); const [content, setContent] = useState<any>({});
export default function EditQRPage() { useEffect(() => {
const router = useRouter(); const fetchQRCode = async () => {
const params = useParams(); try {
const qrId = params.id as string; const response = await fetch(`/api/qrs/${qrId}`);
const { fetchWithCsrf, loading: csrfLoading } = useCsrf(); if (response.ok) {
const data = await response.json();
const [loading, setLoading] = useState(true); setQrCode(data);
const [saving, setSaving] = useState(false); setTitle(data.title);
const [uploading, setUploading] = useState(false); setContent(data.content || {});
const [qrCode, setQrCode] = useState<any>(null); } else {
const [title, setTitle] = useState(''); showToast('Failed to load QR code', 'error');
const [content, setContent] = useState<any>({}); router.push('/dashboard');
}
useEffect(() => { } catch (error) {
const fetchQRCode = async () => { console.error('Error fetching QR code:', error);
try { showToast('Failed to load QR code', 'error');
const response = await fetch(`/api/qrs/${qrId}`); router.push('/dashboard');
if (response.ok) { } finally {
const data = await response.json(); setLoading(false);
setQrCode(data); }
setTitle(data.title); };
setContent(data.content || {});
} else { fetchQRCode();
showToast('Failed to load QR code', 'error'); }, [qrId, router]);
router.push('/dashboard');
} const handleSave = async () => {
} catch (error) { setSaving(true);
console.error('Error fetching QR code:', error);
showToast('Failed to load QR code', 'error'); try {
router.push('/dashboard'); const response = await fetchWithCsrf(`/api/qrs/${qrId}`, {
} finally { method: 'PATCH',
setLoading(false); body: JSON.stringify({
} title,
}; content,
}),
fetchQRCode(); });
}, [qrId, router]);
if (response.ok) {
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { showToast('QR code updated successfully!', 'success');
const file = e.target.files?.[0]; router.push('/dashboard');
if (!file) return; } else {
const error = await response.json();
// 10MB limit showToast(error.error || 'Failed to update QR code', 'error');
if (file.size > 10 * 1024 * 1024) { }
showToast('File size too large (max 10MB)', 'error'); } catch (error) {
return; console.error('Error updating QR code:', error);
} showToast('Failed to update QR code', 'error');
} finally {
setUploading(true); setSaving(false);
const formData = new FormData(); }
formData.append('file', file); };
try { if (loading) {
const response = await fetch('/api/upload', { return (
method: 'POST', <div className="flex items-center justify-center min-h-screen">
body: formData, <div className="text-center">
}); <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
const data = await response.json(); <p className="text-gray-600">Loading QR code...</p>
</div>
if (response.ok) { </div>
setContent({ ...content, fileUrl: data.url, fileName: data.filename }); );
showToast('File uploaded successfully!', 'success'); }
} else {
showToast(data.error || 'Upload failed', 'error'); if (!qrCode) {
} return null;
} catch (error) { }
console.error('Upload error:', error);
showToast('Error uploading file', 'error'); // Static QR codes cannot be edited
} finally { if (qrCode.type === 'STATIC') {
setUploading(false); return (
} <div className="max-w-2xl mx-auto mt-12">
}; <Card>
<CardContent className="p-12 text-center">
const handleSave = async () => { <div className="w-20 h-20 bg-warning-100 rounded-full flex items-center justify-center mx-auto mb-6">
setSaving(true); <svg className="w-10 h-10 text-warning-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
try { </svg>
const response = await fetchWithCsrf(`/api/qrs/${qrId}`, { </div>
method: 'PATCH', <h2 className="text-2xl font-bold text-gray-900 mb-2">Static QR Code</h2>
body: JSON.stringify({ <p className="text-gray-600 mb-8">
title, Static QR codes cannot be edited because their content is embedded directly in the QR code image.
content, </p>
}), <Button onClick={() => router.push('/dashboard')}>
}); Back to Dashboard
</Button>
if (response.ok) { </CardContent>
showToast('QR code updated successfully!', 'success'); </Card>
router.push('/dashboard'); </div>
} else { );
const error = await response.json(); }
showToast(error.error || 'Failed to update QR code', 'error');
} return (
} catch (error) { <div className="max-w-3xl mx-auto">
console.error('Error updating QR code:', error); <div className="mb-8">
showToast('Failed to update QR code', 'error'); <h1 className="text-3xl font-bold text-gray-900">Edit QR Code</h1>
} finally { <p className="text-gray-600 mt-2">Update your dynamic QR code content</p>
setSaving(false); </div>
}
}; <Card>
<CardHeader>
if (loading) { <CardTitle>QR Code Details</CardTitle>
return ( </CardHeader>
<div className="flex items-center justify-center min-h-screen"> <CardContent className="space-y-6">
<div className="text-center"> <Input
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div> label="Title"
<p className="text-gray-600">Loading QR code...</p> value={title}
</div> onChange={(e) => setTitle(e.target.value)}
</div> placeholder="Enter QR code title"
); required
} />
if (!qrCode) { {qrCode.contentType === 'URL' && (
return null; <Input
} label="URL"
type="url"
// Static QR codes cannot be edited value={content.url || ''}
if (qrCode.type === 'STATIC') { onChange={(e) => setContent({ ...content, url: e.target.value })}
return ( placeholder="https://example.com"
<div className="max-w-2xl mx-auto mt-12"> required
<Card> />
<CardContent className="p-12 text-center"> )}
<div className="w-20 h-20 bg-warning-100 rounded-full flex items-center justify-center mx-auto mb-6">
<svg className="w-10 h-10 text-warning-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> {qrCode.contentType === 'PHONE' && (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /> <Input
</svg> label="Phone Number"
</div> type="tel"
<h2 className="text-2xl font-bold text-gray-900 mb-2">Static QR Code</h2> value={content.phone || ''}
<p className="text-gray-600 mb-8"> onChange={(e) => setContent({ ...content, phone: e.target.value })}
Static QR codes cannot be edited because their content is embedded directly in the QR code image. placeholder="+1234567890"
</p> required
<Button onClick={() => router.push('/dashboard')}> />
Back to Dashboard )}
</Button>
</CardContent> {qrCode.contentType === 'VCARD' && (
</Card> <>
</div> <Input
); label="First Name"
} value={content.firstName || ''}
onChange={(e) => setContent({ ...content, firstName: e.target.value })}
return ( placeholder="John"
<div className="max-w-3xl mx-auto"> required
<div className="mb-8"> />
<h1 className="text-3xl font-bold text-gray-900">Edit QR Code</h1> <Input
<p className="text-gray-600 mt-2">Update your dynamic QR code content</p> label="Last Name"
</div> value={content.lastName || ''}
onChange={(e) => setContent({ ...content, lastName: e.target.value })}
<Card> placeholder="Doe"
<CardHeader> required
<CardTitle>QR Code Details</CardTitle> />
</CardHeader> <Input
<CardContent className="space-y-6"> label="Email"
<Input type="email"
label="Title" value={content.email || ''}
value={title} onChange={(e) => setContent({ ...content, email: e.target.value })}
onChange={(e) => setTitle(e.target.value)} placeholder="john@example.com"
placeholder="Enter QR code title" />
required <Input
/> label="Phone"
value={content.phone || ''}
{qrCode.contentType === 'URL' && ( onChange={(e) => setContent({ ...content, phone: e.target.value })}
<Input placeholder="+1234567890"
label="URL" />
type="url" <Input
value={content.url || ''} label="Organization"
onChange={(e) => setContent({ ...content, url: e.target.value })} value={content.organization || ''}
placeholder="https://example.com" onChange={(e) => setContent({ ...content, organization: e.target.value })}
required placeholder="Company Name"
/> />
)} <Input
label="Job Title"
{qrCode.contentType === 'PHONE' && ( value={content.title || ''}
<Input onChange={(e) => setContent({ ...content, title: e.target.value })}
label="Phone Number" placeholder="CEO"
type="tel" />
value={content.phone || ''} </>
onChange={(e) => setContent({ ...content, phone: e.target.value })} )}
placeholder="+1234567890"
required {qrCode.contentType === 'GEO' && (
/> <>
)} <Input
label="Latitude"
{qrCode.contentType === 'VCARD' && ( type="number"
<> step="any"
<Input value={content.latitude || ''}
label="First Name" onChange={(e) => setContent({ ...content, latitude: parseFloat(e.target.value) || 0 })}
value={content.firstName || ''} placeholder="37.7749"
onChange={(e) => setContent({ ...content, firstName: e.target.value })} required
placeholder="John" />
required <Input
/> label="Longitude"
<Input type="number"
label="Last Name" step="any"
value={content.lastName || ''} value={content.longitude || ''}
onChange={(e) => setContent({ ...content, lastName: e.target.value })} onChange={(e) => setContent({ ...content, longitude: parseFloat(e.target.value) || 0 })}
placeholder="Doe" placeholder="-122.4194"
required required
/> />
<Input <Input
label="Email" label="Location Label (Optional)"
type="email" value={content.label || ''}
value={content.email || ''} onChange={(e) => setContent({ ...content, label: e.target.value })}
onChange={(e) => setContent({ ...content, email: e.target.value })} placeholder="Golden Gate Bridge"
placeholder="john@example.com" />
/> </>
<Input )}
label="Phone"
value={content.phone || ''} {qrCode.contentType === 'TEXT' && (
onChange={(e) => setContent({ ...content, phone: e.target.value })} <div>
placeholder="+1234567890" <label className="block text-sm font-medium text-gray-700 mb-2">
/> Text Content
<Input </label>
label="Organization" <textarea
value={content.organization || ''} value={content.text || ''}
onChange={(e) => setContent({ ...content, organization: e.target.value })} onChange={(e) => setContent({ ...content, text: e.target.value })}
placeholder="Company Name" className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/> rows={4}
<Input placeholder="Enter your text content"
label="Job Title" required
value={content.title || ''} />
onChange={(e) => setContent({ ...content, title: e.target.value })} </div>
placeholder="CEO" )}
/>
</> <div className="flex justify-end space-x-4 pt-4">
)} <Button
variant="outline"
{qrCode.contentType === 'GEO' && ( onClick={() => router.push('/dashboard')}
<> >
<Input Cancel
label="Latitude" </Button>
type="number" <Button
step="any" onClick={handleSave}
value={content.latitude || ''} loading={saving}
onChange={(e) => setContent({ ...content, latitude: parseFloat(e.target.value) || 0 })} disabled={csrfLoading || saving}
placeholder="37.7749" >
required {csrfLoading ? 'Loading...' : 'Save Changes'}
/> </Button>
<Input </div>
label="Longitude" </CardContent>
type="number" </Card>
step="any" </div>
value={content.longitude || ''} );
onChange={(e) => setContent({ ...content, longitude: parseFloat(e.target.value) || 0 })} }
placeholder="-122.4194"
required
/>
<Input
label="Location Label (Optional)"
value={content.label || ''}
onChange={(e) => setContent({ ...content, label: e.target.value })}
placeholder="Golden Gate Bridge"
/>
</>
)}
{qrCode.contentType === 'TEXT' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Text Content
</label>
<textarea
value={content.text || ''}
onChange={(e) => setContent({ ...content, text: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
rows={4}
placeholder="Enter your text content"
required
/>
</div>
)}
{qrCode.contentType === 'PDF' && (
<>
<div>
<div className="flex items-center mb-1">
<label className="block text-sm font-medium text-gray-700">Upload Menu / PDF</label>
<Tooltip text="Upload your menu PDF (Max 10MB). Hosted securely." />
</div>
<div className="mt-2 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-lg hover:bg-gray-50 transition-colors relative">
<div className="space-y-1 text-center">
{uploading ? (
<div className="flex flex-col items-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500 mb-2"></div>
<p className="text-sm text-gray-500">Uploading...</p>
</div>
) : content.fileUrl ? (
<div className="flex flex-col items-center">
<div className="mx-auto h-12 w-12 text-primary-500 bg-primary-50 rounded-full flex items-center justify-center mb-2">
<FileText className="h-6 w-6" />
</div>
<p className="text-sm text-green-600 font-medium mb-1">Upload Complete!</p>
<a href={content.fileUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-primary-500 hover:underline break-all max-w-xs mb-3 block">
{content.fileName || 'View File'}
</a>
<label htmlFor="file-upload" className="cursor-pointer bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
<span>Replace File</span>
<input id="file-upload" name="file-upload" type="file" className="sr-only" accept=".pdf,image/*" onChange={handleFileUpload} />
</label>
</div>
) : (
<>
<Upload className="mx-auto h-12 w-12 text-gray-400" />
<div className="flex text-sm text-gray-600 justify-center">
<label htmlFor="file-upload" className="relative cursor-pointer bg-white rounded-md font-medium text-primary-600 hover:text-primary-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-primary-500">
<span>Upload a file</span>
<input id="file-upload" name="file-upload" type="file" className="sr-only" accept=".pdf,image/*" onChange={handleFileUpload} />
</label>
<p className="pl-1">or drag and drop</p>
</div>
<p className="text-xs text-gray-500">PDF, PNG, JPG up to 10MB</p>
</>
)}
</div>
</div>
</div>
{content.fileUrl && (
<Input
label="File Name / Menu Title"
value={content.fileName || ''}
onChange={(e) => setContent({ ...content, fileName: e.target.value })}
placeholder="Product Catalog 2026"
/>
)}
</>
)}
{qrCode.contentType === 'APP' && (
<>
<Input
label="iOS App Store URL"
value={content.iosUrl || ''}
onChange={(e) => setContent({ ...content, iosUrl: e.target.value })}
placeholder="https://apps.apple.com/app/..."
/>
<Input
label="Android Play Store URL"
value={content.androidUrl || ''}
onChange={(e) => setContent({ ...content, androidUrl: e.target.value })}
placeholder="https://play.google.com/store/apps/..."
/>
<Input
label="Fallback URL (Desktop)"
value={content.fallbackUrl || ''}
onChange={(e) => setContent({ ...content, fallbackUrl: e.target.value })}
placeholder="https://yourapp.com"
/>
</>
)}
{qrCode.contentType === 'COUPON' && (
<>
<Input
label="Coupon Code"
value={content.code || ''}
onChange={(e) => setContent({ ...content, code: e.target.value })}
placeholder="SUMMER20"
required
/>
<Input
label="Discount"
value={content.discount || ''}
onChange={(e) => setContent({ ...content, discount: e.target.value })}
placeholder="20% OFF"
required
/>
<Input
label="Title"
value={content.title || ''}
onChange={(e) => setContent({ ...content, title: e.target.value })}
placeholder="Summer Sale 2026"
/>
<Input
label="Description (optional)"
value={content.description || ''}
onChange={(e) => setContent({ ...content, description: e.target.value })}
placeholder="Valid on all products"
/>
<Input
label="Expiry Date (optional)"
type="date"
value={content.expiryDate || ''}
onChange={(e) => setContent({ ...content, expiryDate: e.target.value })}
/>
<Input
label="Redeem URL (optional)"
value={content.redeemUrl || ''}
onChange={(e) => setContent({ ...content, redeemUrl: e.target.value })}
placeholder="https://shop.example.com"
/>
</>
)}
{qrCode.contentType === 'FEEDBACK' && (
<>
<Input
label="Business Name"
value={content.businessName || ''}
onChange={(e) => setContent({ ...content, businessName: e.target.value })}
placeholder="Your Restaurant Name"
required
/>
<Input
label="Google Review URL (optional)"
value={content.googleReviewUrl || ''}
onChange={(e) => setContent({ ...content, googleReviewUrl: e.target.value })}
placeholder="https://search.google.com/local/writereview?placeid=..."
/>
<Input
label="Thank You Message"
value={content.thankYouMessage || ''}
onChange={(e) => setContent({ ...content, thankYouMessage: e.target.value })}
placeholder="Thanks for your feedback!"
/>
</>
)}
<div className="flex justify-end space-x-4 pt-4">
<Button
variant="outline"
onClick={() => router.push('/dashboard')}
>
Cancel
</Button>
<Button
onClick={handleSave}
loading={saving}
disabled={csrfLoading || saving}
>
{csrfLoading ? 'Loading...' : 'Save Changes'}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,196 +0,0 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Star, ArrowLeft, ChevronLeft, ChevronRight, MessageSquare } from 'lucide-react';
interface Feedback {
id: string;
rating: number;
comment: string;
date: string;
}
interface FeedbackStats {
total: number;
avgRating: number;
distribution: { [key: number]: number };
}
interface Pagination {
page: number;
totalPages: number;
hasMore: boolean;
}
export default function FeedbackListPage() {
const params = useParams();
const router = useRouter();
const qrId = params.id as string;
const [feedbacks, setFeedbacks] = useState<Feedback[]>([]);
const [stats, setStats] = useState<FeedbackStats | null>(null);
const [pagination, setPagination] = useState<Pagination>({ page: 1, totalPages: 1, hasMore: false });
const [loading, setLoading] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
useEffect(() => {
fetchFeedback(currentPage);
}, [qrId, currentPage]);
const fetchFeedback = async (page: number) => {
setLoading(true);
try {
const res = await fetch(`/api/qrs/${qrId}/feedback?page=${page}&limit=20`);
if (res.ok) {
const data = await res.json();
setFeedbacks(data.feedbacks);
setStats(data.stats);
setPagination(data.pagination);
}
} catch (error) {
console.error('Error fetching feedback:', error);
} finally {
setLoading(false);
}
};
const renderStars = (rating: number) => (
<div className="flex gap-0.5">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
className={`w-4 h-4 ${star <= rating ? 'text-amber-400 fill-amber-400' : 'text-gray-200'}`}
/>
))}
</div>
);
if (loading && !stats) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
</div>
);
}
return (
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="mb-8">
<Link href={`/qr/${qrId}`} className="inline-flex items-center text-gray-500 hover:text-gray-700 mb-4">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to QR Code
</Link>
<h1 className="text-3xl font-bold text-gray-900">Customer Feedback</h1>
<p className="text-gray-600 mt-1">{stats?.total || 0} total responses</p>
</div>
{/* Stats Overview */}
{stats && (
<Card className="mb-8">
<CardContent className="p-6">
<div className="flex flex-col md:flex-row md:items-center gap-8">
{/* Average Rating */}
<div className="text-center md:text-left">
<div className="text-5xl font-bold text-gray-900 mb-1">{stats.avgRating}</div>
<div className="flex justify-center md:justify-start mb-1">
{renderStars(Math.round(stats.avgRating))}
</div>
<p className="text-sm text-gray-500">{stats.total} reviews</p>
</div>
{/* Distribution */}
<div className="flex-1 space-y-2">
{[5, 4, 3, 2, 1].map((rating) => {
const count = stats.distribution[rating] || 0;
const percentage = stats.total > 0 ? (count / stats.total) * 100 : 0;
return (
<div key={rating} className="flex items-center gap-3">
<span className="text-sm text-gray-600 w-12">{rating} stars</span>
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-amber-400 rounded-full transition-all"
style={{ width: `${percentage}%` }}
/>
</div>
<span className="text-sm text-gray-500 w-12 text-right">{count}</span>
</div>
);
})}
</div>
</div>
</CardContent>
</Card>
)}
{/* Feedback List */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageSquare className="w-5 h-5" />
All Reviews
</CardTitle>
</CardHeader>
<CardContent>
{feedbacks.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<Star className="w-12 h-12 mx-auto mb-4 text-gray-300" />
<p>No feedback received yet</p>
</div>
) : (
<div className="divide-y divide-gray-100">
{feedbacks.map((feedback) => (
<div key={feedback.id} className="py-4">
<div className="flex items-center justify-between mb-2">
{renderStars(feedback.rating)}
<span className="text-sm text-gray-400">
{new Date(feedback.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</span>
</div>
{feedback.comment && (
<p className="text-gray-700">{feedback.comment}</p>
)}
</div>
))}
</div>
)}
{/* Pagination */}
{pagination.totalPages > 1 && (
<div className="flex items-center justify-between mt-6 pt-6 border-t">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
>
<ChevronLeft className="w-4 h-4 mr-1" />
Previous
</Button>
<span className="text-sm text-gray-500">
Page {currentPage} of {pagination.totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => p + 1)}
disabled={!pagination.hasMore}
>
Next
<ChevronRight className="w-4 h-4 ml-1" />
</Button>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,287 +0,0 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { QRCodeSVG } from 'qrcode.react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import {
ArrowLeft, Edit, ExternalLink, Star, MessageSquare,
BarChart3, Copy, Check, Pause, Play
} from 'lucide-react';
import { showToast } from '@/components/ui/Toast';
import { useCsrf } from '@/hooks/useCsrf';
interface QRCode {
id: string;
title: string;
type: 'STATIC' | 'DYNAMIC';
contentType: string;
content: any;
slug: string;
status: 'ACTIVE' | 'PAUSED';
style: any;
createdAt: string;
_count?: { scans: number };
}
interface FeedbackStats {
total: number;
avgRating: number;
distribution: { [key: number]: number };
}
export default function QRDetailPage() {
const params = useParams();
const router = useRouter();
const qrId = params.id as string;
const { fetchWithCsrf } = useCsrf();
const [qrCode, setQrCode] = useState<QRCode | null>(null);
const [feedbackStats, setFeedbackStats] = useState<FeedbackStats | null>(null);
const [loading, setLoading] = useState(true);
const [copied, setCopied] = useState(false);
useEffect(() => {
fetchQRCode();
}, [qrId]);
const fetchQRCode = async () => {
try {
const res = await fetch(`/api/qrs/${qrId}`);
if (res.ok) {
const data = await res.json();
setQrCode(data);
// Fetch feedback stats if it's a feedback QR
if (data.contentType === 'FEEDBACK') {
const feedbackRes = await fetch(`/api/qrs/${qrId}/feedback?limit=1`);
if (feedbackRes.ok) {
const feedbackData = await feedbackRes.json();
setFeedbackStats(feedbackData.stats);
}
}
} else {
showToast('QR code not found', 'error');
router.push('/dashboard');
}
} catch (error) {
console.error('Error fetching QR code:', error);
} finally {
setLoading(false);
}
};
const copyLink = async () => {
const url = `${window.location.origin}/r/${qrCode?.slug}`;
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
showToast('Link copied!', 'success');
};
const toggleStatus = async () => {
if (!qrCode) return;
const newStatus = qrCode.status === 'ACTIVE' ? 'PAUSED' : 'ACTIVE';
try {
const res = await fetchWithCsrf(`/api/qrs/${qrId}`, {
method: 'PATCH',
body: JSON.stringify({ status: newStatus }),
});
if (res.ok) {
setQrCode({ ...qrCode, status: newStatus });
showToast(`QR code ${newStatus === 'ACTIVE' ? 'activated' : 'paused'}`, 'success');
}
} catch (error) {
showToast('Failed to update status', 'error');
}
};
const renderStars = (rating: number) => (
<div className="flex gap-0.5">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
className={`w-4 h-4 ${star <= rating ? 'text-amber-400 fill-amber-400' : 'text-gray-200'}`}
/>
))}
</div>
);
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
</div>
);
}
if (!qrCode) return null;
const qrUrl = `${typeof window !== 'undefined' ? window.location.origin : ''}/r/${qrCode.slug}`;
return (
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="mb-8">
<Link href="/dashboard" className="inline-flex items-center text-gray-500 hover:text-gray-700 mb-4">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Dashboard
</Link>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900">{qrCode.title}</h1>
<div className="flex items-center gap-2 mt-2">
<Badge variant={qrCode.type === 'DYNAMIC' ? 'info' : 'default'}>
{qrCode.type}
</Badge>
<Badge variant={qrCode.status === 'ACTIVE' ? 'success' : 'warning'}>
{qrCode.status}
</Badge>
<Badge>{qrCode.contentType}</Badge>
</div>
</div>
<div className="flex gap-2">
{qrCode.type === 'DYNAMIC' && (
<>
<Button variant="outline" size="sm" onClick={toggleStatus}>
{qrCode.status === 'ACTIVE' ? <Pause className="w-4 h-4 mr-1" /> : <Play className="w-4 h-4 mr-1" />}
{qrCode.status === 'ACTIVE' ? 'Pause' : 'Activate'}
</Button>
<Link href={`/qr/${qrId}/edit`}>
<Button variant="outline" size="sm">
<Edit className="w-4 h-4 mr-1" /> Edit
</Button>
</Link>
</>
)}
</div>
</div>
</div>
<div className="grid lg:grid-cols-3 gap-8">
{/* Left: QR Code */}
<div>
<Card>
<CardContent className="p-6 flex flex-col items-center">
<div className="bg-white p-4 rounded-xl shadow-sm mb-4">
<QRCodeSVG
value={qrUrl}
size={200}
fgColor={qrCode.style?.foregroundColor || '#000000'}
bgColor={qrCode.style?.backgroundColor || '#FFFFFF'}
/>
</div>
<div className="w-full space-y-2">
<Button variant="outline" className="w-full" onClick={copyLink}>
{copied ? <Check className="w-4 h-4 mr-2" /> : <Copy className="w-4 h-4 mr-2" />}
{copied ? 'Copied!' : 'Copy Link'}
</Button>
<a href={qrUrl} target="_blank" rel="noopener noreferrer" className="block">
<Button variant="outline" className="w-full">
<ExternalLink className="w-4 h-4 mr-2" /> Open Link
</Button>
</a>
</div>
</CardContent>
</Card>
</div>
{/* Right: Stats & Info */}
<div className="lg:col-span-2 space-y-6">
{/* Quick Stats */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
<Card>
<CardContent className="p-4 text-center">
<BarChart3 className="w-6 h-6 mx-auto mb-2 text-indigo-500" />
<p className="text-2xl font-bold text-gray-900">{qrCode._count?.scans || 0}</p>
<p className="text-sm text-gray-500">Total Scans</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<p className="text-2xl font-bold text-gray-900">{qrCode.type}</p>
<p className="text-sm text-gray-500">QR Type</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<p className="text-2xl font-bold text-gray-900">
{new Date(qrCode.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</p>
<p className="text-sm text-gray-500">Created</p>
</CardContent>
</Card>
</div>
{/* Feedback Summary (only for FEEDBACK type) */}
{qrCode.contentType === 'FEEDBACK' && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Star className="w-5 h-5 text-amber-400" />
Customer Feedback
</CardTitle>
</CardHeader>
<CardContent>
{feedbackStats && feedbackStats.total > 0 ? (
<div className="flex flex-col sm:flex-row sm:items-center gap-6 mb-4">
{/* Average */}
<div className="text-center sm:text-left">
<div className="text-4xl font-bold text-gray-900">{feedbackStats.avgRating}</div>
{renderStars(Math.round(feedbackStats.avgRating))}
<p className="text-sm text-gray-500 mt-1">{feedbackStats.total} reviews</p>
</div>
{/* Distribution */}
<div className="flex-1 space-y-1">
{[5, 4, 3, 2, 1].map((rating) => {
const count = feedbackStats.distribution[rating] || 0;
const pct = feedbackStats.total > 0 ? (count / feedbackStats.total) * 100 : 0;
return (
<div key={rating} className="flex items-center gap-2 text-sm">
<span className="w-8 text-gray-500">{rating}</span>
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-amber-400 rounded-full" style={{ width: `${pct}%` }} />
</div>
<span className="w-8 text-gray-400 text-right">{count}</span>
</div>
);
})}
</div>
</div>
) : (
<p className="text-gray-500 mb-4">No feedback received yet. Share your QR code to collect reviews!</p>
)}
<Link href={`/qr/${qrId}/feedback`} className="block">
<Button variant="outline" className="w-full">
<MessageSquare className="w-4 h-4 mr-2" />
View All Feedback
</Button>
</Link>
</CardContent>
</Card>
)}
{/* Content Info */}
<Card>
<CardHeader>
<CardTitle>Content Details</CardTitle>
</CardHeader>
<CardContent>
<pre className="bg-gray-50 p-4 rounded-lg text-sm overflow-auto">
{JSON.stringify(qrCode.content, null, 2)}
</pre>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -4,11 +4,12 @@ import React, { useState, useEffect } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { Input } from '@/components/ui/Input';
import { useCsrf } from '@/hooks/useCsrf'; import { useCsrf } from '@/hooks/useCsrf';
import { showToast } from '@/components/ui/Toast'; import { showToast } from '@/components/ui/Toast';
import ChangePasswordModal from '@/components/settings/ChangePasswordModal'; import ChangePasswordModal from '@/components/settings/ChangePasswordModal';
type TabType = 'profile' | 'subscription'; type TabType = 'profile' | 'subscription' | 'whitelabel';
export default function SettingsPage() { export default function SettingsPage() {
const { fetchWithCsrf } = useCsrf(); const { fetchWithCsrf } = useCsrf();
@@ -28,6 +29,11 @@ export default function SettingsPage() {
staticUsed: 0, 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 // Load user data
useEffect(() => { useEffect(() => {
const fetchUserData = async () => { const fetchUserData = async () => {
@@ -53,6 +59,14 @@ export default function SettingsPage() {
const data = await statsResponse.json(); const data = await statsResponse.json();
setUsageStats(data); 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) { } catch (e) {
console.error('Failed to load user data:', 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"> <nav className="-mb-px flex space-x-8">
<button <button
onClick={() => setActiveTab('profile')} onClick={() => setActiveTab('profile')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${ className={`py-4 px-1 border-b-2 font-medium text-sm ${activeTab === 'profile'
activeTab === 'profile' ? 'border-primary-500 text-primary-600'
? 'border-primary-500 text-primary-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }`}
}`}
> >
Profile Profile
</button> </button>
<button <button
onClick={() => setActiveTab('subscription')} onClick={() => setActiveTab('subscription')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${ className={`py-4 px-1 border-b-2 font-medium text-sm ${activeTab === 'subscription'
activeTab === 'subscription' ? 'border-primary-500 text-primary-600'
? 'border-primary-500 text-primary-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }`}
}`}
> >
Subscription Subscription
</button> </button>
<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> </nav>
</div> </div>
@@ -373,6 +394,143 @@ export default function SettingsPage() {
</div> </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 */} {/* Change Password Modal */}
<ChangePasswordModal <ChangePasswordModal
isOpen={showPasswordModal} isOpen={showPasswordModal}

View File

@@ -1,11 +1,11 @@
export default function AuthLayout({ export default function AuthLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white"> <div className="min-h-screen bg-gradient-to-br from-primary-50 to-white">
{children} {children}
</div> </div>
); );
} }

View File

@@ -1,164 +0,0 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { useTranslation } from '@/hooks/useTranslation';
import { useCsrf } from '@/hooks/useCsrf';
export default function LoginClientPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { t } = useTranslation();
const { fetchWithCsrf, loading: csrfLoading } = useCsrf();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const response = await fetchWithCsrf('/api/auth/simple-login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
const data = await response.json();
if (response.ok && data.success) {
// Store user in localStorage for client-side
localStorage.setItem('user', JSON.stringify(data.user));
// Track successful login with PostHog
try {
const { identifyUser, trackEvent } = await import('@/components/PostHogProvider');
identifyUser(data.user.id, {
email: data.user.email,
name: data.user.name,
plan: data.user.plan || 'FREE',
});
trackEvent('user_login', {
method: 'email',
email: data.user.email,
});
} catch (error) {
console.error('PostHog tracking error:', error);
}
// Check for redirect parameter
const redirectUrl = searchParams.get('redirect') || '/dashboard';
router.push(redirectUrl);
router.refresh();
} else {
setError(data.error || 'Invalid email or password');
}
} catch (err) {
setError('An error occurred. Please try again.');
} finally {
setLoading(false);
}
};
const handleGoogleSignIn = () => {
// Redirect to Google OAuth API route
window.location.href = '/api/auth/google';
};
return (
<Card>
<CardContent className="p-6">
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
{error}
</div>
)}
<Input
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
/>
<Input
label="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
/>
<div className="flex items-center justify-between">
<label className="flex items-center">
<input type="checkbox" className="mr-2" />
<span className="text-sm text-gray-600">Remember me</span>
</label>
<Link href="/forgot-password" className="text-sm text-primary-600 hover:text-primary-700">
Forgot password?
</Link>
</div>
<Button type="submit" className="w-full" loading={loading} disabled={csrfLoading || loading}>
{csrfLoading ? 'Loading...' : 'Sign In'}
</Button>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">Or continue with</span>
</div>
</div>
<Button
type="button"
variant="outline"
className="w-full"
onClick={handleGoogleSignIn}
>
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Sign in with Google
</Button>
</form>
<div className="mt-6 text-center">
<p className="text-sm text-gray-600">
Don't have an account?{' '}
<Link href="/signup" className="text-primary-600 hover:text-primary-700 font-medium">
Sign up
</Link>
</p>
</div>
</CardContent>
</Card>
);
}

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