Compare commits
No commits in common. "master" and "icons" have entirely different histories.
|
|
@ -36,7 +36,7 @@ yarn-error.log*
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
# prisma
|
# prisma
|
||||||
|
/prisma/migrations/
|
||||||
|
|
||||||
# docker
|
# docker
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
|
|
|
||||||
|
|
@ -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.*
|
|
||||||
|
|
@ -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.
|
|
||||||
|
Before Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
|
@ -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. |
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"firecrawl": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": [
|
|
||||||
"-y",
|
|
||||||
"firecrawl-mcp"
|
|
||||||
],
|
|
||||||
"env": {
|
|
||||||
"FIRECRAWL_API_KEY": "fc-268826f038ad4bf0a38c48690ba9c1fa"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
41
ideen.md
|
|
@ -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
|
|
||||||
|
|
@ -1,641 +0,0 @@
|
||||||
Issues
|
|
||||||
/
|
|
||||||
Open Graph tags incomplete
|
|
||||||
|
|
||||||
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
|
|
||||||
15 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
|
|
||||||
Is valid Open graph
|
|
||||||
Open graph attributes
|
|
||||||
Open graph values
|
|
||||||
Depth
|
|
||||||
Is indexable page
|
|
||||||
No. of all inlinks
|
|
||||||
24
|
|
||||||
html
|
|
||||||
Free vCard QR Generator: Digital Cards | QR Master
|
|
||||||
https://www.qrmaster.net/blog/vcard-qr-code-generator
|
|
||||||
0
|
|
||||||
No
|
|
||||||
og:type
|
|
||||||
og:image:alt
|
|
||||||
og:image
|
|
||||||
og:description
|
|
||||||
og:title
|
|
||||||
article
|
|
||||||
Professional business card with vCard QR code being scanned by smartphone
|
|
||||||
https://www.qrmaster.net/blog/vcard-qr-code.png
|
|
||||||
Create professional vCard QR codes for digital business cards. Share contact info instantly with a scan—includes templates and best practices.
|
|
||||||
Free vCard QR Generator: Digital Cards
|
|
||||||
0
|
|
||||||
Yes
|
|
||||||
8
|
|
||||||
24
|
|
||||||
html
|
|
||||||
Restaurant Menu QR Codes: 2025 Guide | QR Master
|
|
||||||
https://www.qrmaster.net/blog/qr-code-restaurant-menu
|
|
||||||
0
|
|
||||||
No
|
|
||||||
og:type
|
|
||||||
og:image:alt
|
|
||||||
og:image
|
|
||||||
og:description
|
|
||||||
og:title
|
|
||||||
article
|
|
||||||
Restaurant table with QR code menu card and smartphone scanning
|
|
||||||
https://www.qrmaster.net/blog/restaurant-qr-menu.png
|
|
||||||
Step-by-step guide to creating digital menu QR codes for your restaurant. Learn best practices for touchless menus, placement tips, and tracking.
|
|
||||||
Restaurant Menu QR Codes: 2025 Guide
|
|
||||||
0
|
|
||||||
Yes
|
|
||||||
8
|
|
||||||
24
|
|
||||||
html
|
|
||||||
QR Code Analytics: The Complete Guide | QR Master
|
|
||||||
https://www.qrmaster.net/blog/qr-code-analytics
|
|
||||||
0
|
|
||||||
No
|
|
||||||
og:type
|
|
||||||
og:image:alt
|
|
||||||
og:image
|
|
||||||
og:description
|
|
||||||
og:title
|
|
||||||
article
|
|
||||||
QR Code Analytics dashboard displaying scan metrics and user data
|
|
||||||
https://www.qrmaster.net/blog/qr-code-analytics-hero.webp
|
|
||||||
Master QR Code Analytics with our complete guide. Learn how to track scans, measure ROI, and optimize your marketing campaigns using real-time data.
|
|
||||||
QR Code Analytics: The Complete Guide
|
|
||||||
0
|
|
||||||
Yes
|
|
||||||
8
|
|
||||||
24
|
|
||||||
html
|
|
||||||
Dynamic vs Static QR Codes: The Ultimate Comparison | QR Master
|
|
||||||
https://www.qrmaster.net/blog/dynamic-vs-static-qr-codes
|
|
||||||
0
|
|
||||||
No
|
|
||||||
og:type
|
|
||||||
og:image:alt
|
|
||||||
og:image
|
|
||||||
og:description
|
|
||||||
og:title
|
|
||||||
article
|
|
||||||
Comparison graphic showing features of static versus dynamic QR codes
|
|
||||||
https://www.qrmaster.net/blog/static-vs-dynamic-qr-codes-hero.png
|
|
||||||
Static vs Dynamic QR Codes: Which should you choose? Learn the key differences, pros and cons, and why dynamic codes are better for business.
|
|
||||||
Dynamic vs Static QR Codes: The Ultimate Comparison
|
|
||||||
0
|
|
||||||
Yes
|
|
||||||
8
|
|
||||||
24
|
|
||||||
html
|
|
||||||
How to Generate Bulk QR Codes from Excel | QR Master
|
|
||||||
https://www.qrmaster.net/blog/bulk-qr-code-generator-excel
|
|
||||||
0
|
|
||||||
No
|
|
||||||
og:type
|
|
||||||
og:image:alt
|
|
||||||
og:image
|
|
||||||
og:description
|
|
||||||
og:title
|
|
||||||
article
|
|
||||||
Excel spreadsheet being converted into multiple QR codes
|
|
||||||
https://www.qrmaster.net/blog/building-qr-generator.png
|
|
||||||
Generate hundreds of QR codes from Excel or CSV files in minutes. Step-by-step guide with templates, best practices, and free tools.
|
|
||||||
How to Generate Bulk QR Codes from Excel
|
|
||||||
0
|
|
||||||
Yes
|
|
||||||
8
|
|
||||||
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
|
|
||||||
No
|
|
||||||
og:type
|
|
||||||
og:image:alt
|
|
||||||
og:image
|
|
||||||
og:description
|
|
||||||
og:title
|
|
||||||
article
|
|
||||||
Various print materials showing different QR code sizes
|
|
||||||
https://www.qrmaster.net/blog/qr-print-sizes.png
|
|
||||||
Complete guide to QR code print sizes. Learn minimum dimensions for business cards, posters, banners, and more to ensure reliable scanning.
|
|
||||||
QR Code Print Size Guide: Minimum Sizes for Every Use Case
|
|
||||||
0
|
|
||||||
Yes
|
|
||||||
8
|
|
||||||
24
|
|
||||||
html
|
|
||||||
Best QR Code Generator for Small Business 2025 | QR Master
|
|
||||||
https://www.qrmaster.net/blog/qr-code-small-business
|
|
||||||
0
|
|
||||||
No
|
|
||||||
og:type
|
|
||||||
og:image:alt
|
|
||||||
og:image
|
|
||||||
og:description
|
|
||||||
og:title
|
|
||||||
article
|
|
||||||
Small business owner using QR codes for customer engagement
|
|
||||||
https://www.qrmaster.net/blog/small-business-qr.png
|
|
||||||
Find the best QR code solution for your small business. Compare features, pricing, and use cases for marketing, payments, and operations.
|
|
||||||
Best QR Code Generator for Small Business 2025
|
|
||||||
0
|
|
||||||
Yes
|
|
||||||
8
|
|
||||||
24
|
|
||||||
html
|
|
||||||
QR Code Tracking: Complete Guide 2025 | QR Master
|
|
||||||
https://www.qrmaster.net/blog/qr-code-tracking-guide-2025
|
|
||||||
0
|
|
||||||
No
|
|
||||||
og:type
|
|
||||||
og:image:alt
|
|
||||||
og:image
|
|
||||||
og:description
|
|
||||||
og:title
|
|
||||||
article
|
|
||||||
QR Code Tracking and analytics dashboard visualization
|
|
||||||
https://www.qrmaster.net/blog/qr-code-tracking-guide-hero.webp
|
|
||||||
The complete guide to QR Code Tracking in 2025. Learn how to track scans, measure ROI, and optimize your marketing campaigns.
|
|
||||||
QR Code Tracking: Complete Guide 2025
|
|
||||||
0
|
|
||||||
Yes
|
|
||||||
8
|
|
||||||
Showing 8 of 8
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Issues
|
|
||||||
/
|
|
||||||
Pages to submit to IndexNow
|
|
||||||
|
|
||||||
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
|
|
||||||
15 Jan
|
|
||||||
0
|
|
||||||
9
|
|
||||||
18
|
|
||||||
27
|
|
||||||
36
|
|
||||||
All filter results
|
|
||||||
|
|
||||||
All filter results
|
|
||||||
12
|
|
||||||
|
|
||||||
Lost from filter results
|
|
||||||
|
|
||||||
Lost
|
|
||||||
|
|
||||||
Patches: Show all
|
|
||||||
|
|
||||||
Changes: Absolute
|
|
||||||
|
|
||||||
Columns
|
|
||||||
|
|
||||||
Export
|
|
||||||
PR
|
|
||||||
URL
|
|
||||||
Organic traffic
|
|
||||||
Changes
|
|
||||||
HTTP status code
|
|
||||||
Content type
|
|
||||||
Is indexable page
|
|
||||||
Title
|
|
||||||
Patch it
|
|
||||||
|
|
||||||
Batch AI
|
|
||||||
Meta description
|
|
||||||
Patch it
|
|
||||||
|
|
||||||
Batch AI
|
|
||||||
H1
|
|
||||||
H2
|
|
||||||
No. of content words
|
|
||||||
Changes
|
|
||||||
No. of internal outlinks
|
|
||||||
Changes
|
|
||||||
No. of external outlinks
|
|
||||||
Changes
|
|
||||||
Page text
|
|
||||||
First found at
|
|
||||||
40
|
|
||||||
html
|
|
||||||
QR Master: Dynamic QR Generator
|
|
||||||
https://www.qrmaster.net/
|
|
||||||
0
|
|
||||||
200
|
|
||||||
text/html; charset=utf-8
|
|
||||||
Yes
|
|
||||||
QR Master: Dynamic QR Generator
|
|
||||||
Enter new title
|
|
||||||
Create professional QR codes with QR Master. Dynamic QR with tracking, bulk generation, custom branding, and real-time analytics for all your campaigns.
|
|
||||||
Enter new meta description
|
|
||||||
QR Master: Dynamic QR Code Generator with Analytics
|
|
||||||
Create QR Codes That Work Everywhere
|
|
||||||
Create QR Codes That Work Everywhere
|
|
||||||
Instant QR Code Generator
|
|
||||||
The Future of QR Codes is AI-Powered
|
|
||||||
More Free QR Code Tools
|
|
||||||
Why Dynamic QR Codes Save You Money
|
|
||||||
All 8
|
|
||||||
777
|
|
||||||
29
|
|
||||||
0
|
|
||||||
View text
|
|
||||||
5 KB
|
|
||||||
38
|
|
||||||
html
|
|
||||||
QR Insights: Latest QR Strategies | QR Master
|
|
||||||
https://www.qrmaster.net/blog
|
|
||||||
0
|
|
||||||
200
|
|
||||||
text/html; charset=utf-8
|
|
||||||
Yes
|
|
||||||
QR Insights: Latest QR Strategies | QR Master
|
|
||||||
Enter new title
|
|
||||||
Expert guides on QR code analytics, dynamic vs static codes, bulk generation, and smart marketing use cases. Learn how to maximize your QR campaign ROI.
|
|
||||||
Enter new meta description
|
|
||||||
QR Code Insights
|
|
||||||
481
|
|
||||||
495
|
|
||||||
−14
|
|
||||||
37
|
|
||||||
0
|
|
||||||
View changes
|
|
||||||
3 KB
|
|
||||||
3 KB
|
|
||||||
38
|
|
||||||
html
|
|
||||||
Pricing Plans | QR Master
|
|
||||||
https://www.qrmaster.net/pricing
|
|
||||||
0
|
|
||||||
200
|
|
||||||
text/html; charset=utf-8
|
|
||||||
Yes
|
|
||||||
Pricing Plans | QR Master
|
|
||||||
Enter new title
|
|
||||||
Choose the perfect QR code plan for your needs. Free, Pro, and Business plans with dynamic QR codes, analytics, bulk generation, and custom branding.
|
|
||||||
Enter new meta description
|
|
||||||
QR Master Pricing – Choose Your QR Code Plan
|
|
||||||
Choose Your Plan
|
|
||||||
Compare our plans
|
|
||||||
Choose Your Plan
|
|
||||||
271
|
|
||||||
29
|
|
||||||
30
|
|
||||||
−1
|
|
||||||
0
|
|
||||||
View text
|
|
||||||
2 KB
|
|
||||||
38
|
|
||||||
html
|
|
||||||
QR Code Erstellen – Kostenlos | QR Master
|
|
||||||
https://www.qrmaster.net/qr-code-erstellen
|
|
||||||
0
|
|
||||||
200
|
|
||||||
text/html; charset=utf-8
|
|
||||||
Yes
|
|
||||||
QR Code Erstellen – Kostenlos | QR Master
|
|
||||||
Enter new title
|
|
||||||
Erstellen Sie QR Codes kostenlos in Sekunden. Dynamische QR-Codes mit Tracking, Branding und Massen-Erstellung. Für immer kostenlos.
|
|
||||||
Enter new meta description
|
|
||||||
QR Code Erstellen – Kostenloser QR Code Generator mit Tracking
|
|
||||||
Erstellen Sie QR-Codes, die überall funktionieren
|
|
||||||
Erstellen Sie QR-Codes, die überall funktionieren
|
|
||||||
Sofortiger QR-Code-Generator
|
|
||||||
Warum dynamische QR-Codes Geld sparen
|
|
||||||
Alles was Sie brauchen, um professionelle QR-Codes zu erstellen
|
|
||||||
Wählen Sie Ihren Plan
|
|
||||||
All 6
|
|
||||||
554
|
|
||||||
29
|
|
||||||
0
|
|
||||||
View text
|
|
||||||
4 KB
|
|
||||||
24
|
|
||||||
html
|
|
||||||
Free vCard QR Generator: Digital Cards | QR Master
|
|
||||||
https://www.qrmaster.net/blog/vcard-qr-code-generator
|
|
||||||
0
|
|
||||||
200
|
|
||||||
text/html; charset=utf-8
|
|
||||||
Yes
|
|
||||||
Free vCard QR Generator: Digital Cards | QR Master
|
|
||||||
Enter new title
|
|
||||||
Create professional vCard QR codes for digital business cards. Share contact info instantly with a scan—includes templates and best practices.
|
|
||||||
Enter new meta description
|
|
||||||
Free vCard QR Generator: Digital Cards
|
|
||||||
Quick Answer
|
|
||||||
What is a vCard QR Code?
|
|
||||||
Why Use a Digital Business Card QR Code?
|
|
||||||
Information You Can Include in a vCard
|
|
||||||
Static vs Dynamic vCard QR Codes
|
|
||||||
All 13
|
|
||||||
1,135
|
|
||||||
1,149
|
|
||||||
−14
|
|
||||||
37
|
|
||||||
0
|
|
||||||
View changes
|
|
||||||
7 KB
|
|
||||||
7 KB
|
|
||||||
24
|
|
||||||
html
|
|
||||||
Restaurant Menu QR Codes: 2025 Guide | QR Master
|
|
||||||
https://www.qrmaster.net/blog/qr-code-restaurant-menu
|
|
||||||
0
|
|
||||||
200
|
|
||||||
text/html; charset=utf-8
|
|
||||||
Yes
|
|
||||||
Restaurant Menu QR Codes: 2025 Guide | QR Master
|
|
||||||
Enter new title
|
|
||||||
Step-by-step guide to creating digital menu QR codes for your restaurant. Learn best practices for touchless menus, placement tips, and tracking.
|
|
||||||
Enter new meta description
|
|
||||||
Restaurant Menu QR Codes: 2025 Guide
|
|
||||||
Quick Answer
|
|
||||||
Why Restaurants Need QR Code Menus in 2025
|
|
||||||
Step 1: Prepare Your Digital Menu
|
|
||||||
Step 2: Create Your QR Code with QR Master
|
|
||||||
Step 3: Customize Your Restaurant QR Code
|
|
||||||
All 13
|
|
||||||
1,242
|
|
||||||
1,256
|
|
||||||
−14
|
|
||||||
38
|
|
||||||
0
|
|
||||||
View changes
|
|
||||||
8 KB
|
|
||||||
8 KB
|
|
||||||
24
|
|
||||||
html
|
|
||||||
QR Code Analytics: The Complete Guide | QR Master
|
|
||||||
https://www.qrmaster.net/blog/qr-code-analytics
|
|
||||||
0
|
|
||||||
200
|
|
||||||
text/html; charset=utf-8
|
|
||||||
Yes
|
|
||||||
QR Code Analytics: The Complete Guide | QR Master
|
|
||||||
Enter new title
|
|
||||||
Master QR Code Analytics with our complete guide. Learn how to track scans, measure ROI, and optimize your marketing campaigns using real-time data.
|
|
||||||
Master QR Code Analytics with our complete guide. Learn how to track scans, measure ROI, and optimize your marketing campaigns using real-time data and insights.
|
|
||||||
Enter new meta description
|
|
||||||
QR Code Analytics: The Complete Guide
|
|
||||||
Quick Answer
|
|
||||||
What Are Scan Analytics?
|
|
||||||
How to Set Up QR Code Analytics
|
|
||||||
Key Metrics in QR Code Analytics
|
|
||||||
Advanced Campaign Tracking Strategies
|
|
||||||
All 12
|
|
||||||
1,526
|
|
||||||
1,538
|
|
||||||
−12
|
|
||||||
37
|
|
||||||
0
|
|
||||||
View changes
|
|
||||||
10 KB
|
|
||||||
10 KB
|
|
||||||
24
|
|
||||||
html
|
|
||||||
Dynamic vs Static QR Codes: The Ultimate Comparison | QR Master
|
|
||||||
https://www.qrmaster.net/blog/dynamic-vs-static-qr-codes
|
|
||||||
0
|
|
||||||
200
|
|
||||||
text/html; charset=utf-8
|
|
||||||
Yes
|
|
||||||
Dynamic vs Static QR Codes: The Ultimate Comparison | QR Master
|
|
||||||
Enter new title
|
|
||||||
Static vs Dynamic QR Codes: Which should you choose? Learn the key differences, pros and cons, and why dynamic codes are better for business.
|
|
||||||
Static vs Dynamic QR Codes: Which one should you choose? Learn the key differences, pros and cons, and why dynamic QR codes are the better choice for business and marketing.
|
|
||||||
Enter new meta description
|
|
||||||
Dynamic vs Static QR Codes: The Ultimate Comparison
|
|
||||||
Quick Answer
|
|
||||||
What is a Static QR Code?
|
|
||||||
What is a Dynamic QR Code?
|
|
||||||
Direct Comparison: Static vs Dynamic
|
|
||||||
Why Dynamic QR Codes Are Better for Business
|
|
||||||
All 10
|
|
||||||
1,074
|
|
||||||
1,082
|
|
||||||
−8
|
|
||||||
37
|
|
||||||
0
|
|
||||||
View changes
|
|
||||||
7 KB
|
|
||||||
7 KB
|
|
||||||
24
|
|
||||||
html
|
|
||||||
How to Generate Bulk QR Codes from Excel | QR Master
|
|
||||||
https://www.qrmaster.net/blog/bulk-qr-code-generator-excel
|
|
||||||
0
|
|
||||||
200
|
|
||||||
text/html; charset=utf-8
|
|
||||||
Yes
|
|
||||||
How to Generate Bulk QR Codes from Excel | QR Master
|
|
||||||
Enter new title
|
|
||||||
Generate hundreds of QR codes from Excel or CSV files in minutes. Step-by-step guide with templates, best practices, and free tools.
|
|
||||||
Enter new meta description
|
|
||||||
How to Generate Bulk QR Codes from Excel
|
|
||||||
Quick Answer
|
|
||||||
How Bulk QR Code Generation Works
|
|
||||||
Step-by-Step Guide: Excel to QR Codes
|
|
||||||
Use Cases for Bulk QR Codes
|
|
||||||
Free vs Paid Bulk QR Tools
|
|
||||||
All 12
|
|
||||||
1,882
|
|
||||||
1,896
|
|
||||||
−14
|
|
||||||
37
|
|
||||||
1
|
|
||||||
View changes
|
|
||||||
12 KB
|
|
||||||
13 KB
|
|
||||||
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
|
|
||||||
200
|
|
||||||
text/html; charset=utf-8
|
|
||||||
Yes
|
|
||||||
QR Code Print Size Guide: Minimum Sizes for Every Use Case | QR Master
|
|
||||||
Enter new title
|
|
||||||
Complete guide to QR code print sizes. Learn minimum dimensions for business cards, posters, banners, and more to ensure reliable scanning.
|
|
||||||
Enter new meta description
|
|
||||||
QR Code Print Size Guide: Minimum Sizes for Every Use Case
|
|
||||||
Quick Answer
|
|
||||||
Why QR Code Size Matters
|
|
||||||
The Scanning Distance Formula
|
|
||||||
QR Code Sizes by Application
|
|
||||||
Factors Affecting Scanability
|
|
||||||
All 12
|
|
||||||
948
|
|
||||||
962
|
|
||||||
−14
|
|
||||||
37
|
|
||||||
0
|
|
||||||
View changes
|
|
||||||
6 KB
|
|
||||||
6 KB
|
|
||||||
24
|
|
||||||
html
|
|
||||||
Best QR Code Generator for Small Business 2025 | QR Master
|
|
||||||
https://www.qrmaster.net/blog/qr-code-small-business
|
|
||||||
0
|
|
||||||
200
|
|
||||||
text/html; charset=utf-8
|
|
||||||
Yes
|
|
||||||
Best QR Code Generator for Small Business 2025 | QR Master
|
|
||||||
Enter new title
|
|
||||||
Find the best QR code solution for your small business. Compare features, pricing, and use cases for marketing, payments, and operations.
|
|
||||||
Enter new meta description
|
|
||||||
Best QR Code Generator for Small Business 2025
|
|
||||||
Quick Answer
|
|
||||||
Why Small Businesses Need QR Codes
|
|
||||||
Top 10 QR Code Use Cases for Small Business
|
|
||||||
What to Look for in a Small Business QR Solution
|
|
||||||
QR Master for Small Business
|
|
||||||
All 11
|
|
||||||
1,034
|
|
||||||
1,048
|
|
||||||
−14
|
|
||||||
37
|
|
||||||
0
|
|
||||||
View changes
|
|
||||||
7 KB
|
|
||||||
7 KB
|
|
||||||
24
|
|
||||||
html
|
|
||||||
QR Code Tracking: Complete Guide 2025 | QR Master
|
|
||||||
https://www.qrmaster.net/blog/qr-code-tracking-guide-2025
|
|
||||||
0
|
|
||||||
200
|
|
||||||
text/html; charset=utf-8
|
|
||||||
Yes
|
|
||||||
QR Code Tracking: Complete Guide 2025 | QR Master
|
|
||||||
Enter new title
|
|
||||||
The complete guide to QR Code Tracking in 2025. Learn how to track scans, measure ROI, and optimize your marketing campaigns.
|
|
||||||
The complete guide to QR Code Tracking in 2025. Learn how to track scans, measure ROI with analytics tools, and optimize your marketing campaigns for maximum engagement.
|
|
||||||
Enter new meta description
|
|
||||||
QR Code Tracking: Complete Guide 2025
|
|
||||||
Quick Answer
|
|
||||||
What is QR Code Tracking?
|
|
||||||
Why Track QR Codes? Key Benefits
|
|
||||||
How to Track QR Code Scans: 4 Methods
|
|
||||||
QR Code Tracking Tools Comparison
|
|
||||||
All 15
|
|
||||||
2,959
|
|
||||||
2,967
|
|
||||||
−8
|
|
||||||
38
|
|
||||||
1
|
|
||||||
View changes
|
|
||||||
19 KB
|
|
||||||
19 KB
|
|
||||||
Showing 12 of 12
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
/** @type {import('next-sitemap').IConfig} */
|
||||||
|
module.exports = {
|
||||||
|
siteUrl: 'https://www.qrmaster.net',
|
||||||
|
generateRobotsTxt: true,
|
||||||
|
robotsTxtOptions: {
|
||||||
|
policies: [
|
||||||
|
{
|
||||||
|
userAgent: '*',
|
||||||
|
allow: '/',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
transform: async (config, path) => {
|
||||||
|
// Custom priority and changefreq based on path
|
||||||
|
let priority = 0.7;
|
||||||
|
let changefreq = 'weekly';
|
||||||
|
|
||||||
|
if (path === '/') {
|
||||||
|
priority = 0.9;
|
||||||
|
changefreq = 'daily';
|
||||||
|
} else if (path === '/blog') {
|
||||||
|
priority = 0.7;
|
||||||
|
changefreq = 'daily';
|
||||||
|
} else if (path === '/pricing') {
|
||||||
|
priority = 0.8;
|
||||||
|
changefreq = 'weekly';
|
||||||
|
} else if (path === '/faq') {
|
||||||
|
priority = 0.6;
|
||||||
|
changefreq = 'weekly';
|
||||||
|
} else if (path.startsWith('/blog/')) {
|
||||||
|
priority = 0.6;
|
||||||
|
changefreq = 'weekly';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
loc: path,
|
||||||
|
changefreq,
|
||||||
|
priority,
|
||||||
|
lastmod: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -20,16 +20,6 @@ const nextConfig = {
|
||||||
pagesBufferLength: 2,
|
pagesBufferLength: 2,
|
||||||
},
|
},
|
||||||
poweredByHeader: false,
|
poweredByHeader: false,
|
||||||
async redirects() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
source: '/blog/bulk-qr-codes-excel',
|
|
||||||
destination: '/blog/bulk-qr-code-generator-excel',
|
|
||||||
permanent: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
];
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
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).*
|
|
||||||
11
package.json
|
|
@ -6,10 +6,8 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3050",
|
"dev": "next dev -p 3050",
|
||||||
"build": "prisma generate && next build",
|
"build": "prisma generate && next build",
|
||||||
"submit:indexnow": "tsx scripts/submit-indexnow.ts",
|
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"indexnow": "tsx scripts/submit-indexnow.ts",
|
|
||||||
"db:generate": "prisma generate",
|
"db:generate": "prisma generate",
|
||||||
"db:migrate": "prisma migrate dev",
|
"db:migrate": "prisma migrate dev",
|
||||||
"db:deploy": "prisma migrate deploy",
|
"db:deploy": "prisma migrate deploy",
|
||||||
|
|
@ -32,20 +30,15 @@
|
||||||
"@prisma/client": "^5.7.0",
|
"@prisma/client": "^5.7.0",
|
||||||
"@stripe/stripe-js": "^8.0.0",
|
"@stripe/stripe-js": "^8.0.0",
|
||||||
"@types/d3-scale": "^4.0.9",
|
"@types/d3-scale": "^4.0.9",
|
||||||
"axios": "^1.13.2",
|
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"chart.js": "^4.4.0",
|
"chart.js": "^4.4.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"d3-scale": "^4.0.2",
|
"d3-scale": "^4.0.2",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"dotenv": "^17.2.3",
|
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"framer-motion": "^12.24.10",
|
|
||||||
"html-to-image": "^1.11.13",
|
|
||||||
"i18next": "^23.7.6",
|
"i18next": "^23.7.6",
|
||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"jspdf": "^4.0.0",
|
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next": "^14.2.35",
|
"next": "^14.2.35",
|
||||||
|
|
@ -62,6 +55,7 @@
|
||||||
"react-i18next": "^13.5.0",
|
"react-i18next": "^13.5.0",
|
||||||
"react-simple-maps": "^3.0.0",
|
"react-simple-maps": "^3.0.0",
|
||||||
"resend": "^6.4.2",
|
"resend": "^6.4.2",
|
||||||
|
"sharp": "^0.33.1",
|
||||||
"stripe": "^19.1.0",
|
"stripe": "^19.1.0",
|
||||||
"tailwind-merge": "^2.2.0",
|
"tailwind-merge": "^2.2.0",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
|
|
@ -82,7 +76,6 @@
|
||||||
"postcss": "^8.4.32",
|
"postcss": "^8.4.32",
|
||||||
"prettier": "^3.1.1",
|
"prettier": "^3.1.1",
|
||||||
"prisma": "^5.7.0",
|
"prisma": "^5.7.0",
|
||||||
"sharp": "^0.34.5",
|
|
||||||
"tailwindcss": "^3.3.6",
|
"tailwindcss": "^3.3.6",
|
||||||
"tsx": "^4.7.0",
|
"tsx": "^4.7.0",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
|
|
@ -90,4 +83,4 @@
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -161,18 +161,4 @@ model NewsletterSubscription {
|
||||||
|
|
||||||
@@index([email])
|
@@index([email])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
}
|
|
||||||
|
|
||||||
model Lead {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
email String
|
|
||||||
source String @default("reprint-calculator")
|
|
||||||
reprintCost Float?
|
|
||||||
updatesPerYear Int?
|
|
||||||
annualSavings Float?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
@@index([email])
|
|
||||||
@@index([createdAt])
|
|
||||||
@@index([source])
|
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
bb6dfaacf1ed41a880281c426c54ed7c
|
|
||||||
|
After Width: | Height: | Size: 4.2 MiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
Before Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 4.1 MiB |
|
Before Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 3.8 MiB |
|
Before Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 5.5 MiB |
|
After Width: | Height: | Size: 4.6 MiB |
|
After Width: | Height: | Size: 5.8 MiB |
|
After Width: | Height: | Size: 3.7 MiB |
|
Before Width: | Height: | Size: 737 KiB |
|
Before Width: | Height: | Size: 350 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 320 KiB |
|
Before Width: | Height: | Size: 266 KiB |
|
Before Width: | Height: | Size: 726 KiB |
|
|
@ -1 +0,0 @@
|
||||||
google-site-verification: googleccd5315437d68a49.html
|
|
||||||
|
Before Width: | Height: | Size: 518 KiB |
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
Before Width: | Height: | Size: 531 KiB |
|
Before Width: | Height: | Size: 496 KiB |
|
Before Width: | Height: | Size: 593 KiB |
|
Before Width: | Height: | Size: 583 KiB |
|
Before Width: | Height: | Size: 511 KiB |
|
Before Width: | Height: | Size: 464 KiB |
|
Before Width: | Height: | Size: 448 KiB |
|
Before Width: | Height: | Size: 511 KiB |
|
Before Width: | Height: | Size: 466 KiB |
|
Before Width: | Height: | Size: 498 KiB |
|
Before Width: | Height: | Size: 442 KiB |
|
Before Width: | Height: | Size: 486 KiB |
|
Before Width: | Height: | Size: 462 KiB |
|
Before Width: | Height: | Size: 397 KiB |
|
Before Width: | Height: | Size: 335 KiB |
|
Before Width: | Height: | Size: 535 KiB |
|
Before Width: | Height: | Size: 555 KiB |
|
Before Width: | Height: | Size: 600 KiB |
|
Before Width: | Height: | Size: 429 KiB |
|
Before Width: | Height: | Size: 392 KiB |
|
Before Width: | Height: | Size: 551 KiB |
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>https://www.qrmaster.net/</loc>
|
||||||
|
<lastmod>2025-10-16T00:00:00Z</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://www.qrmaster.net/blog</loc>
|
||||||
|
<lastmod>2025-10-16T00:00:00Z</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://www.qrmaster.net/pricing</loc>
|
||||||
|
<lastmod>2025-10-16T00:00:00Z</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://www.qrmaster.net/faq</loc>
|
||||||
|
<lastmod>2025-10-16T00:00:00Z</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://www.qrmaster.net/blog/qr-code-analytics</loc>
|
||||||
|
<lastmod>2025-10-16T00:00:00Z</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
|
|
@ -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();
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
|
|
||||||
// Helper script to run IndexNow submission
|
|
||||||
// Run with: npx tsx scripts/submit-indexnow.ts
|
|
||||||
|
|
||||||
import { getAllIndexableUrls, submitToIndexNow } from '../src/lib/indexnow';
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
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. Using placeholder.');
|
|
||||||
// In production, you'd fail here. For dev/demo, we proceed but expect failure from API.
|
|
||||||
}
|
|
||||||
|
|
||||||
await submitToIndexNow(urls);
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(console.error);
|
|
||||||
|
|
@ -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();
|
|
||||||
|
|
@ -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();
|
|
||||||
|
|
@ -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
|
|
||||||
68
seo_tasks.md
|
|
@ -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.
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +1,25 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { QRCodeSVG } from 'qrcode.react';
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
import { toPng } from 'html-to-image';
|
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { Select } from '@/components/ui/Select';
|
import { Select } from '@/components/ui/Select';
|
||||||
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 { calculateContrast, cn } from '@/lib/utils';
|
import { calculateContrast } from '@/lib/utils';
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { useCsrf } from '@/hooks/useCsrf';
|
import { useCsrf } from '@/hooks/useCsrf';
|
||||||
import { showToast } from '@/components/ui/Toast';
|
import { showToast } from '@/components/ui/Toast';
|
||||||
|
|
||||||
// Content-type specific frame options
|
|
||||||
const getFrameOptionsForContentType = (contentType: string) => {
|
|
||||||
const baseOptions = [{ id: 'none', label: 'No Frame' }, { id: 'scanme', label: 'Scan Me' }];
|
|
||||||
|
|
||||||
switch (contentType) {
|
|
||||||
case 'URL':
|
|
||||||
return [...baseOptions, { id: 'website', label: 'Website' }, { id: 'visit', label: 'Visit' }];
|
|
||||||
case 'PHONE':
|
|
||||||
return [...baseOptions, { id: 'callme', label: 'Call Me' }, { id: 'call', label: 'Call' }];
|
|
||||||
case 'GEO':
|
|
||||||
return [...baseOptions, { id: 'findus', label: 'Find Us' }, { id: 'navigate', label: 'Navigate' }];
|
|
||||||
case 'VCARD':
|
|
||||||
return [...baseOptions, { id: 'contact', label: 'Contact' }, { id: 'save', label: 'Save' }];
|
|
||||||
case 'SMS':
|
|
||||||
return [...baseOptions, { id: 'textme', label: 'Text Me' }, { id: 'message', label: 'Message' }];
|
|
||||||
case 'WHATSAPP':
|
|
||||||
return [...baseOptions, { id: 'chatme', label: 'Chat Me' }, { id: 'whatsapp', label: 'WhatsApp' }];
|
|
||||||
case 'TEXT':
|
|
||||||
return [...baseOptions, { id: 'read', label: 'Read' }, { id: 'info', label: 'Info' }];
|
|
||||||
default:
|
|
||||||
return [...baseOptions, { id: 'website', label: 'Website' }, { id: 'visit', label: 'Visit' }];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function CreatePage() {
|
export default function CreatePage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { fetchWithCsrf } = useCsrf();
|
const { fetchWithCsrf } = useCsrf();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [userPlan, setUserPlan] = useState<string>('FREE');
|
const [userPlan, setUserPlan] = useState<string>('FREE');
|
||||||
const qrRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
|
|
@ -58,18 +32,6 @@ export default function CreatePage() {
|
||||||
const [backgroundColor, setBackgroundColor] = useState('#FFFFFF');
|
const [backgroundColor, setBackgroundColor] = useState('#FFFFFF');
|
||||||
const [cornerStyle, setCornerStyle] = useState('square');
|
const [cornerStyle, setCornerStyle] = useState('square');
|
||||||
const [size, setSize] = useState(200);
|
const [size, setSize] = useState(200);
|
||||||
const [frameType, setFrameType] = useState('none');
|
|
||||||
|
|
||||||
// Get frame options for current content type
|
|
||||||
const frameOptions = getFrameOptionsForContentType(contentType);
|
|
||||||
|
|
||||||
// Reset frame type when content type changes (if current frame is not valid)
|
|
||||||
useEffect(() => {
|
|
||||||
const validIds = frameOptions.map(f => f.id);
|
|
||||||
if (!validIds.includes(frameType)) {
|
|
||||||
setFrameType('none');
|
|
||||||
}
|
|
||||||
}, [contentType, frameOptions, frameType]);
|
|
||||||
|
|
||||||
// Logo state
|
// Logo state
|
||||||
const [logoUrl, setLogoUrl] = useState('');
|
const [logoUrl, setLogoUrl] = useState('');
|
||||||
|
|
@ -135,58 +97,61 @@ export default function CreatePage() {
|
||||||
|
|
||||||
const qrContent = getQRContent();
|
const qrContent = getQRContent();
|
||||||
|
|
||||||
const getFrameLabel = () => {
|
|
||||||
const frame = frameOptions.find((f: { id: string; label: string }) => f.id === frameType);
|
|
||||||
return frame?.id !== 'none' ? frame?.label : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadQR = async (format: 'svg' | 'png') => {
|
const downloadQR = async (format: 'svg' | 'png') => {
|
||||||
if (!qrRef.current) return;
|
|
||||||
try {
|
try {
|
||||||
if (format === 'png') {
|
// Get the content based on content type
|
||||||
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3, backgroundColor: 'transparent' });
|
let qrContent = '';
|
||||||
const link = document.createElement('a');
|
switch (contentType) {
|
||||||
link.download = `qrcode-${title || 'download'}.png`;
|
case 'URL':
|
||||||
link.href = dataUrl;
|
qrContent = content.url || '';
|
||||||
link.click();
|
break;
|
||||||
|
case 'PHONE':
|
||||||
|
qrContent = `tel:${content.phone || ''}`;
|
||||||
|
break;
|
||||||
|
case 'EMAIL':
|
||||||
|
qrContent = `mailto:${content.email || ''}${content.subject ? `?subject=${encodeURIComponent(content.subject)}` : ''}`;
|
||||||
|
break;
|
||||||
|
case 'TEXT':
|
||||||
|
qrContent = content.text || '';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
qrContent = content.url || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!qrContent) return;
|
||||||
|
|
||||||
|
const QRCode = (await import('qrcode')).default;
|
||||||
|
|
||||||
|
if (format === 'svg') {
|
||||||
|
const svg = await QRCode.toString(qrContent, {
|
||||||
|
type: 'svg',
|
||||||
|
width: size,
|
||||||
|
margin: 2,
|
||||||
|
color: {
|
||||||
|
dark: foregroundColor,
|
||||||
|
light: backgroundColor,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const blob = new Blob([svg], { type: 'image/svg+xml' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `qrcode-${title || 'download'}.svg`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
} else {
|
} else {
|
||||||
// For SVG, we might still want to use the library or just toPng if SVG export of HTML is not needed
|
const a = document.createElement('a');
|
||||||
// Simplest is to check if we can export the SVG element directly but that misses the frame HTML.
|
a.href = qrDataUrl;
|
||||||
// html-to-image can generate SVG too.
|
a.download = `qrcode-${title || 'download'}.png`;
|
||||||
// But usually for SVG users want the vector. Capturing HTML to SVG is possible but complex.
|
document.body.appendChild(a);
|
||||||
// For now, let's just stick to the SVG code export if NO FRAME is selected,
|
a.click();
|
||||||
// otherwise warn or use toPng (as SVG).
|
document.body.removeChild(a);
|
||||||
// Actually, the previous implementation was good for pure QR.
|
|
||||||
// If frame is selected, we MUST use a raster export (PNG) or complex HTML-to-SVG.
|
|
||||||
// Let's rely on toPng for consistency with frames.
|
|
||||||
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3, backgroundColor: 'transparent' });
|
|
||||||
// Wait, exporting HTML to valid vector SVG is hard.
|
|
||||||
// Let's just offer PNG for frames for now to be safe, or just use the same PNG download for both buttons if frame is active?
|
|
||||||
// No, let's try to grab the INNER SVG if no frame, else...
|
|
||||||
if (frameType === 'none') {
|
|
||||||
const svgElement = qrRef.current.querySelector('svg');
|
|
||||||
if (svgElement) {
|
|
||||||
const svgData = new XMLSerializer().serializeToString(svgElement);
|
|
||||||
const blob = new Blob([svgData], { type: 'image/svg+xml' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `qrcode-${title || 'download'}.svg`;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
showToast('SVG download not available with frames yet. Downloading PNG instead.', 'info');
|
|
||||||
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3, backgroundColor: 'transparent' });
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.download = `qrcode-${title || 'download'}.png`;
|
|
||||||
link.href = dataUrl;
|
|
||||||
link.click();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error downloading QR code:', err);
|
console.error('Error downloading QR code:', err);
|
||||||
showToast('Error downloading QR code', 'error');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -255,7 +220,6 @@ export default function CreatePage() {
|
||||||
width: logoSize,
|
width: logoSize,
|
||||||
excavate,
|
excavate,
|
||||||
} : undefined,
|
} : undefined,
|
||||||
frameType, // Save frame type
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -484,7 +448,7 @@ export default function CreatePage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-4">
|
||||||
{!canCustomizeColors && (
|
{!canCustomizeColors && (
|
||||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg mb-4">
|
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg mb-4">
|
||||||
<p className="text-sm text-blue-900">
|
<p className="text-sm text-blue-900">
|
||||||
|
|
@ -497,29 +461,6 @@ export default function CreatePage() {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Frame Options */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-3">Frame</label>
|
|
||||||
<div className="grid grid-cols-4 gap-2">
|
|
||||||
{frameOptions.map((frame: { id: string; label: string }) => (
|
|
||||||
<button
|
|
||||||
key={frame.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFrameType(frame.id)}
|
|
||||||
className={cn(
|
|
||||||
"py-2 px-3 rounded-lg text-sm font-medium transition-all border",
|
|
||||||
frameType === frame.id
|
|
||||||
? "bg-slate-900 text-white border-slate-900"
|
|
||||||
: "bg-gray-50 text-gray-600 border-gray-200 hover:border-gray-300"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{frame.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
|
@ -694,48 +635,27 @@ export default function CreatePage() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="text-center">
|
<CardContent className="text-center">
|
||||||
<div id="create-qr-preview" className="flex justify-center mb-4">
|
<div id="create-qr-preview" className="flex justify-center mb-4">
|
||||||
{/* WRAPPER FOR REF AND FRAME */}
|
{qrContent ? (
|
||||||
<div
|
<div className={cornerStyle === 'rounded' ? 'rounded-lg overflow-hidden' : ''}>
|
||||||
ref={qrRef}
|
<QRCodeSVG
|
||||||
className="relative bg-white rounded-xl p-4 flex flex-col items-center justify-center transition-all duration-300"
|
value={qrContent}
|
||||||
style={{
|
size={200}
|
||||||
minWidth: '280px',
|
fgColor={foregroundColor}
|
||||||
minHeight: '280px',
|
bgColor={backgroundColor}
|
||||||
}}
|
level="H"
|
||||||
>
|
imageSettings={logoUrl ? {
|
||||||
{/* Frame Label */}
|
src: logoUrl,
|
||||||
{getFrameLabel() && (
|
height: logoSize,
|
||||||
<div
|
width: logoSize,
|
||||||
className="mb-4 px-6 py-2 rounded-full font-bold text-sm tracking-widest uppercase shadow-md text-white"
|
excavate: excavate,
|
||||||
style={{ backgroundColor: foregroundColor }}
|
} : undefined}
|
||||||
>
|
/>
|
||||||
{getFrameLabel()}
|
</div>
|
||||||
</div>
|
) : (
|
||||||
)}
|
<div className="w-[200px] h-[200px] bg-gray-100 rounded flex items-center justify-center text-gray-500">
|
||||||
|
Enter content
|
||||||
{qrContent ? (
|
</div>
|
||||||
<div className={cornerStyle === 'rounded' ? 'rounded-lg overflow-hidden' : ''}>
|
)}
|
||||||
<QRCodeSVG
|
|
||||||
value={qrContent}
|
|
||||||
size={size}
|
|
||||||
fgColor={foregroundColor}
|
|
||||||
bgColor={backgroundColor}
|
|
||||||
level="H"
|
|
||||||
includeMargin={false}
|
|
||||||
imageSettings={logoUrl ? {
|
|
||||||
src: logoUrl,
|
|
||||||
height: logoSize,
|
|
||||||
width: logoSize,
|
|
||||||
excavate: excavate,
|
|
||||||
} : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="w-[200px] h-[200px] bg-gray-100 rounded flex items-center justify-center text-gray-500">
|
|
||||||
Enter content
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
@ -743,7 +663,38 @@ export default function CreatePage() {
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => downloadQR('svg')}
|
onClick={() => {
|
||||||
|
const svg = document.querySelector('#create-qr-preview svg');
|
||||||
|
if (!svg) return;
|
||||||
|
|
||||||
|
let svgData = new XMLSerializer().serializeToString(svg);
|
||||||
|
|
||||||
|
// If rounded corners, wrap in a clipped SVG
|
||||||
|
if (cornerStyle === 'rounded') {
|
||||||
|
const width = svg.getAttribute('width') || '200';
|
||||||
|
const height = svg.getAttribute('height') || '200';
|
||||||
|
const borderRadius = 20;
|
||||||
|
|
||||||
|
svgData = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||||
|
<defs>
|
||||||
|
<clipPath id="rounded-corners">
|
||||||
|
<rect x="0" y="0" width="${width}" height="${height}" rx="${borderRadius}" ry="${borderRadius}"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<g clip-path="url(#rounded-corners)">
|
||||||
|
${svgData}
|
||||||
|
</g>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob([svgData], { type: 'image/svg+xml' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${title || 'qrcode'}.svg`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}}
|
||||||
disabled={!qrContent}
|
disabled={!qrContent}
|
||||||
>
|
>
|
||||||
Download SVG
|
Download SVG
|
||||||
|
|
@ -752,7 +703,54 @@ export default function CreatePage() {
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => downloadQR('png')}
|
onClick={() => {
|
||||||
|
const svg = document.querySelector('#create-qr-preview svg');
|
||||||
|
if (!svg) return;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
const svgData = new XMLSerializer().serializeToString(svg);
|
||||||
|
const blob = new Blob([svgData], { type: 'image/svg+xml' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
canvas.width = 200;
|
||||||
|
canvas.height = 200;
|
||||||
|
|
||||||
|
// Apply rounded corners if needed
|
||||||
|
if (cornerStyle === 'rounded') {
|
||||||
|
const borderRadius = 20;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(borderRadius, 0);
|
||||||
|
ctx.lineTo(200 - borderRadius, 0);
|
||||||
|
ctx.quadraticCurveTo(200, 0, 200, borderRadius);
|
||||||
|
ctx.lineTo(200, 200 - borderRadius);
|
||||||
|
ctx.quadraticCurveTo(200, 200, 200 - borderRadius, 200);
|
||||||
|
ctx.lineTo(borderRadius, 200);
|
||||||
|
ctx.quadraticCurveTo(0, 200, 0, 200 - borderRadius);
|
||||||
|
ctx.lineTo(0, borderRadius);
|
||||||
|
ctx.quadraticCurveTo(0, 0, borderRadius, 0);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.clip();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (blob) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${title || 'qrcode'}.png`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
}}
|
||||||
disabled={!qrContent}
|
disabled={!qrContent}
|
||||||
>
|
>
|
||||||
Download PNG
|
Download PNG
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,254 @@
|
||||||
import type { Metadata } from 'next';
|
'use client';
|
||||||
import '@/styles/globals.css';
|
|
||||||
import { Suspense } from 'react';
|
|
||||||
import { Providers } from '@/components/Providers';
|
|
||||||
import AppLayout from './AppLayout';
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
import React, { useState, useEffect } from 'react';
|
||||||
title: 'Dashboard | QR Master',
|
import Link from 'next/link';
|
||||||
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 { usePathname, useRouter } from 'next/navigation';
|
||||||
robots: { index: false, follow: false },
|
import { Button } from '@/components/ui/Button';
|
||||||
icons: {
|
import { Dropdown, DropdownItem } from '@/components/ui/Dropdown';
|
||||||
icon: [
|
import { Footer } from '@/components/ui/Footer';
|
||||||
{ url: '/favicon.svg', type: 'image/svg+xml' },
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
{ url: '/logo.svg', type: 'image/svg+xml' },
|
|
||||||
],
|
|
||||||
apple: '/logo.svg',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RootAppLayout({
|
interface User {
|
||||||
children,
|
id: string;
|
||||||
}: {
|
name: string | null;
|
||||||
children: React.ReactNode;
|
email: string;
|
||||||
}) {
|
plan: string | null;
|
||||||
return (
|
|
||||||
<html lang="en">
|
|
||||||
<body className="font-sans">
|
|
||||||
<Providers>
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<AppLayout>
|
|
||||||
{children}
|
|
||||||
</AppLayout>
|
|
||||||
</Suspense>
|
|
||||||
</Providers>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -7,9 +7,8 @@ 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';
|
||||||
import { ObfuscatedMailto } from '@/components/ui/ObfuscatedMailto';
|
|
||||||
|
|
||||||
export default function PricingClient() {
|
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');
|
||||||
|
|
@ -142,13 +141,13 @@ export default function PricingClient() {
|
||||||
'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,
|
||||||
|
|
@ -171,8 +170,8 @@ export default function PricingClient() {
|
||||||
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,
|
||||||
|
|
@ -183,9 +182,9 @@ export default function PricingClient() {
|
||||||
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">
|
||||||
<h2 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
|
||||||
</h2>
|
</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>
|
||||||
|
|
@ -261,7 +260,7 @@ export default function PricingClient() {
|
||||||
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? <ObfuscatedMailto email="support@qrmaster.net" className="text-primary-600 hover:text-primary-700 underline">Contact our team</ObfuscatedMailto>
|
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>
|
||||||
|
|
@ -1,38 +1,11 @@
|
||||||
import '@/styles/globals.css';
|
export default function AuthLayout({
|
||||||
import { Providers } from '@/components/Providers';
|
|
||||||
import type { Metadata } from 'next';
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: 'Authentication | QR Master',
|
|
||||||
description: 'Securely login or sign up to QR Master to manage your dynamic QR codes, track analytics, and access premium features. Your gateway to professional QR management.',
|
|
||||||
icons: {
|
|
||||||
icon: [
|
|
||||||
{ url: '/favicon.svg', type: 'image/svg+xml' },
|
|
||||||
{ url: '/logo.svg', type: 'image/svg+xml' },
|
|
||||||
],
|
|
||||||
apple: '/logo.svg',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AuthRootLayout({
|
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white">
|
||||||
<body className="font-sans">
|
{children}
|
||||||
<Providers>
|
</div>
|
||||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white">
|
|
||||||
{children}
|
|
||||||
<div className="py-6 text-center text-sm text-slate-500 space-x-4">
|
|
||||||
<a href="/" className="hover:text-primary-600 transition-colors">Home</a>
|
|
||||||
<a href="/privacy" className="hover:text-primary-600 transition-colors">Privacy</a>
|
|
||||||
<a href="/faq" className="hover:text-primary-600 transition-colors">FAQ</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Providers>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +1,76 @@
|
||||||
import React, { Suspense } from 'react';
|
'use client';
|
||||||
import type { Metadata } from 'next';
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import LoginClientPage from './ClientPage';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
export const metadata: Metadata = {
|
import { Button } from '@/components/ui/Button';
|
||||||
title: {
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
absolute: 'Login to QR Master | Access Your Dashboard'
|
import { useCsrf } from '@/hooks/useCsrf';
|
||||||
},
|
|
||||||
description: 'Sign in to QR Master to create, manage, and track your QR codes. Access your dashboard and view analytics.',
|
|
||||||
alternates: {
|
|
||||||
canonical: 'https://www.qrmaster.net/login',
|
|
||||||
},
|
|
||||||
openGraph: {
|
|
||||||
title: 'Login to QR Master | Access Your Dashboard',
|
|
||||||
description: 'Sign in to QR Master to create, manage, and track your QR codes.',
|
|
||||||
url: 'https://www.qrmaster.net/login',
|
|
||||||
type: 'website',
|
|
||||||
images: [{
|
|
||||||
url: 'https://www.qrmaster.net/og-image.png',
|
|
||||||
width: 1200,
|
|
||||||
height: 630,
|
|
||||||
alt: 'QR Master Login',
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
twitter: {
|
|
||||||
card: 'summary_large_image',
|
|
||||||
title: 'Login to QR Master | Access Your Dashboard',
|
|
||||||
description: 'Sign in to QR Master to create, manage, and track your QR codes.',
|
|
||||||
images: ['https://www.qrmaster.net/og-image.png'],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
|
|
@ -48,13 +86,94 @@ export default function LoginPage() {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Suspense fallback={
|
<Card>
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-8 flex items-center justify-center min-h-[400px]">
|
<CardContent className="p-6">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
</div>
|
{error && (
|
||||||
}>
|
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
|
||||||
<LoginClientPage />
|
{error}
|
||||||
</Suspense>
|
</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>
|
||||||
|
|
||||||
<p className="text-center text-sm text-gray-500 mt-6">
|
<p className="text-center text-sm text-gray-500 mt-6">
|
||||||
By signing in, you agree to our{' '}
|
By signing in, you agree to our{' '}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,7 @@ import { Input } from '@/components/ui/Input';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { useCsrf } from '@/hooks/useCsrf';
|
import { useCsrf } from '@/hooks/useCsrf';
|
||||||
|
|
||||||
import { Suspense } from 'react';
|
export default function ResetPasswordPage() {
|
||||||
|
|
||||||
function ResetPasswordContent() {
|
|
||||||
const { fetchWithCsrf, loading: csrfLoading } = useCsrf();
|
const { fetchWithCsrf, loading: csrfLoading } = useCsrf();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -208,11 +206,3 @@ function ResetPasswordContent() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ResetPasswordPage() {
|
|
||||||
return (
|
|
||||||
<Suspense fallback={<div className="min-h-screen flex items-center justify-center">Loading...</div>}>
|
|
||||||
<ResetPasswordContent />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,185 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { useRouter } 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 SignupClientPage() {
|
|
||||||
const router = useRouter();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { fetchWithCsrf } = useCsrf();
|
|
||||||
const [name, setName] = useState('');
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setLoading(true);
|
|
||||||
setError('');
|
|
||||||
|
|
||||||
if (password !== confirmPassword) {
|
|
||||||
setError('Passwords do not match');
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password.length < 8) {
|
|
||||||
setError('Password must be at least 8 characters');
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetchWithCsrf('/api/auth/signup', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ name, 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 signup 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',
|
|
||||||
signupMethod: 'email',
|
|
||||||
});
|
|
||||||
trackEvent('user_signup', {
|
|
||||||
method: 'email',
|
|
||||||
email: data.user.email,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('PostHog tracking error:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect to dashboard
|
|
||||||
router.push('/dashboard');
|
|
||||||
router.refresh();
|
|
||||||
} else {
|
|
||||||
setError(data.error || 'Failed to create account');
|
|
||||||
}
|
|
||||||
} 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="Full Name"
|
|
||||||
type="text"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder="John Doe"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<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
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
label="Confirm Password"
|
|
||||||
type="password"
|
|
||||||
value={confirmPassword}
|
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
||||||
placeholder="••••••••"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button type="submit" className="w-full" loading={loading}>
|
|
||||||
Create Account
|
|
||||||
</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 up with Google
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="mt-6 text-center">
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Already have an account?{' '}
|
|
||||||
<Link href="/login" className="text-primary-600 hover:text-primary-700 font-medium">
|
|
||||||
Sign in
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +1,89 @@
|
||||||
import React, { Suspense } from 'react';
|
'use client';
|
||||||
import type { Metadata } from 'next';
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import SignupClientPage from './ClientPage';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
export const metadata: Metadata = {
|
import { Button } from '@/components/ui/Button';
|
||||||
title: {
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
absolute: 'Create Free Account | QR Master'
|
import { useCsrf } from '@/hooks/useCsrf';
|
||||||
},
|
|
||||||
description: 'Sign up for QR Master to create free QR codes. Start with tracking, customization, and bulk generation features.',
|
|
||||||
alternates: {
|
|
||||||
canonical: 'https://www.qrmaster.net/signup',
|
|
||||||
},
|
|
||||||
openGraph: {
|
|
||||||
title: 'Create Free Account | QR Master',
|
|
||||||
description: 'Sign up for QR Master to create free QR codes with tracking and customization.',
|
|
||||||
url: 'https://www.qrmaster.net/signup',
|
|
||||||
type: 'website',
|
|
||||||
images: [{
|
|
||||||
url: 'https://www.qrmaster.net/og-image.png',
|
|
||||||
width: 1200,
|
|
||||||
height: 630,
|
|
||||||
alt: 'QR Master Sign Up',
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
twitter: {
|
|
||||||
card: 'summary_large_image',
|
|
||||||
title: 'Create Free Account | QR Master',
|
|
||||||
description: 'Sign up for QR Master to create free QR codes with tracking and customization.',
|
|
||||||
images: ['https://www.qrmaster.net/og-image.png'],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default function SignupPage() {
|
export default function SignupPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { fetchWithCsrf } = useCsrf();
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError('Passwords do not match');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError('Password must be at least 8 characters');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchWithCsrf('/api/auth/signup', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name, 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 signup 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',
|
||||||
|
signupMethod: 'email',
|
||||||
|
});
|
||||||
|
trackEvent('user_signup', {
|
||||||
|
method: 'email',
|
||||||
|
email: data.user.email,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('PostHog tracking error:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to dashboard
|
||||||
|
router.push('/dashboard');
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
setError(data.error || 'Failed to create account');
|
||||||
|
}
|
||||||
|
} 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 (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
|
|
@ -49,13 +99,102 @@ export default function SignupPage() {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Suspense fallback={
|
<Card>
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-8 flex items-center justify-center min-h-[500px]">
|
<CardContent className="p-6">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
</div>
|
{error && (
|
||||||
}>
|
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
|
||||||
<SignupClientPage />
|
{error}
|
||||||
</Suspense>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Full Name"
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="John Doe"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Confirm Password"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" loading={loading}>
|
||||||
|
Create Account
|
||||||
|
</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 up with Google
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Already have an account?{' '}
|
||||||
|
<Link href="/login" className="text-primary-600 hover:text-primary-700 font-medium">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<p className="text-center text-sm text-gray-500 mt-6">
|
<p className="text-center text-sm text-gray-500 mt-6">
|
||||||
By signing up, you agree to our{' '}
|
By signing up, you agree to our{' '}
|
||||||
|
|
|
||||||
|
|
@ -1,287 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Footer } from '@/components/ui/Footer';
|
|
||||||
import en from '@/i18n/en.json';
|
|
||||||
import { ChevronDown, Wifi, Contact, MessageCircle, QrCode, Link2, Type, Mail, MessageSquare, Phone, Calendar, MapPin, Facebook, Instagram, Twitter, Youtube, Music, Bitcoin, CreditCard, Video, Users } from 'lucide-react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
|
||||||
|
|
||||||
export default function MarketingLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
|
||||||
const [scrolled, setScrolled] = useState(false);
|
|
||||||
const [toolsOpen, setToolsOpen] = useState(false);
|
|
||||||
const [mobileToolsOpen, setMobileToolsOpen] = useState(false);
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleScroll = () => {
|
|
||||||
setScrolled(window.scrollY > 20);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check immediately on mount
|
|
||||||
handleScroll();
|
|
||||||
|
|
||||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
||||||
return () => window.removeEventListener('scroll', handleScroll);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Close simple menus when path changes
|
|
||||||
useEffect(() => {
|
|
||||||
setMobileMenuOpen(false);
|
|
||||||
setToolsOpen(false);
|
|
||||||
}, [pathname]);
|
|
||||||
|
|
||||||
// Default to English for general marketing pages
|
|
||||||
const t = en;
|
|
||||||
|
|
||||||
const tools = [
|
|
||||||
{ name: 'URL / Link', description: 'Link to any website', href: '/tools/url-qr-code', icon: Link2, color: 'text-blue-500', bgColor: 'bg-blue-50' },
|
|
||||||
{ name: 'Text', description: 'Plain text message', href: '/tools/text-qr-code', icon: Type, color: 'text-slate-500', bgColor: 'bg-slate-50' },
|
|
||||||
{ name: 'WiFi', description: 'Share WiFi credentials', href: '/tools/wifi-qr-code', icon: Wifi, color: 'text-indigo-500', bgColor: 'bg-indigo-50' },
|
|
||||||
{ name: 'VCard', description: 'Digital business card', href: '/tools/vcard-qr-code', icon: Contact, color: 'text-pink-500', bgColor: 'bg-pink-50' },
|
|
||||||
{ name: 'WhatsApp', description: 'Start a chat', href: '/tools/whatsapp-qr-code', icon: MessageCircle, color: 'text-green-500', bgColor: 'bg-green-50' },
|
|
||||||
{ name: 'Email', description: 'Compose an email', href: '/tools/email-qr-code', icon: Mail, color: 'text-amber-500', bgColor: 'bg-amber-50' },
|
|
||||||
{ name: 'SMS', description: 'Send a text message', href: '/tools/sms-qr-code', icon: MessageSquare, color: 'text-cyan-500', bgColor: 'bg-cyan-50' },
|
|
||||||
{ name: 'Phone', description: 'Start a call', href: '/tools/phone-qr-code', icon: Phone, color: 'text-violet-500', bgColor: 'bg-violet-50' },
|
|
||||||
{ name: 'Event', description: 'Add calendar event', href: '/tools/event-qr-code', icon: Calendar, color: 'text-red-500', bgColor: 'bg-red-50' },
|
|
||||||
{ name: 'Location', description: 'Share a place', href: '/tools/geolocation-qr-code', icon: MapPin, color: 'text-emerald-500', bgColor: 'bg-emerald-50' },
|
|
||||||
{ name: 'Facebook', description: 'Facebook profile/page', href: '/tools/facebook-qr-code', icon: Facebook, color: 'text-blue-600', bgColor: 'bg-blue-50' },
|
|
||||||
{ name: 'Instagram', description: 'Instagram profile', href: '/tools/instagram-qr-code', icon: Instagram, color: 'text-pink-600', bgColor: 'bg-pink-50' },
|
|
||||||
{ name: 'Twitter / X', description: 'Twitter profile', href: '/tools/twitter-qr-code', icon: Twitter, color: 'text-sky-500', bgColor: 'bg-sky-50' },
|
|
||||||
{ name: 'YouTube', description: 'YouTube video/channel', href: '/tools/youtube-qr-code', icon: Youtube, color: 'text-red-600', bgColor: 'bg-red-50' },
|
|
||||||
{ name: 'TikTok', description: 'TikTok profile', href: '/tools/tiktok-qr-code', icon: Music, color: 'text-slate-800', bgColor: 'bg-slate-100' },
|
|
||||||
{ name: 'Crypto', description: 'Share wallet address', href: '/tools/crypto-qr-code', icon: Bitcoin, color: 'text-orange-500', bgColor: 'bg-orange-50' },
|
|
||||||
{ name: 'PayPal', description: 'Receive payments', href: '/tools/paypal-qr-code', icon: CreditCard, color: 'text-blue-700', bgColor: 'bg-blue-50' },
|
|
||||||
{ name: 'Zoom', description: 'Join Zoom meeting', href: '/tools/zoom-qr-code', icon: Video, color: 'text-sky-500', bgColor: 'bg-sky-50' },
|
|
||||||
{ name: 'Teams', description: 'Join Teams meeting', href: '/tools/teams-qr-code', icon: Users, color: 'text-violet-500', bgColor: 'bg-violet-50' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-white">
|
|
||||||
{/* Server-rendered navigation links for SEO (crawlers) - Placed first for priority */}
|
|
||||||
<div className="sr-only" aria-hidden="false">
|
|
||||||
<nav aria-label="Site Map">
|
|
||||||
<ul>
|
|
||||||
<li><a href="/">Home</a></li>
|
|
||||||
<li><a href="/pricing">Pricing</a></li>
|
|
||||||
<li><a href="/blog">Blog</a></li>
|
|
||||||
<li><a href="/faq">FAQ</a></li>
|
|
||||||
<li><a href="/login">Login</a></li>
|
|
||||||
<li><a href="/signup">Sign Up</a></li>
|
|
||||||
{/* Tools */}
|
|
||||||
<li><a href="/tools/url-qr-code">URL QR Code</a></li>
|
|
||||||
<li><a href="/tools/text-qr-code">Text QR Code</a></li>
|
|
||||||
<li><a href="/tools/wifi-qr-code">WiFi QR Code</a></li>
|
|
||||||
<li><a href="/tools/vcard-qr-code">vCard QR Code</a></li>
|
|
||||||
<li><a href="/tools/whatsapp-qr-code">WhatsApp QR Code</a></li>
|
|
||||||
<li><a href="/tools/email-qr-code">Email QR Code</a></li>
|
|
||||||
<li><a href="/tools/sms-qr-code">SMS QR Code</a></li>
|
|
||||||
<li><a href="/tools/phone-qr-code">Phone QR Code</a></li>
|
|
||||||
<li><a href="/tools/event-qr-code">Event QR Code</a></li>
|
|
||||||
<li><a href="/tools/geolocation-qr-code">Location QR Code</a></li>
|
|
||||||
<li><a href="/tools/facebook-qr-code">Facebook QR Code</a></li>
|
|
||||||
<li><a href="/tools/instagram-qr-code">Instagram QR Code</a></li>
|
|
||||||
<li><a href="/tools/twitter-qr-code">Twitter QR Code</a></li>
|
|
||||||
<li><a href="/tools/youtube-qr-code">YouTube QR Code</a></li>
|
|
||||||
<li><a href="/tools/tiktok-qr-code">TikTok QR Code</a></li>
|
|
||||||
<li><a href="/tools/crypto-qr-code">Crypto QR Code</a></li>
|
|
||||||
<li><a href="/tools/paypal-qr-code">PayPal QR Code</a></li>
|
|
||||||
<li><a href="/tools/zoom-qr-code">Zoom QR Code</a></li>
|
|
||||||
<li><a href="/tools/teams-qr-code">Teams QR Code</a></li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<header
|
|
||||||
className="fixed top-0 left-0 right-0 z-50 bg-white/80 backdrop-blur-md border-b border-slate-200 shadow-sm"
|
|
||||||
|
|
||||||
>
|
|
||||||
<nav className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl h-20 flex items-center justify-between">
|
|
||||||
{/* Logo */}
|
|
||||||
<Link href="/" className="flex items-center space-x-2.5 group">
|
|
||||||
<div className="relative w-9 h-9 flex items-center justify-center bg-indigo-600 rounded-lg shadow-indigo-200 shadow-lg group-hover:scale-105 transition-transform duration-200">
|
|
||||||
<QrCode className="w-5 h-5 text-white" />
|
|
||||||
</div>
|
|
||||||
<span className="text-xl font-bold text-slate-900 tracking-tight group-hover:text-indigo-600 transition-colors">QR Master</span>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
|
|
||||||
{/* Desktop Navigation */}
|
|
||||||
<div className="hidden md:flex items-center space-x-1">
|
|
||||||
|
|
||||||
{/* Tools Dropdown */}
|
|
||||||
<div
|
|
||||||
className="relative group px-3 py-2"
|
|
||||||
onMouseEnter={() => setToolsOpen(true)}
|
|
||||||
onMouseLeave={() => setToolsOpen(false)}
|
|
||||||
>
|
|
||||||
<button className="flex items-center space-x-1 text-sm font-medium text-slate-600 group-hover:text-slate-900 transition-colors">
|
|
||||||
<span>{t.nav.tools}</span>
|
|
||||||
<ChevronDown className={cn("w-4 h-4 transition-transform duration-200", toolsOpen && "rotate-180")} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{toolsOpen && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: 10 }}
|
|
||||||
transition={{ duration: 0.15 }}
|
|
||||||
className="absolute left-1/2 -translate-x-1/2 top-full mt-2 w-[750px] bg-white rounded-2xl shadow-lg border border-slate-100 p-4 overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-3 gap-1">
|
|
||||||
{tools.map((tool) => (
|
|
||||||
<Link
|
|
||||||
key={tool.name}
|
|
||||||
href={tool.href}
|
|
||||||
className="flex items-center space-x-3 p-2.5 rounded-xl transition-colors hover:bg-slate-50"
|
|
||||||
>
|
|
||||||
<div className={cn("p-2 rounded-lg shrink-0", tool.bgColor, tool.color)}>
|
|
||||||
<tool.icon className="w-4 h-4" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-semibold text-slate-900">{tool.name}</div>
|
|
||||||
<p className="text-xs text-slate-500 leading-snug">{tool.description}</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 pt-3 border-t border-slate-100 -mx-4 -mb-4 px-4 py-3 text-center bg-slate-50/50">
|
|
||||||
<p className="text-xs text-slate-500 font-medium">{t.nav.all_free}</p>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Link href="/#features" className="px-3 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 transition-colors">
|
|
||||||
{t.nav.features}
|
|
||||||
</Link>
|
|
||||||
<Link href="/#pricing" className="px-3 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 transition-colors">
|
|
||||||
{t.nav.pricing}
|
|
||||||
</Link>
|
|
||||||
<Link href="/blog" className="px-3 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 transition-colors">
|
|
||||||
{t.nav.blog}
|
|
||||||
</Link>
|
|
||||||
<Link href="/#faq" className="px-3 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 transition-colors">
|
|
||||||
{t.nav.faq}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hidden md:flex items-center space-x-4">
|
|
||||||
<Link href="/login" className="text-sm font-medium text-slate-600 hover:text-slate-900 transition-colors">
|
|
||||||
{t.nav.login}
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link href="/signup">
|
|
||||||
<Button className={cn(
|
|
||||||
"font-semibold shadow-lg shadow-indigo-500/20 transition-all hover:scale-105",
|
|
||||||
scrolled ? "bg-blue-600 text-white hover:bg-blue-700" : "bg-blue-600 text-white hover:bg-blue-700"
|
|
||||||
)}>
|
|
||||||
{t.nav.cta || "Get Started Free"}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Menu Button - Always dark */}
|
|
||||||
<button
|
|
||||||
className="md:hidden p-2 text-slate-900"
|
|
||||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
|
||||||
aria-label="Toggle menu"
|
|
||||||
>
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
{mobileMenuOpen ? (
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
) : (
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Mobile Menu */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{mobileMenuOpen && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, height: 0 }}
|
|
||||||
animate={{ opacity: 1, height: 'auto' }}
|
|
||||||
exit={{ opacity: 0, height: 0 }}
|
|
||||||
className="md:hidden bg-white border-b border-slate-100 overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="container mx-auto px-4 py-6 space-y-2">
|
|
||||||
{/* Free Tools Accordion */}
|
|
||||||
<button
|
|
||||||
onClick={() => setMobileToolsOpen(!mobileToolsOpen)}
|
|
||||||
className="flex items-center justify-between w-full px-4 py-3 rounded-xl hover:bg-slate-50 text-slate-700 font-semibold"
|
|
||||||
>
|
|
||||||
<span>{t.nav.tools}</span>
|
|
||||||
<ChevronDown className={cn("w-5 h-5 transition-transform", mobileToolsOpen && "rotate-180")} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{mobileToolsOpen && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, height: 0 }}
|
|
||||||
animate={{ opacity: 1, height: 'auto' }}
|
|
||||||
exit={{ opacity: 0, height: 0 }}
|
|
||||||
className="overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="max-h-[50vh] overflow-y-auto pl-4 space-y-1 border-l-2 border-slate-100 ml-4">
|
|
||||||
{tools.map((tool) => (
|
|
||||||
<Link
|
|
||||||
key={tool.name}
|
|
||||||
href={tool.href}
|
|
||||||
className="flex items-center gap-3 px-4 py-2.5 rounded-lg hover:bg-slate-50 text-slate-600 text-sm"
|
|
||||||
onClick={() => { setMobileMenuOpen(false); setMobileToolsOpen(false); }}
|
|
||||||
>
|
|
||||||
<tool.icon className={cn("w-4 h-4", tool.color)} />
|
|
||||||
{tool.name}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<div className="h-px bg-slate-100 my-2"></div>
|
|
||||||
|
|
||||||
<Link href="/#features" className="block px-4 py-3 text-slate-700 font-medium rounded-xl hover:bg-slate-50" onClick={() => setMobileMenuOpen(false)}>{t.nav.features}</Link>
|
|
||||||
<Link href="/#pricing" className="block px-4 py-3 text-slate-700 font-medium rounded-xl hover:bg-slate-50" onClick={() => setMobileMenuOpen(false)}>{t.nav.pricing}</Link>
|
|
||||||
<Link href="/blog" className="block px-4 py-3 text-slate-700 font-medium rounded-xl hover:bg-slate-50" onClick={() => setMobileMenuOpen(false)}>{t.nav.blog}</Link>
|
|
||||||
<Link href="/#faq" className="block px-4 py-3 text-slate-700 font-medium rounded-xl hover:bg-slate-50" onClick={() => setMobileMenuOpen(false)}>{t.nav.faq}</Link>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 pt-4">
|
|
||||||
<Link href="/login" onClick={() => setMobileMenuOpen(false)}>
|
|
||||||
<Button variant="outline" className="w-full justify-center">{t.nav.login}</Button>
|
|
||||||
</Link>
|
|
||||||
<Link href="/signup" onClick={() => setMobileMenuOpen(false)}>
|
|
||||||
<Button className="w-full justify-center bg-indigo-600 hover:bg-indigo-700">{t.nav.cta}</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<main className="pt-20">
|
|
||||||
{/* Server-rendered navigation links for SEO (crawlers) */}
|
|
||||||
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<Footer t={t} />
|
|
||||||
</div >
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -7,7 +7,6 @@ import { websiteSchema, breadcrumbSchema } from '@/lib/schema';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
import Breadcrumbs, { BreadcrumbItem } from '@/components/Breadcrumbs';
|
import Breadcrumbs, { BreadcrumbItem } from '@/components/Breadcrumbs';
|
||||||
import { blogPostList } from '@/lib/blog-data';
|
|
||||||
|
|
||||||
function truncateAtWord(text: string, maxLength: number): string {
|
function truncateAtWord(text: string, maxLength: number): string {
|
||||||
if (text.length <= maxLength) return text;
|
if (text.length <= maxLength) return text;
|
||||||
|
|
@ -19,7 +18,7 @@ function truncateAtWord(text: string, maxLength: number): string {
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const title = truncateAtWord('QR Insights: Latest QR Strategies', 60);
|
const title = truncateAtWord('QR Insights: Latest QR Strategies', 60);
|
||||||
const description = truncateAtWord(
|
const description = truncateAtWord(
|
||||||
'Expert guides on QR code analytics, dynamic vs static codes, bulk generation, and smart marketing use cases. Learn how to maximize your QR campaign ROI.',
|
'Expert guides on QR analytics, dynamic codes & smart marketing uses.',
|
||||||
160
|
160
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -38,14 +37,6 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||||
description,
|
description,
|
||||||
url: 'https://www.qrmaster.net/blog',
|
url: 'https://www.qrmaster.net/blog',
|
||||||
type: 'website',
|
type: 'website',
|
||||||
images: [
|
|
||||||
{
|
|
||||||
url: 'https://www.qrmaster.net/og-image.png',
|
|
||||||
width: 1200,
|
|
||||||
height: 630,
|
|
||||||
alt: 'QR Insights - QR Code Marketing & Analytics Blog',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
title,
|
title,
|
||||||
|
|
@ -54,7 +45,82 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const blogPosts = [
|
||||||
|
// NEW POSTS (January 2026)
|
||||||
|
{
|
||||||
|
slug: 'qr-code-restaurant-menu',
|
||||||
|
title: 'How to Create a QR Code for Restaurant Menu',
|
||||||
|
excerpt: 'Step-by-step guide to creating digital menu QR codes for your restaurant. Learn best practices for touchless menus, placement tips, and tracking.',
|
||||||
|
date: 'January 5, 2026',
|
||||||
|
readTime: '12 Min',
|
||||||
|
category: 'Restaurant',
|
||||||
|
image: '/blog/restaurant-qr-menu.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'vcard-qr-code-generator',
|
||||||
|
title: 'Free vCard QR Code Generator: Digital Business Cards',
|
||||||
|
excerpt: 'Create professional vCard QR codes for digital business cards. Share contact info instantly with a scan—includes templates and best practices.',
|
||||||
|
date: 'January 5, 2026',
|
||||||
|
readTime: '10 Min',
|
||||||
|
category: 'Business Cards',
|
||||||
|
image: '/blog/vcard-qr-code.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'qr-code-small-business',
|
||||||
|
title: 'Best QR Code Generator for Small Business: 2025 Guide',
|
||||||
|
excerpt: 'Find the best QR code solution for your small business. Compare features, pricing, and use cases for marketing, payments, and operations.',
|
||||||
|
date: 'January 5, 2026',
|
||||||
|
readTime: '14 Min',
|
||||||
|
category: 'Business',
|
||||||
|
image: '/blog/small-business-qr.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'qr-code-print-size-guide',
|
||||||
|
title: 'QR Code Print Size Guide: Minimum Sizes for Every Use Case',
|
||||||
|
excerpt: 'Complete guide to QR code print sizes. Learn minimum dimensions for business cards, posters, banners, and more to ensure reliable scanning.',
|
||||||
|
date: 'January 5, 2026',
|
||||||
|
readTime: '8 Min',
|
||||||
|
category: 'Printing',
|
||||||
|
image: '/blog/qr-print-sizes.png',
|
||||||
|
},
|
||||||
|
// EXISTING POSTS
|
||||||
|
{
|
||||||
|
slug: 'qr-code-tracking-guide-2025',
|
||||||
|
title: 'QR Code Tracking: Complete Guide 2025',
|
||||||
|
excerpt: 'Learn how to track QR code scans with real-time analytics. Compare free vs paid tracking tools, setup Google Analytics, and measure ROI.',
|
||||||
|
date: 'October 18, 2025',
|
||||||
|
readTime: '12 Min',
|
||||||
|
category: 'Tracking & Analytics',
|
||||||
|
image: '/blog/1-hero.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'dynamic-vs-static-qr-codes',
|
||||||
|
title: 'Dynamic vs Static QR Codes: Which Should You Use?',
|
||||||
|
excerpt: 'Understand the difference between static and dynamic QR codes. Learn when to use each type, pros/cons, and how dynamic QR codes save money.',
|
||||||
|
date: 'October 17, 2025',
|
||||||
|
readTime: '10 Min',
|
||||||
|
category: 'QR Code Basics',
|
||||||
|
image: '/blog/2-hero.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'bulk-qr-code-generator-excel',
|
||||||
|
title: 'How to Generate Bulk QR Codes from Excel',
|
||||||
|
excerpt: 'Generate hundreds of QR codes from Excel or CSV files in minutes. Step-by-step guide with templates, best practices, and free tools.',
|
||||||
|
date: 'October 16, 2025',
|
||||||
|
readTime: '13 Min',
|
||||||
|
category: 'Bulk Generation',
|
||||||
|
image: '/blog/3-hero.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'qr-code-analytics',
|
||||||
|
title: 'QR Code Analytics: Track, Measure & Optimize Campaigns',
|
||||||
|
excerpt: 'Learn how to leverage scan analytics, campaign tracking, and dashboard insights to maximize QR code ROI.',
|
||||||
|
date: 'October 16, 2025',
|
||||||
|
readTime: '15 Min',
|
||||||
|
category: 'Analytics',
|
||||||
|
image: '/blog/4-hero.png',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function BlogPage() {
|
export default function BlogPage() {
|
||||||
const breadcrumbItems: BreadcrumbItem[] = [
|
const breadcrumbItems: BreadcrumbItem[] = [
|
||||||
|
|
@ -79,8 +145,8 @@ export default function BlogPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8 max-w-6xl mx-auto">
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8 max-w-6xl mx-auto">
|
||||||
{blogPostList.map((post: any) => (
|
{blogPosts.map((post) => (
|
||||||
<Link key={post.slug} href={post.link || `/blog/${post.slug}`}>
|
<Link key={post.slug} href={`/blog/${post.slug}`}>
|
||||||
<Card hover className="h-full overflow-hidden shadow-md hover:shadow-xl transition-all duration-300">
|
<Card hover className="h-full overflow-hidden shadow-md hover:shadow-xl transition-all duration-300">
|
||||||
<div className="relative h-56 overflow-hidden">
|
<div className="relative h-56 overflow-hidden">
|
||||||
<Image
|
<Image
|
||||||
|
|
@ -102,9 +168,7 @@ export default function BlogPage() {
|
||||||
<p className="text-gray-600 mb-4 leading-relaxed">{post.excerpt}</p>
|
<p className="text-gray-600 mb-4 leading-relaxed">{post.excerpt}</p>
|
||||||
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
|
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
|
||||||
<p className="text-sm text-gray-500">{post.date}</p>
|
<p className="text-sm text-gray-500">{post.date}</p>
|
||||||
<span className="text-primary-600 text-sm font-medium">
|
<span className="text-primary-600 text-sm font-medium">Read more →</span>
|
||||||
{post.link ? 'Try Now →' : 'Read Article →'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ import Breadcrumbs, { BreadcrumbItem } from '@/components/Breadcrumbs';
|
||||||
import { breadcrumbSchema } from '@/lib/schema';
|
import { breadcrumbSchema } from '@/lib/schema';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Bulk QR Code Generator | Create from Excel | QR Master',
|
title: 'Bulk QR Code Generator - Create 1000s of QR Codes from Excel | QR Master',
|
||||||
description: 'Generate hundreds of QR codes instantly from Excel/CSV. Create URLs, vCards, and text codes in bulk. Perfect for inventory, events, and product tagging.',
|
description: 'Generate hundreds of QR codes at once from CSV or Excel files. Create URLs, vCards, locations, phone numbers, and text QR codes in bulk. Perfect for products, events, inventory management.',
|
||||||
keywords: 'bulk qr code generator, batch qr code, qr code from excel, csv qr code generator, mass qr code generation, bulk vcard qr code, bulk qr codes free',
|
keywords: 'bulk qr code generator, batch qr code, qr code from excel, csv qr code generator, mass qr code generation, bulk vcard qr code, bulk qr codes free',
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: 'https://www.qrmaster.net/bulk-qr-code-generator',
|
canonical: 'https://www.qrmaster.net/bulk-qr-code-generator',
|
||||||
|
|
@ -23,14 +23,6 @@ export const metadata: Metadata = {
|
||||||
description: 'Generate hundreds of QR codes at once from CSV or Excel files. Perfect for products, events, and inventory.',
|
description: 'Generate hundreds of QR codes at once from CSV or Excel files. Perfect for products, events, and inventory.',
|
||||||
url: 'https://www.qrmaster.net/bulk-qr-code-generator',
|
url: 'https://www.qrmaster.net/bulk-qr-code-generator',
|
||||||
type: 'website',
|
type: 'website',
|
||||||
images: [
|
|
||||||
{
|
|
||||||
url: 'https://www.qrmaster.net/og-image.png',
|
|
||||||
width: 1200,
|
|
||||||
height: 630,
|
|
||||||
alt: 'Bulk QR Code Generator - QR Master',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
title: 'Bulk QR Code Generator - Create 1000s of QR Codes from Excel',
|
title: 'Bulk QR Code Generator - Create 1000s of QR Codes from Excel',
|
||||||
|
|
@ -54,7 +46,7 @@ export default function BulkQRCodeGeneratorPage() {
|
||||||
title: 'Contact Cards',
|
title: 'Contact Cards',
|
||||||
description: 'Create vCard QR codes with contact information',
|
description: 'Create vCard QR codes with contact information',
|
||||||
format: 'FirstName,LastName,Email,Phone,Organization,Title',
|
format: 'FirstName,LastName,Email,Phone,Organization,Title',
|
||||||
example: 'John Doe,VCARD,John,Doe,john' + '@' + 'example.com,+1234567890,Company Inc,CEO',
|
example: 'John Doe,VCARD,John,Doe,john@example.com,+1234567890,Company Inc,CEO',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'GEO',
|
type: 'GEO',
|
||||||
|
|
@ -341,7 +333,7 @@ export default function BulkQRCodeGeneratorPage() {
|
||||||
Start Bulk Generation
|
Start Bulk Generation
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/signup">
|
<Link href="/create">
|
||||||
<Button variant="outline" size="lg" className="text-lg px-8 py-4 w-full sm:w-auto">
|
<Button variant="outline" size="lg" className="text-lg px-8 py-4 w-full sm:w-auto">
|
||||||
Try Single QR First
|
Try Single QR First
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -448,7 +440,7 @@ export default function BulkQRCodeGeneratorPage() {
|
||||||
<tr className="border-b border-gray-200">
|
<tr className="border-b border-gray-200">
|
||||||
<td className="py-2 px-3">John Doe</td>
|
<td className="py-2 px-3">John Doe</td>
|
||||||
<td className="py-2 px-3">VCARD</td>
|
<td className="py-2 px-3">VCARD</td>
|
||||||
<td className="py-2 px-3">John,Doe,john{'@'}example.com,+1234567890,Company,CEO</td>
|
<td className="py-2 px-3">John,Doe,john@example.com,+1234567890,Company,CEO</td>
|
||||||
<td className="py-2 px-3">contact</td>
|
<td className="py-2 px-3">contact</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr className="border-b border-gray-200">
|
<tr className="border-b border-gray-200">
|
||||||
|
|
@ -641,35 +633,26 @@ Product C,https://example.com/product-c,Budget Widget,electronics,sale`}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* CTA Section */}
|
{/* CTA Section */}
|
||||||
{/* CTA Section */}
|
<section className="py-20 bg-gradient-to-r from-green-600 to-blue-600 text-white">
|
||||||
<section className="py-24 bg-slate-900 relative overflow-hidden">
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-4xl text-center">
|
||||||
{/* Background Decorations */}
|
<h2 className="text-4xl font-bold mb-6">
|
||||||
<div className="absolute top-0 right-0 -mr-20 -mt-20 w-96 h-96 bg-blue-500/20 rounded-full blur-3xl opacity-50" />
|
Generate 1000s of QR Codes in Minutes
|
||||||
<div className="absolute bottom-0 left-0 -ml-20 -mb-20 w-80 h-80 bg-green-500/20 rounded-full blur-3xl opacity-50" />
|
|
||||||
|
|
||||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-4xl text-center relative z-10">
|
|
||||||
<h2 className="text-4xl lg:text-5xl font-bold mb-6 text-white tracking-tight">
|
|
||||||
Ready to Generate <span className="text-transparent bg-clip-text bg-gradient-to-r from-green-400 to-blue-400">1000s of Codes?</span>
|
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl mb-10 text-slate-300 leading-relaxed max-w-2xl mx-auto">
|
<p className="text-xl mb-8 text-green-100">
|
||||||
Stop doing it manually. Upload your Excel file and get your QR codes in seconds. Professional, branded, and trackable.
|
Save hours of manual work. Upload your file and get all QR codes ready instantly.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row gap-5 justify-center">
|
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
<Link href="/signup">
|
<Link href="/signup">
|
||||||
<Button size="lg" className="text-lg px-8 py-6 h-auto w-full sm:w-auto bg-white text-slate-900 hover:bg-slate-50 font-bold shadow-xl shadow-blue-900/20 transition-all hover:-translate-y-1">
|
<Button size="lg" variant="secondary" className="text-lg px-8 py-4 w-full sm:w-auto bg-white text-green-600 hover:bg-gray-100">
|
||||||
Start Bulk Generation
|
Start Bulk Generation
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/pricing">
|
<Link href="/pricing">
|
||||||
<Button size="lg" variant="outline" className="text-lg px-8 py-6 h-auto w-full sm:w-auto border-slate-700 text-white hover:bg-slate-800 hover:border-slate-600 transition-all">
|
<Button size="lg" variant="outline" className="text-lg px-8 py-4 w-full sm:w-auto border-white text-white hover:bg-white/10">
|
||||||
View Pricing
|
View Pricing
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-8 text-sm text-slate-500">
|
|
||||||
No credit card required for free trial.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ import Breadcrumbs, { BreadcrumbItem } from '@/components/Breadcrumbs';
|
||||||
import { breadcrumbSchema } from '@/lib/schema';
|
import { breadcrumbSchema } from '@/lib/schema';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Dynamic QR Code Generator | Edit & Track QR | QR Master',
|
title: 'Dynamic QR Code Generator - Edit QR Codes Anytime | QR Master',
|
||||||
description: 'Create editable dynamic QR codes. Update destination URLs, track scans, and manage content anytime without reprinting. Free generator with analytics.',
|
description: 'Create dynamic QR codes that can be edited after printing. Change destination URL, track scans, and update content without reprinting. Free dynamic QR code generator.',
|
||||||
keywords: 'dynamic qr code generator, editable qr code, dynamic qr code, free dynamic qr code, qr code generator dynamic, best dynamic qr code generator',
|
keywords: 'dynamic qr code generator, editable qr code, dynamic qr code, free dynamic qr code, qr code generator dynamic, best dynamic qr code generator',
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: 'https://www.qrmaster.net/dynamic-qr-code-generator',
|
canonical: 'https://www.qrmaster.net/dynamic-qr-code-generator',
|
||||||
|
|
@ -23,14 +23,6 @@ export const metadata: Metadata = {
|
||||||
description: 'Create dynamic QR codes that can be edited after printing. Change URLs, track scans, and update content anytime.',
|
description: 'Create dynamic QR codes that can be edited after printing. Change URLs, track scans, and update content anytime.',
|
||||||
url: 'https://www.qrmaster.net/dynamic-qr-code-generator',
|
url: 'https://www.qrmaster.net/dynamic-qr-code-generator',
|
||||||
type: 'website',
|
type: 'website',
|
||||||
images: [
|
|
||||||
{
|
|
||||||
url: 'https://www.qrmaster.net/og-image.png',
|
|
||||||
width: 1200,
|
|
||||||
height: 630,
|
|
||||||
alt: 'Dynamic QR Code Generator - QR Master',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
title: 'Dynamic QR Code Generator - Edit QR Codes Anytime | QR Master',
|
title: 'Dynamic QR Code Generator - Edit QR Codes Anytime | QR Master',
|
||||||
|
|
@ -188,7 +180,7 @@ export default function DynamicQRCodeGeneratorPage() {
|
||||||
position: 2,
|
position: 2,
|
||||||
name: 'Generate QR Code',
|
name: 'Generate QR Code',
|
||||||
text: 'Enter your destination URL and customize the design with your branding',
|
text: 'Enter your destination URL and customize the design with your branding',
|
||||||
url: 'https://www.qrmaster.net/signup',
|
url: 'https://www.qrmaster.net/create',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'@type': 'HowToStep',
|
'@type': 'HowToStep',
|
||||||
|
|
@ -512,7 +504,7 @@ export default function DynamicQRCodeGeneratorPage() {
|
||||||
Get Started Free
|
Get Started Free
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/signup">
|
<Link href="/create">
|
||||||
<Button size="lg" variant="outline" className="text-lg px-8 py-4 w-full sm:w-auto border-white text-white hover:bg-white/10">
|
<Button size="lg" variant="outline" className="text-lg px-8 py-4 w-full sm:w-auto border-white text-white hover:bg-white/10">
|
||||||
Create QR Code Now
|
Create QR Code Now
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { ObfuscatedMailto } from '@/components/ui/ObfuscatedMailto';
|
|
||||||
|
|
||||||
export function ContactSupport() {
|
|
||||||
return (
|
|
||||||
<div className="mt-16 bg-blue-50 border-l-4 border-blue-500 p-8 rounded-r-lg">
|
|
||||||
<h2 className="text-2xl font-bold mb-4 text-gray-900">
|
|
||||||
Still have questions?
|
|
||||||
</h2>
|
|
||||||
<p className="text-lg text-gray-700 mb-6 leading-relaxed">
|
|
||||||
Our support team is here to help. Contact us at{' '}
|
|
||||||
<ObfuscatedMailto
|
|
||||||
email="support@qrmaster.net"
|
|
||||||
className="text-blue-600 hover:text-blue-700 font-semibold"
|
|
||||||
/>{' '}
|
|
||||||
or reach out through our live chat.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -3,7 +3,6 @@ import type { Metadata } from 'next';
|
||||||
import SeoJsonLd from '@/components/SeoJsonLd';
|
import SeoJsonLd from '@/components/SeoJsonLd';
|
||||||
import { faqPageSchema } from '@/lib/schema';
|
import { faqPageSchema } from '@/lib/schema';
|
||||||
import { Card, CardContent } from '@/components/ui/Card';
|
import { Card, CardContent } from '@/components/ui/Card';
|
||||||
import { ContactSupport } from './ContactSupport';
|
|
||||||
|
|
||||||
function truncateAtWord(text: string, maxLength: number): string {
|
function truncateAtWord(text: string, maxLength: number): string {
|
||||||
if (text.length <= maxLength) return text;
|
if (text.length <= maxLength) return text;
|
||||||
|
|
@ -15,7 +14,7 @@ function truncateAtWord(text: string, maxLength: number): string {
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const title = truncateAtWord('QR Master FAQ: Dynamic & Bulk QR', 60);
|
const title = truncateAtWord('QR Master FAQ: Dynamic & Bulk QR', 60);
|
||||||
const description = truncateAtWord(
|
const description = truncateAtWord(
|
||||||
'Find answers about dynamic QR codes, scan tracking, security, bulk generation, and event QR codes. Everything you need to know about QR Master features.',
|
'All answers: dynamic QR, security, analytics, bulk, events & print.',
|
||||||
160
|
160
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -34,14 +33,6 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||||
description,
|
description,
|
||||||
url: 'https://www.qrmaster.net/faq',
|
url: 'https://www.qrmaster.net/faq',
|
||||||
type: 'website',
|
type: 'website',
|
||||||
images: [
|
|
||||||
{
|
|
||||||
url: 'https://www.qrmaster.net/og-image.png',
|
|
||||||
width: 1200,
|
|
||||||
height: 630,
|
|
||||||
alt: 'QR Master FAQ',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
title,
|
title,
|
||||||
|
|
@ -132,7 +123,18 @@ export default function FAQPage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ContactSupport />
|
<div className="mt-16 bg-blue-50 border-l-4 border-blue-500 p-8 rounded-r-lg">
|
||||||
|
<h2 className="text-2xl font-bold mb-4 text-gray-900">
|
||||||
|
Still have questions?
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg text-gray-700 mb-6 leading-relaxed">
|
||||||
|
Our support team is here to help. Contact us at{' '}
|
||||||
|
<a href="mailto:support@qrmaster.net" className="text-blue-600 hover:text-blue-700 font-semibold">
|
||||||
|
support@qrmaster.net
|
||||||
|
</a>{' '}
|
||||||
|
or reach out through our live chat.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,76 +1,164 @@
|
||||||
import type { Metadata } from 'next';
|
'use client';
|
||||||
import '@/styles/globals.css';
|
|
||||||
import { Providers } from '@/components/Providers';
|
|
||||||
import MarketingLayout from './MarketingLayout';
|
|
||||||
// Import schema functions from library
|
|
||||||
import { organizationSchema, websiteSchema } from '@/lib/schema';
|
|
||||||
|
|
||||||
const isIndexable = process.env.NEXT_PUBLIC_INDEXABLE === 'true';
|
import React, { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import en from '@/i18n/en.json';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export default function MarketingLayout({
|
||||||
metadataBase: new URL('https://www.qrmaster.net'),
|
children,
|
||||||
title: {
|
|
||||||
default: 'QR Master – Smart QR Generator & Analytics',
|
|
||||||
template: '%s | QR Master',
|
|
||||||
},
|
|
||||||
description: 'Create dynamic QR codes, track scans, and scale campaigns with secure analytics.',
|
|
||||||
keywords: 'QR code, QR generator, dynamic QR, QR tracking, QR analytics, branded QR, bulk QR generator',
|
|
||||||
robots: isIndexable
|
|
||||||
? { index: true, follow: true }
|
|
||||||
: { index: false, follow: false },
|
|
||||||
icons: {
|
|
||||||
icon: [
|
|
||||||
{ url: '/favicon.svg', type: 'image/svg+xml' },
|
|
||||||
{ url: '/logo.svg', type: 'image/svg+xml' },
|
|
||||||
],
|
|
||||||
apple: '/logo.svg',
|
|
||||||
},
|
|
||||||
twitter: {
|
|
||||||
card: 'summary_large_image',
|
|
||||||
site: '@qrmaster',
|
|
||||||
images: ['https://www.qrmaster.net/og-image.png'],
|
|
||||||
},
|
|
||||||
openGraph: {
|
|
||||||
type: 'website',
|
|
||||||
siteName: 'QR Master',
|
|
||||||
title: 'QR Master – Smart QR Generator & Analytics',
|
|
||||||
description: 'Create dynamic QR codes, track scans, and scale campaigns with secure analytics.',
|
|
||||||
images: [
|
|
||||||
{
|
|
||||||
url: 'https://www.qrmaster.net/og-image.png',
|
|
||||||
width: 1200,
|
|
||||||
height: 630,
|
|
||||||
alt: 'QR Master - Dynamic QR Code Generator and Analytics Platform',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
locale: 'en_US',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RootMarketingLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
<html lang="en">
|
|
||||||
<head>
|
// Always use English for marketing pages
|
||||||
<script
|
const t = en;
|
||||||
type="application/ld+json"
|
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationSchema()) }}
|
const navigation = [
|
||||||
/>
|
{ name: t.nav.features, href: '/#features' },
|
||||||
<script
|
{ name: t.nav.pricing, href: '/#pricing' },
|
||||||
type="application/ld+json"
|
{ name: t.nav.faq, href: '/#faq' },
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteSchema()) }}
|
{ name: t.nav.blog, href: '/blog' },
|
||||||
/>
|
];
|
||||||
</head>
|
|
||||||
<body className="font-sans">
|
return (
|
||||||
<Providers>
|
<div className="min-h-screen bg-white">
|
||||||
<MarketingLayout>
|
{/* Header */}
|
||||||
{children}
|
<header className="sticky top-0 z-50 bg-white border-b border-gray-200">
|
||||||
</MarketingLayout>
|
<nav className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl py-4">
|
||||||
</Providers>
|
<div className="flex items-center justify-between">
|
||||||
</body>
|
{/* Logo */}
|
||||||
</html>
|
<Link href="/" className="flex items-center space-x-2">
|
||||||
);
|
<img src="/favicon.svg" alt="QR Master" className="w-8 h-8" />
|
||||||
}
|
<span className="text-xl font-bold text-gray-900">QR Master</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Desktop Navigation */}
|
||||||
|
<div className="hidden md:flex items-center space-x-8">
|
||||||
|
{navigation.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
href={item.href}
|
||||||
|
className="text-gray-600 hover:text-gray-900 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Actions */}
|
||||||
|
<div className="hidden md:flex items-center space-x-4">
|
||||||
|
<Link href="/login">
|
||||||
|
<Button variant="outline">{t.nav.login}</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/signup">
|
||||||
|
<Button>Get Started Free</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Menu Button */}
|
||||||
|
<button
|
||||||
|
className="md:hidden text-gray-900"
|
||||||
|
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||||
|
aria-label={mobileMenuOpen ? 'Close menu' : 'Open menu'}
|
||||||
|
aria-expanded={mobileMenuOpen}
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
{mobileMenuOpen ? (
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
) : (
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Menu */}
|
||||||
|
{mobileMenuOpen && (
|
||||||
|
<div className="md:hidden mt-4 pb-4 border-t border-gray-200 pt-4">
|
||||||
|
<div className="flex flex-col space-y-4">
|
||||||
|
{navigation.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
href={item.href}
|
||||||
|
className="text-gray-600 hover:text-gray-900 font-medium"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
<Link href="/login" onClick={() => setMobileMenuOpen(false)}>
|
||||||
|
<Button variant="outline" className="w-full">{t.nav.login}</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/signup" onClick={() => setMobileMenuOpen(false)}>
|
||||||
|
<Button className="w-full">Get Started Free</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main>{children}</main>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="bg-gray-900 text-white py-12 mt-20">
|
||||||
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||||
|
<div className="grid md:grid-cols-4 gap-8">
|
||||||
|
<div>
|
||||||
|
<Link href="/" className="flex items-center space-x-2 mb-4 hover:opacity-80 transition-opacity">
|
||||||
|
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
|
||||||
|
<span className="text-xl font-bold">QR Master</span>
|
||||||
|
</Link>
|
||||||
|
<p className="text-gray-400">
|
||||||
|
Create custom QR codes in seconds with advanced tracking and analytics.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-4">Product</h3>
|
||||||
|
<ul className="space-y-2 text-gray-400">
|
||||||
|
<li><Link href="/#features" className="hover:text-white">Features</Link></li>
|
||||||
|
<li><Link href="/#pricing" className="hover:text-white">Pricing</Link></li>
|
||||||
|
<li><Link href="/#faq" className="hover:text-white">FAQ</Link></li>
|
||||||
|
<li><Link href="/blog" className="hover:text-white">Blog</Link></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-4">Resources</h3>
|
||||||
|
<ul className="space-y-2 text-gray-400">
|
||||||
|
<li><Link href="/#pricing" className="hover:text-white">Full Pricing</Link></li>
|
||||||
|
<li><Link href="/faq" className="hover:text-white">All Questions</Link></li>
|
||||||
|
<li><Link href="/blog" className="hover:text-white">Blog</Link></li>
|
||||||
|
<li><Link href="/signup" className="hover:text-white">Get Started</Link></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-4">Legal</h3>
|
||||||
|
<ul className="space-y-2 text-gray-400">
|
||||||
|
<li><Link href="/privacy" className="hover:text-white">Privacy Policy</Link></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-800 mt-8 pt-8 flex items-center justify-between text-gray-400">
|
||||||
|
<Link
|
||||||
|
href="/newsletter"
|
||||||
|
className="text-[6px] text-gray-700 opacity-[0.25] hover:opacity-100 hover:text-white transition-opacity duration-300"
|
||||||
|
>
|
||||||
|
•
|
||||||
|
</Link>
|
||||||
|
<p>© 2025 QR Master. All rights reserved.</p>
|
||||||
|
<div className="w-12"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,754 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { Card } from '@/components/ui/Card';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Badge } from '@/components/ui/Badge';
|
|
||||||
import {
|
|
||||||
Mail,
|
|
||||||
Users,
|
|
||||||
QrCode,
|
|
||||||
BarChart3,
|
|
||||||
TrendingUp,
|
|
||||||
Crown,
|
|
||||||
Activity,
|
|
||||||
Loader2,
|
|
||||||
Lock,
|
|
||||||
LogOut,
|
|
||||||
Zap,
|
|
||||||
Send,
|
|
||||||
CheckCircle2,
|
|
||||||
FileDown,
|
|
||||||
DollarSign,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
interface AdminStats {
|
|
||||||
users: {
|
|
||||||
total: number;
|
|
||||||
premium: number;
|
|
||||||
newThisWeek: number;
|
|
||||||
newThisMonth: number;
|
|
||||||
recent: Array<{
|
|
||||||
email: string;
|
|
||||||
name: string | null;
|
|
||||||
plan: string;
|
|
||||||
createdAt: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
qrCodes: {
|
|
||||||
total: number;
|
|
||||||
dynamic: number;
|
|
||||||
static: number;
|
|
||||||
active: number;
|
|
||||||
};
|
|
||||||
scans: {
|
|
||||||
total: number;
|
|
||||||
dynamicOnly: number;
|
|
||||||
avgPerDynamicQR: string;
|
|
||||||
};
|
|
||||||
newsletter: {
|
|
||||||
subscribers: number;
|
|
||||||
};
|
|
||||||
topQRCodes: Array<{
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
scans: number;
|
|
||||||
owner: string;
|
|
||||||
createdAt: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function NewsletterClient() {
|
|
||||||
const router = useRouter();
|
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
|
||||||
const [isAuthenticating, setIsAuthenticating] = useState(true);
|
|
||||||
const [loginError, setLoginError] = useState('');
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
|
|
||||||
const [stats, setStats] = useState<AdminStats | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
// Newsletter management state
|
|
||||||
const [newsletterData, setNewsletterData] = useState<{
|
|
||||||
total: number;
|
|
||||||
recent: Array<{ email: string; createdAt: string }>;
|
|
||||||
} | null>(null);
|
|
||||||
const [sendingBroadcast, setSendingBroadcast] = useState(false);
|
|
||||||
const [broadcastResult, setBroadcastResult] = useState<{
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
// Lead management state
|
|
||||||
const [leadData, setLeadData] = useState<{
|
|
||||||
total: number;
|
|
||||||
recent: Array<{
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
source: string;
|
|
||||||
reprintCost: number | null;
|
|
||||||
updatesPerYear: number | null;
|
|
||||||
annualSavings: number | null;
|
|
||||||
createdAt: string;
|
|
||||||
}>;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
checkAuth();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const checkAuth = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/admin/stats');
|
|
||||||
if (response.ok) {
|
|
||||||
setIsAuthenticated(true);
|
|
||||||
const data = await response.json();
|
|
||||||
setStats(data);
|
|
||||||
setLoading(false);
|
|
||||||
// Also fetch newsletter and lead data
|
|
||||||
fetchNewsletterData();
|
|
||||||
fetchLeadsData();
|
|
||||||
} else {
|
|
||||||
setIsAuthenticated(false);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setIsAuthenticated(false);
|
|
||||||
} finally {
|
|
||||||
setIsAuthenticating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchNewsletterData = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/newsletter/broadcast');
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
setNewsletterData(data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch newsletter data:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchLeadsData = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/leads');
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
setLeadData(data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch leads data:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSendBroadcast = async () => {
|
|
||||||
if (!confirm(`Are you sure you want to send the AI Feature Launch email to all ${newsletterData?.total || 0} subscribers?`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSendingBroadcast(true);
|
|
||||||
setBroadcastResult(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/newsletter/broadcast', {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
setBroadcastResult({
|
|
||||||
success: true,
|
|
||||||
message: data.message || `Successfully sent to ${data.sent} subscribers!`,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setBroadcastResult({
|
|
||||||
success: false,
|
|
||||||
message: data.error || 'Failed to send broadcast',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setBroadcastResult({
|
|
||||||
success: false,
|
|
||||||
message: 'Network error. Please try again.',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setSendingBroadcast(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLogin = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setLoginError('');
|
|
||||||
setIsAuthenticating(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/newsletter/admin-login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email, password }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
setIsAuthenticated(true);
|
|
||||||
await checkAuth();
|
|
||||||
} else {
|
|
||||||
const data = await response.json();
|
|
||||||
setLoginError(data.error || 'Invalid credentials');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setLoginError('Login failed. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setIsAuthenticating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
|
||||||
await fetch('/api/auth/logout', { method: 'POST' });
|
|
||||||
router.push('/');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Login Screen
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-purple-50 to-pink-50 dark:from-purple-950/20 dark:to-pink-950/20 px-4">
|
|
||||||
<Card className="w-full max-w-md p-8">
|
|
||||||
<div className="text-center mb-6">
|
|
||||||
<div className="w-16 h-16 bg-gradient-to-br from-purple-100 to-pink-100 dark:from-purple-900/30 dark:to-pink-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
||||||
<Lock className="w-8 h-8 text-purple-600 dark:text-purple-400" />
|
|
||||||
</div>
|
|
||||||
<h1 className="text-2xl font-bold mb-2">Admin Dashboard</h1>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Sign in to access admin panel
|
|
||||||
</p>
|
|
||||||
<Link href="/" className="text-sm text-slate-500 hover:text-slate-900 block mt-2">
|
|
||||||
← Back to Home
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleLogin} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">Email</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
placeholder="admin@example.com"
|
|
||||||
required
|
|
||||||
className="w-full px-4 py-3 rounded-xl bg-background border border-border focus:border-purple-500 focus:ring-2 focus:ring-purple-500/20 outline-none transition-all"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">Password</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
placeholder="••••••••"
|
|
||||||
required
|
|
||||||
className="w-full px-4 py-3 rounded-xl bg-background border border-border focus:border-purple-500 focus:ring-2 focus:ring-purple-500/20 outline-none transition-all"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loginError && (
|
|
||||||
<p className="text-sm text-red-600 dark:text-red-400">{loginError}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={isAuthenticating}
|
|
||||||
className="w-full bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white"
|
|
||||||
>
|
|
||||||
{isAuthenticating ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
Signing in...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'Sign In'
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="mt-6 pt-6 border-t text-center">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Admin credentials required
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Loading
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Admin Dashboard
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gradient-to-br from-purple-50/30 to-pink-50/30 dark:from-purple-950/10 dark:to-pink-950/10">
|
|
||||||
<div className="container mx-auto px-4 py-8 max-w-7xl">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between mb-8">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold mb-2">Admin Dashboard</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Platform overview and statistics
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={handleLogout}
|
|
||||||
variant="outline"
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<LogOut className="w-4 h-4" />
|
|
||||||
Logout
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Stats Grid */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
|
||||||
{/* All Time Users */}
|
|
||||||
<Card className="p-6 hover:shadow-lg transition-shadow">
|
|
||||||
<div className="flex items-start justify-between mb-4">
|
|
||||||
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900/20 rounded-lg flex items-center justify-center">
|
|
||||||
<Users className="w-6 h-6 text-blue-600 dark:text-blue-400" />
|
|
||||||
</div>
|
|
||||||
<Badge className="bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
|
|
||||||
All Time
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-3xl font-bold mb-1">{stats?.users.total || 0}</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">Total Users</p>
|
|
||||||
<div className="mt-3 pt-3 border-t space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-xs text-muted-foreground">This Month</span>
|
|
||||||
<span className="text-sm font-semibold text-green-600 dark:text-green-400">
|
|
||||||
+{stats?.users.newThisMonth || 0}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-xs text-muted-foreground">This Week</span>
|
|
||||||
<span className="text-sm font-semibold text-green-600 dark:text-green-400">
|
|
||||||
+{stats?.users.newThisWeek || 0}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Dynamic QR Codes */}
|
|
||||||
<Card className="p-6 hover:shadow-lg transition-shadow">
|
|
||||||
<div className="flex items-start justify-between mb-4">
|
|
||||||
<div className="w-12 h-12 bg-purple-100 dark:bg-purple-900/20 rounded-lg flex items-center justify-center">
|
|
||||||
<QrCode className="w-6 h-6 text-purple-600 dark:text-purple-400" />
|
|
||||||
</div>
|
|
||||||
<Badge className="bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">
|
|
||||||
Dynamic
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-3xl font-bold mb-1">{stats?.qrCodes.dynamic || 0}</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">Dynamic QR Codes</p>
|
|
||||||
<div className="mt-3 pt-3 border-t flex items-center justify-between">
|
|
||||||
<span className="text-xs text-muted-foreground">Static</span>
|
|
||||||
<span className="text-sm font-semibold">{stats?.qrCodes.static || 0}</span>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Total Scans */}
|
|
||||||
<Card className="p-6 hover:shadow-lg transition-shadow">
|
|
||||||
<div className="flex items-start justify-between mb-4">
|
|
||||||
<div className="w-12 h-12 bg-green-100 dark:bg-green-900/20 rounded-lg flex items-center justify-center">
|
|
||||||
<BarChart3 className="w-6 h-6 text-green-600 dark:text-green-400" />
|
|
||||||
</div>
|
|
||||||
<Badge className="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
|
|
||||||
All Time
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-3xl font-bold mb-1">
|
|
||||||
{stats?.scans.dynamicOnly.toLocaleString() || 0}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">Dynamic QR Scans</p>
|
|
||||||
<div className="mt-3 pt-3 border-t flex items-center justify-between">
|
|
||||||
<span className="text-xs text-muted-foreground">Avg per QR</span>
|
|
||||||
<span className="text-sm font-semibold">{stats?.scans.avgPerDynamicQR || 0}</span>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Total QR Codes */}
|
|
||||||
<Card className="p-6 hover:shadow-lg transition-shadow">
|
|
||||||
<div className="flex items-start justify-between mb-4">
|
|
||||||
<div className="w-12 h-12 bg-amber-100 dark:bg-amber-900/20 rounded-lg flex items-center justify-center">
|
|
||||||
<QrCode className="w-6 h-6 text-amber-600 dark:text-amber-400" />
|
|
||||||
</div>
|
|
||||||
<Badge className="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
|
|
||||||
All Time
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-3xl font-bold mb-1">{stats?.qrCodes.total || 0}</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">Total QR Codes</p>
|
|
||||||
<div className="mt-3 pt-3 border-t space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-xs text-muted-foreground">Dynamic</span>
|
|
||||||
<span className="text-sm font-semibold">{stats?.qrCodes.dynamic || 0}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-xs text-muted-foreground">Static</span>
|
|
||||||
<span className="text-sm font-semibold">{stats?.qrCodes.static || 0}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Secondary Stats Row */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
|
||||||
{/* Total All Scans */}
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-12 h-12 bg-indigo-100 dark:bg-indigo-900/20 rounded-lg flex items-center justify-center">
|
|
||||||
<Zap className="w-6 h-6 text-indigo-600 dark:text-indigo-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-2xl font-bold">
|
|
||||||
{stats?.scans.total.toLocaleString() || 0}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">Total All Scans</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Total QR Codes */}
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-12 h-12 bg-pink-100 dark:bg-pink-900/20 rounded-lg flex items-center justify-center">
|
|
||||||
<QrCode className="w-6 h-6 text-pink-600 dark:text-pink-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-2xl font-bold">{stats?.qrCodes.total || 0}</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">Total QR Codes</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Premium Users */}
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-12 h-12 bg-amber-100 dark:bg-amber-900/20 rounded-lg flex items-center justify-center">
|
|
||||||
<Crown className="w-6 h-6 text-amber-600 dark:text-amber-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-2xl font-bold">{stats?.users.premium || 0}</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">Premium Users</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom Grid */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
{/* Top QR Codes */}
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
|
||||||
<div className="w-10 h-10 bg-gradient-to-br from-purple-100 to-pink-100 dark:from-purple-900/30 dark:to-pink-900/30 rounded-lg flex items-center justify-center">
|
|
||||||
<TrendingUp className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-lg">Top QR Codes</h3>
|
|
||||||
<p className="text-xs text-muted-foreground">Most scanned</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{stats?.topQRCodes && stats.topQRCodes.length > 0 ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{stats.topQRCodes.map((qr, index) => (
|
|
||||||
<div
|
|
||||||
key={qr.id}
|
|
||||||
className="flex items-center justify-between py-3 border-b border-border last:border-0"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
|
||||||
<div className="w-8 h-8 bg-gradient-to-br from-purple-500 to-pink-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
|
||||||
<span className="text-white text-sm font-bold">
|
|
||||||
#{index + 1}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="text-sm font-medium truncate">{qr.title}</p>
|
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
|
||||||
{qr.owner}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right flex-shrink-0 ml-4">
|
|
||||||
<p className="text-lg font-bold">{qr.scans.toLocaleString()}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">scans</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">No QR codes yet</p>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Recent Users */}
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
|
||||||
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/20 rounded-lg flex items-center justify-center">
|
|
||||||
<Users className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-lg">Recent Users</h3>
|
|
||||||
<p className="text-xs text-muted-foreground">Latest signups</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{stats?.users.recent && stats.users.recent.length > 0 ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{stats.users.recent.map((user, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="flex items-center justify-between py-3 border-b border-border last:border-0"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
|
||||||
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-cyan-500 rounded-full flex items-center justify-center flex-shrink-0">
|
|
||||||
<span className="text-white text-xs font-bold">
|
|
||||||
{(user.name || user.email).charAt(0).toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="text-sm font-medium truncate">
|
|
||||||
{user.name || user.email}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
|
||||||
{new Date(user.createdAt).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
className={
|
|
||||||
user.plan === 'FREE'
|
|
||||||
? 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-300'
|
|
||||||
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{user.plan === 'PRO' && <Crown className="w-3 h-3 mr-1" />}
|
|
||||||
{user.plan}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">No users yet</p>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Newsletter Management Section */}
|
|
||||||
<div className="mt-8">
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
|
||||||
<div className="w-10 h-10 bg-gradient-to-br from-purple-100 to-pink-100 dark:from-purple-900/30 dark:to-pink-900/30 rounded-lg flex items-center justify-center">
|
|
||||||
<Users className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-semibold text-lg">Newsletter Management</h3>
|
|
||||||
<p className="text-xs text-muted-foreground">Manage AI feature launch notifications</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<span className="text-2xl font-bold">{newsletterData?.total || 0}</span>
|
|
||||||
<p className="text-xs text-muted-foreground">Total Subscribers</p>
|
|
||||||
</div>
|
|
||||||
<Badge className="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
|
|
||||||
Active
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Broadcast Section */}
|
|
||||||
<div className="p-4 bg-gray-50 dark:bg-gray-900/50 rounded-xl mb-6">
|
|
||||||
<div className="flex items-start gap-3 mb-3">
|
|
||||||
<Send className="w-5 h-5 text-purple-600 dark:text-purple-400 mt-0.5" />
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium">Broadcast AI Feature Launch</h4>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Send the AI feature launch announcement to all {newsletterData?.total || 0} subscribers.
|
|
||||||
This will inform them that the features are now available.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Resend Free Tier Warning */}
|
|
||||||
{(newsletterData?.total || 0) > 100 && (
|
|
||||||
<div className="p-3 rounded-lg mb-3 bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-200 flex items-start gap-2">
|
|
||||||
<Activity className="w-5 h-5 flex-shrink-0 mt-0.5" />
|
|
||||||
<div className="text-sm">
|
|
||||||
<strong>Warning: Resend Free Limit</strong>
|
|
||||||
<p>You have more than 100 subscribers. The Resend Free Tier only allows 100 emails per day. Sending this broadcast might fail for some users or block your account.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{broadcastResult && (
|
|
||||||
<div className={`p-3 rounded-lg mb-3 flex items-center gap-2 ${broadcastResult.success
|
|
||||||
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
|
|
||||||
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300'
|
|
||||||
}`}>
|
|
||||||
{broadcastResult.success && <CheckCircle2 className="w-4 h-4" />}
|
|
||||||
<span className="text-sm">{broadcastResult.message}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleSendBroadcast}
|
|
||||||
disabled={sendingBroadcast || (newsletterData?.total || 0) === 0 || (newsletterData?.total || 0) > 100}
|
|
||||||
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white"
|
|
||||||
>
|
|
||||||
{sendingBroadcast ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
Sending...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Mail className="w-4 h-4 mr-2" />
|
|
||||||
Send Launch Notification to All
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Recent Subscribers */}
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium mb-3">Recent Subscribers</h4>
|
|
||||||
{newsletterData?.recent && newsletterData.recent.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{newsletterData.recent.map((subscriber, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="flex items-center justify-between py-2 border-b border-border last:border-0"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Mail className="w-4 h-4 text-muted-foreground" />
|
|
||||||
<span className="text-sm">{subscriber.email}</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{new Date(subscriber.createdAt).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">No subscribers yet</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tip */}
|
|
||||||
<div className="mt-4 pt-4 border-t">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
💡 Tip: View all subscribers in{' '}
|
|
||||||
<a
|
|
||||||
href="http://localhost:5555"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-purple-600 dark:text-purple-400 hover:underline"
|
|
||||||
>
|
|
||||||
Prisma Studio
|
|
||||||
</a>
|
|
||||||
{' '}(NewsletterSubscription table)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Lead Management Section */}
|
|
||||||
<div className="mt-8">
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
|
||||||
<div className="w-10 h-10 bg-gradient-to-br from-emerald-100 to-teal-100 dark:from-emerald-900/30 dark:to-teal-900/30 rounded-lg flex items-center justify-center">
|
|
||||||
<FileDown className="w-5 h-5 text-emerald-600 dark:text-emerald-400" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-semibold text-lg">Lead Management</h3>
|
|
||||||
<p className="text-xs text-muted-foreground">Reprint Calculator PDF downloads</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<span className="text-2xl font-bold">{leadData?.total || 0}</span>
|
|
||||||
<p className="text-xs text-muted-foreground">Total Leads</p>
|
|
||||||
</div>
|
|
||||||
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">
|
|
||||||
Active
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Recent Leads */}
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium mb-3">Recent Leads</h4>
|
|
||||||
{leadData?.recent && leadData.recent.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{leadData.recent.map((lead) => (
|
|
||||||
<div
|
|
||||||
key={lead.id}
|
|
||||||
className="flex items-center justify-between py-3 px-4 border border-border rounded-lg bg-gray-50/50 dark:bg-gray-900/30"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
|
||||||
<Mail className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
|
||||||
<div className="min-w-0">
|
|
||||||
<span className="text-sm font-medium block truncate">{lead.email}</span>
|
|
||||||
{lead.annualSavings && (
|
|
||||||
<span className="text-xs text-emerald-600 flex items-center gap-1">
|
|
||||||
<DollarSign className="w-3 h-3" />
|
|
||||||
€{lead.annualSavings.toLocaleString()} potential savings
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right flex-shrink-0 ml-4">
|
|
||||||
<span className="text-xs text-muted-foreground block">
|
|
||||||
{new Date(lead.createdAt).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
{lead.reprintCost && lead.updatesPerYear && (
|
|
||||||
<span className="text-xs text-slate-500">
|
|
||||||
€{lead.reprintCost} × {lead.updatesPerYear}/yr
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">No leads yet. Leads appear when users download a PDF report from the Reprint Calculator.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tip */}
|
|
||||||
<div className="mt-4 pt-4 border-t">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
💡 Tip: View all leads in{' '}
|
|
||||||
<a
|
|
||||||
href="http://localhost:5555"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-emerald-600 dark:text-emerald-400 hover:underline"
|
|
||||||
>
|
|
||||||
Prisma Studio
|
|
||||||
</a>
|
|
||||||
{' '}(Lead table)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +1,643 @@
|
||||||
import React from 'react';
|
'use client';
|
||||||
import type { Metadata } from 'next';
|
|
||||||
import NewsletterClient from './NewsletterClient';
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
import React, { useState, useEffect } from 'react';
|
||||||
title: 'Newsletter Admin | QR Master',
|
import { useRouter } from 'next/navigation';
|
||||||
description: 'Administrative access for QR Master newsletter management. This area is restricted to authorized personnel only.',
|
import { Card } from '@/components/ui/Card';
|
||||||
robots: {
|
import { Button } from '@/components/ui/Button';
|
||||||
index: false,
|
import { Badge } from '@/components/ui/Badge';
|
||||||
follow: false,
|
import {
|
||||||
},
|
Mail,
|
||||||
alternates: {
|
Users,
|
||||||
canonical: 'https://www.qrmaster.net/newsletter',
|
QrCode,
|
||||||
},
|
BarChart3,
|
||||||
};
|
TrendingUp,
|
||||||
|
Crown,
|
||||||
|
Activity,
|
||||||
|
Loader2,
|
||||||
|
Lock,
|
||||||
|
LogOut,
|
||||||
|
Zap,
|
||||||
|
Send,
|
||||||
|
CheckCircle2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
export default function NewsletterPage() {
|
interface AdminStats {
|
||||||
return <NewsletterClient />;
|
users: {
|
||||||
|
total: number;
|
||||||
|
premium: number;
|
||||||
|
newThisWeek: number;
|
||||||
|
newThisMonth: number;
|
||||||
|
recent: Array<{
|
||||||
|
email: string;
|
||||||
|
name: string | null;
|
||||||
|
plan: string;
|
||||||
|
createdAt: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
qrCodes: {
|
||||||
|
total: number;
|
||||||
|
dynamic: number;
|
||||||
|
static: number;
|
||||||
|
active: number;
|
||||||
|
};
|
||||||
|
scans: {
|
||||||
|
total: number;
|
||||||
|
dynamicOnly: number;
|
||||||
|
avgPerDynamicQR: string;
|
||||||
|
};
|
||||||
|
newsletter: {
|
||||||
|
subscribers: number;
|
||||||
|
};
|
||||||
|
topQRCodes: Array<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
scans: number;
|
||||||
|
owner: string;
|
||||||
|
createdAt: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminDashboard() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
|
const [isAuthenticating, setIsAuthenticating] = useState(true);
|
||||||
|
const [loginError, setLoginError] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
|
||||||
|
const [stats, setStats] = useState<AdminStats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Newsletter management state
|
||||||
|
const [newsletterData, setNewsletterData] = useState<{
|
||||||
|
total: number;
|
||||||
|
recent: Array<{ email: string; createdAt: string }>;
|
||||||
|
} | null>(null);
|
||||||
|
const [sendingBroadcast, setSendingBroadcast] = useState(false);
|
||||||
|
const [broadcastResult, setBroadcastResult] = useState<{
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const checkAuth = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/stats');
|
||||||
|
if (response.ok) {
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
const data = await response.json();
|
||||||
|
setStats(data);
|
||||||
|
setLoading(false);
|
||||||
|
// Also fetch newsletter data
|
||||||
|
fetchNewsletterData();
|
||||||
|
} else {
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
} finally {
|
||||||
|
setIsAuthenticating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchNewsletterData = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/newsletter/broadcast');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setNewsletterData(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch newsletter data:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendBroadcast = async () => {
|
||||||
|
if (!confirm(`Are you sure you want to send the AI Feature Launch email to all ${newsletterData?.total || 0} subscribers?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSendingBroadcast(true);
|
||||||
|
setBroadcastResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/newsletter/broadcast', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setBroadcastResult({
|
||||||
|
success: true,
|
||||||
|
message: data.message || `Successfully sent to ${data.sent} subscribers!`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setBroadcastResult({
|
||||||
|
success: false,
|
||||||
|
message: data.error || 'Failed to send broadcast',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setBroadcastResult({
|
||||||
|
success: false,
|
||||||
|
message: 'Network error. Please try again.',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSendingBroadcast(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoginError('');
|
||||||
|
setIsAuthenticating(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/newsletter/admin-login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
await checkAuth();
|
||||||
|
} else {
|
||||||
|
const data = await response.json();
|
||||||
|
setLoginError(data.error || 'Invalid credentials');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setLoginError('Login failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsAuthenticating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await fetch('/api/auth/logout', { method: 'POST' });
|
||||||
|
router.push('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Login Screen
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-purple-50 to-pink-50 dark:from-purple-950/20 dark:to-pink-950/20 px-4">
|
||||||
|
<Card className="w-full max-w-md p-8">
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<div className="w-16 h-16 bg-gradient-to-br from-purple-100 to-pink-100 dark:from-purple-900/30 dark:to-pink-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Lock className="w-8 h-8 text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold mb-2">Admin Dashboard</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Sign in to access admin panel
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleLogin} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="admin@example.com"
|
||||||
|
required
|
||||||
|
className="w-full px-4 py-3 rounded-xl bg-background border border-border focus:border-purple-500 focus:ring-2 focus:ring-purple-500/20 outline-none transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
className="w-full px-4 py-3 rounded-xl bg-background border border-border focus:border-purple-500 focus:ring-2 focus:ring-purple-500/20 outline-none transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loginError && (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">{loginError}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isAuthenticating}
|
||||||
|
className="w-full bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white"
|
||||||
|
>
|
||||||
|
{isAuthenticating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Signing in...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Sign In'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 pt-6 border-t text-center">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Admin credentials required
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin Dashboard
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-purple-50/30 to-pink-50/30 dark:from-purple-950/10 dark:to-pink-950/10">
|
||||||
|
<div className="container mx-auto px-4 py-8 max-w-7xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold mb-2">Admin Dashboard</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Platform overview and statistics
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleLogout}
|
||||||
|
variant="outline"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Stats Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
|
{/* All Time Users */}
|
||||||
|
<Card className="p-6 hover:shadow-lg transition-shadow">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900/20 rounded-lg flex items-center justify-center">
|
||||||
|
<Users className="w-6 h-6 text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<Badge className="bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
|
||||||
|
All Time
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-3xl font-bold mb-1">{stats?.users.total || 0}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">Total Users</p>
|
||||||
|
<div className="mt-3 pt-3 border-t space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-muted-foreground">This Month</span>
|
||||||
|
<span className="text-sm font-semibold text-green-600 dark:text-green-400">
|
||||||
|
+{stats?.users.newThisMonth || 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-muted-foreground">This Week</span>
|
||||||
|
<span className="text-sm font-semibold text-green-600 dark:text-green-400">
|
||||||
|
+{stats?.users.newThisWeek || 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Dynamic QR Codes */}
|
||||||
|
<Card className="p-6 hover:shadow-lg transition-shadow">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="w-12 h-12 bg-purple-100 dark:bg-purple-900/20 rounded-lg flex items-center justify-center">
|
||||||
|
<QrCode className="w-6 h-6 text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<Badge className="bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">
|
||||||
|
Dynamic
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-3xl font-bold mb-1">{stats?.qrCodes.dynamic || 0}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">Dynamic QR Codes</p>
|
||||||
|
<div className="mt-3 pt-3 border-t flex items-center justify-between">
|
||||||
|
<span className="text-xs text-muted-foreground">Static</span>
|
||||||
|
<span className="text-sm font-semibold">{stats?.qrCodes.static || 0}</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Total Scans */}
|
||||||
|
<Card className="p-6 hover:shadow-lg transition-shadow">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="w-12 h-12 bg-green-100 dark:bg-green-900/20 rounded-lg flex items-center justify-center">
|
||||||
|
<BarChart3 className="w-6 h-6 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<Badge className="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
|
||||||
|
All Time
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-3xl font-bold mb-1">
|
||||||
|
{stats?.scans.dynamicOnly.toLocaleString() || 0}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">Dynamic QR Scans</p>
|
||||||
|
<div className="mt-3 pt-3 border-t flex items-center justify-between">
|
||||||
|
<span className="text-xs text-muted-foreground">Avg per QR</span>
|
||||||
|
<span className="text-sm font-semibold">{stats?.scans.avgPerDynamicQR || 0}</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Total QR Codes */}
|
||||||
|
<Card className="p-6 hover:shadow-lg transition-shadow">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="w-12 h-12 bg-amber-100 dark:bg-amber-900/20 rounded-lg flex items-center justify-center">
|
||||||
|
<QrCode className="w-6 h-6 text-amber-600 dark:text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<Badge className="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
|
||||||
|
All Time
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-3xl font-bold mb-1">{stats?.qrCodes.total || 0}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">Total QR Codes</p>
|
||||||
|
<div className="mt-3 pt-3 border-t space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-muted-foreground">Dynamic</span>
|
||||||
|
<span className="text-sm font-semibold">{stats?.qrCodes.dynamic || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-muted-foreground">Static</span>
|
||||||
|
<span className="text-sm font-semibold">{stats?.qrCodes.static || 0}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Secondary Stats Row */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
|
{/* Total All Scans */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 bg-indigo-100 dark:bg-indigo-900/20 rounded-lg flex items-center justify-center">
|
||||||
|
<Zap className="w-6 h-6 text-indigo-600 dark:text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-bold">
|
||||||
|
{stats?.scans.total.toLocaleString() || 0}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">Total All Scans</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Total QR Codes */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 bg-pink-100 dark:bg-pink-900/20 rounded-lg flex items-center justify-center">
|
||||||
|
<QrCode className="w-6 h-6 text-pink-600 dark:text-pink-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-bold">{stats?.qrCodes.total || 0}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">Total QR Codes</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Premium Users */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 bg-amber-100 dark:bg-amber-900/20 rounded-lg flex items-center justify-center">
|
||||||
|
<Crown className="w-6 h-6 text-amber-600 dark:text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-bold">{stats?.users.premium || 0}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">Premium Users</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Grid */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Top QR Codes */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="w-10 h-10 bg-gradient-to-br from-purple-100 to-pink-100 dark:from-purple-900/30 dark:to-pink-900/30 rounded-lg flex items-center justify-center">
|
||||||
|
<TrendingUp className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-lg">Top QR Codes</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">Most scanned</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stats?.topQRCodes && stats.topQRCodes.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{stats.topQRCodes.map((qr, index) => (
|
||||||
|
<div
|
||||||
|
key={qr.id}
|
||||||
|
className="flex items-center justify-between py-3 border-b border-border last:border-0"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<div className="w-8 h-8 bg-gradient-to-br from-purple-500 to-pink-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-white text-sm font-bold">
|
||||||
|
#{index + 1}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium truncate">{qr.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{qr.owner}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right flex-shrink-0 ml-4">
|
||||||
|
<p className="text-lg font-bold">{qr.scans.toLocaleString()}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">scans</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No QR codes yet</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Recent Users */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/20 rounded-lg flex items-center justify-center">
|
||||||
|
<Users className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-lg">Recent Users</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">Latest signups</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stats?.users.recent && stats.users.recent.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{stats.users.recent.map((user, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center justify-between py-3 border-b border-border last:border-0"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-cyan-500 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-white text-xs font-bold">
|
||||||
|
{(user.name || user.email).charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium truncate">
|
||||||
|
{user.name || user.email}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{new Date(user.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
className={
|
||||||
|
user.plan === 'FREE'
|
||||||
|
? 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-300'
|
||||||
|
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{user.plan === 'PRO' && <Crown className="w-3 h-3 mr-1" />}
|
||||||
|
{user.plan}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No users yet</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Newsletter Management Section */}
|
||||||
|
<div className="mt-8">
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="w-10 h-10 bg-gradient-to-br from-purple-100 to-pink-100 dark:from-purple-900/30 dark:to-pink-900/30 rounded-lg flex items-center justify-center">
|
||||||
|
<Users className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold text-lg">Newsletter Management</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">Manage AI feature launch notifications</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className="text-2xl font-bold">{newsletterData?.total || 0}</span>
|
||||||
|
<p className="text-xs text-muted-foreground">Total Subscribers</p>
|
||||||
|
</div>
|
||||||
|
<Badge className="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
|
||||||
|
Active
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Broadcast Section */}
|
||||||
|
<div className="p-4 bg-gray-50 dark:bg-gray-900/50 rounded-xl mb-6">
|
||||||
|
<div className="flex items-start gap-3 mb-3">
|
||||||
|
<Send className="w-5 h-5 text-purple-600 dark:text-purple-400 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium">Broadcast AI Feature Launch</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Send the AI feature launch announcement to all {newsletterData?.total || 0} subscribers.
|
||||||
|
This will inform them that the features are now available.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resend Free Tier Warning */}
|
||||||
|
{(newsletterData?.total || 0) > 100 && (
|
||||||
|
<div className="p-3 rounded-lg mb-3 bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-200 flex items-start gap-2">
|
||||||
|
<Activity className="w-5 h-5 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<strong>Warning: Resend Free Limit</strong>
|
||||||
|
<p>You have more than 100 subscribers. The Resend Free Tier only allows 100 emails per day. Sending this broadcast might fail for some users or block your account.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{broadcastResult && (
|
||||||
|
<div className={`p-3 rounded-lg mb-3 flex items-center gap-2 ${broadcastResult.success
|
||||||
|
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
|
||||||
|
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300'
|
||||||
|
}`}>
|
||||||
|
{broadcastResult.success && <CheckCircle2 className="w-4 h-4" />}
|
||||||
|
<span className="text-sm">{broadcastResult.message}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleSendBroadcast}
|
||||||
|
disabled={sendingBroadcast || (newsletterData?.total || 0) === 0 || (newsletterData?.total || 0) > 100}
|
||||||
|
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white"
|
||||||
|
>
|
||||||
|
{sendingBroadcast ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Sending...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Mail className="w-4 h-4 mr-2" />
|
||||||
|
Send Launch Notification to All
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Subscribers */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium mb-3">Recent Subscribers</h4>
|
||||||
|
{newsletterData?.recent && newsletterData.recent.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{newsletterData.recent.map((subscriber, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center justify-between py-2 border-b border-border last:border-0"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Mail className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm">{subscriber.email}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{new Date(subscriber.createdAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No subscribers yet</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tip */}
|
||||||
|
<div className="mt-4 pt-4 border-t">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
💡 Tip: View all subscribers in{' '}
|
||||||
|
<a
|
||||||
|
href="http://localhost:5555"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-purple-600 dark:text-purple-400 hover:underline"
|
||||||
|
>
|
||||||
|
Prisma Studio
|
||||||
|
</a>
|
||||||
|
{' '}(NewsletterSubscription table)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,85 +1,71 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import SeoJsonLd from '@/components/SeoJsonLd';
|
import SeoJsonLd from '@/components/SeoJsonLd';
|
||||||
import { organizationSchema, websiteSchema } from '@/lib/schema';
|
import { organizationSchema, websiteSchema } from '@/lib/schema';
|
||||||
import HomePageClient from '@/components/marketing/HomePageClient';
|
import HomePageClient from '@/components/marketing/HomePageClient';
|
||||||
import { generateFaqSchema } from '@/lib/schema-utils';
|
|
||||||
import en from '@/i18n/en.json'; // Import English translations for schema generation
|
function truncateAtWord(text: string, maxLength: number): string {
|
||||||
|
if (text.length <= maxLength) return text;
|
||||||
function truncateAtWord(text: string, maxLength: number): string {
|
const truncated = text.slice(0, maxLength);
|
||||||
if (text.length <= maxLength) return text;
|
const lastSpace = truncated.lastIndexOf(' ');
|
||||||
const truncated = text.slice(0, maxLength);
|
return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated;
|
||||||
const lastSpace = truncated.lastIndexOf(' ');
|
}
|
||||||
return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated;
|
|
||||||
}
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const title = truncateAtWord('QR Master: Dynamic QR Generator', 60);
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
const description = truncateAtWord(
|
||||||
const title = truncateAtWord('QR Master: Dynamic QR Generator', 60);
|
'Dynamic QR, branding, bulk generation & analytics for all campaigns.',
|
||||||
const description = truncateAtWord(
|
160
|
||||||
'Create professional QR codes with QR Master. Dynamic QR with tracking, bulk generation, custom branding, and real-time analytics for all your campaigns.',
|
);
|
||||||
160
|
|
||||||
);
|
return {
|
||||||
|
title,
|
||||||
return {
|
description,
|
||||||
title,
|
alternates: {
|
||||||
description,
|
canonical: 'https://www.qrmaster.net/',
|
||||||
alternates: {
|
languages: {
|
||||||
canonical: 'https://www.qrmaster.net/',
|
'x-default': 'https://www.qrmaster.net/',
|
||||||
languages: {
|
en: 'https://www.qrmaster.net/',
|
||||||
'x-default': 'https://www.qrmaster.net/',
|
},
|
||||||
en: 'https://www.qrmaster.net/',
|
},
|
||||||
de: 'https://www.qrmaster.net/qr-code-erstellen',
|
openGraph: {
|
||||||
},
|
title,
|
||||||
},
|
description,
|
||||||
openGraph: {
|
url: 'https://www.qrmaster.net/',
|
||||||
title,
|
type: 'website',
|
||||||
description,
|
},
|
||||||
url: 'https://www.qrmaster.net/',
|
twitter: {
|
||||||
type: 'website',
|
title,
|
||||||
images: [
|
description,
|
||||||
{
|
},
|
||||||
url: 'https://www.qrmaster.net/og-image.png',
|
};
|
||||||
width: 1200,
|
}
|
||||||
height: 630,
|
|
||||||
alt: 'QR Master - Dynamic QR Code Generator and Analytics Platform',
|
export default function HomePage() {
|
||||||
},
|
return (
|
||||||
],
|
<>
|
||||||
},
|
<SeoJsonLd data={[organizationSchema(), websiteSchema()]} />
|
||||||
twitter: {
|
|
||||||
title,
|
{/* Server-rendered SEO content for crawlers */}
|
||||||
description,
|
<div className="sr-only" aria-hidden="false">
|
||||||
images: ['https://www.qrmaster.net/og-image.png'],
|
<h1>QR Master: Free Dynamic QR Code Generator with Tracking & Analytics</h1>
|
||||||
},
|
<p>
|
||||||
};
|
Create professional QR codes for your business with QR Master. Our dynamic QR code generator
|
||||||
}
|
lets you create trackable QR codes, edit destinations anytime, and view detailed analytics.
|
||||||
|
Perfect for restaurants, retail, events, and marketing campaigns.
|
||||||
export default function HomePage() {
|
</p>
|
||||||
return (
|
<p>
|
||||||
<>
|
Features include: Dynamic QR codes with real-time tracking, bulk QR code generation from Excel/CSV,
|
||||||
<SeoJsonLd data={[organizationSchema(), websiteSchema(), generateFaqSchema(en.faq.questions)]} />
|
custom branding with colors and logos, advanced scan analytics showing device types and locations,
|
||||||
|
vCard QR codes for digital business cards, and restaurant menu QR codes.
|
||||||
{/* Server-rendered H1 for SEO - visually hidden but crawlable */}
|
</p>
|
||||||
<h1 className="sr-only">QR Master: Dynamic QR Code Generator with Analytics</h1>
|
<p>
|
||||||
|
Start free with 3 dynamic QR codes and unlimited static codes. Upgrade to Pro for 50 codes
|
||||||
{/* Server-rendered SEO content for crawlers */}
|
with advanced analytics, or Business for 500 codes with bulk creation and priority support.
|
||||||
<div className="sr-only" aria-hidden="false">
|
</p>
|
||||||
<p>
|
</div>
|
||||||
Create professional QR codes for your business with QR Master. Our dynamic QR code generator
|
|
||||||
lets you create trackable QR codes, edit destinations anytime, and view detailed analytics.
|
<HomePageClient />
|
||||||
Perfect for restaurants, retail, events, and marketing campaigns.
|
</>
|
||||||
</p>
|
);
|
||||||
<p>
|
}
|
||||||
Features include: Dynamic QR codes with real-time tracking, bulk QR code generation from Excel/CSV,
|
|
||||||
custom branding with colors and logos, advanced scan analytics showing device types and locations,
|
|
||||||
vCard QR codes for digital business cards, and restaurant menu QR codes.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Start free with 3 dynamic QR codes and unlimited static codes. Upgrade to Pro for 50 codes
|
|
||||||
with advanced analytics, or Business for 500 codes with bulk creation and priority support.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<HomePageClient />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import type { Metadata } from 'next';
|
|
||||||
import PricingClient from './PricingClient';
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: {
|
|
||||||
absolute: 'Pricing Plans | QR Master'
|
|
||||||
},
|
|
||||||
description: 'Choose the perfect QR code plan for your needs. Free, Pro, and Business plans with dynamic QR codes, analytics, bulk generation, and custom branding.',
|
|
||||||
alternates: {
|
|
||||||
canonical: 'https://www.qrmaster.net/pricing',
|
|
||||||
},
|
|
||||||
robots: {
|
|
||||||
index: true,
|
|
||||||
follow: true,
|
|
||||||
},
|
|
||||||
openGraph: {
|
|
||||||
title: 'Pricing Plans | QR Master',
|
|
||||||
description: 'Choose the perfect QR code plan for your needs.',
|
|
||||||
url: 'https://www.qrmaster.net/pricing',
|
|
||||||
type: 'website',
|
|
||||||
images: [
|
|
||||||
{
|
|
||||||
url: 'https://www.qrmaster.net/og-image.png',
|
|
||||||
width: 1200,
|
|
||||||
height: 630,
|
|
||||||
alt: 'QR Master Pricing Plans',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function PricingPage() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Server-rendered H1 for SEO */}
|
|
||||||
<h1 className="sr-only">QR Master Pricing – Choose Your QR Code Plan</h1>
|
|
||||||
<div className="sr-only">
|
|
||||||
<h2>Compare our plans</h2>
|
|
||||||
<p>Find the best QR code solution for your business. From free personal tiers to enterprise-grade dynamic code management.</p>
|
|
||||||
</div>
|
|
||||||
<PricingClient />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { ObfuscatedMailto } from '@/components/ui/ObfuscatedMailto';
|
|
||||||
|
|
||||||
export function PrivacyEmailLink() {
|
|
||||||
return (
|
|
||||||
<ObfuscatedMailto
|
|
||||||
email="support@qrmaster.net"
|
|
||||||
className="text-primary-600 hover:text-primary-700"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +1,9 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { PrivacyEmailLink } from './PrivacyEmailLink';
|
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Privacy Policy | QR Master',
|
title: 'Privacy Policy | QR Master',
|
||||||
description: 'Read our Privacy Policy to understand how QR Master collects, uses, and protects your data. We are committed to GDPR compliance and data security.',
|
description: 'Privacy Policy and data protection information for QR Master',
|
||||||
alternates: {
|
|
||||||
canonical: 'https://www.qrmaster.net/privacy',
|
|
||||||
},
|
|
||||||
openGraph: {
|
|
||||||
title: 'Privacy Policy | QR Master',
|
|
||||||
description: 'Read our Privacy Policy to understand how QR Master collects, uses, and protects your data.',
|
|
||||||
url: 'https://www.qrmaster.net/privacy',
|
|
||||||
type: 'website',
|
|
||||||
images: [
|
|
||||||
{
|
|
||||||
url: 'https://www.qrmaster.net/og-image.png',
|
|
||||||
width: 1200,
|
|
||||||
height: 630,
|
|
||||||
alt: 'QR Master Privacy Policy',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PrivacyPage() {
|
export default function PrivacyPage() {
|
||||||
|
|
@ -111,7 +93,9 @@ export default function PrivacyPage() {
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-gray-700 mb-4">
|
<p className="text-gray-700 mb-4">
|
||||||
To exercise these rights, contact us at{' '}
|
To exercise these rights, contact us at{' '}
|
||||||
<PrivacyEmailLink />
|
<a href="mailto:support@qrmaster.net" className="text-primary-600 hover:text-primary-700">
|
||||||
|
support@qrmaster.net
|
||||||
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-700 mb-4">
|
<p className="text-gray-700 mb-4">
|
||||||
Our service is for users 16 years and older. If you're in the EEA and have concerns,
|
Our service is for users 16 years and older. If you're in the EEA and have concerns,
|
||||||
|
|
@ -127,7 +111,9 @@ export default function PrivacyPage() {
|
||||||
<div className="bg-gray-50 p-6 rounded-lg">
|
<div className="bg-gray-50 p-6 rounded-lg">
|
||||||
<p className="text-gray-700 mb-2">
|
<p className="text-gray-700 mb-2">
|
||||||
<strong>Email:</strong>{' '}
|
<strong>Email:</strong>{' '}
|
||||||
<PrivacyEmailLink />
|
<a href="mailto:support@qrmaster.net" className="text-primary-600 hover:text-primary-700">
|
||||||
|
support@qrmaster.net
|
||||||
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-700 mb-2"><strong>Website:</strong> <a href="/" className="text-primary-600 hover:text-primary-700">qrmaster.net</a></p>
|
<p className="text-gray-700 mb-2"><strong>Website:</strong> <a href="/" className="text-primary-600 hover:text-primary-700">qrmaster.net</a></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||