Seobility und Ahrefs 100/100 score
This commit is contained in:
parent
5b74b7b405
commit
ffe4cca5e5
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
|
|
@ -0,0 +1,68 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,943 @@
|
|||
guck dir hier das an, ich paste alles rein und du erstellst ein ausführliches md file im root. mit allen problemen und verbesserungen, wenn du schon was verbessert hast musst du es nicht einfügen
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
QR Master
|
||||
—
|
||||
screenshot
|
||||
Crawling-Status
|
||||
Fertig
|
||||
Crawling gestartet
|
||||
12.01.26 16:19
|
||||
Crawling beendet
|
||||
12.01.26 16:20
|
||||
Crawling-Dauer
|
||||
57 Sek.
|
||||
Anzahl an Crawlings:2
|
||||
Regelmäßiges Crawling:Nein
|
||||
60 %
|
||||
Onpage Score
|
||||
-1%
|
||||
64%
|
||||
0%
|
||||
Technik & Meta
|
||||
60%
|
||||
-1%
|
||||
Struktur
|
||||
56%
|
||||
-1%
|
||||
Inhalt
|
||||
Gefundene Probleme
|
||||
111
|
||||
+14
|
||||
Gecrawlte Seiten
|
||||
25
|
||||
+1
|
||||
Indexierte Seiten
|
||||
17
|
||||
+1
|
||||
Externe Links
|
||||
2
|
||||
-
|
||||
Probleme & Tipps
|
||||
Alle Probleme, die auf Deiner Website gefunden wurden, sortiert nach absteigender Priorität
|
||||
|
||||
Suche ...
|
||||
|
||||
Alle
|
||||
|
||||
Tech. & Meta
|
||||
|
||||
Struktur
|
||||
|
||||
Inhalt
|
||||
9 Seiten haben nur sehr wenige interne Links.
|
||||
Struktur
|
||||
8 Seiten sind mit verbesserungswürdigen Linktexten intern verlinkt.
|
||||
Struktur
|
||||
13 Seiten haben einen verbesserungswürdigen Seitentitel.
|
||||
Tech. & Meta
|
||||
Auf 9 Seiten werden Keywords aus dem Seitentitel nicht im Text wiederverwendet.
|
||||
Inhalt
|
||||
Es gibt 4 identische HTML-Seiten auf dieser Website.
|
||||
Inhalt
|
||||
9 Seiten enthalten weniger als 500 Wörter Text.
|
||||
Inhalt
|
||||
9 Seiten ohne auswertbarem Textinhalt gefunden.
|
||||
Inhalt
|
||||
6 interne Weiterleitungen gefunden.
|
||||
Struktur
|
||||
4 Seiten haben keinen individuellen Seitentitel.
|
||||
Tech. & Meta
|
||||
1 Seiten weisen Probleme mit Alternate Links auf.
|
||||
Struktur
|
||||
9 Seiten weisen Probleme mit H1-Überschriften auf.
|
||||
Tech. & Meta
|
||||
17 Seiten, die Probleme mit Überschriften oder ihrer Struktur haben.
|
||||
Tech. & Meta
|
||||
4 Seiten haben keine individuelle Meta-Description.
|
||||
Tech. & Meta
|
||||
3 Seiten weisen Probleme mit Strong- oder Bold-Tags auf.
|
||||
Tech. & Meta
|
||||
2 Seiten weisen Probleme mit der Meta-Description auf.
|
||||
Tech. & Meta
|
||||
3 Probleme mit eingebundenen Dateien gefunden.
|
||||
Tech. & Meta
|
||||
1 Seiten haben eine mittlere Antwortzeit zwischen 0,5 und 1 Sekunde.
|
||||
|
||||
|
||||
|
||||
|
||||
ich copy paste jetzt von oben nach unten rein
|
||||
|
||||
|
||||
|
||||
Seiten mit wenigen internen Links
|
||||
Wichtig
|
||||
Seiten mit wenigen internen Links im Vergleich zur durchschnittlichen Anzahl interner Links pro Seite.
|
||||
|
||||
Gefundene Probleme
|
||||
9
|
||||
+1
|
||||
Betroffene Seiten
|
||||
9
|
||||
/25
|
||||
+1
|
||||
Eingeschränkte Funktion - Jetzt Upgrade durchführen und alle Analysen freischalten!
|
||||
|
||||
Verlauf: Seiten mit wenigen internen Links
|
||||
Erfahre mehr über den Report
|
||||
"Seiten mit wenigen internen Links"
|
||||
|
||||
Alle
|
||||
|
||||
URL
|
||||
|
||||
Anzahl der Links
|
||||
https://www.qrmaster.net/
|
||||
0
|
||||
https://www.qrmaster.net/qr-code-erstellen
|
||||
0
|
||||
https://www.qrmaster.net/blog
|
||||
0
|
||||
https://www.qrmaster.net/pricing
|
||||
0
|
||||
https://www.qrmaster.net/faq
|
||||
0
|
||||
https://www.qrmaster.net/login
|
||||
0
|
||||
https://www.qrmaster.net/signup
|
||||
0
|
||||
https://www.qrmaster.net/privacy
|
||||
0
|
||||
https://www.qrmaster.net/newsletter
|
||||
0
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Verbesserungswürdige interne Linktexte
|
||||
Wichtig
|
||||
Der Linktext eines Links ist aus SEO-Sicht ein sehr wichtiges Element, da er Suchmaschinen mitteilt, worum es auf der verlinkten Seite geht.
|
||||
|
||||
Gefundene Probleme
|
||||
8
|
||||
-
|
||||
Betroffene Seiten
|
||||
8
|
||||
/25
|
||||
-
|
||||
Eingeschränkte Funktion - Jetzt Upgrade durchführen und alle Analysen freischalten!
|
||||
|
||||
Verlauf: Verbesserungswürdige interne Linktexte
|
||||
Erfahre mehr über den Report
|
||||
"Verbesserungswürdige interne Linktexte"
|
||||
|
||||
Alle
|
||||
|
||||
URL
|
||||
|
||||
Probleme
|
||||
|
||||
Details
|
||||
https://www.qrmaster.net/blog/qr-code-analytics
|
||||
Linktext zur Seite ist zu lang
|
||||
Details
|
||||
https://www.qrmaster.net/blog/qr-code-tracking-guide-2025
|
||||
Linktext zur Seite ist zu lang
|
||||
Details
|
||||
https://www.qrmaster.net/blog/dynamic-vs-static-qr-codes
|
||||
Linktext zur Seite ist zu lang
|
||||
Details
|
||||
Upgrade
|
||||
|
||||
Details
|
||||
Upgrade
|
||||
|
||||
Details
|
||||
Upgrade
|
||||
|
||||
Details
|
||||
Upgrade
|
||||
|
||||
Details
|
||||
Upgrade
|
||||
|
||||
Details
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Probleme mit Seitentiteln
|
||||
Wichtig
|
||||
Der Meta Title einer Seite ist eines der wichtigsten Elemente für die Onpage-Suchmaschinenoptimierung und sollte möglichst gut optimiert sein.
|
||||
|
||||
Gefundene Probleme
|
||||
13
|
||||
+1
|
||||
Betroffene Seiten
|
||||
13/25
|
||||
+1
|
||||
Eingeschränkte Funktion - Jetzt Upgrade durchführen und alle Analysen freischalten!
|
||||
|
||||
Verlauf: Probleme mit Seitentiteln
|
||||
Erfahre mehr über den Report
|
||||
"Probleme mit Seitentiteln"
|
||||
|
||||
Alle
|
||||
|
||||
URL
|
||||
|
||||
Seitentitel
|
||||
|
||||
https://www.qrmaster.net/
|
||||
QR Master: Dynamic QR Generator \| QR Master
|
||||
Wortwiederholung
|
||||
433/580 Pixel
|
||||
|
||||
https://www.qrmaster.net/qr-code-erstellen
|
||||
QR Code Erstellen – Kostenlos & Sofort \| QR Master \| QR Master
|
||||
Zu lang
|
||||
Wortwiederholung
|
||||
586/580 Pixel
|
||||
|
||||
https://www.qrmaster.net/blog
|
||||
QR Insights: Latest QR Strategies \| QR Master
|
||||
Wortwiederholung
|
||||
418/580 Pixel
|
||||
|
||||
https://www.qrmaster.net/faq
|
||||
QR Master FAQ: Dynamic & Bulk QR \| QR Master
|
||||
Wortwiederholung
|
||||
447/580 Pixel
|
||||
|
||||
https://www.qrmaster.net/blog/qr-code-analytics
|
||||
QR Code Analytics: Track, Measure & Optimize - QR Analytics \| QR Master
|
||||
Zu lang
|
||||
Wortwiederholung
|
||||
673/580 Pixel
|
||||
|
||||
https://www.qrmaster.net/privacy
|
||||
Privacy Policy \| QR Master \| QR Master
|
||||
Wortwiederholung
|
||||
352/580 Pixel
|
||||
|
||||
https://www.qrmaster.net/blog/qr-code-tracking-guide-2025
|
||||
QR Code Tracking: Complete Guide 2025 \(Free Tools & Best \| QR Master
|
||||
Zu lang
|
||||
659/580 Pixel
|
||||
|
||||
https://www.qrmaster.net/blog/dynamic-vs-static-qr-codes
|
||||
Dynamic vs Static QR Codes: Which Should You Use? Complete \| QR Master
|
||||
Zu lang
|
||||
694/580 Pixel
|
||||
|
||||
https://www.qrmaster.net/blog/bulk-qr-code-generator-excel
|
||||
How to Generate Bulk QR Codes from Excel: Complete Tutorial \| QR Master
|
||||
Zu lang
|
||||
681/580 Pixel
|
||||
|
||||
https://www.qrmaster.net/blog/qr-code-restaurant-menu
|
||||
How to Create a QR Code for Restaurant Menu: Complete 2025 \| QR Master
|
||||
Zu lang
|
||||
687/580 Pixel
|
||||
|
||||
https://www.qrmaster.net/blog/vcard-qr-code-generator
|
||||
Free vCard QR Code Generator: Digital Business Cards Made \| QR Master
|
||||
Zu lang
|
||||
671/580 Pixel
|
||||
|
||||
https://www.qrmaster.net/blog/qr-code-small-business
|
||||
Best QR Code Generator for Small Business: 2025 Complete \| QR Master
|
||||
Zu lang
|
||||
661/580 Pixel
|
||||
|
||||
https://www.qrmaster.net/blog/qr-code-print-size-guide
|
||||
QR Code Print Size Guide: Minimum Sizes for Every Use Case \| QR Master
|
||||
Zu lang
|
||||
679/580 Pixel
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Keywords aus Seitentitel nicht im Text
|
||||
Wichtig
|
||||
Keywords aus dem Seitentitel sollten sich auch im Inhalt bzw. Text der Seite wiederfinden, um eine Konsistenz zwischen Titel und Inhalt zu gewährleisten.
|
||||
|
||||
Gefundene Probleme
|
||||
9
|
||||
+1
|
||||
Betroffene Seiten
|
||||
9
|
||||
/25
|
||||
+1
|
||||
Eingeschränkte Funktion - Jetzt Upgrade durchführen und alle Analysen freischalten!
|
||||
|
||||
Verlauf: Keywords aus Seitentitel nicht im Text
|
||||
Erfahre mehr über den Report
|
||||
"Keywords aus Seitentitel nicht im Text"
|
||||
|
||||
Alle
|
||||
|
||||
URL
|
||||
|
||||
Wörter aus dem Seitentitel, die nicht im Text vorkommen
|
||||
https://www.qrmaster.net/
|
||||
QR Master: Dynamic QR Generator | QR Master
|
||||
|
||||
https://www.qrmaster.net/qr-code-erstellen
|
||||
QR Code Erstellen – Kostenlos & Sofort | QR Master | QR Master
|
||||
|
||||
https://www.qrmaster.net/blog
|
||||
QR Insights: Latest QR Strategies | QR Master
|
||||
|
||||
Upgrade your plan
|
||||
Upgrade
|
||||
Upgrade
|
||||
Upgrade your plan
|
||||
Upgrade your plan
|
||||
Upgrade
|
||||
Upgrade
|
||||
Upgrade your plan
|
||||
Upgrade your plan
|
||||
Upgrade your plan
|
||||
Upgrade
|
||||
Upgrade
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
dentische HTML Seiten
|
||||
Wichtig
|
||||
Identische HTML-Seiten sind URLs, bei denen der gesamte HTML-Code mit dem Code einer oder mehrerer anderer Seiten identisch ist.
|
||||
|
||||
Gefundene Probleme
|
||||
4
|
||||
-
|
||||
Betroffene Seiten
|
||||
4
|
||||
/25
|
||||
-
|
||||
Eingeschränkte Funktion - Jetzt Upgrade durchführen und alle Analysen freischalten!
|
||||
|
||||
Verlauf: Identische HTML Seiten
|
||||
Erfahre mehr über den Report
|
||||
"Identische HTML Seiten"
|
||||
|
||||
Alle
|
||||
|
||||
Identische HTML Seiten
|
||||
|
||||
https://www.qrmaster.net/pricing
|
||||
|
||||
https://www.qrmaster.net/login
|
||||
|
||||
https://www.qrmaster.net/signup
|
||||
|
||||
https://www.qrmaster.net/newsletter
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Seiten mit wenig Text
|
||||
Wichtig
|
||||
Ein guter Text oder Artikel sollte mindestens 500 Wörter umfassen. Seiten mit wenig Inhalt haben manchmal Schwierigkeiten, ein gutes Ranking zu erreichen.
|
||||
|
||||
Gefundene Probleme
|
||||
9
|
||||
+1
|
||||
Betroffene Seiten
|
||||
9
|
||||
/25
|
||||
+1
|
||||
Eingeschränkte Funktion - Jetzt Upgrade durchführen und alle Analysen freischalten!
|
||||
|
||||
Verlauf: Seiten mit wenig Text
|
||||
Erfahre mehr über den Report
|
||||
"Seiten mit wenig Text"
|
||||
|
||||
Alle
|
||||
|
||||
URL
|
||||
|
||||
Wortanzahl
|
||||
|
||||
Details
|
||||
https://www.qrmaster.net/
|
||||
0
|
||||
Details
|
||||
https://www.qrmaster.net/qr-code-erstellen
|
||||
0
|
||||
Details
|
||||
https://www.qrmaster.net/blog
|
||||
0
|
||||
Details
|
||||
https://www.qrmaster.net/pricing
|
||||
0
|
||||
Details
|
||||
https://www.qrmaster.net/faq
|
||||
0
|
||||
Details
|
||||
https://www.qrmaster.net/login
|
||||
0
|
||||
Details
|
||||
https://www.qrmaster.net/signup
|
||||
0
|
||||
Details
|
||||
https://www.qrmaster.net/privacy
|
||||
0
|
||||
Details
|
||||
https://www.qrmaster.net/newsletter
|
||||
0
|
||||
Details
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Seiten ohne Fließtext
|
||||
Wichtig
|
||||
Wenn Suchmaschinen-Bots auf einer Seite keinen auswertbaren Textinhalt finden, haben sie wenig Anhaltspunkte, um das Thema der Seite zu erfassen und sie zu indexieren.
|
||||
|
||||
Gefundene Probleme
|
||||
9
|
||||
+1
|
||||
Betroffene Seiten
|
||||
9
|
||||
/25
|
||||
+1
|
||||
Eingeschränkte Funktion - Jetzt Upgrade durchführen und alle Analysen freischalten!
|
||||
|
||||
Verlauf: Seiten ohne Fließtext
|
||||
Erfahre mehr über den Report
|
||||
"Seiten ohne Fließtext"
|
||||
|
||||
Alle
|
||||
|
||||
URL
|
||||
https://www.qrmaster.net/
|
||||
https://www.qrmaster.net/qr-code-erstellen
|
||||
https://www.qrmaster.net/blog
|
||||
https://www.qrmaster.net/pricing
|
||||
https://www.qrmaster.net/faq
|
||||
https://www.qrmaster.net/login
|
||||
https://www.qrmaster.net/signup
|
||||
https://www.qrmaster.net/privacy
|
||||
https://www.qrmaster.net/newsletter
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Weiterleitungen
|
||||
Intern verlinkte URLs, die per serverseitigem Redirect (Statuscode 301) auf eine andere URL weiterleiten, solltest Du vermeiden und stattdessen direkt auf die Zielseite verlinken.
|
||||
|
||||
Gefundene Weiterleitungen
|
||||
6
|
||||
-
|
||||
Gecrawlt
|
||||
25
|
||||
Eingeschränkte Funktion - Jetzt Upgrade durchführen und alle Analysen freischalten!
|
||||
|
||||
Verlauf: Weiterleitungen
|
||||
Erfahre mehr über den Report
|
||||
"Weiterleitungen"
|
||||
|
||||
Alle
|
||||
|
||||
Weiterleitung
|
||||
|
||||
Seiten mit Link auf die Weiterleitung
|
||||
|
||||
https://www.qrmaster.net
|
||||
|
||||
https://www.qrmaster.net/
|
||||
Verlinkt über Canonical Link, Alternate Link oder ähnlichem
|
||||
|
||||
https://www.qrmaster.net/dynamic-qr-code-generator
|
||||
|
||||
https://www.qrmaster.net/signup
|
||||
|
||||
https://www.qrmaster.net/blog/qr-code-tracking-guide-2025
|
||||
|
||||
https://www.qrmaster.net/blog/dynamic-vs-static-qr-codes
|
||||
|
||||
https://www.qrmaster.net/blog/qr-code-restaurant-menu
|
||||
|
||||
https://www.qrmaster.net/bulk-qr-code-generator
|
||||
|
||||
https://www.qrmaster.net/signup
|
||||
|
||||
https://www.qrmaster.net/blog/bulk-qr-code-generator-excel
|
||||
|
||||
https://www.qrmaster.net/qr-code-tracking
|
||||
|
||||
https://www.qrmaster.net/signup
|
||||
|
||||
https://www.qrmaster.net/blog/qr-code-restaurant-menu
|
||||
|
||||
https://www.qrmaster.net/create
|
||||
|
||||
https://www.qrmaster.net/signup
|
||||
|
||||
https://www.qrmaster.net/blog/vcard-qr-code-generator
|
||||
|
||||
https://www.qrmaster.net/blog/qr-code-small-business
|
||||
|
||||
https://www.qrmaster.net/blog/qr-code-print-size-guide
|
||||
|
||||
https://www.qrmaster.net/analytics
|
||||
|
||||
https://www.qrmaster.net/signup
|
||||
|
||||
https://www.qrmaster.net/blog/qr-code-small-business
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Doppelte Seitentitel
|
||||
Wichtig
|
||||
Der Seitentitel einer Seite ist eines der wichtigsten Elemente der Onpage-Optimierung. Aus diesem Grund sollte jede Seite einen individuellen Seitentitel haben.
|
||||
|
||||
Gefundene Probleme
|
||||
4
|
||||
-
|
||||
Betroffene Seiten
|
||||
4/25
|
||||
-
|
||||
Eingeschränkte Funktion - Jetzt Upgrade durchführen und alle Analysen freischalten!
|
||||
|
||||
Verlauf: Doppelte Seitentitel
|
||||
Erfahre mehr über den Report
|
||||
"Doppelte Seitentitel"
|
||||
|
||||
Alle
|
||||
|
||||
Seitentitel
|
||||
|
||||
Betroffene URLs
|
||||
QR Master – Smart QR Generator & Analytics
|
||||
|
||||
https://www.qrmaster.net/pricing
|
||||
|
||||
https://www.qrmaster.net/login
|
||||
|
||||
https://www.qrmaster.net/signup
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Alternate Link Fehler
|
||||
Wichtig
|
||||
Bei Websites mit mehreren Sprachen oder regionalen Unterschieden können Suchmaschinen anhand von Alternate Links erkennen, welche URLs für welche Sprachen und Regionen vorgesehen sind.
|
||||
|
||||
Mehr anzeigen (+)
|
||||
Gefundene Probleme
|
||||
1
|
||||
+1
|
||||
Betroffene Seiten
|
||||
1
|
||||
/25
|
||||
+1
|
||||
Eingeschränkte Funktion - Jetzt Upgrade durchführen und alle Analysen freischalten!
|
||||
|
||||
Verlauf: Alternate Link Fehler
|
||||
Erfahre mehr über den Report
|
||||
"Alternate Link Fehler"
|
||||
|
||||
Alle
|
||||
|
||||
URL
|
||||
|
||||
Probleme
|
||||
|
||||
Details
|
||||
https://www.qrmaster.net/qr-code-erstellen
|
||||
Der Alternate Link auf die Seite selbst ist fehlerhaft.
|
||||
Details
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Probleme mit H1 Überschriften
|
||||
Wenig wichtig
|
||||
Überschriften strukturieren den Fließtext einer Seite. Die H1 gibt außerdem wichtige Informationen über den Inhalt der Seite und sollte daher optimiert werden.
|
||||
|
||||
Gefundene Probleme
|
||||
9
|
||||
+1
|
||||
Betroffene Seiten
|
||||
9
|
||||
/25
|
||||
+1
|
||||
Eingeschränkte Funktion - Jetzt Upgrade durchführen und alle Analysen freischalten!
|
||||
|
||||
Verlauf: Probleme mit H1 Überschriften
|
||||
Erfahre mehr über den Report
|
||||
"Probleme mit H1 Überschriften"
|
||||
|
||||
Alle
|
||||
|
||||
URL
|
||||
|
||||
H1 Überschrift
|
||||
|
||||
Details
|
||||
https://www.qrmaster.net/
|
||||
Fehlt
|
||||
Details
|
||||
https://www.qrmaster.net/qr-code-erstellen
|
||||
Fehlt
|
||||
Details
|
||||
https://www.qrmaster.net/blog
|
||||
Fehlt
|
||||
Details
|
||||
https://www.qrmaster.net/pricing
|
||||
Fehlt
|
||||
Details
|
||||
https://www.qrmaster.net/faq
|
||||
Fehlt
|
||||
Details
|
||||
https://www.qrmaster.net/login
|
||||
Fehlt
|
||||
Details
|
||||
https://www.qrmaster.net/signup
|
||||
Fehlt
|
||||
Details
|
||||
https://www.qrmaster.net/privacy
|
||||
Fehlt
|
||||
Details
|
||||
https://www.qrmaster.net/newsletter
|
||||
Fehlt
|
||||
Details
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Probleme mit Überschriften
|
||||
Wenig wichtig
|
||||
Überschriften sind wichtig, um den Inhalt einer Seite zu strukturieren, und sollten daher korrekt verwendet werden.
|
||||
|
||||
Gefundene Probleme
|
||||
17
|
||||
+1
|
||||
Betroffene Seiten
|
||||
17
|
||||
/25
|
||||
+1
|
||||
Eingeschränkte Funktion - Jetzt Upgrade durchführen und alle Analysen freischalten!
|
||||
|
||||
Verlauf: Probleme mit Überschriften
|
||||
Erfahre mehr über den Report
|
||||
"Probleme mit Überschriften"
|
||||
|
||||
Alle
|
||||
|
||||
URL
|
||||
|
||||
Probleme
|
||||
|
||||
Details
|
||||
https://www.qrmaster.net/blog/qr-code-tracking-guide-2025
|
||||
Doppelte Überschrift
|
||||
Zu viele
|
||||
Details
|
||||
https://www.qrmaster.net/blog/vcard-qr-code-generator
|
||||
Doppelte Überschrift
|
||||
Zu viele
|
||||
Details
|
||||
https://www.qrmaster.net/blog/qr-code-analytics
|
||||
Zu viele
|
||||
Details
|
||||
https://www.qrmaster.net/blog/dynamic-vs-static-qr-codes
|
||||
Zu viele
|
||||
Details
|
||||
https://www.qrmaster.net/blog/bulk-qr-code-generator-excel
|
||||
Zu viele
|
||||
Details
|
||||
https://www.qrmaster.net/blog/qr-code-restaurant-menu
|
||||
Zu viele
|
||||
Details
|
||||
https://www.qrmaster.net/blog/qr-code-small-business
|
||||
Zu viele
|
||||
Details
|
||||
https://www.qrmaster.net/blog/qr-code-print-size-guide
|
||||
Zu viele
|
||||
Details
|
||||
https://www.qrmaster.net/
|
||||
Keine
|
||||
Details
|
||||
https://www.qrmaster.net/qr-code-erstellen
|
||||
Keine
|
||||
Details
|
||||
https://www.qrmaster.net/blog
|
||||
Keine
|
||||
Details
|
||||
https://www.qrmaster.net/pricing
|
||||
Keine
|
||||
Details
|
||||
https://www.qrmaster.net/faq
|
||||
Keine
|
||||
Details
|
||||
https://www.qrmaster.net/login
|
||||
Keine
|
||||
Details
|
||||
https://www.qrmaster.net/signup
|
||||
Keine
|
||||
Details
|
||||
https://www.qrmaster.net/privacy
|
||||
Keine
|
||||
Details
|
||||
https://www.qrmaster.net/newsletter
|
||||
Keine
|
||||
Details
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Doppelte Meta-Descriptions
|
||||
Wenig wichtig
|
||||
Die Meta-Description sollte für jede Seite individuell erstellt werden. Sie ist zwar nicht direkt relevant für das Ranking in Suchmaschinen, aber sehr wichtig für die CTR (Click-Through-Rate) Deiner Seite.
|
||||
|
||||
Mehr anzeigen (+)
|
||||
Gefundene Probleme
|
||||
4
|
||||
-
|
||||
Betroffene Seiten
|
||||
4
|
||||
/25
|
||||
-
|
||||
Eingeschränkte Funktion - Jetzt Upgrade durchführen und alle Analysen freischalten!
|
||||
|
||||
Verlauf: Doppelte Meta-Descriptions
|
||||
Erfahre mehr über den Report
|
||||
"Doppelte Meta-Descriptions"
|
||||
|
||||
Alle
|
||||
|
||||
Meta-Description
|
||||
|
||||
Betroffene URLs
|
||||
Create dynamic QR codes, track scans, and scale campaigns with secure analytics.
|
||||
|
||||
https://www.qrmaster.net/pricing
|
||||
|
||||
https://www.qrmaster.net/login
|
||||
|
||||
https://www.qrmaster.net/signup
|
||||
|
||||
https://www.qrmaster.net/newsletter
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Probleme mit Strong- und Bold-Tags
|
||||
Wenig wichtig
|
||||
Strong- und Bold-Tags werden verwendet, um einzelne Wörter oder Aussagen in einem Text optisch und/oder inhaltlich hervorzuheben.
|
||||
|
||||
Gefundene Probleme
|
||||
3
|
||||
-1
|
||||
Betroffene Seiten
|
||||
3
|
||||
/25
|
||||
-1
|
||||
Eingeschränkte Funktion - Jetzt Upgrade durchführen und alle Analysen freischalten!
|
||||
|
||||
Verlauf: Probleme mit Strong- und Bold-Tags
|
||||
Erfahre mehr über den Report
|
||||
"Probleme mit Strong- und Bold-Tags"
|
||||
|
||||
Alle
|
||||
|
||||
URL
|
||||
|
||||
Probleme
|
||||
|
||||
Details
|
||||
https://www.qrmaster.net/blog/dynamic-vs-static-qr-codes
|
||||
Viele Tags
|
||||
Duplikat
|
||||
Details
|
||||
https://www.qrmaster.net/blog/bulk-qr-code-generator-excel
|
||||
Viele Tags
|
||||
Duplikat
|
||||
Details
|
||||
https://www.qrmaster.net/blog/vcard-qr-code-generator
|
||||
Viele Tags
|
||||
Details
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Probleme mit Meta-Descriptions
|
||||
Wenig wichtig
|
||||
Eine fehlende oder schlecht optimierte Meta-Description führt zu einem schlechten Erstkontakt mit potenziellen Besuchern in den Suchergebnissen und sollte daher vermieden werden.
|
||||
|
||||
Gefundene Probleme
|
||||
2
|
||||
+1
|
||||
Betroffene Seiten
|
||||
2/25
|
||||
+1
|
||||
Eingeschränkte Funktion - Jetzt Upgrade durchführen und alle Analysen freischalten!
|
||||
|
||||
Verlauf: Probleme mit Meta-Descriptions
|
||||
Erfahre mehr über den Report
|
||||
"Probleme mit Meta-Descriptions"
|
||||
|
||||
Alle
|
||||
|
||||
URL
|
||||
|
||||
Meta Description
|
||||
|
||||
https://www.qrmaster.net/qr-code-erstellen
|
||||
Erstellen Sie QR Codes kostenlos in Sekunden. Statische und dynamische QR-Codes mit Tracking, individuellem Branding und Massen-Erstellung. Für immer kostenlos.
|
||||
Zu lang
|
||||
1042/1000 Pixel
|
||||
|
||||
https://www.qrmaster.net/blog/dynamic-vs-static-qr-codes
|
||||
Understand the difference between static and dynamic QR codes. Learn when to use each type, pros/cons, and how dynamic QR codes save money. Expert guide 2025.
|
||||
Zu lang
|
||||
1037/1000 Pixel
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Probleme mit eingebundenen Dateien
|
||||
Wenig wichtig
|
||||
Probleme mit eingebundenen Dateien können sich negativ auf die User Experience und die Ladegeschwindigkeit auswirken.
|
||||
|
||||
Gefundene Probleme
|
||||
3
|
||||
+1
|
||||
Betroffene Seiten
|
||||
3
|
||||
/25
|
||||
+1
|
||||
Eingeschränkte Funktion - Jetzt Upgrade durchführen und alle Analysen freischalten!
|
||||
|
||||
Verlauf: Probleme mit eingebundenen Dateien
|
||||
Erfahre mehr über den Report
|
||||
"Probleme mit eingebundenen Dateien"
|
||||
|
||||
Alle
|
||||
|
||||
URL
|
||||
|
||||
Statuscode
|
||||
|
||||
Ladezeit
|
||||
|
||||
Dateigröße
|
||||
|
||||
Content Type
|
||||
|
||||
Grund
|
||||
https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-2782770414424875
|
||||
200
|
||||
0,0619s
|
||||
191,8 kB
|
||||
text/javascript; charset=ISO-8859-1
|
||||
Fehlende Komprimierung (gzip, Brotli)
|
||||
https://www.qrmaster.net/blog/1-boy.png
|
||||
200
|
||||
0,4301s
|
||||
4.243,4 kB
|
||||
image/png
|
||||
Große Dateigröße
|
||||
https://www.qrmaster.net/blog/2-body.png
|
||||
200
|
||||
0,4703s
|
||||
4.107,4 kB
|
||||
image/png
|
||||
Große Dateigröße
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Mittlere Antwortzeiten
|
||||
Wenig wichtig
|
||||
Antwortzeiten zwischen 0,5 bis 1 Sekunde werden als "mittel" eingestuft. Die Antwortzeit wird von Google als Qualitätsfaktor angesehen und sollte so schnell wie möglich sein.
|
||||
|
||||
Gefundene Probleme
|
||||
1
|
||||
-1
|
||||
Betroffene Seiten
|
||||
1
|
||||
/25
|
||||
-1
|
||||
Eingeschränkte Funktion - Jetzt Upgrade durchführen und alle Analysen freischalten!
|
||||
|
||||
Verlauf: Mittlere Antwortzeiten
|
||||
Erfahre mehr über den Report
|
||||
"Mittlere Antwortzeiten"
|
||||
|
||||
Alle
|
||||
|
||||
URL
|
||||
|
||||
Antwortzeit
|
||||
|
||||
% durchschn. Antwortzeit
|
||||
https://www.qrmaster.net/qr-code-erstellen
|
||||
0,51510s
|
||||
191%
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
'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,254 +1,27 @@
|
|||
'use client';
|
||||
import type { Metadata } from 'next';
|
||||
import '@/styles/globals.css';
|
||||
import { Providers } from '@/components/Providers';
|
||||
import AppLayout from './AppLayout';
|
||||
|
||||
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';
|
||||
export const metadata: Metadata = {
|
||||
title: 'Dashboard | QR Master',
|
||||
robots: { index: false, follow: false }, // Dashboard pages shouldn't be indexed generally
|
||||
};
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
plan: string | null;
|
||||
}
|
||||
|
||||
export default function AppLayout({
|
||||
children,
|
||||
export default function RootAppLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
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>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="font-sans">
|
||||
<Providers>
|
||||
<AppLayout>
|
||||
{children}
|
||||
</AppLayout>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ import { showToast } from '@/components/ui/Toast';
|
|||
import { useRouter } from 'next/navigation';
|
||||
import { BillingToggle } from '@/components/ui/BillingToggle';
|
||||
|
||||
// Note: Metadata is defined in a separate metadata.ts file for client components
|
||||
// or the parent layout should be updated to allow indexing for this specific page.
|
||||
|
||||
export default function PricingPage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState<string | null>(null);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,26 @@
|
|||
export default function AuthLayout({
|
||||
import '@/styles/globals.css';
|
||||
import { Providers } from '@/components/Providers';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Authentication | QR Master',
|
||||
description: 'Login or Sign Up to QR Master',
|
||||
};
|
||||
|
||||
export default function AuthRootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white">
|
||||
{children}
|
||||
</div>
|
||||
<html lang="en">
|
||||
<body className="font-sans">
|
||||
<Providers>
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white">
|
||||
{children}
|
||||
</div>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
'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 (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
|
||||
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
|
||||
<span className="text-2xl font-bold text-gray-900">QR Master</span>
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Welcome Back</h1>
|
||||
<p className="text-gray-600 mt-2">Sign in to your account</p>
|
||||
<Link href="/" className="text-sm text-primary-600 hover:text-primary-700 font-medium mt-2 inline-block border border-primary-600 hover:border-primary-700 px-4 py-2 rounded-lg transition-colors">
|
||||
← Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
/>
|
||||
|
||||
<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">
|
||||
By signing in, you agree to our{' '}
|
||||
<Link href="/privacy" className="text-primary-600 hover:text-primary-700">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,187 +1,24 @@
|
|||
'use client';
|
||||
import React from 'react';
|
||||
import type { Metadata } from 'next';
|
||||
import LoginClientPage from './ClientPage';
|
||||
|
||||
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 const metadata: Metadata = {
|
||||
title: 'Login to QR Master | Access Your Dashboard',
|
||||
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',
|
||||
},
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
|
||||
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
|
||||
<span className="text-2xl font-bold text-gray-900">QR Master</span>
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Welcome Back</h1>
|
||||
<p className="text-gray-600 mt-2">Sign in to your account</p>
|
||||
<Link href="/" className="text-sm text-primary-600 hover:text-primary-700 font-medium mt-2 inline-block border border-primary-600 hover:border-primary-700 px-4 py-2 rounded-lg transition-colors">
|
||||
← Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
/>
|
||||
|
||||
<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">
|
||||
By signing in, you agree to our{' '}
|
||||
<Link href="/privacy" className="text-primary-600 hover:text-primary-700">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</p>
|
||||
<>
|
||||
<h1 className="sr-only">Login to QR Master – Create & Track QR Codes</h1>
|
||||
<div className="sr-only">
|
||||
<h2>Secure Dashboard Access</h2>
|
||||
<p>Log in to manage your dynamic QR codes, view scan analytics, and update destination URLs instantly.</p>
|
||||
</div>
|
||||
</div>
|
||||
<LoginClientPage />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
'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 (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
|
||||
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
|
||||
<span className="text-2xl font-bold text-gray-900">QR Master</span>
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Create Account</h1>
|
||||
<p className="text-gray-600 mt-2">Start creating QR codes in seconds</p>
|
||||
<Link href="/" className="text-sm text-primary-600 hover:text-primary-700 font-medium mt-2 inline-block border border-primary-600 hover:border-primary-700 px-4 py-2 rounded-lg transition-colors">
|
||||
← Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="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">
|
||||
By signing up, you agree to our{' '}
|
||||
<Link href="/privacy" className="text-primary-600 hover:text-primary-700">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,208 +1,24 @@
|
|||
'use client';
|
||||
import React from 'react';
|
||||
import type { Metadata } from 'next';
|
||||
import SignupClientPage from './ClientPage';
|
||||
|
||||
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 const metadata: Metadata = {
|
||||
title: 'Create Free Account | QR Master',
|
||||
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',
|
||||
},
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
|
||||
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
|
||||
<span className="text-2xl font-bold text-gray-900">QR Master</span>
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Create Account</h1>
|
||||
<p className="text-gray-600 mt-2">Start creating QR codes in seconds</p>
|
||||
<Link href="/" className="text-sm text-primary-600 hover:text-primary-700 font-medium mt-2 inline-block border border-primary-600 hover:border-primary-700 px-4 py-2 rounded-lg transition-colors">
|
||||
← Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="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">
|
||||
By signing up, you agree to our{' '}
|
||||
<Link href="/privacy" className="text-primary-600 hover:text-primary-700">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</p>
|
||||
<>
|
||||
<h1 className="sr-only">Sign Up for QR Master – Free QR Code Generator</h1>
|
||||
<div className="sr-only">
|
||||
<h2>Get Started Free</h2>
|
||||
<p>Create your account today. No credit card required for the free tier. Start generating trackable QR codes in minutes.</p>
|
||||
</div>
|
||||
</div>
|
||||
<SignupClientPage />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
'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">
|
||||
{/* 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">{children}</main>
|
||||
|
||||
{/* Footer */}
|
||||
<Footer t={t} />
|
||||
</div >
|
||||
);
|
||||
}
|
||||
|
|
@ -50,7 +50,7 @@ const blogPosts: Record<string, BlogPostData> = {
|
|||
{
|
||||
name: 'Create a Dynamic QR Code',
|
||||
text: 'Log into your QR Master dashboard and select "Create Dynamic QR Code". Enter your destination URL and customize design options.',
|
||||
url: 'https://www.qrmaster.net/create',
|
||||
url: 'https://www.qrmaster.net/signup',
|
||||
},
|
||||
{
|
||||
name: 'Enable UTM Tracking',
|
||||
|
|
@ -278,7 +278,7 @@ const blogPosts: Record<string, BlogPostData> = {
|
|||
|
||||
<h4>Step-by-Step with QR Master:</h4>
|
||||
<ol>
|
||||
<li>Sign up for free at <a href="https://www.qrmaster.net/signup">qrmaster.net/signup</a></li>
|
||||
<li>Sign up for free at <a href="/signup">qrmaster.net/signup</a></li>
|
||||
<li>Create a dynamic QR code by clicking "Create QR Code" and selecting "Dynamic QR"</li>
|
||||
<li>Enter the destination URL for your website, landing page, or content</li>
|
||||
<li>Customize the design with your logo, brand colors, and custom frame</li>
|
||||
|
|
@ -354,7 +354,7 @@ app.get('/qr/:id', async (req, res) => {
|
|||
<p>Privacy Note: Always hash IP addresses, respect Do Not Track headers, and comply with GDPR when collecting scan data.</p>
|
||||
|
||||
<div class="my-8">
|
||||
<img src="/blog/1-boy.png" alt="Person scanning QR code with smartphone in office" class="rounded-lg shadow-lg w-full" />
|
||||
<img src="/blog/1-boy.webp" alt="Person scanning QR code with smartphone in office" class="rounded-lg shadow-lg w-full" />
|
||||
</div>
|
||||
|
||||
<h2>QR Code Tracking Tools Comparison</h2>
|
||||
|
|
@ -809,7 +809,7 @@ Tracking ✓ | Editable ✓ | Analytics ✓
|
|||
</pre>
|
||||
|
||||
<div class="my-8">
|
||||
<img src="/blog/2-body.png" alt="Business card with elegant QR code" class="rounded-lg shadow-lg w-full" />
|
||||
<img src="/blog/2-body.webp" alt="Business card with elegant QR code" class="rounded-lg shadow-lg w-full" />
|
||||
</div>
|
||||
|
||||
<h2>Static vs Dynamic QR Codes: Side-by-Side Comparison</h2>
|
||||
|
|
@ -1441,7 +1441,7 @@ Event Ticket 1 | https://event.com/ticket/1 | events, tickets
|
|||
|
||||
<h3>Step 2: Sign Up for QR Master</h3>
|
||||
<ol>
|
||||
<li>Go to <a href="https://www.qrmaster.net/signup">qrmaster.net/signup</a></li>
|
||||
<li>Go to <a href="/signup">qrmaster.net/signup</a></li>
|
||||
<li>Create free account (email + password)</li>
|
||||
<li>Verify your email</li>
|
||||
<li><strong>Free plan:</strong> Up to 3 dynamic QR codes (no bulk upload)</li>
|
||||
|
|
@ -1870,7 +1870,7 @@ const response = await fetch('https://api.qrmaster.net/v1/bulk', {
|
|||
{
|
||||
name: 'Generate a Dynamic QR Code',
|
||||
text: 'Use QR Master to create a dynamic QR code. This allows you to update your menu URL anytime without reprinting codes.',
|
||||
url: 'https://www.qrmaster.net/create',
|
||||
url: 'https://www.qrmaster.net/signup',
|
||||
},
|
||||
{
|
||||
name: 'Customize Your QR Code Design',
|
||||
|
|
@ -2098,7 +2098,7 @@ const response = await fetch('https://api.qrmaster.net/v1/bulk', {
|
|||
|
||||
<h2>How to Create a vCard QR Code</h2>
|
||||
<h3>Step 1: Choose Your QR Code Type</h3>
|
||||
<p>Go to the <a href="/create">QR Master generator</a> and select "Contact Card" or vCard type. Choose between static (data embedded) or dynamic (editable, trackable).</p>
|
||||
<p>Go to the <a href="/signup">QR Master generator</a> and select "Contact Card" or vCard type. Choose between static (data embedded) or dynamic (editable, trackable).</p>
|
||||
|
||||
<h3>Step 2: Enter Your Information</h3>
|
||||
<p>Fill in the contact form with your details. Required fields typically include:</p>
|
||||
|
|
@ -2156,7 +2156,7 @@ const response = await fetch('https://api.qrmaster.net/v1/bulk', {
|
|||
|
||||
<h2>Related Resources</h2>
|
||||
<ul>
|
||||
<li><a href="/create">QR Code Generator</a></li>
|
||||
<li><a href="/signup">QR Code Generator</a></li>
|
||||
<li><a href="/blog/dynamic-vs-static-qr-codes">Dynamic vs Static QR Codes</a></li>
|
||||
<li><a href="/blog/qr-code-print-size-guide">QR Code Print Size Guide</a></li>
|
||||
</ul>
|
||||
|
|
@ -2253,9 +2253,9 @@ const response = await fetch('https://api.qrmaster.net/v1/bulk', {
|
|||
<ol>
|
||||
<li><strong>Identify Your Goal:</strong> What do you want customers to do after scanning?</li>
|
||||
<li><strong>Choose Code Type:</strong> Static for permanent content, dynamic for flexibility</li>
|
||||
<li><strong>Create Your QR Code:</strong> Use <a href="/create">our generator</a> to design and customize</li>
|
||||
<li><strong>Create Your QR Code:</strong> Use <a href="/signup">our generator</a> to design and customize</li>
|
||||
<li><strong>Print at Right Size:</strong> Follow our <a href="/blog/qr-code-print-size-guide">print size guide</a></li>
|
||||
<li><strong>Track Performance:</strong> Monitor scans in your <a href="/analytics">analytics dashboard</a></li>
|
||||
<li><strong>Track Performance:</strong> Monitor scans in your <a href="/signup">analytics dashboard</a></li>
|
||||
</ol>
|
||||
|
||||
<h2>Common Mistakes Small Businesses Make</h2>
|
||||
|
|
@ -2411,7 +2411,7 @@ const response = await fetch('https://api.qrmaster.net/v1/bulk', {
|
|||
<div class="bg-gradient-to-br from-primary-50 to-primary-100 p-8 rounded-2xl my-12 border border-primary-200">
|
||||
<h3 class="text-2xl font-bold text-gray-900 mb-4">Create Print-Ready QR Codes</h3>
|
||||
<p class="text-lg text-gray-700 mb-6">Download high-resolution SVG and PNG files ready for any print application.</p>
|
||||
<a href="/create" class="inline-block bg-primary-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-primary-700 transition-colors">Create QR Code →</a>
|
||||
<a href="/signup" class="inline-block bg-primary-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-primary-700 transition-colors">Create QR Code →</a>
|
||||
</div>
|
||||
|
||||
<h2>Related Resources</h2>
|
||||
|
|
@ -2440,7 +2440,7 @@ export async function generateMetadata({ params }: { params: { slug: string } })
|
|||
};
|
||||
}
|
||||
|
||||
const title = truncateAtWord(`${post.title} - QR Analytics Tips`, 60);
|
||||
const title = post.title;
|
||||
const description = truncateAtWord(post.excerpt, 160);
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ function truncateAtWord(text: string, maxLength: number): string {
|
|||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const title = truncateAtWord('QR Insights: Latest QR Strategies', 60);
|
||||
const description = truncateAtWord(
|
||||
'Expert guides on QR analytics, dynamic codes & smart marketing uses.',
|
||||
'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.',
|
||||
160
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -333,7 +333,7 @@ export default function BulkQRCodeGeneratorPage() {
|
|||
Start Bulk Generation
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/create">
|
||||
<Link href="/signup">
|
||||
<Button variant="outline" size="lg" className="text-lg px-8 py-4 w-full sm:w-auto">
|
||||
Try Single QR First
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@ export default function DynamicQRCodeGeneratorPage() {
|
|||
position: 2,
|
||||
name: 'Generate QR Code',
|
||||
text: 'Enter your destination URL and customize the design with your branding',
|
||||
url: 'https://www.qrmaster.net/create',
|
||||
url: 'https://www.qrmaster.net/signup',
|
||||
},
|
||||
{
|
||||
'@type': 'HowToStep',
|
||||
|
|
@ -504,7 +504,7 @@ export default function DynamicQRCodeGeneratorPage() {
|
|||
Get Started Free
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/create">
|
||||
<Link href="/signup">
|
||||
<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
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ function truncateAtWord(text: string, maxLength: number): string {
|
|||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const title = truncateAtWord('QR Master FAQ: Dynamic & Bulk QR', 60);
|
||||
const description = truncateAtWord(
|
||||
'All answers: dynamic QR, security, analytics, bulk, events & print.',
|
||||
'Find answers about dynamic QR codes, scan tracking, security, bulk generation, and event QR codes. Everything you need to know about QR Master features.',
|
||||
160
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,248 +1,77 @@
|
|||
'use client';
|
||||
import type { Metadata } from 'next';
|
||||
import '@/styles/globals.css';
|
||||
import { Providers } from '@/components/Providers';
|
||||
import MarketingLayout from './MarketingLayout';
|
||||
// Import schema functions from library
|
||||
import { organizationSchema, websiteSchema } from '@/lib/schema';
|
||||
|
||||
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';
|
||||
const isIndexable = process.env.NEXT_PUBLIC_INDEXABLE === 'true';
|
||||
|
||||
export default function MarketingLayout({
|
||||
children,
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL('https://www.qrmaster.net'),
|
||||
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/static/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.',
|
||||
url: 'https://www.qrmaster.net',
|
||||
images: [
|
||||
{
|
||||
url: 'https://www.qrmaster.net/static/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;
|
||||
}) {
|
||||
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">
|
||||
{/* 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">{children}</main>
|
||||
|
||||
{/* Footer */}
|
||||
<Footer t={t} />
|
||||
</div >
|
||||
);
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationSchema()) }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteSchema()) }}
|
||||
/>
|
||||
</head>
|
||||
<body className="font-sans">
|
||||
<Providers>
|
||||
<MarketingLayout>
|
||||
{children}
|
||||
</MarketingLayout>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,643 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import {
|
||||
Mail,
|
||||
Users,
|
||||
QrCode,
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
Crown,
|
||||
Activity,
|
||||
Loader2,
|
||||
Lock,
|
||||
LogOut,
|
||||
Zap,
|
||||
Send,
|
||||
CheckCircle2,
|
||||
} 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);
|
||||
|
||||
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,643 +1,19 @@
|
|||
'use client';
|
||||
import React from 'react';
|
||||
import type { Metadata } from 'next';
|
||||
import NewsletterClient from './NewsletterClient';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import {
|
||||
Mail,
|
||||
Users,
|
||||
QrCode,
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
Crown,
|
||||
Activity,
|
||||
Loader2,
|
||||
Lock,
|
||||
LogOut,
|
||||
Zap,
|
||||
Send,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react';
|
||||
export const metadata: Metadata = {
|
||||
title: 'Admin Dashboard | QR Master',
|
||||
description: 'Admin restricted area.',
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
alternates: {
|
||||
canonical: 'https://www.qrmaster.net/newsletter',
|
||||
},
|
||||
};
|
||||
|
||||
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 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>
|
||||
);
|
||||
export default function NewsletterPage() {
|
||||
return <NewsletterClient />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ function truncateAtWord(text: string, maxLength: number): string {
|
|||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const title = truncateAtWord('QR Master: Dynamic QR Generator', 60);
|
||||
const description = truncateAtWord(
|
||||
'Dynamic QR, branding, bulk generation & analytics for all campaigns.',
|
||||
'Create professional QR codes with QR Master. Dynamic QR with tracking, bulk generation, custom branding, and real-time analytics for all your campaigns.',
|
||||
160
|
||||
);
|
||||
|
||||
|
|
@ -49,9 +49,11 @@ export default function HomePage() {
|
|||
<>
|
||||
<SeoJsonLd data={[organizationSchema(), websiteSchema(), generateFaqSchema(en.faq.questions)]} />
|
||||
|
||||
{/* Server-rendered H1 for SEO - visually hidden but crawlable */}
|
||||
<h1 className="sr-only">QR Master: Dynamic QR Code Generator with Analytics</h1>
|
||||
|
||||
{/* Server-rendered SEO content for crawlers */}
|
||||
<div className="sr-only" aria-hidden="false">
|
||||
<h1>QR Master: Free Dynamic QR Code Generator with Tracking & Analytics</h1>
|
||||
<p>
|
||||
Create professional QR codes for your business with QR Master. Our dynamic QR code generator
|
||||
lets you create trackable QR codes, edit destinations anytime, and view detailed analytics.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,268 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { BillingToggle } from '@/components/ui/BillingToggle';
|
||||
|
||||
export default function PricingClient() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState<string | null>(null);
|
||||
const [currentPlan, setCurrentPlan] = useState<string>('FREE');
|
||||
const [currentInterval, setCurrentInterval] = useState<'month' | 'year' | null>(null);
|
||||
const [billingPeriod, setBillingPeriod] = useState<'month' | 'year'>('month');
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch current user plan
|
||||
const fetchUserPlan = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/user/plan');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCurrentPlan(data.plan || 'FREE');
|
||||
setCurrentInterval(data.interval || null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching user plan:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserPlan();
|
||||
}, []);
|
||||
|
||||
const handleUpgrade = async (plan: 'PRO' | 'BUSINESS') => {
|
||||
setLoading(plan);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/stripe/create-checkout-session', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
plan,
|
||||
billingInterval: billingPeriod === 'month' ? 'month' : 'year',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create checkout session');
|
||||
}
|
||||
|
||||
const { url } = await response.json();
|
||||
window.location.href = url;
|
||||
} catch (error) {
|
||||
console.error('Error creating checkout session:', error);
|
||||
showToast('Failed to start checkout. Please try again.', 'error');
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDowngrade = async () => {
|
||||
// Show confirmation dialog
|
||||
const confirmed = window.confirm(
|
||||
'Are you sure you want to downgrade to the Free plan? Your subscription will be canceled immediately and you will lose access to premium features.'
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading('FREE');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/stripe/cancel-subscription', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to cancel subscription');
|
||||
}
|
||||
|
||||
showToast('Successfully downgraded to Free plan', 'success');
|
||||
|
||||
// Refresh to update the plan
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
} catch (error: any) {
|
||||
console.error('Error canceling subscription:', error);
|
||||
showToast(error.message || 'Failed to downgrade. Please try again.', 'error');
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to check if this is the user's exact current plan (plan + interval)
|
||||
const isCurrentPlanWithInterval = (planType: string, interval: 'month' | 'year') => {
|
||||
return currentPlan === planType && currentInterval === interval;
|
||||
};
|
||||
|
||||
// Helper function to check if user has this plan but different interval
|
||||
const hasPlanDifferentInterval = (planType: string) => {
|
||||
return currentPlan === planType && currentInterval && currentInterval !== billingPeriod;
|
||||
};
|
||||
|
||||
const selectedInterval = billingPeriod === 'month' ? 'month' : 'year';
|
||||
|
||||
const plans = [
|
||||
{
|
||||
key: 'free',
|
||||
name: 'Free',
|
||||
price: '€0',
|
||||
period: 'forever',
|
||||
showDiscount: false,
|
||||
features: [
|
||||
'3 dynamic QR codes',
|
||||
'Unlimited static QR codes',
|
||||
'Basic scan tracking',
|
||||
'Standard QR design templates',
|
||||
'Download as SVG/PNG',
|
||||
],
|
||||
buttonText: currentPlan === 'FREE' ? 'Current Plan' : 'Downgrade to Free',
|
||||
buttonVariant: 'outline' as const,
|
||||
disabled: currentPlan === 'FREE',
|
||||
popular: false,
|
||||
onDowngrade: handleDowngrade,
|
||||
},
|
||||
{
|
||||
key: 'pro',
|
||||
name: 'Pro',
|
||||
price: billingPeriod === 'month' ? '€9' : '€90',
|
||||
period: billingPeriod === 'month' ? 'per month' : 'per year',
|
||||
showDiscount: billingPeriod === 'year',
|
||||
features: [
|
||||
'50 dynamic QR codes',
|
||||
'Unlimited static QR codes',
|
||||
'Advanced analytics (scans, devices, locations)',
|
||||
'Custom branding (colors & logos)',
|
||||
],
|
||||
buttonText: isCurrentPlanWithInterval('PRO', selectedInterval)
|
||||
? 'Current Plan'
|
||||
: hasPlanDifferentInterval('PRO')
|
||||
? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}`
|
||||
: 'Upgrade to Pro',
|
||||
buttonVariant: 'primary' as const,
|
||||
disabled: isCurrentPlanWithInterval('PRO', selectedInterval),
|
||||
popular: true,
|
||||
onUpgrade: () => handleUpgrade('PRO'),
|
||||
},
|
||||
{
|
||||
key: 'business',
|
||||
name: 'Business',
|
||||
price: billingPeriod === 'month' ? '€29' : '€290',
|
||||
period: billingPeriod === 'month' ? 'per month' : 'per year',
|
||||
showDiscount: billingPeriod === 'year',
|
||||
features: [
|
||||
'500 dynamic QR codes',
|
||||
'Unlimited static QR codes',
|
||||
'Everything from Pro',
|
||||
'Bulk QR Creation (up to 1,000)',
|
||||
'Priority email support',
|
||||
'Advanced tracking & insights',
|
||||
],
|
||||
buttonText: isCurrentPlanWithInterval('BUSINESS', selectedInterval)
|
||||
? 'Current Plan'
|
||||
: hasPlanDifferentInterval('BUSINESS')
|
||||
? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}`
|
||||
: 'Upgrade to Business',
|
||||
buttonVariant: 'primary' as const,
|
||||
disabled: isCurrentPlanWithInterval('BUSINESS', selectedInterval),
|
||||
popular: false,
|
||||
onUpgrade: () => handleUpgrade('BUSINESS'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
Choose Your Plan
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600">
|
||||
Select the perfect plan for your QR code needs
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center mb-8">
|
||||
<BillingToggle value={billingPeriod} onChange={setBillingPeriod} />
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8 max-w-6xl mx-auto">
|
||||
{plans.map((plan) => (
|
||||
<Card
|
||||
key={plan.key}
|
||||
className={plan.popular ? 'border-primary-500 shadow-xl relative' : ''}
|
||||
>
|
||||
{plan.popular && (
|
||||
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
|
||||
<Badge variant="info" className="px-3 py-1">
|
||||
Most Popular
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardHeader className="text-center pb-8">
|
||||
<CardTitle className="text-2xl mb-4">
|
||||
{plan.name}
|
||||
</CardTitle>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-baseline justify-center">
|
||||
<span className="text-4xl font-bold">
|
||||
{plan.price}
|
||||
</span>
|
||||
<span className="text-gray-600 ml-2">
|
||||
{plan.period}
|
||||
</span>
|
||||
</div>
|
||||
{plan.showDiscount && (
|
||||
<Badge variant="success" className="mt-2">
|
||||
Save 16%
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
<ul className="space-y-3">
|
||||
{plan.features.map((feature: string, index: number) => (
|
||||
<li key={index} className="flex items-start space-x-3">
|
||||
<svg className="w-5 h-5 text-success-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-gray-700">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
variant={plan.buttonVariant}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={plan.disabled || loading === plan.key.toUpperCase()}
|
||||
onClick={plan.key === 'free' ? (plan as any).onDowngrade : (plan as any).onUpgrade}
|
||||
>
|
||||
{loading === plan.key.toUpperCase() ? 'Processing...' : plan.buttonText}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-12">
|
||||
<p className="text-gray-600">
|
||||
All plans include unlimited static QR codes and basic customization.
|
||||
</p>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Need help choosing? <a href="mailto:support@qrmaster.net" className="text-primary-600 hover:text-primary-700 underline">Contact our team</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import React from 'react';
|
||||
import type { Metadata } from 'next';
|
||||
import PricingClient from './PricingClient';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '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,
|
||||
},
|
||||
};
|
||||
|
||||
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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import Link from 'next/link';
|
|||
|
||||
export const metadata = {
|
||||
title: 'Privacy Policy | QR Master',
|
||||
description: 'Privacy Policy and data protection information for 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.',
|
||||
};
|
||||
|
||||
export default function PrivacyPage() {
|
||||
|
|
|
|||
|
|
@ -199,7 +199,7 @@ export default function QRCodeTrackingPage() {
|
|||
Start Tracking Free
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/create">
|
||||
<Link href="/signup">
|
||||
<Button variant="outline" size="lg" className="text-lg px-8 py-4 w-full sm:w-auto">
|
||||
Create Trackable QR Code
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import CryptoGenerator from './CryptoGenerator';
|
|||
import { Bitcoin, Shield, Zap, Smartphone, Wallet, Coins, Sparkles, Download, Share2 } from 'lucide-react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { ToolBreadcrumb } from '@/components/seo/BreadcrumbSchema';
|
||||
import { RelatedTools } from '@/components/marketing/RelatedTools';
|
||||
|
||||
// SEO Optimized Metadata
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -305,6 +306,9 @@ export default function CryptoQRCodePage() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{/* RELATED TOOLS */}
|
||||
<RelatedTools />
|
||||
|
||||
{/* FAQ SECTION */}
|
||||
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||
<div className="max-w-3xl mx-auto">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import EmailGenerator from './EmailGenerator';
|
|||
import { Mail, Zap, Smartphone, Lock, Download, Sparkles } from 'lucide-react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { ToolBreadcrumb } from '@/components/seo/BreadcrumbSchema';
|
||||
import { RelatedTools } from '@/components/marketing/RelatedTools';
|
||||
|
||||
// SEO Optimized Metadata
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -233,6 +234,9 @@ export default function EmailPage() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{/* RELATED TOOLS */}
|
||||
<RelatedTools />
|
||||
|
||||
{/* FAQ SECTION */}
|
||||
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||
<div className="max-w-3xl mx-auto">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import EventGenerator from './EventGenerator';
|
|||
import { Calendar, Shield, Zap, Smartphone, Clock, UserCheck, Download, Share2, Check } from 'lucide-react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { ToolBreadcrumb } from '@/components/seo/BreadcrumbSchema';
|
||||
import { RelatedTools } from '@/components/marketing/RelatedTools';
|
||||
|
||||
// SEO Optimized Metadata
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -300,6 +301,9 @@ export default function EventQRCodePage() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{/* RELATED TOOLS */}
|
||||
<RelatedTools />
|
||||
|
||||
{/* FAQ SECTION */}
|
||||
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||
<div className="max-w-3xl mx-auto">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import FacebookGenerator from './FacebookGenerator';
|
|||
import { Facebook, Shield, Zap, Smartphone, ThumbsUp, Users, Download, Share2 } from 'lucide-react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { ToolBreadcrumb } from '@/components/seo/BreadcrumbSchema';
|
||||
import { RelatedTools } from '@/components/marketing/RelatedTools';
|
||||
|
||||
// SEO Optimized Metadata
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -308,6 +309,9 @@ export default function FacebookQRCodePage() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{/* RELATED TOOLS */}
|
||||
<RelatedTools />
|
||||
|
||||
{/* FAQ SECTION */}
|
||||
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||
<div className="max-w-3xl mx-auto">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import GeolocationGenerator from './GeolocationGenerator';
|
|||
import { MapPin, Shield, Zap, Smartphone, Navigation, Map, Download, Share2 } from 'lucide-react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { ToolBreadcrumb } from '@/components/seo/BreadcrumbSchema';
|
||||
import { RelatedTools } from '@/components/marketing/RelatedTools';
|
||||
|
||||
// SEO Optimized Metadata
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -303,6 +304,9 @@ export default function GeolocationQRCodePage() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{/* RELATED TOOLS */}
|
||||
<RelatedTools />
|
||||
|
||||
{/* FAQ SECTION */}
|
||||
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||
<div className="max-w-3xl mx-auto">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import InstagramGenerator from './InstagramGenerator';
|
|||
import { Instagram, Shield, Zap, Smartphone, Camera, Heart, Download, Share2 } from 'lucide-react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { ToolBreadcrumb } from '@/components/seo/BreadcrumbSchema';
|
||||
import { RelatedTools } from '@/components/marketing/RelatedTools';
|
||||
|
||||
// SEO Optimized Metadata
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -297,6 +298,8 @@ export default function InstagramQRCodePage() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<RelatedTools />
|
||||
|
||||
{/* FAQ SECTION */}
|
||||
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||
<div className="max-w-3xl mx-auto">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import PayPalGenerator from './PayPalGenerator';
|
|||
import { CreditCard, Shield, Zap, Smartphone, DollarSign, Download, Share2, Banknote } from 'lucide-react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { ToolBreadcrumb } from '@/components/seo/BreadcrumbSchema';
|
||||
import { RelatedTools } from '@/components/marketing/RelatedTools';
|
||||
|
||||
// SEO Optimized Metadata
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -293,6 +294,9 @@ export default function PayPalQRCodePage() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{/* RELATED TOOLS */}
|
||||
<RelatedTools />
|
||||
|
||||
{/* FAQ SECTION */}
|
||||
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||
<div className="max-w-3xl mx-auto">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import PhoneGenerator from './PhoneGenerator';
|
|||
import { Phone, Shield, Zap, Smartphone, PhoneCall, Download } from 'lucide-react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { ToolBreadcrumb } from '@/components/seo/BreadcrumbSchema';
|
||||
import { RelatedTools } from '@/components/marketing/RelatedTools';
|
||||
|
||||
// SEO Optimized Metadata
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -305,6 +306,9 @@ export default function PhoneQRCodePage() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{/* RELATED TOOLS */}
|
||||
<RelatedTools />
|
||||
|
||||
{/* FAQ SECTION */}
|
||||
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||
<div className="max-w-3xl mx-auto">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import SMSGenerator from './SMSGenerator';
|
|||
import { MessageSquare, Shield, Zap, Smartphone, Send } from 'lucide-react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { ToolBreadcrumb } from '@/components/seo/BreadcrumbSchema';
|
||||
import { RelatedTools } from '@/components/marketing/RelatedTools';
|
||||
|
||||
// SEO Optimized Metadata
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -262,6 +263,9 @@ export default function SMSQRCodePage() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{/* RELATED TOOLS */}
|
||||
<RelatedTools />
|
||||
|
||||
{/* FAQ SECTION */}
|
||||
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||
<div className="max-w-3xl mx-auto">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import TeamsGenerator from './TeamsGenerator';
|
|||
import { Users, Shield, Zap, Video, MessageCircle, Download, Share2 } from 'lucide-react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { ToolBreadcrumb } from '@/components/seo/BreadcrumbSchema';
|
||||
import { RelatedTools } from '@/components/marketing/RelatedTools';
|
||||
|
||||
// SEO Optimized Metadata
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -278,6 +279,9 @@ export default function TeamsQRCodePage() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{/* RELATED TOOLS */}
|
||||
<RelatedTools />
|
||||
|
||||
{/* FAQ SECTION */}
|
||||
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||
<div className="max-w-3xl mx-auto">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import TextGenerator from './TextGenerator';
|
|||
import { Type, Shield, Zap, Smartphone, FileText, QrCode, Download, Share2 } from 'lucide-react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { ToolBreadcrumb } from '@/components/seo/BreadcrumbSchema';
|
||||
import { RelatedTools } from '@/components/marketing/RelatedTools';
|
||||
|
||||
// SEO Optimized Metadata
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -295,6 +296,9 @@ export default function TextQRCodePage() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{/* RELATED TOOLS */}
|
||||
<RelatedTools />
|
||||
|
||||
{/* FAQ SECTION */}
|
||||
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||
<div className="max-w-3xl mx-auto">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import TiktokGenerator from './TikTokGenerator';
|
|||
import { Music, Shield, Zap, Smartphone, Video, Heart, Download, Share2 } from 'lucide-react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { ToolBreadcrumb } from '@/components/seo/BreadcrumbSchema';
|
||||
import { RelatedTools } from '@/components/marketing/RelatedTools';
|
||||
|
||||
// SEO Optimized Metadata
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -305,6 +306,8 @@ export default function TiktokQRCodePage() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<RelatedTools />
|
||||
|
||||
{/* FAQ SECTION */}
|
||||
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||
<div className="max-w-3xl mx-auto">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import TwitterGenerator from './TwitterGenerator';
|
|||
import { Twitter, Shield, Zap, Smartphone, MessageCircle, UserPlus, Download, Share2 } from 'lucide-react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { ToolBreadcrumb } from '@/components/seo/BreadcrumbSchema';
|
||||
import { RelatedTools } from '@/components/marketing/RelatedTools';
|
||||
|
||||
// SEO Optimized Metadata
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -306,6 +307,9 @@ export default function TwitterQRCodePage() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{/* RELATED TOOLS */}
|
||||
<RelatedTools />
|
||||
|
||||
{/* FAQ SECTION */}
|
||||
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||
<div className="max-w-3xl mx-auto">
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import React from 'react';
|
||||
import type { Metadata } from 'next';
|
||||
import URLGenerator from './URLGenerator';
|
||||
import { Link as LinkIcon, Shield, Zap, Smartphone, Globe, BarChart } from 'lucide-react';
|
||||
import { Link as LinkIcon, Shield, Zap, Smartphone, Globe } from 'lucide-react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { ToolBreadcrumb } from '@/components/seo/BreadcrumbSchema';
|
||||
import { RelatedTools } from '@/components/marketing/RelatedTools';
|
||||
|
||||
// SEO Optimized Metadata
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -254,6 +255,8 @@ export default function URLQRCodePage() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<RelatedTools />
|
||||
|
||||
{/* FAQ SECTION */}
|
||||
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||
<div className="max-w-3xl mx-auto">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import VCardGenerator from './VCardGenerator';
|
|||
import { User, Shield, Zap, Smartphone, Contact, Share2, Check, UserPlus } from 'lucide-react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { ToolBreadcrumb } from '@/components/seo/BreadcrumbSchema';
|
||||
import { RelatedTools } from '@/components/marketing/RelatedTools';
|
||||
|
||||
// SEO Optimized Metadata
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -267,6 +268,9 @@ export default function VCardQRCodePage() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{/* RELATED TOOLS */}
|
||||
<RelatedTools />
|
||||
|
||||
{/* FAQ SECTION */}
|
||||
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||
<div className="max-w-3xl mx-auto">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import WhatsappGenerator from './WhatsAppGenerator';
|
|||
import { MessageCircle, Shield, Zap, Smartphone, Send, Phone, Download, Check } from 'lucide-react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { ToolBreadcrumb } from '@/components/seo/BreadcrumbSchema';
|
||||
import { RelatedTools } from '@/components/marketing/RelatedTools';
|
||||
|
||||
// SEO Optimized Metadata
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -306,6 +307,9 @@ export default function WhatsappQRCodePage() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{/* RELATED TOOLS */}
|
||||
<RelatedTools />
|
||||
|
||||
{/* FAQ SECTION */}
|
||||
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||
<div className="max-w-3xl mx-auto">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import WiFiGenerator from './WiFiGenerator';
|
|||
import { Wifi, Shield, Zap, Smartphone, Lock, QrCode, Download, Share2 } from 'lucide-react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { ToolBreadcrumb } from '@/components/seo/BreadcrumbSchema';
|
||||
import { RelatedTools } from '@/components/marketing/RelatedTools';
|
||||
|
||||
// SEO Optimized Metadata
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -311,6 +312,9 @@ export default function WiFiQRCodePage() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{/* RELATED TOOLS */}
|
||||
<RelatedTools />
|
||||
|
||||
{/* FAQ SECTION - Featured Snippet Optimized */}
|
||||
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||
<div className="max-w-3xl mx-auto">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import YoutubeGenerator from './YouTubeGenerator';
|
|||
import { Youtube, Shield, Zap, Smartphone, Play, Radio, Download, Share2 } from 'lucide-react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { ToolBreadcrumb } from '@/components/seo/BreadcrumbSchema';
|
||||
import { RelatedTools } from '@/components/marketing/RelatedTools';
|
||||
|
||||
// SEO Optimized Metadata
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -301,6 +302,9 @@ export default function YoutubeQRCodePage() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{/* RELATED TOOLS */}
|
||||
<RelatedTools />
|
||||
|
||||
{/* FAQ SECTION */}
|
||||
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||
<div className="max-w-3xl mx-auto">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import ZoomGenerator from './ZoomGenerator';
|
|||
import { Video, Shield, Zap, Smartphone, Users, Download, Share2 } from 'lucide-react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { ToolBreadcrumb } from '@/components/seo/BreadcrumbSchema';
|
||||
import { RelatedTools } from '@/components/marketing/RelatedTools';
|
||||
|
||||
// SEO Optimized Metadata
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -278,6 +279,9 @@ export default function ZoomQRCodePage() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{/* RELATED TOOLS */}
|
||||
<RelatedTools />
|
||||
|
||||
{/* FAQ SECTION */}
|
||||
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||
<div className="max-w-3xl mx-auto">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,248 @@
|
|||
'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 de from '@/i18n/de.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]);
|
||||
|
||||
// Always German for this layout
|
||||
const t = de;
|
||||
|
||||
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">
|
||||
{/* 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">{children}</main>
|
||||
|
||||
{/* Footer */}
|
||||
<Footer t={t} />
|
||||
</div >
|
||||
);
|
||||
}
|
||||
|
|
@ -1,248 +1,42 @@
|
|||
'use client';
|
||||
import type { Metadata } from 'next';
|
||||
import '@/styles/globals.css';
|
||||
import { Providers } from '@/components/Providers';
|
||||
import MarketingDeLayout from './MarketingDeLayout';
|
||||
import { organizationSchema, websiteSchema } from '@/lib/schema';
|
||||
|
||||
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 de from '@/i18n/de.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 const metadata: Metadata = {
|
||||
title: {
|
||||
default: 'QR Master – QR Code Generator & Analytics',
|
||||
template: '%s | QR Master',
|
||||
},
|
||||
description: 'Erstellen Sie dynamische QR Codes, verfolgen Sie Scans und skalieren Sie Kampagnen mit sicheren Analysen.',
|
||||
robots: { index: true, follow: true },
|
||||
};
|
||||
|
||||
export default function MarketingLayout({
|
||||
export default function RootMarketingDeLayout({
|
||||
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]);
|
||||
|
||||
// Always German for this layout
|
||||
const t = de;
|
||||
|
||||
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">
|
||||
{/* 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">{children}</main>
|
||||
|
||||
{/* Footer */}
|
||||
<Footer t={t} />
|
||||
</div >
|
||||
<html lang="de">
|
||||
<head>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationSchema()) }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteSchema()) }}
|
||||
/>
|
||||
</head>
|
||||
<body className="font-sans">
|
||||
<Providers>
|
||||
<MarketingDeLayout>
|
||||
{children}
|
||||
</MarketingDeLayout>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,11 +20,8 @@ function truncateAtWord(text: string, maxLength: number): string {
|
|||
}
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const title = truncateAtWord('QR Code Erstellen – Kostenlos & Sofort | QR Master', 60);
|
||||
const description = truncateAtWord(
|
||||
'Erstellen Sie QR Codes kostenlos in Sekunden. Statische und dynamische QR-Codes mit Tracking, individuellem Branding und Massen-Erstellung. Für immer kostenlos.',
|
||||
160
|
||||
);
|
||||
const title = 'QR Code Erstellen – Kostenlos | QR Master';
|
||||
const description = 'Erstellen Sie QR Codes kostenlos in Sekunden. Dynamische QR-Codes mit Tracking, Branding und Massen-Erstellung. Für immer kostenlos.';
|
||||
|
||||
return {
|
||||
title,
|
||||
|
|
@ -69,9 +66,11 @@ export default function QRCodeErstellenPage() {
|
|||
<>
|
||||
<SeoJsonLd data={[organizationSchema(), websiteSchema(), generateFaqSchema(t.faq.questions)]} />
|
||||
|
||||
{/* Server-rendered H1 for SEO - visually hidden but crawlable */}
|
||||
<h1 className="sr-only">QR Code Erstellen – Kostenloser QR Code Generator mit Tracking</h1>
|
||||
|
||||
{/* Server-rendered SEO content for crawlers - GERMAN */}
|
||||
<div className="sr-only" aria-hidden="false">
|
||||
<h1>QR Code Erstellen – Kostenloser QR Code Generator mit Tracking & Statistiken</h1>
|
||||
<p>
|
||||
Erstellen Sie professionelle QR Codes für Ihr Unternehmen mit QR Master. Unser dynamischer QR Code Generator
|
||||
ermöglicht es Ihnen, trackbare QR Codes zu erstellen, Ziel-URLs jederzeit zu ändern und detaillierte Statistiken einzusehen.
|
||||
|
|
|
|||
|
|
@ -1,133 +0,0 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { Suspense } from 'react';
|
||||
import '@/styles/globals.css';
|
||||
import { ToastContainer } from '@/components/ui/Toast';
|
||||
import AuthProvider from '@/components/SessionProvider';
|
||||
import { PostHogProvider } from '@/components/PostHogProvider';
|
||||
import CookieBanner from '@/components/CookieBanner';
|
||||
|
||||
const isIndexable = process.env.NEXT_PUBLIC_INDEXABLE === 'true';
|
||||
|
||||
// Organization Schema for all pages
|
||||
const organizationSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
'@id': 'https://www.qrmaster.net/#organization',
|
||||
name: 'QR Master',
|
||||
alternateName: 'QRMaster',
|
||||
url: 'https://www.qrmaster.net',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: 'https://www.qrmaster.net/static/og-image.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
},
|
||||
image: 'https://www.qrmaster.net/static/og-image.png',
|
||||
sameAs: ['https://twitter.com/qrmaster'],
|
||||
contactPoint: {
|
||||
'@type': 'ContactPoint',
|
||||
contactType: 'Customer Support',
|
||||
email: 'support@qrmaster.net',
|
||||
availableLanguage: ['English', 'German'],
|
||||
},
|
||||
description: 'B2B SaaS platform for dynamic QR code generation with analytics, branding, and bulk generation for enterprise marketing campaigns.',
|
||||
slogan: 'Dynamic QR codes that work smarter',
|
||||
foundingDate: '2025',
|
||||
areaServed: 'Worldwide',
|
||||
serviceType: 'Software as a Service',
|
||||
priceRange: '$0 - $29',
|
||||
};
|
||||
|
||||
// Website Schema for all pages
|
||||
const websiteSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
'@id': 'https://www.qrmaster.net/#website',
|
||||
name: 'QR Master',
|
||||
url: 'https://www.qrmaster.net',
|
||||
inLanguage: 'en',
|
||||
publisher: { '@id': 'https://www.qrmaster.net/#organization' },
|
||||
potentialAction: {
|
||||
'@type': 'SearchAction',
|
||||
target: {
|
||||
'@type': 'EntryPoint',
|
||||
urlTemplate: 'https://www.qrmaster.net/blog?q={search_term_string}',
|
||||
},
|
||||
'query-input': 'required name=search_term_string',
|
||||
},
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL('https://www.qrmaster.net'),
|
||||
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/static/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.',
|
||||
url: 'https://www.qrmaster.net',
|
||||
images: [
|
||||
{
|
||||
url: 'https://www.qrmaster.net/static/og-image.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'QR Master - Dynamic QR Code Generator and Analytics Platform',
|
||||
},
|
||||
],
|
||||
locale: 'en_US',
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationSchema) }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteSchema) }}
|
||||
/>
|
||||
{/* AdSense script removed from global layout to prevent auto-ads on landing pages.
|
||||
Ads are loaded locally via the AdBanner component only on tool pages. */}
|
||||
</head>
|
||||
<body className="font-sans">
|
||||
<Suspense fallback={null}>
|
||||
<PostHogProvider>
|
||||
<AuthProvider>
|
||||
{children}
|
||||
</AuthProvider>
|
||||
<CookieBanner />
|
||||
<ToastContainer />
|
||||
</PostHogProvider>
|
||||
</Suspense>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
@ -40,6 +40,12 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
|||
changeFrequency: 'weekly',
|
||||
priority: 1.0,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/qr-code-erstellen`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'weekly',
|
||||
priority: 1.0,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/qr-code-tracking`,
|
||||
lastModified: new Date(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
'use client';
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { ToastContainer } from '@/components/ui/Toast';
|
||||
import AuthProvider from '@/components/SessionProvider';
|
||||
import { PostHogProvider } from '@/components/PostHogProvider';
|
||||
import CookieBanner from '@/components/CookieBanner';
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<PostHogProvider>
|
||||
<AuthProvider>
|
||||
{children}
|
||||
</AuthProvider>
|
||||
<CookieBanner />
|
||||
<ToastContainer />
|
||||
</PostHogProvider>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import Link from 'next/link';
|
||||
import {
|
||||
Link as LinkIcon,
|
||||
Type,
|
||||
Wifi,
|
||||
Contact,
|
||||
MessageCircle,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
Phone,
|
||||
Calendar,
|
||||
MapPin,
|
||||
Facebook,
|
||||
Instagram,
|
||||
Twitter,
|
||||
Youtube,
|
||||
Music,
|
||||
Bitcoin,
|
||||
CreditCard,
|
||||
Video,
|
||||
Users
|
||||
} from 'lucide-react';
|
||||
|
||||
const tools = [
|
||||
{ name: 'URL / Link', href: '/tools/url-qr-code', icon: LinkIcon, color: 'text-blue-500', bgColor: 'bg-blue-50' },
|
||||
{ name: 'vCard', href: '/tools/vcard-qr-code', icon: Contact, color: 'text-pink-500', bgColor: 'bg-pink-50' },
|
||||
{ name: 'WiFi', href: '/tools/wifi-qr-code', icon: Wifi, color: 'text-indigo-500', bgColor: 'bg-indigo-50' },
|
||||
{ name: 'Text', href: '/tools/text-qr-code', icon: Type, color: 'text-slate-500', bgColor: 'bg-slate-50' },
|
||||
{ name: 'Email', href: '/tools/email-qr-code', icon: Mail, color: 'text-amber-500', bgColor: 'bg-amber-50' },
|
||||
{ name: 'SMS', href: '/tools/sms-qr-code', icon: MessageSquare, color: 'text-cyan-500', bgColor: 'bg-cyan-50' },
|
||||
{ name: 'Instagram', href: '/tools/instagram-qr-code', icon: Instagram, color: 'text-pink-600', bgColor: 'bg-pink-50' },
|
||||
{ name: 'TikTok', href: '/tools/tiktok-qr-code', icon: Music, color: 'text-slate-800', bgColor: 'bg-slate-100' },
|
||||
];
|
||||
|
||||
export function RelatedTools() {
|
||||
return (
|
||||
<section className="py-12 bg-slate-50 border-t border-slate-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<h2 className="text-2xl font-bold text-slate-900 mb-8 text-center">
|
||||
More Free QR Code Generators
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{tools.map((tool) => (
|
||||
<Link
|
||||
key={tool.name}
|
||||
href={tool.href}
|
||||
className="flex items-center space-x-3 p-4 bg-white rounded-xl shadow-sm border border-slate-100 transition-all hover:shadow-md hover:border-slate-200 hover:-translate-y-1"
|
||||
>
|
||||
<div className={`p-2 rounded-lg shrink-0 ${tool.bgColor} ${tool.color}`}>
|
||||
<tool.icon className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="font-semibold text-slate-700 text-sm">{tool.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -16,6 +16,9 @@ export function middleware(req: NextRequest) {
|
|||
'/newsletter',
|
||||
'/tools',
|
||||
'/qr-code-erstellen',
|
||||
'/dynamic-qr-code-generator',
|
||||
'/bulk-qr-code-generator',
|
||||
'/qr-code-tracking',
|
||||
];
|
||||
|
||||
// Check if path is public
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
Loading…
Reference in New Issue