Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3682673852 | |||
|
|
1251584b13 | ||
|
|
dd93ca560a | ||
|
|
efb1654370 | ||
|
|
05531cda3f | ||
|
|
268689f2ee | ||
|
|
fb9058688e | ||
|
|
eb2faec952 | ||
|
|
e539aaf9a1 | ||
|
|
95e062eab6 | ||
|
|
a48045dacb | ||
|
|
b4fe905d8e | ||
|
|
d04e7a1f70 |
100
.gitignore
vendored
@@ -1,51 +1,51 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
/node_modules
|
/node_modules
|
||||||
/.pnp
|
/.pnp
|
||||||
.pnp.js
|
.pnp.js
|
||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
/out/
|
/out/
|
||||||
|
|
||||||
# production
|
# production
|
||||||
/build
|
/build
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
|
|
||||||
# debug
|
# debug
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
# local env files
|
# local env files
|
||||||
.env*.local
|
.env*.local
|
||||||
.env
|
.env
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
# prisma
|
# prisma
|
||||||
/prisma/migrations/
|
/prisma/migrations/
|
||||||
|
|
||||||
# docker
|
# docker
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
*.sql
|
*.sql
|
||||||
/backups/
|
/backups/
|
||||||
|
|
||||||
# logs
|
# logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# local dev script
|
# local dev script
|
||||||
dev-server.js
|
dev-server.js
|
||||||
291
SIDE_PROJECT_STRATEGY.md
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
# 🚀 Side Project Marketing Strategy
|
||||||
|
|
||||||
|
> **"Engineering as Marketing"** – Kostenlose Micro-Tools bauen, um SEO-Traffic abzufangen und in zahlende Kunden zu konvertieren.
|
||||||
|
|
||||||
|
**Status:** Planung abgeschlossen, bereit für Implementierung
|
||||||
|
**Autor:** QR Master Team
|
||||||
|
**Letzte Aktualisierung:** 2026-01-08
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
Wir nutzen die bewiesene "Engineering as Marketing" Strategie (bekannt von HubSpot's Website Grader, Ahrefs' Free Tools, Shopify's Business Tools), um organischen Traffic über spezialisierte, kostenlose QR-Generatoren zu gewinnen.
|
||||||
|
|
||||||
|
### Das Konzept in einem Satz
|
||||||
|
|
||||||
|
> Anstatt gegen "QR Code Generator" (DA 90+ Konkurrenz) zu kämpfen, bauen wir 10 spezialisierte Tools für Long-Tail-Keywords wie "WiFi QR Code erstellen" oder "VCard QR Generator".
|
||||||
|
|
||||||
|
### Warum das funktioniert
|
||||||
|
|
||||||
|
1. **Weniger Konkurrenz:** "WiFi QR Code Generator" hat 1/10 der Konkurrenz von "QR Code Generator"
|
||||||
|
2. **Höhere Kaufabsicht:** Wer "Restaurant Menu QR Code" sucht, ist bereit für ein Premium-Tool
|
||||||
|
3. **Natürliche Backlinks:** Leute teilen nützliche Tools ("Hier, dieser Generator ist kostenlos")
|
||||||
|
4. **Zero Marginal Cost:** Client-Side Generierung = 0€ Serverkosten pro User
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ROI Projektion (Konservativ)
|
||||||
|
|
||||||
|
| Metrik | Monat 3 | Monat 6 | Monat 12 |
|
||||||
|
|--------|---------|---------|----------|
|
||||||
|
| Organischer Traffic (alle Tools) | 2.000 | 10.000 | 25.000 |
|
||||||
|
| Free Signups (20% Conv.) | 400 | 2.000 | 5.000 |
|
||||||
|
| Paid Customers (3% der Signups) | 12 | 60 | 150 |
|
||||||
|
| **Zusätzlicher MRR** | **108€** | **540€** | **1.350€** |
|
||||||
|
|
||||||
|
> **Benchmarks verwendet:** 2-3% Free-to-Paid Conversion (Industry Standard), 20% Tool-to-Signup (optimistisch, aber erreichbar mit gutem UX).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Die Tools-Roadmap
|
||||||
|
|
||||||
|
### Phase 1: Quick Wins (Woche 1-2)
|
||||||
|
|
||||||
|
Fokus auf **hohes Suchvolumen + geringe Komplexität**.
|
||||||
|
|
||||||
|
| Tool | URL | Geschätztes SV | Implementierungs-Aufwand |
|
||||||
|
|------|-----|----------------|-------------------------|
|
||||||
|
| **WiFi QR Generator** | `/tools/wifi-qr-code` | 40.000/Monat | 4h |
|
||||||
|
| **VCard QR Generator** | `/tools/vcard-qr-code` | 15.000/Monat | 4h |
|
||||||
|
| **WhatsApp QR Generator** | `/tools/whatsapp-qr-code` | 20.000/Monat | 3h |
|
||||||
|
|
||||||
|
### Phase 2: Monetization Focus (Woche 3-4)
|
||||||
|
|
||||||
|
Fokus auf **hohe Conversion-Wahrscheinlichkeit** (B2B Use Cases).
|
||||||
|
|
||||||
|
| Tool | URL | Geschätztes SV | Upsell-Hook |
|
||||||
|
|------|-----|----------------|-------------|
|
||||||
|
| **App Store Link QR** | `/tools/app-store-qr-code` | 5.000/Monat | Smart Routing (iOS/Android) |
|
||||||
|
| **PDF to QR** | `/tools/pdf-qr-code` | 15.000/Monat | PDF Hosting (benötigt Account) |
|
||||||
|
| **Menu QR Generator** | `/tools/menu-qr-code` | 8.000/Monat | Multi-Sprache, Analytics |
|
||||||
|
|
||||||
|
### Phase 3: Differenzierung (Monat 2+)
|
||||||
|
|
||||||
|
Fokus auf **Unique Features** die Konkurrenten nicht haben.
|
||||||
|
|
||||||
|
| Tool | URL | Differenzierung |
|
||||||
|
|------|-----|-----------------|
|
||||||
|
| **Barcode Generator** | `/tools/barcode-generator` | EAN/UPC/ISBN Unterstützung |
|
||||||
|
| **Bitcoin/Crypto QR** | `/tools/bitcoin-qr-code` | Multi-Wallet Format |
|
||||||
|
| **AI Art QR (Viral)** | `/tools/ai-qr-code` | Stable Diffusion Integration |
|
||||||
|
|
||||||
|
## Geplantes Portfolio: Kostenlose Statische Generatoren (15 Typen)
|
||||||
|
|
||||||
|
Wir werden die folgenden 15 statischen QR-Code-Typen anbieten. Diese sind **dauerhaft kostenlos** und erfordern keine Server-Infrastruktur für Redirects (im Gegensatz zu dynamischen Codes).
|
||||||
|
|
||||||
|
> **Wichtig:** Alle diese Generatoren stehen sowohl **öffentlich als SEO-Landingpages** zur Verfügung (zur Neukundengewinnung), als auch im **eingeloggten Bereich** für registrierte Nutzer (für Komfort und Zentralisierung).
|
||||||
|
|
||||||
|
1. **URL / Link**: Der Standard. Öffnet eine Webseite.
|
||||||
|
2. **Text**: Zeigt reinen Text an (bis zu 300 Zeichen).
|
||||||
|
3. **WiFi**: Verbindet direkt mit einem WLAN-Netzwerk (WPA/WEP/Open).
|
||||||
|
4. **VCard / Kontakt**: Speichert einen Kontakt direkt im Adressbuch.
|
||||||
|
5. **WhatsApp**: Startet einen Chat mit einer Nummer (und optionalem Text).
|
||||||
|
6. **E-Mail**: Öffnet das E-Mail-Programm mit Empfänger, Betreff und Body.
|
||||||
|
7. **SMS**: Bereitet eine SMS an eine Nummer vor.
|
||||||
|
8. **Anruf / Tel**: Startet einen Anruf an eine Nummer.
|
||||||
|
9. **Event / Kalender**: Fügt einen Termin zum Kalender hinzu (.ics).
|
||||||
|
10. **Geo / Maps**: Öffnet einen Standort in Google Maps/Apple Maps.
|
||||||
|
11. **Facebook**: Öffnet ein Profil oder eine Seite.
|
||||||
|
12. **Instagram**: Öffnet ein Instagram-Profil.
|
||||||
|
13. **Twitter / X**: Öffnet ein Profil oder erstellt einen Tweet.
|
||||||
|
14. **YouTube**: Öffnet ein Video oder einen Kanal.
|
||||||
|
15. **TikTok**: Öffnet ein TikTok-Profil.
|
||||||
|
|
||||||
|
Diese Breite deckt 99% der "Everyday Use Cases" ab und maximiert die SEO-Angriffsfläche.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technische Architektur
|
||||||
|
|
||||||
|
### Warum Client-Side Generierung?
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ USER BROWSER │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────┐ │
|
||||||
|
│ │ Form Input │ -> │ qrcode.js │ -> │ Canvas/SVG │ │
|
||||||
|
│ │ (SSID, PW) │ │ (generation) │ │ (download) │ │
|
||||||
|
│ └─────────────┘ └──────────────┘ └────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ KEINE Server-Calls! │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Vorteile:**
|
||||||
|
- **Privatsphäre:** Passwörter verlassen nie den Browser
|
||||||
|
- **Speed:** Instant Generation (kein Network Latency)
|
||||||
|
- **Kosten:** 0€ pro generiertem Code
|
||||||
|
- **Scale:** Kein Backend-Limit
|
||||||
|
|
||||||
|
### Datei-Struktur (Next.js)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/app/(marketing)/tools/
|
||||||
|
├── wifi-qr-code/
|
||||||
|
│ ├── page.tsx # Server Component (SEO)
|
||||||
|
│ └── WiFiGenerator.tsx # Client Component (Interaktion)
|
||||||
|
├── vcard-qr-code/
|
||||||
|
│ ├── page.tsx
|
||||||
|
│ └── VCardGenerator.tsx
|
||||||
|
└── [weitere tools]/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Shared Components
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/components/tools/QRDownloadButtons.tsx
|
||||||
|
// Wiederverwendbare Download-Buttons für alle Tools
|
||||||
|
|
||||||
|
// src/components/tools/UpgradePrompt.tsx
|
||||||
|
// "Willst du Scans tracken?" CTA Box
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SEO-Strategie pro Tool-Page
|
||||||
|
|
||||||
|
Jede Seite folgt dem gleichen bewährten Muster:
|
||||||
|
|
||||||
|
### 1. Above the Fold: Sofort nutzbar
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────┐
|
||||||
|
│ H1: Free WiFi QR Code Generator │
|
||||||
|
│ Subline: Teile dein WLAN in Sekunden │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────┐ │
|
||||||
|
│ │ [SSID] [Password] [WPA▼] │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ [ Generate QR Code ] │ │
|
||||||
|
│ └─────────────────────────────────┘ │
|
||||||
|
└────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Regel:** Der User muss SOFORT interagieren können. Kein langer Intro-Text.
|
||||||
|
|
||||||
|
### 2. Schema Markup (Pflicht!)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "SoftwareApplication",
|
||||||
|
"name": "WiFi QR Code Generator",
|
||||||
|
"applicationCategory": "UtilitiesApplication",
|
||||||
|
"operatingSystem": "Web Browser",
|
||||||
|
"offers": {
|
||||||
|
"@type": "Offer",
|
||||||
|
"price": "0",
|
||||||
|
"priceCurrency": "EUR"
|
||||||
|
},
|
||||||
|
"aggregateRating": {
|
||||||
|
"@type": "AggregateRating",
|
||||||
|
"ratingValue": "4.8",
|
||||||
|
"ratingCount": "1247"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. FAQ Section (Long-Tail Keywords)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Häufig gestellte Fragen
|
||||||
|
|
||||||
|
### Wie funktioniert ein WiFi QR Code?
|
||||||
|
Der QR Code enthält deine WLAN-Daten im Format...
|
||||||
|
|
||||||
|
### Ist es sicher, mein WiFi Passwort in einem QR Code zu speichern?
|
||||||
|
Ja, der QR Code wird nur lokal in deinem Browser generiert...
|
||||||
|
|
||||||
|
### Kann ich den QR Code später bearbeiten?
|
||||||
|
Dieser Generator erstellt statische Codes. Für editierbare...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Conversion Prompt (Der Hook)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ ✅ QR Code erfolgreich erstellt! │
|
||||||
|
│ │
|
||||||
|
│ ⚠️ Hinweis: Dies ist ein statischer Code. │
|
||||||
|
│ Wenn du dein Passwort änderst, musst du neu drucken. │
|
||||||
|
│ │
|
||||||
|
│ → Erstelle einen dynamischen Code (jederzeit änderbar) │
|
||||||
|
│ │
|
||||||
|
│ Bonus: Sieh wer deinen Code scannt (Datum, Standort) │
|
||||||
|
│ │
|
||||||
|
│ [ Kostenlos registrieren ] │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conversion Optimierung
|
||||||
|
|
||||||
|
### Die "Limitation Awareness" Methode
|
||||||
|
|
||||||
|
Jedes Tool zeigt nach der Generierung **sanft** die Limitierungen auf:
|
||||||
|
|
||||||
|
| Tool | Statische Limitation | Upsell-Feature |
|
||||||
|
|------|---------------------|----------------|
|
||||||
|
| WiFi | Passwort-Änderung = Neudruck | Dynamischer Code (editierbar) |
|
||||||
|
| VCard | Kontakt-Update = Neudruck | Immer aktuelle Visitenkarte |
|
||||||
|
| Menu | Neue Speisekarte = Neudruck | PDF-Hosting + Analytics |
|
||||||
|
| App Store | Nur ein Store-Link | Smart Device Detection |
|
||||||
|
|
||||||
|
### Email Capture vor Download
|
||||||
|
|
||||||
|
**Optional (A/B testen):**
|
||||||
|
```
|
||||||
|
"Gib deine Email ein, um den QR als hochauflösende PNG zu erhalten"
|
||||||
|
```
|
||||||
|
→ Baut Email-Liste, auch wenn User nicht sofort konvertiert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Erfolgsmetriken (KPIs)
|
||||||
|
|
||||||
|
| KPI | Tool | Ziel (Monat 3) |
|
||||||
|
|-----|------|----------------|
|
||||||
|
| **Organic Sessions** | Google Analytics | 2.000/Monat |
|
||||||
|
| **QR Generations** | PostHog Event | 500/Monat |
|
||||||
|
| **Signup Clicks** | PostHog Event | 100/Monat |
|
||||||
|
| **Actual Signups** | DB Query | 50/Monat |
|
||||||
|
| **Paid Conversion** | Stripe | 5/Monat |
|
||||||
|
|
||||||
|
### Tracking Events implementieren
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Auf jeder Tool-Page
|
||||||
|
posthog.capture('tool_qr_generated', {
|
||||||
|
tool: 'wifi',
|
||||||
|
format: 'png'
|
||||||
|
});
|
||||||
|
|
||||||
|
posthog.capture('tool_signup_cta_clicked', {
|
||||||
|
tool: 'wifi'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nächste Schritte
|
||||||
|
|
||||||
|
1. [ ] **Heute:** WiFi QR Generator implementieren (`/tools/wifi-qr-code`)
|
||||||
|
2. [ ] **Diese Woche:** VCard + WhatsApp Generator
|
||||||
|
3. [ ] **Nächste Woche:** Google Search Console monitoren für erste Impressions
|
||||||
|
4. [ ] **Monat 2:** A/B Test Email-Capture vs. Direct Download
|
||||||
|
5. [ ] **Monat 3:** Phase 2 Tools (App Store, PDF, Menu)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Referenzen & Inspiration
|
||||||
|
|
||||||
|
- [HubSpot Website Grader](https://website.grader.com/) – Das Original "Engineering as Marketing"
|
||||||
|
- [Ahrefs Free Tools](https://ahrefs.com/free-seo-tools) – 12+ Free Tools als Lead Magnets
|
||||||
|
- [Shopify Business Tools](https://www.shopify.com/tools) – Logo Maker, Invoice Generator, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Dieses Dokument wird regelmäßig aktualisiert basierend auf Traffic-Daten und Conversion-Rates.*
|
||||||
@@ -1,180 +1,180 @@
|
|||||||
# Claude Artifact Prompts for Parasite SEO
|
# Claude Artifact Prompts for Parasite SEO
|
||||||
|
|
||||||
**Goal:** Publish educational content on claude.ai that naturally links to qrmaster.net
|
**Goal:** Publish educational content on claude.ai that naturally links to qrmaster.net
|
||||||
**Strategy:** Informative, helpful content that does NOT look like advertising
|
**Strategy:** Informative, helpful content that does NOT look like advertising
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎯 Prompt 1: Restaurant QR Menu Guide
|
## 🎯 Prompt 1: Restaurant QR Menu Guide
|
||||||
|
|
||||||
```
|
```
|
||||||
Create an informative HTML article titled "Restaurant Menu QR Codes: A Practical Guide for Restaurant Owners (2025)"
|
Create an informative HTML article titled "Restaurant Menu QR Codes: A Practical Guide for Restaurant Owners (2025)"
|
||||||
|
|
||||||
The article should:
|
The article should:
|
||||||
- Be 1500-2000 words long
|
- Be 1500-2000 words long
|
||||||
- Contain practical tips for restaurant owners
|
- Contain practical tips for restaurant owners
|
||||||
- Cover the following topics:
|
- Cover the following topics:
|
||||||
1. Why digital menus are the new standard
|
1. Why digital menus are the new standard
|
||||||
2. PDF vs. online menu - pros and cons
|
2. PDF vs. online menu - pros and cons
|
||||||
3. Optimal placement of QR codes in restaurants
|
3. Optimal placement of QR codes in restaurants
|
||||||
4. Mistakes restaurants should avoid
|
4. Mistakes restaurants should avoid
|
||||||
5. Using tracking and analytics
|
5. Using tracking and analytics
|
||||||
|
|
||||||
Naturally incorporate these keywords:
|
Naturally incorporate these keywords:
|
||||||
- "restaurant menu qr code" (main keyword)
|
- "restaurant menu qr code" (main keyword)
|
||||||
- "digital menu"
|
- "digital menu"
|
||||||
- "touchless menu"
|
- "touchless menu"
|
||||||
- "qr code for restaurants"
|
- "qr code for restaurants"
|
||||||
|
|
||||||
Link ONCE naturally to https://www.qrmaster.net as "a free tool for creating restaurant QR codes"
|
Link ONCE naturally to https://www.qrmaster.net as "a free tool for creating restaurant QR codes"
|
||||||
|
|
||||||
Tone: Friendly, helpful, like an experienced consultant. NOT salesy.
|
Tone: Friendly, helpful, like an experienced consultant. NOT salesy.
|
||||||
|
|
||||||
HTML with clean CSS, mobile-friendly. No external dependencies.
|
HTML with clean CSS, mobile-friendly. No external dependencies.
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎯 Prompt 2: Dynamic vs Static QR Comparison
|
## 🎯 Prompt 2: Dynamic vs Static QR Comparison
|
||||||
|
|
||||||
```
|
```
|
||||||
Create a technical comparison article as HTML: "Static vs Dynamic QR Codes: What's the Difference and When to Use Each"
|
Create a technical comparison article as HTML: "Static vs Dynamic QR Codes: What's the Difference and When to Use Each"
|
||||||
|
|
||||||
Structure:
|
Structure:
|
||||||
1. Brief explanation of what QR codes are technically
|
1. Brief explanation of what QR codes are technically
|
||||||
2. Static QR codes - how they work
|
2. Static QR codes - how they work
|
||||||
3. Dynamic QR codes - how they work
|
3. Dynamic QR codes - how they work
|
||||||
4. Comparison table (very important!)
|
4. Comparison table (very important!)
|
||||||
5. Decision guide: When to use which type
|
5. Decision guide: When to use which type
|
||||||
6. Realistic use cases for both
|
6. Realistic use cases for both
|
||||||
|
|
||||||
The article should be NEUTRAL and EDUCATIONAL, like a Wikipedia article but more readable.
|
The article should be NEUTRAL and EDUCATIONAL, like a Wikipedia article but more readable.
|
||||||
|
|
||||||
Keywords:
|
Keywords:
|
||||||
- "dynamic vs static qr code" (main keyword)
|
- "dynamic vs static qr code" (main keyword)
|
||||||
- "editable qr code"
|
- "editable qr code"
|
||||||
- "trackable qr code"
|
- "trackable qr code"
|
||||||
- "qr code types"
|
- "qr code types"
|
||||||
|
|
||||||
At the end, include a short "Tools for Creating QR Codes" section with 3-4 options. One of them is https://www.qrmaster.net - listed as an equal entry, NOT as a recommendation.
|
At the end, include a short "Tools for Creating QR Codes" section with 3-4 options. One of them is https://www.qrmaster.net - listed as an equal entry, NOT as a recommendation.
|
||||||
|
|
||||||
HTML with professional, minimalist design.
|
HTML with professional, minimalist design.
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎯 Prompt 3: Small Business Marketing Guide
|
## 🎯 Prompt 3: Small Business Marketing Guide
|
||||||
|
|
||||||
```
|
```
|
||||||
Create a comprehensive HTML guide: "10 Ways Small Businesses Can Use QR Codes in 2025"
|
Create a comprehensive HTML guide: "10 Ways Small Businesses Can Use QR Codes in 2025"
|
||||||
|
|
||||||
The article is aimed at small businesses without technical knowledge.
|
The article is aimed at small businesses without technical knowledge.
|
||||||
|
|
||||||
The 10 use cases:
|
The 10 use cases:
|
||||||
1. Digital business cards (vCard)
|
1. Digital business cards (vCard)
|
||||||
2. Collecting Google reviews
|
2. Collecting Google reviews
|
||||||
3. Contactless payments
|
3. Contactless payments
|
||||||
4. Sharing Wi-Fi access
|
4. Sharing Wi-Fi access
|
||||||
5. Growing social media followers
|
5. Growing social media followers
|
||||||
6. Linking product information
|
6. Linking product information
|
||||||
7. Simplifying appointment booking
|
7. Simplifying appointment booking
|
||||||
8. Discount promotions & coupons
|
8. Discount promotions & coupons
|
||||||
9. Event tickets & check-in
|
9. Event tickets & check-in
|
||||||
10. Feedback & surveys
|
10. Feedback & surveys
|
||||||
|
|
||||||
For each point: Brief explanation + concrete example + one tip.
|
For each point: Brief explanation + concrete example + one tip.
|
||||||
|
|
||||||
Keywords:
|
Keywords:
|
||||||
- "qr code for small business"
|
- "qr code for small business"
|
||||||
- "qr code marketing"
|
- "qr code marketing"
|
||||||
- "qr code uses"
|
- "qr code uses"
|
||||||
- "business qr codes"
|
- "business qr codes"
|
||||||
|
|
||||||
Link ONCE naturally in the context of vCard creation to https://www.qrmaster.net/blog/vcard-qr-code-generator
|
Link ONCE naturally in the context of vCard creation to https://www.qrmaster.net/blog/vcard-qr-code-generator
|
||||||
|
|
||||||
Tone: Enthusiastic but not over the top. Like a helpful friend explaining technology.
|
Tone: Enthusiastic but not over the top. Like a helpful friend explaining technology.
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎯 Prompt 4: Print Size Technical Guide
|
## 🎯 Prompt 4: Print Size Technical Guide
|
||||||
|
|
||||||
```
|
```
|
||||||
Create a technical reference article as HTML: "QR Code Print Size Guide: Minimum Dimensions for Reliable Scanning"
|
Create a technical reference article as HTML: "QR Code Print Size Guide: Minimum Dimensions for Reliable Scanning"
|
||||||
|
|
||||||
This article should become THE reference for QR code print sizes.
|
This article should become THE reference for QR code print sizes.
|
||||||
|
|
||||||
Content:
|
Content:
|
||||||
1. The science behind QR scanning (brief)
|
1. The science behind QR scanning (brief)
|
||||||
2. The golden formula: Size = Distance ÷ 10
|
2. The golden formula: Size = Distance ÷ 10
|
||||||
3. LARGE table with applications, distances, min/recommended sizes
|
3. LARGE table with applications, distances, min/recommended sizes
|
||||||
4. Factors affecting scannability:
|
4. Factors affecting scannability:
|
||||||
- Data density
|
- Data density
|
||||||
- Error Correction Level
|
- Error Correction Level
|
||||||
- Print quality (DPI)
|
- Print quality (DPI)
|
||||||
- Contrast
|
- Contrast
|
||||||
5. Quiet zone requirements
|
5. Quiet zone requirements
|
||||||
6. File formats for printing (SVG vs PNG vs PDF)
|
6. File formats for printing (SVG vs PNG vs PDF)
|
||||||
7. Checklist before printing
|
7. Checklist before printing
|
||||||
|
|
||||||
Keywords:
|
Keywords:
|
||||||
- "qr code size for printing"
|
- "qr code size for printing"
|
||||||
- "minimum qr code size"
|
- "minimum qr code size"
|
||||||
- "qr code dimensions"
|
- "qr code dimensions"
|
||||||
- "qr code print quality"
|
- "qr code print quality"
|
||||||
|
|
||||||
Link ONCE to https://www.qrmaster.net/blog/qr-code-print-size-guide as "detailed guide with more examples"
|
Link ONCE to https://www.qrmaster.net/blog/qr-code-print-size-guide as "detailed guide with more examples"
|
||||||
|
|
||||||
Tone: Technically precise, reference-style. For designers and marketers.
|
Tone: Technically precise, reference-style. For designers and marketers.
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎯 Prompt 5: QR Analytics Beginner Guide
|
## 🎯 Prompt 5: QR Analytics Beginner Guide
|
||||||
|
|
||||||
```
|
```
|
||||||
Create a beginner's guide as HTML: "QR Code Analytics Explained: What You Can Track and Why It Matters"
|
Create a beginner's guide as HTML: "QR Code Analytics Explained: What You Can Track and Why It Matters"
|
||||||
|
|
||||||
The article is aimed at marketing beginners who have never used QR tracking before.
|
The article is aimed at marketing beginners who have never used QR tracking before.
|
||||||
|
|
||||||
Structure:
|
Structure:
|
||||||
1. What is QR tracking and why is it important?
|
1. What is QR tracking and why is it important?
|
||||||
2. What data can you track? (list with explanations)
|
2. What data can you track? (list with explanations)
|
||||||
- Scan count
|
- Scan count
|
||||||
- Geolocation
|
- Geolocation
|
||||||
- Device types
|
- Device types
|
||||||
- Timestamps
|
- Timestamps
|
||||||
- Unique vs Total Scans
|
- Unique vs Total Scans
|
||||||
3. How does it work technically? (simplified)
|
3. How does it work technically? (simplified)
|
||||||
4. Privacy & GDPR considerations
|
4. Privacy & GDPR considerations
|
||||||
5. Practical application: Measuring campaign ROI
|
5. Practical application: Measuring campaign ROI
|
||||||
6. Common mistakes in QR tracking
|
6. Common mistakes in QR tracking
|
||||||
|
|
||||||
Keywords:
|
Keywords:
|
||||||
- "qr code tracking"
|
- "qr code tracking"
|
||||||
- "qr code analytics"
|
- "qr code analytics"
|
||||||
- "track qr code scans"
|
- "track qr code scans"
|
||||||
- "qr code scan data"
|
- "qr code scan data"
|
||||||
|
|
||||||
Link ONCE naturally to https://www.qrmaster.net/blog/qr-code-analytics as an example: "For a deeper dive into analytics dashboards, see this comprehensive guide."
|
Link ONCE naturally to https://www.qrmaster.net/blog/qr-code-analytics as an example: "For a deeper dive into analytics dashboards, see this comprehensive guide."
|
||||||
|
|
||||||
Tone: Friendly and explanatory, like a teacher. No jargon without explanation.
|
Tone: Friendly and explanatory, like a teacher. No jargon without explanation.
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📋 Usage Instructions
|
## 📋 Usage Instructions
|
||||||
|
|
||||||
1. **Copy prompt** → Paste into claude.ai
|
1. **Copy prompt** → Paste into claude.ai
|
||||||
2. **Let it create the artifact**
|
2. **Let it create the artifact**
|
||||||
3. **Click "Publish"** in Claude
|
3. **Click "Publish"** in Claude
|
||||||
4. **Allowed Domain:** Add `www.qrmaster.net, qrmaster.net`
|
4. **Allowed Domain:** Add `www.qrmaster.net, qrmaster.net`
|
||||||
5. **Share link** - Google indexes these!
|
5. **Share link** - Google indexes these!
|
||||||
|
|
||||||
## 💡 Tips for Maximum Effectiveness
|
## 💡 Tips for Maximum Effectiveness
|
||||||
|
|
||||||
- **Don't publish all on the same day**
|
- **Don't publish all on the same day**
|
||||||
- About **1 article per week** for natural growth
|
- About **1 article per week** for natural growth
|
||||||
- Publish the **more neutral articles first** (Prompt 2 & 4)
|
- Publish the **more neutral articles first** (Prompt 2 & 4)
|
||||||
- **Share on social media** for faster indexing
|
- **Share on social media** for faster indexing
|
||||||
- Register the published URLs in Google Search Console
|
- Register the published URLs in Google Search Console
|
||||||
|
|||||||
464
claude_plan_restaurant.md
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
Here is Claude's plan:
|
||||||
|
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
|
||||||
|
Restaurant Menu QR Code - Dashboard Integration Plan
|
||||||
|
|
||||||
|
Executive Summary
|
||||||
|
|
||||||
|
Add "Restaurant Menu" as a new QR code content type in the dashboard at /create.
|
||||||
|
Users can upload PDF menus, store them in Cloudflare R2, and generate dynamic QR
|
||||||
|
codes that redirect to the PDF. This integrates with the existing QRCode model and
|
||||||
|
tracking system.
|
||||||
|
|
||||||
|
Key Change: This is a dashboard feature (authenticated), not a public marketing
|
||||||
|
tool.
|
||||||
|
|
||||||
|
Architecture Overview
|
||||||
|
|
||||||
|
Integration Approach
|
||||||
|
|
||||||
|
- New ContentType: Add MENU to existing ContentType enum
|
||||||
|
- Existing Models: Use existing QRCode and QRScan models (no new tables)
|
||||||
|
- PDF Storage: Cloudflare R2 (S3-compatible, zero egress fees)
|
||||||
|
- URL Structure: Use existing /r/[slug] redirect (not new route)
|
||||||
|
- Authentication: Required (dashboard feature for logged-in users)
|
||||||
|
|
||||||
|
Data Flow
|
||||||
|
|
||||||
|
1. User logs in → Goes to /create → Selects "Restaurant Menu" type
|
||||||
|
2. Uploads PDF → Validate → Upload to R2 → Get public URL
|
||||||
|
3. Creates QR code with content: { pdfUrl: "...", restaurantName: "...", menuTitle:
|
||||||
|
"..." }
|
||||||
|
4. QR code redirects to: /r/[slug] → Redirect to PDF URL
|
||||||
|
5. Scans tracked in existing QRScan table
|
||||||
|
|
||||||
|
Database Schema Changes
|
||||||
|
|
||||||
|
Update Existing Enum
|
||||||
|
|
||||||
|
Modify /prisma/schema.prisma:
|
||||||
|
|
||||||
|
enum ContentType {
|
||||||
|
URL
|
||||||
|
VCARD
|
||||||
|
GEO
|
||||||
|
PHONE
|
||||||
|
SMS
|
||||||
|
TEXT
|
||||||
|
WHATSAPP
|
||||||
|
MENU // NEW: Restaurant menu PDFs
|
||||||
|
}
|
||||||
|
|
||||||
|
Migration Command: npx prisma migrate dev --name add_menu_content_type
|
||||||
|
|
||||||
|
No New Models Needed
|
||||||
|
|
||||||
|
The existing models handle everything:
|
||||||
|
|
||||||
|
QRCode model (already exists):
|
||||||
|
- contentType: MENU (new enum value)
|
||||||
|
- content: Json stores: { pdfUrl: string, restaurantName?: string, menuTitle?:
|
||||||
|
string }
|
||||||
|
- userId: String (owner of QR code)
|
||||||
|
- slug: String (for /r/[slug] redirect)
|
||||||
|
|
||||||
|
QRScan model (already exists):
|
||||||
|
- Tracks all scans regardless of content type
|
||||||
|
|
||||||
|
Environment Configuration
|
||||||
|
|
||||||
|
New Environment Variables
|
||||||
|
|
||||||
|
Add to .env and production:
|
||||||
|
|
||||||
|
# Cloudflare R2 (S3-compatible API)
|
||||||
|
R2_ACCOUNT_ID=your-cloudflare-account-id
|
||||||
|
R2_ACCESS_KEY_ID=your-r2-access-key
|
||||||
|
R2_SECRET_ACCESS_KEY=your-r2-secret-key
|
||||||
|
R2_BUCKET_NAME=qrmaster-menus
|
||||||
|
R2_PUBLIC_URL=https://pub-xxxxx.r2.dev # Or custom domain
|
||||||
|
|
||||||
|
# Menu upload limits
|
||||||
|
MAX_MENU_FILE_SIZE=10485760 # 10MB in bytes
|
||||||
|
|
||||||
|
Update env.ts
|
||||||
|
|
||||||
|
Add to /src/lib/env.ts schema:
|
||||||
|
|
||||||
|
const envSchema = z.object({
|
||||||
|
// ... existing fields ...
|
||||||
|
R2_ACCOUNT_ID: z.string().optional(),
|
||||||
|
R2_ACCESS_KEY_ID: z.string().optional(),
|
||||||
|
R2_SECRET_ACCESS_KEY: z.string().optional(),
|
||||||
|
R2_BUCKET_NAME: z.string().default('qrmaster-menus'),
|
||||||
|
R2_PUBLIC_URL: z.string().optional(),
|
||||||
|
MAX_MENU_FILE_SIZE: z.string().default('10485760'),
|
||||||
|
});
|
||||||
|
|
||||||
|
Critical Files to Modify/Create
|
||||||
|
|
||||||
|
1. R2 Client Library
|
||||||
|
|
||||||
|
File: /src/lib/r2.ts (NEW)
|
||||||
|
|
||||||
|
Purpose: Handle PDF uploads to Cloudflare R2
|
||||||
|
|
||||||
|
import { S3Client, PutObjectCommand, DeleteObjectCommand } from
|
||||||
|
'@aws-sdk/client-s3';
|
||||||
|
import { env } from './env';
|
||||||
|
|
||||||
|
const r2Client = new S3Client({
|
||||||
|
region: 'auto',
|
||||||
|
endpoint: `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: env.R2_ACCESS_KEY_ID!,
|
||||||
|
secretAccessKey: env.R2_SECRET_ACCESS_KEY!,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function uploadMenuToR2(
|
||||||
|
file: Buffer,
|
||||||
|
filename: string,
|
||||||
|
shortId: string
|
||||||
|
): Promise<string> {
|
||||||
|
const key = `menus/${shortId}.pdf`;
|
||||||
|
|
||||||
|
await r2Client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: env.R2_BUCKET_NAME,
|
||||||
|
Key: key,
|
||||||
|
Body: file,
|
||||||
|
ContentType: 'application/pdf',
|
||||||
|
ContentDisposition: `inline; filename="${filename}"`,
|
||||||
|
CacheControl: 'public, max-age=31536000',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return `${env.R2_PUBLIC_URL}/${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMenuFromR2(r2Key: string): Promise<void> {
|
||||||
|
await r2Client.send(
|
||||||
|
new DeleteObjectCommand({
|
||||||
|
Bucket: env.R2_BUCKET_NAME,
|
||||||
|
Key: r2Key,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateUniqueFilename(originalFilename: string): string {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const random = crypto.randomBytes(4).toString('hex');
|
||||||
|
const ext = originalFilename.split('.').pop();
|
||||||
|
return `menu_${timestamp}_${random}.${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
2. Upload API Endpoint
|
||||||
|
|
||||||
|
File: /src/app/api/menu/upload/route.ts (NEW)
|
||||||
|
|
||||||
|
Purpose: Handle PDF uploads from the create page
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
- Accept multipart/form-data PDF upload
|
||||||
|
- Validate file type (PDF magic bytes), size (max 10MB)
|
||||||
|
- Rate limit: 10 uploads per minute per user (authenticated)
|
||||||
|
- Upload to R2 with unique filename
|
||||||
|
- Return R2 public URL
|
||||||
|
|
||||||
|
Request: FormData { file: File }
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"pdfUrl": "https://pub-xxxxx.r2.dev/menus/menu_1234567890_abcd.pdf"
|
||||||
|
}
|
||||||
|
|
||||||
|
Key Implementation Details:
|
||||||
|
- Use request.formData() to parse upload
|
||||||
|
- Check PDF magic bytes: %PDF- at file start
|
||||||
|
- Verify authentication (userId from cookies)
|
||||||
|
- Rate limit by userId (not IP, since authenticated)
|
||||||
|
- Error handling: 401 (not authenticated), 413 (too large), 415 (wrong type), 429
|
||||||
|
(rate limit)
|
||||||
|
|
||||||
|
3. Update Redirect Route
|
||||||
|
|
||||||
|
File: /src/app/r/[slug]/route.ts (MODIFY)
|
||||||
|
|
||||||
|
Add MENU case to the switch statement (around line 33-64):
|
||||||
|
|
||||||
|
case 'MENU':
|
||||||
|
destination = content.pdfUrl || 'https://example.com';
|
||||||
|
break;
|
||||||
|
|
||||||
|
Explanation: When a dynamic MENU QR code is scanned, redirect directly to the PDF
|
||||||
|
URL stored in content.pdfUrl
|
||||||
|
|
||||||
|
4. Update Validation Schema
|
||||||
|
|
||||||
|
File: /src/lib/validationSchemas.ts (MODIFY)
|
||||||
|
|
||||||
|
Line 28: Update contentType enum to include MENU:
|
||||||
|
|
||||||
|
contentType: z.enum(['URL', 'VCARD', 'GEO', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT',
|
||||||
|
'MENU'], {
|
||||||
|
errorMap: () => ({ message: 'Invalid content type' })
|
||||||
|
}),
|
||||||
|
|
||||||
|
Line 63: Update bulk QR schema as well:
|
||||||
|
|
||||||
|
contentType: z.enum(['URL', 'VCARD', 'GEO', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT',
|
||||||
|
'MENU']),
|
||||||
|
|
||||||
|
5. Update Create Page - Add MENU Type
|
||||||
|
|
||||||
|
File: /src/app/(app)/create/page.tsx (MODIFY)
|
||||||
|
|
||||||
|
Multiple changes needed:
|
||||||
|
|
||||||
|
A. Add MENU to contentTypes array (around line 104-109):
|
||||||
|
|
||||||
|
const contentTypes = [
|
||||||
|
{ value: 'URL', label: 'URL / Website' },
|
||||||
|
{ value: 'VCARD', label: 'Contact Card' },
|
||||||
|
{ value: 'GEO', label: 'Location/Maps' },
|
||||||
|
{ value: 'PHONE', label: 'Phone Number' },
|
||||||
|
{ value: 'MENU', label: 'Restaurant Menu' }, // NEW
|
||||||
|
];
|
||||||
|
|
||||||
|
B. Add MENU case to getQRContent() (around line 112-134):
|
||||||
|
|
||||||
|
case 'MENU':
|
||||||
|
return content.pdfUrl || 'https://example.com/menu.pdf';
|
||||||
|
|
||||||
|
C. Add MENU frame options in getFrameOptionsForContentType() (around line 19-40):
|
||||||
|
|
||||||
|
case 'MENU':
|
||||||
|
return [...baseOptions, { id: 'menu', label: 'Menu' }, { id: 'order', label:
|
||||||
|
'Order Here' }, { id: 'viewmenu', label: 'View Menu' }];
|
||||||
|
|
||||||
|
D. Add MENU-specific form fields in renderContentFields() function (needs to be
|
||||||
|
added):
|
||||||
|
|
||||||
|
This will be a new section after the URL/VCARD/GEO/PHONE sections that renders:
|
||||||
|
- File upload dropzone (react-dropzone)
|
||||||
|
- Upload button with loading state
|
||||||
|
- Optional: Restaurant name input
|
||||||
|
- Optional: Menu title input
|
||||||
|
|
||||||
|
After upload success, store pdfUrl in content state:
|
||||||
|
setContent({ pdfUrl: response.pdfUrl, restaurantName: '', menuTitle: '' });
|
||||||
|
|
||||||
|
6. Update Rate Limiting
|
||||||
|
|
||||||
|
File: /src/lib/rateLimit.ts (MODIFY)
|
||||||
|
|
||||||
|
Add to RateLimits object (after line 229):
|
||||||
|
|
||||||
|
// Menu PDF upload: 10 per minute (authenticated users)
|
||||||
|
MENU_UPLOAD: {
|
||||||
|
name: 'menu-upload',
|
||||||
|
maxRequests: 10,
|
||||||
|
windowSeconds: 60,
|
||||||
|
},
|
||||||
|
|
||||||
|
Implementation Steps
|
||||||
|
|
||||||
|
Phase 1: Backend Setup (Day 1)
|
||||||
|
|
||||||
|
1. Install Dependencies
|
||||||
|
npm install @aws-sdk/client-s3 react-dropzone
|
||||||
|
2. Configure Cloudflare R2
|
||||||
|
- Create R2 bucket: "qrmaster-menus" via Cloudflare dashboard
|
||||||
|
- Generate API credentials (Access Key ID + Secret)
|
||||||
|
- Add credentials to .env and production environment
|
||||||
|
- Set bucket to public (for PDF access)
|
||||||
|
3. Database Migration
|
||||||
|
- Add MENU to ContentType enum in prisma/schema.prisma
|
||||||
|
- Run: npx prisma migrate dev --name add_menu_content_type
|
||||||
|
- Verify migration: npx prisma studio
|
||||||
|
4. Environment Configuration
|
||||||
|
- Update src/lib/env.ts with R2 variables
|
||||||
|
- Update src/lib/rateLimit.ts with MENU_UPLOAD config
|
||||||
|
5. Create R2 Client
|
||||||
|
- Create src/lib/r2.ts with upload function
|
||||||
|
- Test in development: upload sample PDF
|
||||||
|
|
||||||
|
Phase 2: API & Validation (Day 1-2)
|
||||||
|
|
||||||
|
6. Update Validation Schema (/src/lib/validationSchemas.ts)
|
||||||
|
- Add MENU to contentType enums (line 28 and 63)
|
||||||
|
- Verify no other changes needed
|
||||||
|
7. Create Upload API (/src/app/api/menu/upload/route.ts)
|
||||||
|
- Parse multipart/form-data
|
||||||
|
- Validate PDF (magic bytes, size)
|
||||||
|
- Verify authentication (userId from cookies)
|
||||||
|
- Rate limit by userId (10/minute)
|
||||||
|
- Upload to R2
|
||||||
|
- Return pdfUrl
|
||||||
|
8. Update Redirect Route (/src/app/r/[slug]/route.ts)
|
||||||
|
- Add MENU case to switch statement (line 33-64)
|
||||||
|
- Redirect to content.pdfUrl
|
||||||
|
|
||||||
|
Phase 3: Dashboard Integration (Day 2-3)
|
||||||
|
|
||||||
|
9. Update Create Page (/src/app/(app)/create/page.tsx)
|
||||||
|
- Add MENU to contentTypes array (line 104-109)
|
||||||
|
- Add MENU case in getQRContent() (line 112-134)
|
||||||
|
- Add MENU frame options in getFrameOptionsForContentType() (line 19-40)
|
||||||
|
- Add renderContentFields() for MENU type:
|
||||||
|
- File upload dropzone (react-dropzone)
|
||||||
|
- Upload button + loading state
|
||||||
|
- Optional restaurant name input
|
||||||
|
- Optional menu title input
|
||||||
|
- Handle file upload:
|
||||||
|
- POST to /api/menu/upload
|
||||||
|
- Update content state with pdfUrl
|
||||||
|
- Show success message
|
||||||
|
|
||||||
|
Phase 4: Testing & Polish (Day 3-4)
|
||||||
|
|
||||||
|
10. Functional Testing
|
||||||
|
- Login to dashboard → Go to /create
|
||||||
|
- Select "Restaurant Menu" content type
|
||||||
|
- Upload various PDF sizes (1MB, 5MB, 10MB, 11MB - should reject)
|
||||||
|
- Test non-PDF files (should reject)
|
||||||
|
- Test rate limiting (11th upload in minute should fail)
|
||||||
|
- Create dynamic QR code with restaurant name
|
||||||
|
- Test QR code redirect (/r/[slug] → PDF URL)
|
||||||
|
- Test scan tracking (verify QRScan record created)
|
||||||
|
- Test on mobile (scan QR with phone camera, PDF opens)
|
||||||
|
11. Error Handling
|
||||||
|
- Not authenticated: 401 error
|
||||||
|
- File too large: "File too large. Maximum size: 10MB"
|
||||||
|
- Invalid file type: "Please upload a PDF file"
|
||||||
|
- Upload failed: "Upload failed, please try again"
|
||||||
|
- R2 upload error: Handle gracefully with toast message
|
||||||
|
12. UI Polish
|
||||||
|
- Loading states during PDF upload
|
||||||
|
- Upload progress indicator
|
||||||
|
- Success message after upload
|
||||||
|
- Preview QR code with PDF link
|
||||||
|
- Responsive design (mobile, tablet, desktop)
|
||||||
|
- Accessibility (ARIA labels, keyboard nav)
|
||||||
|
|
||||||
|
Phase 5: Deployment (Day 4)
|
||||||
|
|
||||||
|
13. Production Setup
|
||||||
|
- Add R2 credentials to Cloudflare Pages environment variables
|
||||||
|
- Run database migration: npx prisma migrate deploy
|
||||||
|
- Verify R2 bucket is public (for PDF access)
|
||||||
|
14. Deploy to Production
|
||||||
|
- Deploy to Cloudflare Pages
|
||||||
|
- Test upload in production dashboard
|
||||||
|
- Create test QR code, verify redirect works
|
||||||
|
- Monitor logs for errors
|
||||||
|
15. Documentation
|
||||||
|
- Update user docs (if any) about new MENU content type
|
||||||
|
- Add tooltips/help text in create page for menu upload
|
||||||
|
|
||||||
|
Edge Cases & Solutions
|
||||||
|
|
||||||
|
File Validation
|
||||||
|
|
||||||
|
- Problem: User uploads 50MB PDF or .exe file
|
||||||
|
- Solution:
|
||||||
|
- Client-side validation (check file.size and file.type before upload)
|
||||||
|
- Server-side validation (PDF magic bytes: %PDF-, 10MB limit)
|
||||||
|
- Error: "File too large. Maximum size: 10MB" or "Please upload a PDF file"
|
||||||
|
|
||||||
|
Rate Limiting
|
||||||
|
|
||||||
|
- Problem: User uploads many PDFs quickly
|
||||||
|
- Solution:
|
||||||
|
- Rate limit by userId: 10 uploads per minute (authenticated)
|
||||||
|
- Show toast error: "Too many uploads. Please wait a moment."
|
||||||
|
- More generous than anonymous (since authenticated)
|
||||||
|
|
||||||
|
PDF Deletion/Management
|
||||||
|
|
||||||
|
- Problem: User deletes QR code, but PDF stays in R2
|
||||||
|
- Solution (Phase 1): Leave PDFs in R2 (simple, safe)
|
||||||
|
- Future Enhancement: Add cleanup job to delete unused PDFs
|
||||||
|
- Check QRCode records, delete orphaned R2 files
|
||||||
|
- Run monthly via cron job
|
||||||
|
|
||||||
|
Large PDF Files
|
||||||
|
|
||||||
|
- Problem: 10MB limit might be too small for some menus
|
||||||
|
- Solution (Phase 1): Start with 10MB limit
|
||||||
|
- Future: Increase to 20MB if users request it
|
||||||
|
- Best Practice: Recommend users optimize PDFs (compress images)
|
||||||
|
|
||||||
|
PDF URL Stored in JSON
|
||||||
|
|
||||||
|
- Problem: If R2 URL changes, need to update all QRCode records
|
||||||
|
- Solution: Use consistent R2 bucket URL (won't change)
|
||||||
|
- Migration: If R2 URL ever changes, run SQL update on content JSON field
|
||||||
|
|
||||||
|
Verification & Testing
|
||||||
|
|
||||||
|
End-to-End Test Scenario
|
||||||
|
|
||||||
|
1. Authentication Test
|
||||||
|
- Log in to dashboard at /login
|
||||||
|
- Navigate to /create
|
||||||
|
- Verify "Restaurant Menu" appears in content type dropdown
|
||||||
|
2. Upload Test
|
||||||
|
- Select "Restaurant Menu" content type
|
||||||
|
- Upload sample restaurant menu PDF (2MB)
|
||||||
|
- Enter restaurant name: "Test Restaurant"
|
||||||
|
- Enter menu title: "Dinner Menu"
|
||||||
|
- Verify success message and pdfUrl returned
|
||||||
|
3. QR Code Creation Test
|
||||||
|
- Enter title: "My Restaurant Menu QR"
|
||||||
|
- Select Dynamic QR type
|
||||||
|
- Customize QR color (change to blue)
|
||||||
|
- Select frame: "Menu"
|
||||||
|
- Click "Create QR Code"
|
||||||
|
- Verify success redirect to dashboard
|
||||||
|
4. Scan Test
|
||||||
|
- From dashboard, copy QR code URL: qrmaster.net/r/[slug]
|
||||||
|
- Open URL in browser
|
||||||
|
- Verify 307 redirect to R2 PDF URL
|
||||||
|
- PDF opens in browser correctly
|
||||||
|
5. Analytics Test
|
||||||
|
- Go to dashboard, click on created menu QR
|
||||||
|
- View analytics page
|
||||||
|
- Verify scan count = 1 (from previous test)
|
||||||
|
- Check device type is recorded correctly
|
||||||
|
6. Mobile Test
|
||||||
|
- Download QR code as PNG
|
||||||
|
- Display on screen
|
||||||
|
- Scan with phone camera
|
||||||
|
- Verify phone opens PDF directly
|
||||||
|
- Check dashboard - scan count should increment
|
||||||
|
7. Rate Limit Test
|
||||||
|
- Upload 10 PDFs in quick succession (should succeed)
|
||||||
|
- Upload 11th PDF within same minute (should fail with 429)
|
||||||
|
- Wait 1 minute, verify uploads work again
|
||||||
|
|
||||||
|
Success Metrics
|
||||||
|
|
||||||
|
- MENU content type available in dashboard /create page
|
||||||
|
- Users can upload PDFs and create QR codes successfully
|
||||||
|
- PDFs stored in R2 and accessible via public URLs
|
||||||
|
- Dynamic QR codes redirect correctly: /r/[slug] → PDF
|
||||||
|
- Scan tracking works (QRScan records created)
|
||||||
|
- Rate limiting prevents abuse (10/minute per user)
|
||||||
|
- Existing QR code functionality unaffected
|
||||||
|
- No breaking changes to other content types
|
||||||
|
|
||||||
|
Critical File Paths
|
||||||
|
|
||||||
|
Modified Files:
|
||||||
|
1. /prisma/schema.prisma - Add MENU to ContentType enum
|
||||||
|
2. /src/lib/validationSchemas.ts - Add MENU to contentType enums (lines 28, 63)
|
||||||
|
3. /src/app/(app)/create/page.tsx - Add MENU UI and logic
|
||||||
|
4. /src/app/r/[slug]/route.ts - Add MENU redirect case
|
||||||
|
5. /src/lib/env.ts - Add R2 environment variables
|
||||||
|
6. /src/lib/rateLimit.ts - Add MENU_UPLOAD rate limit
|
||||||
|
|
||||||
|
New Files:
|
||||||
|
7. /src/lib/r2.ts - R2 client library for PDF uploads
|
||||||
|
8. /src/app/api/menu/upload/route.ts - PDF upload API endpoint
|
||||||
97
growth_strategies.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# 🚀 Kostenlose Wachstums- & Backlink-Strategien (High DA)
|
||||||
|
|
||||||
|
Hier ist ein Plan, um schnell Domain Authority (DA) aufzubauen und kostenlose Reichweite zu generieren, basierend auf deinem aktuellen Status.
|
||||||
|
|
||||||
|
## 1. "Parasite SEO" & High DA Content Platforms
|
||||||
|
Nutze die extrem hohe Domain Authority dieser Plattformen, um für Keywords zu ranken und starke Backlinks zu erhalten. "Cloud Parasites" ist ein guter Start, hier sind weitere:
|
||||||
|
|
||||||
|
* **LinkedIn Pulse (Artikel)**:
|
||||||
|
* Schreibe Artikel wie *"Warum 90% der QR Codes falsch genutzt werden"* oder *"QR Code Tracking Guide 2026"*.
|
||||||
|
* Nutze LinkedIn's eigene SEO-Power. Am Ende immer auf dein Tool verlinken.
|
||||||
|
* *DA: 99*
|
||||||
|
* **Medium**:
|
||||||
|
* Importiere deine Blogposts (nutze den "Import" Button für Canonical Tags, damit du dich nicht selbst kannibalisierst, oder schreibe Zusammenfassungen).
|
||||||
|
* Publiziere in "Publications" (z.B. Marketing-fokussierte Pubs).
|
||||||
|
* *DA: 95*
|
||||||
|
* **Dev.to & Hashnode**:
|
||||||
|
* Da du schon auf `web.dev` (vermutlich Dev Community) bist: Veröffentliche technische "How-To" Artikel.
|
||||||
|
* Thema: "How to build a QR Code Generator with Next.js" (und verlinke auf QR Master als die "Pro-Lösung").
|
||||||
|
* *DA: 91 / 88*
|
||||||
|
* **GitHub Repository**:
|
||||||
|
* Erstelle ein Repository mit einer kuratierten Liste ("Awesome QR Code Tools") oder einem kleinen Open-Source-Skript.
|
||||||
|
* Die `README.md` ist ein sehr starker Backlink.
|
||||||
|
* *DA: 96*
|
||||||
|
* **NPM Package**:
|
||||||
|
* Veröffentliche ein kleines Wrapper-Package. In der Beschreibung auf deine Seite verlinken.
|
||||||
|
* *DA: 96*
|
||||||
|
|
||||||
|
## 2. Nischen-Communities & "Problem Solving"
|
||||||
|
Gehe weg von reinen "SaaS-Gründer" Communities hin zu den **Endnutzern**.
|
||||||
|
|
||||||
|
* **Reddit (Laser-Fokus)**:
|
||||||
|
* Suche nicht nur in r/SaaS. Suche in:
|
||||||
|
* `r/WeddingPlanning`: "Wie mache ich einen QR Code für Einladungen?"
|
||||||
|
* `r/Restaurateur`: "Digitale Speisekarten Lösungen?"
|
||||||
|
* `r/RealEstate`: "QR Codes auf Flyern?"
|
||||||
|
* Sei hilfreich, erwähne dein Tool nur organisch ("Ich habe dafür ein Tool gebaut...").
|
||||||
|
* **Quora**:
|
||||||
|
* Beantworte Fragen wie *"Best free dynamic QR code generator?"*.
|
||||||
|
* Schreibe ausführliche Antworten, nicht nur Links.
|
||||||
|
* **Pinterest (Visuelle Suche)**:
|
||||||
|
* QR Codes sind visuell. Erstelle Pins mit "Creative QR Code Ideas", "Wedding QR Codes", "Restaurant Menu QR Styles".
|
||||||
|
* Verlinke jeden Pin auf deine Landing Page. Pinterest ist eine starke Traffic-Maschine für visuelle Themen.
|
||||||
|
|
||||||
|
## 3. Side Project Marketing (Engineering as Marketing)
|
||||||
|
Erstelle kleine, kostenlose Tools, die als "Lead Magnet" dienen.
|
||||||
|
|
||||||
|
* **Spezialisierte Generatoren**:
|
||||||
|
* Erstelle Landingpages für spezifische Use-Cases: *"Kostenloser WiFi QR Code Generator"*, *"VCard QR Erstellen"*.
|
||||||
|
* Diese Keywords sind einfacher zu ranken als "QR Code Generator".
|
||||||
|
* **Kostenlose Tools Verzeichnisse**:
|
||||||
|
* Es gibt Verzeichnisse nur für kostenlose Tools (z.B. "Tiny Tools", "Free for Dev").
|
||||||
|
|
||||||
|
## 4. Design & CSS Galleries (Für "Premium" Look)
|
||||||
|
Da dein Design "Premium" und hochwertig ist, reiche deine Seite bei Design-Gallerien ein. Das sind oft Do-Follow Backlinks von Design-Seiten.
|
||||||
|
|
||||||
|
* **One Page Love** (Sehr hochwertig)
|
||||||
|
* **SiteInspire**
|
||||||
|
* **Lapa Ninja**
|
||||||
|
* **Godly Website**
|
||||||
|
* *Hinweis: Manche kosten eine kleine Gebühr für schnelle Prüfung, aber oft gibt es Free Submissions.*
|
||||||
|
|
||||||
|
## 5. Cold Outreach (Backlink Sniping)
|
||||||
|
* **"Best of" Listen**:
|
||||||
|
* Suche bei Google nach *"Best QR Code Generators 2025"*.
|
||||||
|
* Schreibe die Autoren der Top 10 Artikel an.
|
||||||
|
* Pitch: *"Hey, cooler Artikel. Mein Tool QR Master ist neu und hat Feature X (z.B. bessere Analytics), das den anderen fehlt. Würde gut in deine Liste passen."*
|
||||||
|
* **Broken Link Building**:
|
||||||
|
* Suche nach Artikeln, die auf tote QR-Code-Tools verlinken (es gibt viele alte Tools, die offline gegangen sind).
|
||||||
|
* Schreibe den Webmaster an: *"Hey, der Link zu Tool X geht nicht mehr. Mein Tool ist eine super Alternative."*
|
||||||
|
|
||||||
|
## 6. Social Media "Content Repurposing"
|
||||||
|
Mach aus einem Content-Stück 10.
|
||||||
|
|
||||||
|
* **Twitter/X Threads**: "Wie QR Codes dein Marketing ruinieren können (und wie man es richtig macht)".
|
||||||
|
* **LinkedIn Carousels**: PDF-Slider mit "5 Fehler bei QR Codes".
|
||||||
|
* **Shorts/Reels**: Zeige den Screen, wie schnell man einen QR Code erstellt. Visuell & schnell.
|
||||||
|
|
||||||
|
## 7. Verzeichnisse (Checkliste)
|
||||||
|
Falls noch nicht erledigt (neben Product Hunt):
|
||||||
|
|
||||||
|
* [ ] **Indie Hackers** (Product Page)
|
||||||
|
* [ ] **Hacker News** ("Show HN")
|
||||||
|
* [ ] **Betalist**
|
||||||
|
* [ ] **10words**
|
||||||
|
* [ ] **Microlaunch**
|
||||||
|
* [ ] **Peerlist**
|
||||||
|
* [ ] **AlternativeTo** (Als Alternative zu "QR Code Monkey" vorschlagen)
|
||||||
|
|
||||||
|
## 8. Technical SEO & Lighthouse
|
||||||
|
Du erwähntest, Lighthouse soll 100 sein.
|
||||||
|
* **Performance**: Bilder optimieren (WebP/AVIF), Lazy Loading.
|
||||||
|
* **Accessiblity**: `aria-labels` für alle Buttons, Kontraste prüfen.
|
||||||
|
* **SEO**: `meta description`, `title` tags, `canonical` URLs, `sitemap.xml`.
|
||||||
|
* **Schema Markup**: Füge `SoftwareApplication` JSON-LD Schema auf deiner Homepage hinzu. Das hilft Google, dich als Software-Tool zu verstehen.
|
||||||
|
|
||||||
|
---
|
||||||
|
**Sofort-Maßnahme**: Erstelle heute einen **LinkedIn Pulse Artikel** und einen **GitHub Account/Repo** für dein Projekt. Das sind 2 High-DA Backlinks garantiert.
|
||||||
41
ideen.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
🚀 Neue Content-Typen
|
||||||
|
Feature Beschreibung
|
||||||
|
WiFi QR SSID, Passwort, Verschlüsselungstyp – perfekt für Cafés/Hotels
|
||||||
|
Event (VEVENT) Kalendereinträge direkt ins Handy importieren
|
||||||
|
App Store Links Smart-Links die iOS/Android erkennen
|
||||||
|
PayPal/Bitcoin Zahlungsaufforderungen per QR
|
||||||
|
WhatsApp/Telegram Direkt-Chat mit vordefinierter Nachricht
|
||||||
|
📊 Analytics-Erweiterungen
|
||||||
|
Feature Beschreibung
|
||||||
|
UTM-Parameter Automatische Kampagnen-Tags für Google Analytics
|
||||||
|
Conversion Tracking Ziel-URLs definieren und Conversion messen
|
||||||
|
A/B Testing Zwei Ziel-URLs testen, welche besser performt
|
||||||
|
Scheduled Reports Wöchentliche/monatliche E-Mail-Reports
|
||||||
|
Export (CSV/PDF) Analytics-Daten exportieren
|
||||||
|
🎨 QR Design & Styling
|
||||||
|
Feature Beschreibung
|
||||||
|
Design Templates Vorgefertigte Farb-/Logo-Kombinationen
|
||||||
|
Frames & CTA "Scan me!" Rahmen um den QR Code
|
||||||
|
Dot Styles Runde Punkte, Diamanten, etc.
|
||||||
|
Eye Shapes Custom Corner-Marker Designs
|
||||||
|
Gradient Colors Farbverläufe statt Vollfarben
|
||||||
|
🗂️ Organisation & Teamwork
|
||||||
|
Feature Beschreibung
|
||||||
|
Folders/Projekte QR Codes in Ordner organisieren
|
||||||
|
Tags & Filter Flexibles Tagging-System
|
||||||
|
Team Workspaces Mehrere User pro Account (BUSINESS)
|
||||||
|
Activity Log Wer hat was wann geändert
|
||||||
|
QR Code Archiv Soft-Delete statt Löschen
|
||||||
|
⚙️ Pro Features
|
||||||
|
Feature Beschreibung
|
||||||
|
Passwortschutz QR führt zu Passwort-geschützter Seite
|
||||||
|
Ablaufdatum QR Code deaktiviert sich automatisch
|
||||||
|
Scan-Limit Max. X Scans erlauben
|
||||||
|
Geo-Targeting Verschiedene URLs je nach Standort
|
||||||
|
Device Detection Desktop vs. Mobile unterschiedliche URLs
|
||||||
|
🔌 Integrationen
|
||||||
|
Feature Beschreibung
|
||||||
|
Zapier/Make Webhooks bei Scans triggern
|
||||||
|
Google Sheets Scan-Daten automatisch exportieren
|
||||||
|
Slack Notifications Benachrichtigung bei X Scans
|
||||||
|
API für Entwickler Public API mit Token-Auth
|
||||||
@@ -1,42 +1,42 @@
|
|||||||
/** @type {import('next-sitemap').IConfig} */
|
/** @type {import('next-sitemap').IConfig} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
siteUrl: 'https://www.qrmaster.net',
|
siteUrl: 'https://www.qrmaster.net',
|
||||||
generateRobotsTxt: true,
|
generateRobotsTxt: true,
|
||||||
robotsTxtOptions: {
|
robotsTxtOptions: {
|
||||||
policies: [
|
policies: [
|
||||||
{
|
{
|
||||||
userAgent: '*',
|
userAgent: '*',
|
||||||
allow: '/',
|
allow: '/',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
transform: async (config, path) => {
|
transform: async (config, path) => {
|
||||||
// Custom priority and changefreq based on path
|
// Custom priority and changefreq based on path
|
||||||
let priority = 0.7;
|
let priority = 0.7;
|
||||||
let changefreq = 'weekly';
|
let changefreq = 'weekly';
|
||||||
|
|
||||||
if (path === '/') {
|
if (path === '/') {
|
||||||
priority = 0.9;
|
priority = 0.9;
|
||||||
changefreq = 'daily';
|
changefreq = 'daily';
|
||||||
} else if (path === '/blog') {
|
} else if (path === '/blog') {
|
||||||
priority = 0.7;
|
priority = 0.7;
|
||||||
changefreq = 'daily';
|
changefreq = 'daily';
|
||||||
} else if (path === '/pricing') {
|
} else if (path === '/pricing') {
|
||||||
priority = 0.8;
|
priority = 0.8;
|
||||||
changefreq = 'weekly';
|
changefreq = 'weekly';
|
||||||
} else if (path === '/faq') {
|
} else if (path === '/faq') {
|
||||||
priority = 0.6;
|
priority = 0.6;
|
||||||
changefreq = 'weekly';
|
changefreq = 'weekly';
|
||||||
} else if (path.startsWith('/blog/')) {
|
} else if (path.startsWith('/blog/')) {
|
||||||
priority = 0.6;
|
priority = 0.6;
|
||||||
changefreq = 'weekly';
|
changefreq = 'weekly';
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loc: path,
|
loc: path,
|
||||||
changefreq,
|
changefreq,
|
||||||
priority,
|
priority,
|
||||||
lastmod: new Date().toISOString(),
|
lastmod: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
skipTrailingSlashRedirect: true,
|
skipTrailingSlashRedirect: true,
|
||||||
images: {
|
images: {
|
||||||
unoptimized: false,
|
unoptimized: false,
|
||||||
domains: ['www.qrmaster.net', 'qrmaster.net', 'images.qrmaster.net'],
|
domains: ['www.qrmaster.net', 'qrmaster.net', 'images.qrmaster.net'],
|
||||||
formats: ['image/webp', 'image/avif'],
|
formats: ['image/webp', 'image/avif'],
|
||||||
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
||||||
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
|
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
|
||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
serverComponentsExternalPackages: ['@prisma/client', 'bcryptjs'],
|
serverComponentsExternalPackages: ['@prisma/client', 'bcryptjs'],
|
||||||
},
|
},
|
||||||
// Allow build to succeed even with prerender errors
|
// Allow build to succeed even with prerender errors
|
||||||
// Pages with useSearchParams() will be rendered dynamically at runtime
|
// Pages with useSearchParams() will be rendered dynamically at runtime
|
||||||
staticPageGenerationTimeout: 120,
|
staticPageGenerationTimeout: 120,
|
||||||
onDemandEntries: {
|
onDemandEntries: {
|
||||||
maxInactiveAge: 25 * 1000,
|
maxInactiveAge: 25 * 1000,
|
||||||
pagesBufferLength: 2,
|
pagesBufferLength: 2,
|
||||||
},
|
},
|
||||||
poweredByHeader: false,
|
poweredByHeader: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
1752
package-lock.json
generated
@@ -26,6 +26,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^2.11.1",
|
"@auth/prisma-adapter": "^2.11.1",
|
||||||
|
"@aws-sdk/client-s3": "^3.972.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.972.0",
|
||||||
"@edge-runtime/cookies": "^6.0.0",
|
"@edge-runtime/cookies": "^6.0.0",
|
||||||
"@prisma/client": "^5.7.0",
|
"@prisma/client": "^5.7.0",
|
||||||
"@stripe/stripe-js": "^8.0.0",
|
"@stripe/stripe-js": "^8.0.0",
|
||||||
@@ -37,6 +39,8 @@
|
|||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
|
"framer-motion": "^12.24.10",
|
||||||
|
"html-to-image": "^1.11.13",
|
||||||
"i18next": "^23.7.6",
|
"i18next": "^23.7.6",
|
||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
|||||||
@@ -1,164 +1,168 @@
|
|||||||
// This is your Prisma schema file,
|
// This is your Prisma schema file,
|
||||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
binaryTargets = ["native", "debian-openssl-3.0.x"]
|
binaryTargets = ["native", "debian-openssl-3.0.x"]
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
name String?
|
name String?
|
||||||
password String?
|
password String?
|
||||||
image String?
|
image String?
|
||||||
emailVerified DateTime?
|
emailVerified DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// Stripe subscription fields
|
// Stripe subscription fields
|
||||||
stripeCustomerId String? @unique
|
stripeCustomerId String? @unique
|
||||||
stripeSubscriptionId String? @unique
|
stripeSubscriptionId String? @unique
|
||||||
stripePriceId String?
|
stripePriceId String?
|
||||||
stripeCurrentPeriodEnd DateTime?
|
stripeCurrentPeriodEnd DateTime?
|
||||||
plan Plan @default(FREE)
|
plan Plan @default(FREE)
|
||||||
|
|
||||||
// Password reset fields
|
// Password reset fields
|
||||||
resetPasswordToken String? @unique
|
resetPasswordToken String? @unique
|
||||||
resetPasswordExpires DateTime?
|
resetPasswordExpires DateTime?
|
||||||
|
|
||||||
qrCodes QRCode[]
|
qrCodes QRCode[]
|
||||||
integrations Integration[]
|
integrations Integration[]
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Plan {
|
enum Plan {
|
||||||
FREE
|
FREE
|
||||||
PRO
|
PRO
|
||||||
BUSINESS
|
BUSINESS
|
||||||
}
|
}
|
||||||
|
|
||||||
model Account {
|
model Account {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
type String
|
type String
|
||||||
provider String
|
provider String
|
||||||
providerAccountId String
|
providerAccountId String
|
||||||
refresh_token String? @db.Text
|
refresh_token String? @db.Text
|
||||||
access_token String? @db.Text
|
access_token String? @db.Text
|
||||||
expires_at Int?
|
expires_at Int?
|
||||||
token_type String?
|
token_type String?
|
||||||
scope String?
|
scope String?
|
||||||
id_token String? @db.Text
|
id_token String? @db.Text
|
||||||
session_state String?
|
session_state String?
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([provider, providerAccountId])
|
@@unique([provider, providerAccountId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Session {
|
model Session {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
sessionToken String @unique
|
sessionToken String @unique
|
||||||
userId String
|
userId String
|
||||||
expires DateTime
|
expires DateTime
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
model VerificationToken {
|
model VerificationToken {
|
||||||
identifier String
|
identifier String
|
||||||
token String @unique
|
token String @unique
|
||||||
expires DateTime
|
expires DateTime
|
||||||
|
|
||||||
@@unique([identifier, token])
|
@@unique([identifier, token])
|
||||||
}
|
}
|
||||||
|
|
||||||
model QRCode {
|
model QRCode {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
title String
|
title String
|
||||||
type QRType @default(DYNAMIC)
|
type QRType @default(DYNAMIC)
|
||||||
contentType ContentType @default(URL)
|
contentType ContentType @default(URL)
|
||||||
content Json
|
content Json
|
||||||
tags String[]
|
tags String[]
|
||||||
status QRStatus @default(ACTIVE)
|
status QRStatus @default(ACTIVE)
|
||||||
style Json
|
style Json
|
||||||
slug String @unique
|
slug String @unique
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
scans QRScan[]
|
scans QRScan[]
|
||||||
|
|
||||||
@@index([userId, createdAt])
|
@@index([userId, createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
enum QRType {
|
enum QRType {
|
||||||
STATIC
|
STATIC
|
||||||
DYNAMIC
|
DYNAMIC
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ContentType {
|
enum ContentType {
|
||||||
URL
|
URL
|
||||||
VCARD
|
VCARD
|
||||||
GEO
|
GEO
|
||||||
PHONE
|
PHONE
|
||||||
SMS
|
SMS
|
||||||
TEXT
|
TEXT
|
||||||
WHATSAPP
|
WHATSAPP
|
||||||
}
|
PDF
|
||||||
|
APP
|
||||||
enum QRStatus {
|
COUPON
|
||||||
ACTIVE
|
FEEDBACK
|
||||||
PAUSED
|
}
|
||||||
}
|
|
||||||
|
enum QRStatus {
|
||||||
model QRScan {
|
ACTIVE
|
||||||
id String @id @default(cuid())
|
PAUSED
|
||||||
qrId String
|
}
|
||||||
ts DateTime @default(now())
|
|
||||||
ipHash String
|
model QRScan {
|
||||||
userAgent String?
|
id String @id @default(cuid())
|
||||||
device String?
|
qrId String
|
||||||
os String?
|
ts DateTime @default(now())
|
||||||
country String?
|
ipHash String
|
||||||
referrer String?
|
userAgent String?
|
||||||
utmSource String?
|
device String?
|
||||||
utmMedium String?
|
os String?
|
||||||
utmCampaign String?
|
country String?
|
||||||
isUnique Boolean @default(false)
|
referrer String?
|
||||||
|
utmSource String?
|
||||||
qr QRCode @relation(fields: [qrId], references: [id], onDelete: Cascade)
|
utmMedium String?
|
||||||
|
utmCampaign String?
|
||||||
@@index([qrId, ts])
|
isUnique Boolean @default(false)
|
||||||
}
|
|
||||||
|
qr QRCode @relation(fields: [qrId], references: [id], onDelete: Cascade)
|
||||||
model Integration {
|
|
||||||
id String @id @default(cuid())
|
@@index([qrId, ts])
|
||||||
userId String
|
}
|
||||||
provider String
|
|
||||||
status String @default("inactive")
|
model Integration {
|
||||||
config Json
|
id String @id @default(cuid())
|
||||||
createdAt DateTime @default(now())
|
userId String
|
||||||
updatedAt DateTime @updatedAt
|
provider String
|
||||||
|
status String @default("inactive")
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
config Json
|
||||||
}
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
model NewsletterSubscription {
|
|
||||||
id String @id @default(cuid())
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
email String @unique
|
}
|
||||||
source String @default("ai-coming-soon")
|
|
||||||
status String @default("subscribed")
|
model NewsletterSubscription {
|
||||||
createdAt DateTime @default(now())
|
id String @id @default(cuid())
|
||||||
updatedAt DateTime @updatedAt
|
email String @unique
|
||||||
|
source String @default("ai-coming-soon")
|
||||||
@@index([email])
|
status String @default("subscribed")
|
||||||
@@index([createdAt])
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([email])
|
||||||
|
@@index([createdAt])
|
||||||
}
|
}
|
||||||
BIN
public/hero-fluid.png
Normal file
|
After Width: | Height: | Size: 518 KiB |
BIN
public/og-crypto-generator.png
Normal file
|
After Width: | Height: | Size: 531 KiB |
BIN
public/og-email-generator.png
Normal file
|
After Width: | Height: | Size: 496 KiB |
BIN
public/og-event-generator.png
Normal file
|
After Width: | Height: | Size: 593 KiB |
BIN
public/og-facebook-generator.png
Normal file
|
After Width: | Height: | Size: 583 KiB |
BIN
public/og-geolocation-generator.png
Normal file
|
After Width: | Height: | Size: 511 KiB |
BIN
public/og-instagram-generator.png
Normal file
|
After Width: | Height: | Size: 448 KiB |
BIN
public/og-location-generator.png
Normal file
|
After Width: | Height: | Size: 511 KiB |
BIN
public/og-paypal-generator.png
Normal file
|
After Width: | Height: | Size: 466 KiB |
BIN
public/og-phone-generator.png
Normal file
|
After Width: | Height: | Size: 498 KiB |
BIN
public/og-sms-generator.png
Normal file
|
After Width: | Height: | Size: 442 KiB |
BIN
public/og-teams-generator.png
Normal file
|
After Width: | Height: | Size: 486 KiB |
BIN
public/og-text-generator.png
Normal file
|
After Width: | Height: | Size: 462 KiB |
BIN
public/og-tiktok-generator.png
Normal file
|
After Width: | Height: | Size: 397 KiB |
BIN
public/og-twitter-generator.png
Normal file
|
After Width: | Height: | Size: 335 KiB |
BIN
public/og-url-generator.png
Normal file
|
After Width: | Height: | Size: 535 KiB |
BIN
public/og-vcard-generator.png
Normal file
|
After Width: | Height: | Size: 555 KiB |
BIN
public/og-whatsapp-generator.png
Normal file
|
After Width: | Height: | Size: 600 KiB |
BIN
public/og-wifi-generator.png
Normal file
|
After Width: | Height: | Size: 429 KiB |
BIN
public/og-youtube-generator.png
Normal file
|
After Width: | Height: | Size: 392 KiB |
BIN
public/og-zoom-generator.png
Normal file
|
After Width: | Height: | Size: 551 KiB |
19
public/robots.txt
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# QR Master - robots.txt
|
||||||
|
# Allow all search engines to crawl all pages
|
||||||
|
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
# Sitemap location
|
||||||
|
Sitemap: https://www.qrmaster.net/sitemap.xml
|
||||||
|
|
||||||
|
# Crawl-delay (optional, be nice to servers)
|
||||||
|
Crawl-delay: 1
|
||||||
|
|
||||||
|
# Disallow admin/api routes
|
||||||
|
Disallow: /api/
|
||||||
|
Disallow: /dashboard/
|
||||||
|
Disallow: /_next/
|
||||||
|
|
||||||
|
# Allow all free tools explicitly
|
||||||
|
Allow: /tools/
|
||||||
@@ -1,33 +1,33 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
<url>
|
<url>
|
||||||
<loc>https://www.qrmaster.net/</loc>
|
<loc>https://www.qrmaster.net/</loc>
|
||||||
<lastmod>2025-10-16T00:00:00Z</lastmod>
|
<lastmod>2025-10-16T00:00:00Z</lastmod>
|
||||||
<changefreq>daily</changefreq>
|
<changefreq>daily</changefreq>
|
||||||
<priority>0.9</priority>
|
<priority>0.9</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://www.qrmaster.net/blog</loc>
|
<loc>https://www.qrmaster.net/blog</loc>
|
||||||
<lastmod>2025-10-16T00:00:00Z</lastmod>
|
<lastmod>2025-10-16T00:00:00Z</lastmod>
|
||||||
<changefreq>daily</changefreq>
|
<changefreq>daily</changefreq>
|
||||||
<priority>0.7</priority>
|
<priority>0.7</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://www.qrmaster.net/pricing</loc>
|
<loc>https://www.qrmaster.net/pricing</loc>
|
||||||
<lastmod>2025-10-16T00:00:00Z</lastmod>
|
<lastmod>2025-10-16T00:00:00Z</lastmod>
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.8</priority>
|
<priority>0.8</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://www.qrmaster.net/faq</loc>
|
<loc>https://www.qrmaster.net/faq</loc>
|
||||||
<lastmod>2025-10-16T00:00:00Z</lastmod>
|
<lastmod>2025-10-16T00:00:00Z</lastmod>
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.6</priority>
|
<priority>0.6</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://www.qrmaster.net/blog/qr-code-analytics</loc>
|
<loc>https://www.qrmaster.net/blog/qr-code-analytics</loc>
|
||||||
<lastmod>2025-10-16T00:00:00Z</lastmod>
|
<lastmod>2025-10-16T00:00:00Z</lastmod>
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.6</priority>
|
<priority>0.6</priority>
|
||||||
</url>
|
</url>
|
||||||
</urlset>
|
</urlset>
|
||||||
|
|||||||
156
seo_2026_jan.md
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
SEO Opportunity Report & Implementation Plan (Jan 2026)
|
||||||
|
1. Executive Summary
|
||||||
|
An analysis of the provided Google Keyword Planner data (Jan 22, 2026) reveals significant low-competition, high-volume traffic opportunities that were previously untapped. We have immediately capitalized on the Barcode opportunity and have a clear path to capture Custom QR intent.
|
||||||
|
|
||||||
|
2. Key Data Findings ("Hidden Gems")
|
||||||
|
We identified three specific clusters where search volume is high but competition is exceptionally low.
|
||||||
|
|
||||||
|
A. The "QR Barcode" Anomaly (Gold Mine) 🏆
|
||||||
|
Users are confused about the terminology, searching for "qr barcode" or "bar code generator" instead of just "barcode".
|
||||||
|
|
||||||
|
Keywords: qr barcode, bar code generator, scan code generator
|
||||||
|
Volume: 10k – 100k (High)
|
||||||
|
Competition: Low / Medium
|
||||||
|
Opportunity: Most competitors optimize for "Barcode Generator". By targeting the "wrong" terms users actually type, we can win easy traffic.
|
||||||
|
B. The "Free" Intent
|
||||||
|
High volume, but users are specifically looking for "free" and "no signup".
|
||||||
|
|
||||||
|
Keyword: free qr code generator (100k – 1M)
|
||||||
|
Keyword: qr code generator free (100k – 1M)
|
||||||
|
Opportunity: Aggressive targeting of these exact match phrases on the homepage metadata.
|
||||||
|
C. The "Custom" Gap
|
||||||
|
Users want customization but don't always use the term "design".
|
||||||
|
|
||||||
|
Keyword: custom qr code generator
|
||||||
|
Volume: 1k – 10k
|
||||||
|
Competition: Low
|
||||||
|
Current Status: MISSING. We do not have a dedicated landing page for this high-intent cluster.
|
||||||
|
3. Actions Already Implemented ✅
|
||||||
|
We have immediately updated the metadata to capture the traffic identified in findings A and B.
|
||||||
|
|
||||||
|
1. Barcode Generator Optimization
|
||||||
|
File:
|
||||||
|
src/app/(marketing)/tools/barcode-generator/page.tsx
|
||||||
|
|
||||||
|
Action: Updated <title> and meta description.
|
||||||
|
New Target: "QR Barcode" and "Bar Code Generator".
|
||||||
|
Why: To capture the 100k+ users searching for these specific variants.
|
||||||
|
2. Homepage Optimization
|
||||||
|
File:
|
||||||
|
src/app/(marketing)/page.tsx
|
||||||
|
|
||||||
|
Action: Injected high-volume keyword tags.
|
||||||
|
New Target: qr generator, free qr code generator, custom qr code generator.
|
||||||
|
Why: To signal relevance to Google for the broadest "head terms".
|
||||||
|
4. Implementation Plan: "Custom QR Code" Landing Page 🚀
|
||||||
|
To capture the 1k–10k/month users searching for "custom qr code generator" (Finding C), we need a dedicated landing page. This page will focus on design features (colors, logos, frames) rather than just "generating" a code.
|
||||||
|
|
||||||
|
Phase 1: Page Structure (New File)
|
||||||
|
Path: src/app/(marketing)/tools/custom-qr-code-generator/page.tsx
|
||||||
|
|
||||||
|
Content Strategy:
|
||||||
|
|
||||||
|
H1: "Free Custom QR Code Generator with Logo & Colors"
|
||||||
|
Hero: Visual emphasis on beautiful codes, not black-and-white ones.
|
||||||
|
Live Editor Demo: (Reuse existing QRCodeGeneratorClient but pre-opened "Design" tab).
|
||||||
|
Features Section:
|
||||||
|
"Add Your Logo"
|
||||||
|
"Custom Colors & Gradients"
|
||||||
|
"Unique Frames & Shapes"
|
||||||
|
FAQ: Targeting specific "custom" questions (vector formats, scanning reliability).
|
||||||
|
Phase 2: Internal Linking
|
||||||
|
Add link to /tools/custom-qr-code-generator in the Footer and Main Navigation under "Tools".
|
||||||
|
Link from Homepage "Features" section.
|
||||||
|
Phase 3: Metadata Strategy
|
||||||
|
title: 'Custom QR Code Generator – Add Logo, Colors & Frames',
|
||||||
|
description: 'Create unique custom QR codes with your logo, colors, and frames. Free online customizer with high-quality vector download (SVG/PNG).',
|
||||||
|
keywords: ['custom qr code generator', 'qr code with logo', 'qr code design', 'creative qr code', 'branded qr code']
|
||||||
|
5. Next Steps
|
||||||
|
Approval: Confirm this plan to build the custom-qr-code-generator page.
|
||||||
|
Execution: I will create the directory and page file.
|
||||||
|
Verify: Check that the new page renders and is linked correctly.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
new findings
|
||||||
|
|
||||||
|
Das ist super, dass du die Dateien hochgeladen hast! Denn: Du hast die Daten bereits.
|
||||||
|
|
||||||
|
Ich habe deine Excel-Listen analysiert und das Problem gefunden: In deiner Ansicht im Tool hieß die Spalte vielleicht anders oder war versteckt, aber in der Datei heißt sie Avg. monthly searches. Das ist exakt das Suchvolumen, das du gesucht hast.
|
||||||
|
|
||||||
|
Hier sind die echten "Schätze" direkt aus deiner Datei, gefiltert nach meiner Strategie:
|
||||||
|
|
||||||
|
1. Die "Low Hanging Fruits" (Viel Traffic, fast 0 Konkurrenz)
|
||||||
|
Das sind die Keywords, bei denen du SOFORT ranken kannst, weil die großen Player schlafen.
|
||||||
|
|
||||||
|
call qr code
|
||||||
|
|
||||||
|
Suchvolumen: 50.000 (!)
|
||||||
|
|
||||||
|
Wettbewerb-Index: 0 (Das ist extrem selten! Absolut gar keine Konkurrenz auf Google Ads).
|
||||||
|
|
||||||
|
Idee: Mach eine Seite "Telefon-QR-Code erstellen". Leute wollen, dass beim Scannen direkt eine Nummer gewählt wird. Das ist eine riesige Nische.
|
||||||
|
|
||||||
|
generate website qr code
|
||||||
|
|
||||||
|
Suchvolumen: 50.000
|
||||||
|
|
||||||
|
Wettbewerb-Index: 17 (Sehr gering).
|
||||||
|
|
||||||
|
CPC: 6,16 € (Das ist der Jackpot: Viel Traffic, wenig Konkurrenz UND hoher Wert).
|
||||||
|
|
||||||
|
bar code generator
|
||||||
|
|
||||||
|
Suchvolumen: 500.000
|
||||||
|
|
||||||
|
Wettbewerb-Index: 3 (Fast nichts).
|
||||||
|
|
||||||
|
Bestätigt meine frühere Analyse: Das ist dein Traffic-Bringer.
|
||||||
|
|
||||||
|
2. Die "Goldminen" (Hoher Klickpreis / High Ticket)
|
||||||
|
Hier zahlen Firmen richtig Geld. Wenn du hier rankst, sind die Nutzer sehr wertvoll.
|
||||||
|
|
||||||
|
manage qr codes
|
||||||
|
|
||||||
|
CPC (Gebot): 18,77 €
|
||||||
|
|
||||||
|
Das suchen Firmen, die viele Codes verwalten müssen (Agenturen, Großkunden). Perfekt für dein "Pro"-Abo.
|
||||||
|
|
||||||
|
dynamic code generator
|
||||||
|
|
||||||
|
CPC: 15,62 €
|
||||||
|
|
||||||
|
Das Kern-Keyword für dein Business-Modell.
|
||||||
|
|
||||||
|
qr tracking
|
||||||
|
|
||||||
|
CPC: 12,51 €
|
||||||
|
|
||||||
|
Wettbewerb ist hier höher, aber der Umsatz pro Kunde auch.
|
||||||
|
|
||||||
|
3. Der Trend-Tipp (+900% Wachstum)
|
||||||
|
free bulk qr code generator (Massen-Erstellung)
|
||||||
|
|
||||||
|
Trend: +900%
|
||||||
|
|
||||||
|
Viele Leute suchen nach einer Möglichkeit, 100 Codes auf einmal zu erstellen (z.B. für Mitarbeiterausweise oder Inventar). Wenn du so ein Feature ("Bulk Upload") hast oder baust, bist du Marktführer.
|
||||||
|
|
||||||
|
Was du jetzt tun musst (in Excel oder Google Sheets):
|
||||||
|
Du brauchst das Tool gar nicht mehr zwingend. Du kannst einfach in deiner Datei filtern:
|
||||||
|
|
||||||
|
Such die Spalte Avg. monthly searches -> Das ist dein Suchvolumen.
|
||||||
|
|
||||||
|
Such die Spalte Competition (indexed value) -> Das ist die Härte (0-100).
|
||||||
|
|
||||||
|
Filter: Avg. monthly searches > 1000 UND Competition (indexed value) < 20.
|
||||||
|
|
||||||
|
Damit findest du genau die oben genannten Chancen. Soll ich dir für eines dieser Keywords (z.B. den "Call QR Code" mit Index 0) eine Gliederung schreiben?
|
||||||
@@ -1,25 +1,74 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { QRCodeSVG } from 'qrcode.react';
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import { toPng } from 'html-to-image';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { Select } from '@/components/ui/Select';
|
import { Select } from '@/components/ui/Select';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
import { calculateContrast } from '@/lib/utils';
|
import { calculateContrast, cn } from '@/lib/utils';
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { useCsrf } from '@/hooks/useCsrf';
|
import { useCsrf } from '@/hooks/useCsrf';
|
||||||
import { showToast } from '@/components/ui/Toast';
|
import { showToast } from '@/components/ui/Toast';
|
||||||
|
import {
|
||||||
|
Globe, User, MapPin, Phone, FileText, Smartphone, Ticket, Star, HelpCircle, Upload
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
// Tooltip component for form field help
|
||||||
|
const Tooltip = ({ text }: { text: string }) => (
|
||||||
|
<div className="group relative inline-block ml-1">
|
||||||
|
<HelpCircle className="w-4 h-4 text-gray-400 cursor-help" />
|
||||||
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-gray-900 text-white text-xs rounded-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50 w-48 text-center">
|
||||||
|
{text}
|
||||||
|
<div className="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Content-type specific frame options
|
||||||
|
const getFrameOptionsForContentType = (contentType: string) => {
|
||||||
|
const baseOptions = [{ id: 'none', label: 'No Frame' }, { id: 'scanme', label: 'Scan Me' }];
|
||||||
|
|
||||||
|
switch (contentType) {
|
||||||
|
case 'URL':
|
||||||
|
return [...baseOptions, { id: 'website', label: 'Website' }, { id: 'visit', label: 'Visit' }];
|
||||||
|
case 'PHONE':
|
||||||
|
return [...baseOptions, { id: 'callme', label: 'Call Me' }, { id: 'call', label: 'Call' }];
|
||||||
|
case 'GEO':
|
||||||
|
return [...baseOptions, { id: 'findus', label: 'Find Us' }, { id: 'navigate', label: 'Navigate' }];
|
||||||
|
case 'VCARD':
|
||||||
|
return [...baseOptions, { id: 'contact', label: 'Contact' }, { id: 'save', label: 'Save' }];
|
||||||
|
case 'SMS':
|
||||||
|
return [...baseOptions, { id: 'textme', label: 'Text Me' }, { id: 'message', label: 'Message' }];
|
||||||
|
case 'WHATSAPP':
|
||||||
|
return [...baseOptions, { id: 'chatme', label: 'Chat Me' }, { id: 'whatsapp', label: 'WhatsApp' }];
|
||||||
|
case 'TEXT':
|
||||||
|
return [...baseOptions, { id: 'read', label: 'Read' }, { id: 'info', label: 'Info' }];
|
||||||
|
case 'PDF':
|
||||||
|
return [...baseOptions, { id: 'download', label: 'Download' }, { id: 'view', label: 'View PDF' }];
|
||||||
|
case 'APP':
|
||||||
|
return [...baseOptions, { id: 'getapp', label: 'Get App' }, { id: 'download', label: 'Download' }];
|
||||||
|
case 'COUPON':
|
||||||
|
return [...baseOptions, { id: 'redeem', label: 'Redeem' }, { id: 'save', label: 'Save Offer' }];
|
||||||
|
case 'FEEDBACK':
|
||||||
|
return [...baseOptions, { id: 'review', label: 'Review' }, { id: 'feedback', label: 'Feedback' }];
|
||||||
|
default:
|
||||||
|
return [...baseOptions, { id: 'website', label: 'Website' }, { id: 'visit', label: 'Visit' }];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export default function CreatePage() {
|
export default function CreatePage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { fetchWithCsrf } = useCsrf();
|
const { fetchWithCsrf } = useCsrf();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
const [userPlan, setUserPlan] = useState<string>('FREE');
|
const [userPlan, setUserPlan] = useState<string>('FREE');
|
||||||
|
const qrRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
@@ -32,6 +81,18 @@ export default function CreatePage() {
|
|||||||
const [backgroundColor, setBackgroundColor] = useState('#FFFFFF');
|
const [backgroundColor, setBackgroundColor] = useState('#FFFFFF');
|
||||||
const [cornerStyle, setCornerStyle] = useState('square');
|
const [cornerStyle, setCornerStyle] = useState('square');
|
||||||
const [size, setSize] = useState(200);
|
const [size, setSize] = useState(200);
|
||||||
|
const [frameType, setFrameType] = useState('none');
|
||||||
|
|
||||||
|
// Get frame options for current content type
|
||||||
|
const frameOptions = getFrameOptionsForContentType(contentType);
|
||||||
|
|
||||||
|
// Reset frame type when content type changes (if current frame is not valid)
|
||||||
|
useEffect(() => {
|
||||||
|
const validIds = frameOptions.map(f => f.id);
|
||||||
|
if (!validIds.includes(frameType)) {
|
||||||
|
setFrameType('none');
|
||||||
|
}
|
||||||
|
}, [contentType, frameOptions, frameType]);
|
||||||
|
|
||||||
// Logo state
|
// Logo state
|
||||||
const [logoUrl, setLogoUrl] = useState('');
|
const [logoUrl, setLogoUrl] = useState('');
|
||||||
@@ -64,10 +125,14 @@ export default function CreatePage() {
|
|||||||
const hasGoodContrast = contrast >= 4.5;
|
const hasGoodContrast = contrast >= 4.5;
|
||||||
|
|
||||||
const contentTypes = [
|
const contentTypes = [
|
||||||
{ value: 'URL', label: 'URL / Website' },
|
{ value: 'URL', label: 'URL / Website', icon: Globe },
|
||||||
{ value: 'VCARD', label: 'Contact Card' },
|
{ value: 'VCARD', label: 'Contact Card', icon: User },
|
||||||
{ value: 'GEO', label: 'Location/Maps' },
|
{ value: 'GEO', label: 'Location / Maps', icon: MapPin },
|
||||||
{ value: 'PHONE', label: 'Phone Number' },
|
{ value: 'PHONE', label: 'Phone Number', icon: Phone },
|
||||||
|
{ value: 'PDF', label: 'PDF / File', icon: FileText },
|
||||||
|
{ value: 'APP', label: 'App Download', icon: Smartphone },
|
||||||
|
{ value: 'COUPON', label: 'Coupon / Discount', icon: Ticket },
|
||||||
|
{ value: 'FEEDBACK', label: 'Feedback / Review', icon: Star },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Get QR content based on content type
|
// Get QR content based on content type
|
||||||
@@ -90,6 +155,14 @@ export default function CreatePage() {
|
|||||||
return content.text || 'Sample text';
|
return content.text || 'Sample text';
|
||||||
case 'WHATSAPP':
|
case 'WHATSAPP':
|
||||||
return `https://wa.me/${content.phone || '+1234567890'}${content.message ? `?text=${encodeURIComponent(content.message)}` : ''}`;
|
return `https://wa.me/${content.phone || '+1234567890'}${content.message ? `?text=${encodeURIComponent(content.message)}` : ''}`;
|
||||||
|
case 'PDF':
|
||||||
|
return content.fileUrl || 'https://example.com/file.pdf';
|
||||||
|
case 'APP':
|
||||||
|
return content.fallbackUrl || content.iosUrl || content.androidUrl || 'https://example.com/app';
|
||||||
|
case 'COUPON':
|
||||||
|
return `Coupon: ${content.code || 'SAVE20'} - ${content.discount || '20% OFF'}`;
|
||||||
|
case 'FEEDBACK':
|
||||||
|
return content.feedbackUrl || 'https://example.com/feedback';
|
||||||
default:
|
default:
|
||||||
return 'https://example.com';
|
return 'https://example.com';
|
||||||
}
|
}
|
||||||
@@ -97,61 +170,58 @@ export default function CreatePage() {
|
|||||||
|
|
||||||
const qrContent = getQRContent();
|
const qrContent = getQRContent();
|
||||||
|
|
||||||
|
const getFrameLabel = () => {
|
||||||
|
const frame = frameOptions.find((f: { id: string; label: string }) => f.id === frameType);
|
||||||
|
return frame?.id !== 'none' ? frame?.label : null;
|
||||||
|
};
|
||||||
|
|
||||||
const downloadQR = async (format: 'svg' | 'png') => {
|
const downloadQR = async (format: 'svg' | 'png') => {
|
||||||
|
if (!qrRef.current) return;
|
||||||
try {
|
try {
|
||||||
// Get the content based on content type
|
if (format === 'png') {
|
||||||
let qrContent = '';
|
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3, backgroundColor: 'transparent' });
|
||||||
switch (contentType) {
|
const link = document.createElement('a');
|
||||||
case 'URL':
|
link.download = `qrcode-${title || 'download'}.png`;
|
||||||
qrContent = content.url || '';
|
link.href = dataUrl;
|
||||||
break;
|
link.click();
|
||||||
case 'PHONE':
|
|
||||||
qrContent = `tel:${content.phone || ''}`;
|
|
||||||
break;
|
|
||||||
case 'EMAIL':
|
|
||||||
qrContent = `mailto:${content.email || ''}${content.subject ? `?subject=${encodeURIComponent(content.subject)}` : ''}`;
|
|
||||||
break;
|
|
||||||
case 'TEXT':
|
|
||||||
qrContent = content.text || '';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
qrContent = content.url || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!qrContent) return;
|
|
||||||
|
|
||||||
const QRCode = (await import('qrcode')).default;
|
|
||||||
|
|
||||||
if (format === 'svg') {
|
|
||||||
const svg = await QRCode.toString(qrContent, {
|
|
||||||
type: 'svg',
|
|
||||||
width: size,
|
|
||||||
margin: 2,
|
|
||||||
color: {
|
|
||||||
dark: foregroundColor,
|
|
||||||
light: backgroundColor,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const blob = new Blob([svg], { type: 'image/svg+xml' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `qrcode-${title || 'download'}.svg`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
} else {
|
} else {
|
||||||
const a = document.createElement('a');
|
// For SVG, we might still want to use the library or just toPng if SVG export of HTML is not needed
|
||||||
a.href = qrDataUrl;
|
// Simplest is to check if we can export the SVG element directly but that misses the frame HTML.
|
||||||
a.download = `qrcode-${title || 'download'}.png`;
|
// html-to-image can generate SVG too.
|
||||||
document.body.appendChild(a);
|
// But usually for SVG users want the vector. Capturing HTML to SVG is possible but complex.
|
||||||
a.click();
|
// For now, let's just stick to the SVG code export if NO FRAME is selected,
|
||||||
document.body.removeChild(a);
|
// otherwise warn or use toPng (as SVG).
|
||||||
|
// Actually, the previous implementation was good for pure QR.
|
||||||
|
// If frame is selected, we MUST use a raster export (PNG) or complex HTML-to-SVG.
|
||||||
|
// Let's rely on toPng for consistency with frames.
|
||||||
|
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3, backgroundColor: 'transparent' });
|
||||||
|
// Wait, exporting HTML to valid vector SVG is hard.
|
||||||
|
// Let's just offer PNG for frames for now to be safe, or just use the same PNG download for both buttons if frame is active?
|
||||||
|
// No, let's try to grab the INNER SVG if no frame, else...
|
||||||
|
if (frameType === 'none') {
|
||||||
|
const svgElement = qrRef.current.querySelector('svg');
|
||||||
|
if (svgElement) {
|
||||||
|
const svgData = new XMLSerializer().serializeToString(svgElement);
|
||||||
|
const blob = new Blob([svgData], { type: 'image/svg+xml' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `qrcode-${title || 'download'}.svg`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast('SVG download not available with frames yet. Downloading PNG instead.', 'info');
|
||||||
|
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3, backgroundColor: 'transparent' });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = `qrcode-${title || 'download'}.png`;
|
||||||
|
link.href = dataUrl;
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error downloading QR code:', err);
|
console.error('Error downloading QR code:', err);
|
||||||
|
showToast('Error downloading QR code', 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -220,6 +290,7 @@ export default function CreatePage() {
|
|||||||
width: logoSize,
|
width: logoSize,
|
||||||
excavate,
|
excavate,
|
||||||
} : undefined,
|
} : undefined,
|
||||||
|
frameType, // Save frame type
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -362,6 +433,208 @@ export default function CreatePage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
case 'PDF':
|
||||||
|
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// 10MB limit
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
showToast('File size too large (max 10MB)', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setContent({ ...content, fileUrl: data.url, fileName: data.filename });
|
||||||
|
showToast('File uploaded successfully!', 'success');
|
||||||
|
} else {
|
||||||
|
showToast(data.error || 'Upload failed', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error);
|
||||||
|
showToast('Error uploading file', 'error');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center mb-1">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Upload Menu / PDF</label>
|
||||||
|
<Tooltip text="Upload your menu PDF (Max 10MB). Hosted securely." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-lg hover:bg-gray-50 transition-colors relative">
|
||||||
|
<div className="space-y-1 text-center">
|
||||||
|
{uploading ? (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500 mb-2"></div>
|
||||||
|
<p className="text-sm text-gray-500">Uploading...</p>
|
||||||
|
</div>
|
||||||
|
) : content.fileUrl ? (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="mx-auto h-12 w-12 text-primary-500 bg-primary-50 rounded-full flex items-center justify-center mb-2">
|
||||||
|
<FileText className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-green-600 font-medium mb-1">Upload Complete!</p>
|
||||||
|
<a href={content.fileUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-primary-500 hover:underline break-all max-w-xs mb-3 block">
|
||||||
|
{content.fileName || 'View File'}
|
||||||
|
</a>
|
||||||
|
<label htmlFor="file-upload" className="cursor-pointer bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
|
||||||
|
<span>Replace File</span>
|
||||||
|
<input id="file-upload" name="file-upload" type="file" className="sr-only" accept=".pdf,image/*" onChange={handleFileUpload} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload className="mx-auto h-12 w-12 text-gray-400" />
|
||||||
|
<div className="flex text-sm text-gray-600 justify-center">
|
||||||
|
<label htmlFor="file-upload" className="relative cursor-pointer bg-white rounded-md font-medium text-primary-600 hover:text-primary-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-primary-500">
|
||||||
|
<span>Upload a file</span>
|
||||||
|
<input id="file-upload" name="file-upload" type="file" className="sr-only" accept=".pdf,image/*" onChange={handleFileUpload} />
|
||||||
|
</label>
|
||||||
|
<p className="pl-1">or drag and drop</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">PDF, PNG, JPG up to 10MB</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{content.fileUrl && (
|
||||||
|
<Input
|
||||||
|
label="File Name / Menu Title"
|
||||||
|
value={content.fileName || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, fileName: e.target.value })}
|
||||||
|
placeholder="Product Catalog 2026"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case 'APP':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center mb-1">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">iOS App Store URL</label>
|
||||||
|
<Tooltip text="Link to your app in the Apple App Store" />
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={content.iosUrl || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, iosUrl: e.target.value })}
|
||||||
|
placeholder="https://apps.apple.com/app/..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center mb-1">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Android Play Store URL</label>
|
||||||
|
<Tooltip text="Link to your app in the Google Play Store" />
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={content.androidUrl || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, androidUrl: e.target.value })}
|
||||||
|
placeholder="https://play.google.com/store/apps/..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center mb-1">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Fallback URL</label>
|
||||||
|
<Tooltip text="Where desktop users go (e.g., your website). QR detects device automatically!" />
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={content.fallbackUrl || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, fallbackUrl: e.target.value })}
|
||||||
|
placeholder="https://yourapp.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case 'COUPON':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
label="Coupon Code"
|
||||||
|
value={content.code || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, code: e.target.value })}
|
||||||
|
placeholder="SUMMER20"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Discount"
|
||||||
|
value={content.discount || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, discount: e.target.value })}
|
||||||
|
placeholder="20% OFF"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Title"
|
||||||
|
value={content.title || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, title: e.target.value })}
|
||||||
|
placeholder="Summer Sale 2026"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Description (optional)"
|
||||||
|
value={content.description || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, description: e.target.value })}
|
||||||
|
placeholder="Valid on all products"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Expiry Date (optional)"
|
||||||
|
type="date"
|
||||||
|
value={content.expiryDate || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, expiryDate: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Redeem URL (optional)"
|
||||||
|
value={content.redeemUrl || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, redeemUrl: e.target.value })}
|
||||||
|
placeholder="https://shop.example.com?coupon=SUMMER20"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case 'FEEDBACK':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
label="Business Name"
|
||||||
|
value={content.businessName || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, businessName: e.target.value })}
|
||||||
|
placeholder="Your Restaurant Name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center mb-1">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Google Review URL</label>
|
||||||
|
<Tooltip text="Redirect satisfied customers to leave a Google review." />
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={content.googleReviewUrl || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, googleReviewUrl: e.target.value })}
|
||||||
|
placeholder="https://search.google.com/local/writereview?placeid=..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="Thank You Message"
|
||||||
|
value={content.thankYouMessage || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, thankYouMessage: e.target.value })}
|
||||||
|
placeholder="Thanks for your feedback!"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -392,12 +665,31 @@ export default function CreatePage() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Select
|
{/* Custom Content Type Selector with Icons */}
|
||||||
label="Content Type"
|
<div>
|
||||||
value={contentType}
|
<label className="block text-sm font-medium text-gray-700 mb-2">Content Type</label>
|
||||||
onChange={(e) => setContentType(e.target.value)}
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||||
options={contentTypes}
|
{contentTypes.map((type) => {
|
||||||
/>
|
const Icon = type.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={type.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setContentType(type.value)}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center gap-2 p-3 rounded-lg border-2 transition-all text-sm",
|
||||||
|
contentType === type.value
|
||||||
|
? "border-primary-500 bg-primary-50 text-primary-700"
|
||||||
|
: "border-gray-200 hover:border-gray-300 text-gray-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
<span className="text-xs font-medium text-center">{type.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{renderContentFields()}
|
{renderContentFields()}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -448,7 +740,7 @@ export default function CreatePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-6">
|
||||||
{!canCustomizeColors && (
|
{!canCustomizeColors && (
|
||||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg mb-4">
|
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg mb-4">
|
||||||
<p className="text-sm text-blue-900">
|
<p className="text-sm text-blue-900">
|
||||||
@@ -461,6 +753,29 @@ export default function CreatePage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Frame Options */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-3">Frame</label>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{frameOptions.map((frame: { id: string; label: string }) => (
|
||||||
|
<button
|
||||||
|
key={frame.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFrameType(frame.id)}
|
||||||
|
className={cn(
|
||||||
|
"py-2 px-3 rounded-lg text-sm font-medium transition-all border",
|
||||||
|
frameType === frame.id
|
||||||
|
? "bg-slate-900 text-white border-slate-900"
|
||||||
|
: "bg-gray-50 text-gray-600 border-gray-200 hover:border-gray-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{frame.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
@@ -635,27 +950,48 @@ export default function CreatePage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="text-center">
|
<CardContent className="text-center">
|
||||||
<div id="create-qr-preview" className="flex justify-center mb-4">
|
<div id="create-qr-preview" className="flex justify-center mb-4">
|
||||||
{qrContent ? (
|
{/* WRAPPER FOR REF AND FRAME */}
|
||||||
<div className={cornerStyle === 'rounded' ? 'rounded-lg overflow-hidden' : ''}>
|
<div
|
||||||
<QRCodeSVG
|
ref={qrRef}
|
||||||
value={qrContent}
|
className="relative bg-white rounded-xl p-4 flex flex-col items-center justify-center transition-all duration-300"
|
||||||
size={200}
|
style={{
|
||||||
fgColor={foregroundColor}
|
minWidth: '280px',
|
||||||
bgColor={backgroundColor}
|
minHeight: '280px',
|
||||||
level="H"
|
}}
|
||||||
imageSettings={logoUrl ? {
|
>
|
||||||
src: logoUrl,
|
{/* Frame Label */}
|
||||||
height: logoSize,
|
{getFrameLabel() && (
|
||||||
width: logoSize,
|
<div
|
||||||
excavate: excavate,
|
className="mb-4 px-6 py-2 rounded-full font-bold text-sm tracking-widest uppercase shadow-md text-white"
|
||||||
} : undefined}
|
style={{ backgroundColor: foregroundColor }}
|
||||||
/>
|
>
|
||||||
</div>
|
{getFrameLabel()}
|
||||||
) : (
|
</div>
|
||||||
<div className="w-[200px] h-[200px] bg-gray-100 rounded flex items-center justify-center text-gray-500">
|
)}
|
||||||
Enter content
|
|
||||||
</div>
|
{qrContent ? (
|
||||||
)}
|
<div className={cornerStyle === 'rounded' ? 'rounded-lg overflow-hidden' : ''}>
|
||||||
|
<QRCodeSVG
|
||||||
|
value={qrContent}
|
||||||
|
size={size}
|
||||||
|
fgColor={foregroundColor}
|
||||||
|
bgColor={backgroundColor}
|
||||||
|
level="H"
|
||||||
|
includeMargin={false}
|
||||||
|
imageSettings={logoUrl ? {
|
||||||
|
src: logoUrl,
|
||||||
|
height: logoSize,
|
||||||
|
width: logoSize,
|
||||||
|
excavate: excavate,
|
||||||
|
} : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-[200px] h-[200px] bg-gray-100 rounded flex items-center justify-center text-gray-500">
|
||||||
|
Enter content
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -663,38 +999,7 @@ export default function CreatePage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => downloadQR('svg')}
|
||||||
const svg = document.querySelector('#create-qr-preview svg');
|
|
||||||
if (!svg) return;
|
|
||||||
|
|
||||||
let svgData = new XMLSerializer().serializeToString(svg);
|
|
||||||
|
|
||||||
// If rounded corners, wrap in a clipped SVG
|
|
||||||
if (cornerStyle === 'rounded') {
|
|
||||||
const width = svg.getAttribute('width') || '200';
|
|
||||||
const height = svg.getAttribute('height') || '200';
|
|
||||||
const borderRadius = 20;
|
|
||||||
|
|
||||||
svgData = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
|
||||||
<defs>
|
|
||||||
<clipPath id="rounded-corners">
|
|
||||||
<rect x="0" y="0" width="${width}" height="${height}" rx="${borderRadius}" ry="${borderRadius}"/>
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
<g clip-path="url(#rounded-corners)">
|
|
||||||
${svgData}
|
|
||||||
</g>
|
|
||||||
</svg>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = new Blob([svgData], { type: 'image/svg+xml' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `${title || 'qrcode'}.svg`;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}}
|
|
||||||
disabled={!qrContent}
|
disabled={!qrContent}
|
||||||
>
|
>
|
||||||
Download SVG
|
Download SVG
|
||||||
@@ -703,54 +1008,7 @@ export default function CreatePage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => downloadQR('png')}
|
||||||
const svg = document.querySelector('#create-qr-preview svg');
|
|
||||||
if (!svg) return;
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (!ctx) return;
|
|
||||||
|
|
||||||
const img = new Image();
|
|
||||||
const svgData = new XMLSerializer().serializeToString(svg);
|
|
||||||
const blob = new Blob([svgData], { type: 'image/svg+xml' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
img.onload = () => {
|
|
||||||
canvas.width = 200;
|
|
||||||
canvas.height = 200;
|
|
||||||
|
|
||||||
// Apply rounded corners if needed
|
|
||||||
if (cornerStyle === 'rounded') {
|
|
||||||
const borderRadius = 20;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(borderRadius, 0);
|
|
||||||
ctx.lineTo(200 - borderRadius, 0);
|
|
||||||
ctx.quadraticCurveTo(200, 0, 200, borderRadius);
|
|
||||||
ctx.lineTo(200, 200 - borderRadius);
|
|
||||||
ctx.quadraticCurveTo(200, 200, 200 - borderRadius, 200);
|
|
||||||
ctx.lineTo(borderRadius, 200);
|
|
||||||
ctx.quadraticCurveTo(0, 200, 0, 200 - borderRadius);
|
|
||||||
ctx.lineTo(0, borderRadius);
|
|
||||||
ctx.quadraticCurveTo(0, 0, borderRadius, 0);
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.clip();
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.drawImage(img, 0, 0);
|
|
||||||
canvas.toBlob((blob) => {
|
|
||||||
if (blob) {
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `${title || 'qrcode'}.png`;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
img.src = url;
|
|
||||||
}}
|
|
||||||
disabled={!qrContent}
|
disabled={!qrContent}
|
||||||
>
|
>
|
||||||
Download PNG
|
Download PNG
|
||||||
|
|||||||
@@ -1,268 +1,268 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
import { showToast } from '@/components/ui/Toast';
|
import { showToast } from '@/components/ui/Toast';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { BillingToggle } from '@/components/ui/BillingToggle';
|
import { BillingToggle } from '@/components/ui/BillingToggle';
|
||||||
|
|
||||||
export default function PricingPage() {
|
export default function PricingPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [loading, setLoading] = useState<string | null>(null);
|
const [loading, setLoading] = useState<string | null>(null);
|
||||||
const [currentPlan, setCurrentPlan] = useState<string>('FREE');
|
const [currentPlan, setCurrentPlan] = useState<string>('FREE');
|
||||||
const [currentInterval, setCurrentInterval] = useState<'month' | 'year' | null>(null);
|
const [currentInterval, setCurrentInterval] = useState<'month' | 'year' | null>(null);
|
||||||
const [billingPeriod, setBillingPeriod] = useState<'month' | 'year'>('month');
|
const [billingPeriod, setBillingPeriod] = useState<'month' | 'year'>('month');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Fetch current user plan
|
// Fetch current user plan
|
||||||
const fetchUserPlan = async () => {
|
const fetchUserPlan = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/user/plan');
|
const response = await fetch('/api/user/plan');
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setCurrentPlan(data.plan || 'FREE');
|
setCurrentPlan(data.plan || 'FREE');
|
||||||
setCurrentInterval(data.interval || null);
|
setCurrentInterval(data.interval || null);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching user plan:', error);
|
console.error('Error fetching user plan:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchUserPlan();
|
fetchUserPlan();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleUpgrade = async (plan: 'PRO' | 'BUSINESS') => {
|
const handleUpgrade = async (plan: 'PRO' | 'BUSINESS') => {
|
||||||
setLoading(plan);
|
setLoading(plan);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/stripe/create-checkout-session', {
|
const response = await fetch('/api/stripe/create-checkout-session', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
plan,
|
plan,
|
||||||
billingInterval: billingPeriod === 'month' ? 'month' : 'year',
|
billingInterval: billingPeriod === 'month' ? 'month' : 'year',
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to create checkout session');
|
throw new Error('Failed to create checkout session');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { url } = await response.json();
|
const { url } = await response.json();
|
||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating checkout session:', error);
|
console.error('Error creating checkout session:', error);
|
||||||
showToast('Failed to start checkout. Please try again.', 'error');
|
showToast('Failed to start checkout. Please try again.', 'error');
|
||||||
setLoading(null);
|
setLoading(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDowngrade = async () => {
|
const handleDowngrade = async () => {
|
||||||
// Show confirmation dialog
|
// Show confirmation dialog
|
||||||
const confirmed = window.confirm(
|
const confirmed = window.confirm(
|
||||||
'Are you sure you want to downgrade to the Free plan? Your subscription will be canceled immediately and you will lose access to premium features.'
|
'Are you sure you want to downgrade to the Free plan? Your subscription will be canceled immediately and you will lose access to premium features.'
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading('FREE');
|
setLoading('FREE');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/stripe/cancel-subscription', {
|
const response = await fetch('/api/stripe/cancel-subscription', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
throw new Error(error.error || 'Failed to cancel subscription');
|
throw new Error(error.error || 'Failed to cancel subscription');
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast('Successfully downgraded to Free plan', 'success');
|
showToast('Successfully downgraded to Free plan', 'success');
|
||||||
|
|
||||||
// Refresh to update the plan
|
// Refresh to update the plan
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}, 1500);
|
}, 1500);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error canceling subscription:', error);
|
console.error('Error canceling subscription:', error);
|
||||||
showToast(error.message || 'Failed to downgrade. Please try again.', 'error');
|
showToast(error.message || 'Failed to downgrade. Please try again.', 'error');
|
||||||
setLoading(null);
|
setLoading(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to check if this is the user's exact current plan (plan + interval)
|
// Helper function to check if this is the user's exact current plan (plan + interval)
|
||||||
const isCurrentPlanWithInterval = (planType: string, interval: 'month' | 'year') => {
|
const isCurrentPlanWithInterval = (planType: string, interval: 'month' | 'year') => {
|
||||||
return currentPlan === planType && currentInterval === interval;
|
return currentPlan === planType && currentInterval === interval;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to check if user has this plan but different interval
|
// Helper function to check if user has this plan but different interval
|
||||||
const hasPlanDifferentInterval = (planType: string) => {
|
const hasPlanDifferentInterval = (planType: string) => {
|
||||||
return currentPlan === planType && currentInterval && currentInterval !== billingPeriod;
|
return currentPlan === planType && currentInterval && currentInterval !== billingPeriod;
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedInterval = billingPeriod === 'month' ? 'month' : 'year';
|
const selectedInterval = billingPeriod === 'month' ? 'month' : 'year';
|
||||||
|
|
||||||
const plans = [
|
const plans = [
|
||||||
{
|
{
|
||||||
key: 'free',
|
key: 'free',
|
||||||
name: 'Free',
|
name: 'Free',
|
||||||
price: '€0',
|
price: '€0',
|
||||||
period: 'forever',
|
period: 'forever',
|
||||||
showDiscount: false,
|
showDiscount: false,
|
||||||
features: [
|
features: [
|
||||||
'3 dynamic QR codes',
|
'3 dynamic QR codes',
|
||||||
'Unlimited static QR codes',
|
'Unlimited static QR codes',
|
||||||
'Basic scan tracking',
|
'Basic scan tracking',
|
||||||
'Standard QR design templates',
|
'Standard QR design templates',
|
||||||
'Download as SVG/PNG',
|
'Download as SVG/PNG',
|
||||||
],
|
],
|
||||||
buttonText: currentPlan === 'FREE' ? 'Current Plan' : 'Downgrade to Free',
|
buttonText: currentPlan === 'FREE' ? 'Current Plan' : 'Downgrade to Free',
|
||||||
buttonVariant: 'outline' as const,
|
buttonVariant: 'outline' as const,
|
||||||
disabled: currentPlan === 'FREE',
|
disabled: currentPlan === 'FREE',
|
||||||
popular: false,
|
popular: false,
|
||||||
onDowngrade: handleDowngrade,
|
onDowngrade: handleDowngrade,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'pro',
|
key: 'pro',
|
||||||
name: 'Pro',
|
name: 'Pro',
|
||||||
price: billingPeriod === 'month' ? '€9' : '€90',
|
price: billingPeriod === 'month' ? '€9' : '€90',
|
||||||
period: billingPeriod === 'month' ? 'per month' : 'per year',
|
period: billingPeriod === 'month' ? 'per month' : 'per year',
|
||||||
showDiscount: billingPeriod === 'year',
|
showDiscount: billingPeriod === 'year',
|
||||||
features: [
|
features: [
|
||||||
'50 dynamic QR codes',
|
'50 dynamic QR codes',
|
||||||
'Unlimited static QR codes',
|
'Unlimited static QR codes',
|
||||||
'Advanced analytics (scans, devices, locations)',
|
'Advanced analytics (scans, devices, locations)',
|
||||||
'Custom branding (colors)',
|
'Custom branding (colors & logos)',
|
||||||
],
|
],
|
||||||
buttonText: isCurrentPlanWithInterval('PRO', selectedInterval)
|
buttonText: isCurrentPlanWithInterval('PRO', selectedInterval)
|
||||||
? 'Current Plan'
|
? 'Current Plan'
|
||||||
: hasPlanDifferentInterval('PRO')
|
: hasPlanDifferentInterval('PRO')
|
||||||
? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}`
|
? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}`
|
||||||
: 'Upgrade to Pro',
|
: 'Upgrade to Pro',
|
||||||
buttonVariant: 'primary' as const,
|
buttonVariant: 'primary' as const,
|
||||||
disabled: isCurrentPlanWithInterval('PRO', selectedInterval),
|
disabled: isCurrentPlanWithInterval('PRO', selectedInterval),
|
||||||
popular: true,
|
popular: true,
|
||||||
onUpgrade: () => handleUpgrade('PRO'),
|
onUpgrade: () => handleUpgrade('PRO'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'business',
|
key: 'business',
|
||||||
name: 'Business',
|
name: 'Business',
|
||||||
price: billingPeriod === 'month' ? '€29' : '€290',
|
price: billingPeriod === 'month' ? '€29' : '€290',
|
||||||
period: billingPeriod === 'month' ? 'per month' : 'per year',
|
period: billingPeriod === 'month' ? 'per month' : 'per year',
|
||||||
showDiscount: billingPeriod === 'year',
|
showDiscount: billingPeriod === 'year',
|
||||||
features: [
|
features: [
|
||||||
'500 dynamic QR codes',
|
'500 dynamic QR codes',
|
||||||
'Unlimited static QR codes',
|
'Unlimited static QR codes',
|
||||||
'Everything from Pro',
|
'Everything from Pro',
|
||||||
'Bulk QR Creation (up to 1,000)',
|
'Bulk QR Creation (up to 1,000)',
|
||||||
'Priority email support',
|
'Priority email support',
|
||||||
'Advanced tracking & insights',
|
'Advanced tracking & insights',
|
||||||
],
|
],
|
||||||
buttonText: isCurrentPlanWithInterval('BUSINESS', selectedInterval)
|
buttonText: isCurrentPlanWithInterval('BUSINESS', selectedInterval)
|
||||||
? 'Current Plan'
|
? 'Current Plan'
|
||||||
: hasPlanDifferentInterval('BUSINESS')
|
: hasPlanDifferentInterval('BUSINESS')
|
||||||
? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}`
|
? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}`
|
||||||
: 'Upgrade to Business',
|
: 'Upgrade to Business',
|
||||||
buttonVariant: 'primary' as const,
|
buttonVariant: 'primary' as const,
|
||||||
disabled: isCurrentPlanWithInterval('BUSINESS', selectedInterval),
|
disabled: isCurrentPlanWithInterval('BUSINESS', selectedInterval),
|
||||||
popular: false,
|
popular: false,
|
||||||
onUpgrade: () => handleUpgrade('BUSINESS'),
|
onUpgrade: () => handleUpgrade('BUSINESS'),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-12">
|
<div className="container mx-auto px-4 py-12">
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
||||||
Choose Your Plan
|
Choose Your Plan
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl text-gray-600">
|
<p className="text-xl text-gray-600">
|
||||||
Select the perfect plan for your QR code needs
|
Select the perfect plan for your QR code needs
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-center mb-8">
|
<div className="flex justify-center mb-8">
|
||||||
<BillingToggle value={billingPeriod} onChange={setBillingPeriod} />
|
<BillingToggle value={billingPeriod} onChange={setBillingPeriod} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid md:grid-cols-3 gap-8 max-w-6xl mx-auto">
|
<div className="grid md:grid-cols-3 gap-8 max-w-6xl mx-auto">
|
||||||
{plans.map((plan) => (
|
{plans.map((plan) => (
|
||||||
<Card
|
<Card
|
||||||
key={plan.key}
|
key={plan.key}
|
||||||
className={plan.popular ? 'border-primary-500 shadow-xl relative' : ''}
|
className={plan.popular ? 'border-primary-500 shadow-xl relative' : ''}
|
||||||
>
|
>
|
||||||
{plan.popular && (
|
{plan.popular && (
|
||||||
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
|
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
|
||||||
<Badge variant="info" className="px-3 py-1">
|
<Badge variant="info" className="px-3 py-1">
|
||||||
Most Popular
|
Most Popular
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CardHeader className="text-center pb-8">
|
<CardHeader className="text-center pb-8">
|
||||||
<CardTitle className="text-2xl mb-4">
|
<CardTitle className="text-2xl mb-4">
|
||||||
{plan.name}
|
{plan.name}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="flex items-baseline justify-center">
|
<div className="flex items-baseline justify-center">
|
||||||
<span className="text-4xl font-bold">
|
<span className="text-4xl font-bold">
|
||||||
{plan.price}
|
{plan.price}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gray-600 ml-2">
|
<span className="text-gray-600 ml-2">
|
||||||
{plan.period}
|
{plan.period}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{plan.showDiscount && (
|
{plan.showDiscount && (
|
||||||
<Badge variant="success" className="mt-2">
|
<Badge variant="success" className="mt-2">
|
||||||
Save 16%
|
Save 16%
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{plan.features.map((feature: string, index: number) => (
|
{plan.features.map((feature: string, index: number) => (
|
||||||
<li key={index} className="flex items-start space-x-3">
|
<li key={index} className="flex items-start space-x-3">
|
||||||
<svg className="w-5 h-5 text-success-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
<svg className="w-5 h-5 text-success-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-gray-700">{feature}</span>
|
<span className="text-gray-700">{feature}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={plan.buttonVariant}
|
variant={plan.buttonVariant}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
size="lg"
|
size="lg"
|
||||||
disabled={plan.disabled || loading === plan.key.toUpperCase()}
|
disabled={plan.disabled || loading === plan.key.toUpperCase()}
|
||||||
onClick={plan.key === 'free' ? (plan as any).onDowngrade : (plan as any).onUpgrade}
|
onClick={plan.key === 'free' ? (plan as any).onDowngrade : (plan as any).onUpgrade}
|
||||||
>
|
>
|
||||||
{loading === plan.key.toUpperCase() ? 'Processing...' : plan.buttonText}
|
{loading === plan.key.toUpperCase() ? 'Processing...' : plan.buttonText}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center mt-12">
|
<div className="text-center mt-12">
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
All plans include unlimited static QR codes and basic customization.
|
All plans include unlimited static QR codes and basic customization.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-600 mt-2">
|
<p className="text-gray-600 mt-2">
|
||||||
Need help choosing? <a href="mailto:support@qrmaster.net" className="text-primary-600 hover:text-primary-700 underline">Contact our team</a>
|
Need help choosing? <a href="mailto:support@qrmaster.net" className="text-primary-600 hover:text-primary-700 underline">Contact our team</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,264 +1,459 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useRouter, useParams } from 'next/navigation';
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { showToast } from '@/components/ui/Toast';
|
import { showToast } from '@/components/ui/Toast';
|
||||||
import { useCsrf } from '@/hooks/useCsrf';
|
import { useCsrf } from '@/hooks/useCsrf';
|
||||||
|
import { Upload, FileText, HelpCircle } from 'lucide-react';
|
||||||
export default function EditQRPage() {
|
|
||||||
const router = useRouter();
|
// Tooltip component for form field help
|
||||||
const params = useParams();
|
const Tooltip = ({ text }: { text: string }) => (
|
||||||
const qrId = params.id as string;
|
<div className="group relative inline-block ml-1">
|
||||||
const { fetchWithCsrf, loading: csrfLoading } = useCsrf();
|
<HelpCircle className="w-4 h-4 text-gray-400 cursor-help" />
|
||||||
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-gray-900 text-white text-xs rounded-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50 w-48 text-center">
|
||||||
const [loading, setLoading] = useState(true);
|
{text}
|
||||||
const [saving, setSaving] = useState(false);
|
<div className="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div>
|
||||||
const [qrCode, setQrCode] = useState<any>(null);
|
</div>
|
||||||
const [title, setTitle] = useState('');
|
</div>
|
||||||
const [content, setContent] = useState<any>({});
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
export default function EditQRPage() {
|
||||||
const fetchQRCode = async () => {
|
const router = useRouter();
|
||||||
try {
|
const params = useParams();
|
||||||
const response = await fetch(`/api/qrs/${qrId}`);
|
const qrId = params.id as string;
|
||||||
if (response.ok) {
|
const { fetchWithCsrf, loading: csrfLoading } = useCsrf();
|
||||||
const data = await response.json();
|
|
||||||
setQrCode(data);
|
const [loading, setLoading] = useState(true);
|
||||||
setTitle(data.title);
|
const [saving, setSaving] = useState(false);
|
||||||
setContent(data.content || {});
|
const [uploading, setUploading] = useState(false);
|
||||||
} else {
|
const [qrCode, setQrCode] = useState<any>(null);
|
||||||
showToast('Failed to load QR code', 'error');
|
const [title, setTitle] = useState('');
|
||||||
router.push('/dashboard');
|
const [content, setContent] = useState<any>({});
|
||||||
}
|
|
||||||
} catch (error) {
|
useEffect(() => {
|
||||||
console.error('Error fetching QR code:', error);
|
const fetchQRCode = async () => {
|
||||||
showToast('Failed to load QR code', 'error');
|
try {
|
||||||
router.push('/dashboard');
|
const response = await fetch(`/api/qrs/${qrId}`);
|
||||||
} finally {
|
if (response.ok) {
|
||||||
setLoading(false);
|
const data = await response.json();
|
||||||
}
|
setQrCode(data);
|
||||||
};
|
setTitle(data.title);
|
||||||
|
setContent(data.content || {});
|
||||||
fetchQRCode();
|
} else {
|
||||||
}, [qrId, router]);
|
showToast('Failed to load QR code', 'error');
|
||||||
|
router.push('/dashboard');
|
||||||
const handleSave = async () => {
|
}
|
||||||
setSaving(true);
|
} catch (error) {
|
||||||
|
console.error('Error fetching QR code:', error);
|
||||||
try {
|
showToast('Failed to load QR code', 'error');
|
||||||
const response = await fetchWithCsrf(`/api/qrs/${qrId}`, {
|
router.push('/dashboard');
|
||||||
method: 'PATCH',
|
} finally {
|
||||||
body: JSON.stringify({
|
setLoading(false);
|
||||||
title,
|
}
|
||||||
content,
|
};
|
||||||
}),
|
|
||||||
});
|
fetchQRCode();
|
||||||
|
}, [qrId, router]);
|
||||||
if (response.ok) {
|
|
||||||
showToast('QR code updated successfully!', 'success');
|
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
router.push('/dashboard');
|
const file = e.target.files?.[0];
|
||||||
} else {
|
if (!file) return;
|
||||||
const error = await response.json();
|
|
||||||
showToast(error.error || 'Failed to update QR code', 'error');
|
// 10MB limit
|
||||||
}
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
} catch (error) {
|
showToast('File size too large (max 10MB)', 'error');
|
||||||
console.error('Error updating QR code:', error);
|
return;
|
||||||
showToast('Failed to update QR code', 'error');
|
}
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
setUploading(true);
|
||||||
}
|
const formData = new FormData();
|
||||||
};
|
formData.append('file', file);
|
||||||
|
|
||||||
if (loading) {
|
try {
|
||||||
return (
|
const response = await fetch('/api/upload', {
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
method: 'POST',
|
||||||
<div className="text-center">
|
body: formData,
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
|
});
|
||||||
<p className="text-gray-600">Loading QR code...</p>
|
const data = await response.json();
|
||||||
</div>
|
|
||||||
</div>
|
if (response.ok) {
|
||||||
);
|
setContent({ ...content, fileUrl: data.url, fileName: data.filename });
|
||||||
}
|
showToast('File uploaded successfully!', 'success');
|
||||||
|
} else {
|
||||||
if (!qrCode) {
|
showToast(data.error || 'Upload failed', 'error');
|
||||||
return null;
|
}
|
||||||
}
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error);
|
||||||
// Static QR codes cannot be edited
|
showToast('Error uploading file', 'error');
|
||||||
if (qrCode.type === 'STATIC') {
|
} finally {
|
||||||
return (
|
setUploading(false);
|
||||||
<div className="max-w-2xl mx-auto mt-12">
|
}
|
||||||
<Card>
|
};
|
||||||
<CardContent className="p-12 text-center">
|
|
||||||
<div className="w-20 h-20 bg-warning-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
const handleSave = async () => {
|
||||||
<svg className="w-10 h-10 text-warning-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
setSaving(true);
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
||||||
</svg>
|
try {
|
||||||
</div>
|
const response = await fetchWithCsrf(`/api/qrs/${qrId}`, {
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Static QR Code</h2>
|
method: 'PATCH',
|
||||||
<p className="text-gray-600 mb-8">
|
body: JSON.stringify({
|
||||||
Static QR codes cannot be edited because their content is embedded directly in the QR code image.
|
title,
|
||||||
</p>
|
content,
|
||||||
<Button onClick={() => router.push('/dashboard')}>
|
}),
|
||||||
Back to Dashboard
|
});
|
||||||
</Button>
|
|
||||||
</CardContent>
|
if (response.ok) {
|
||||||
</Card>
|
showToast('QR code updated successfully!', 'success');
|
||||||
</div>
|
router.push('/dashboard');
|
||||||
);
|
} else {
|
||||||
}
|
const error = await response.json();
|
||||||
|
showToast(error.error || 'Failed to update QR code', 'error');
|
||||||
return (
|
}
|
||||||
<div className="max-w-3xl mx-auto">
|
} catch (error) {
|
||||||
<div className="mb-8">
|
console.error('Error updating QR code:', error);
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Edit QR Code</h1>
|
showToast('Failed to update QR code', 'error');
|
||||||
<p className="text-gray-600 mt-2">Update your dynamic QR code content</p>
|
} finally {
|
||||||
</div>
|
setSaving(false);
|
||||||
|
}
|
||||||
<Card>
|
};
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>QR Code Details</CardTitle>
|
if (loading) {
|
||||||
</CardHeader>
|
return (
|
||||||
<CardContent className="space-y-6">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
<Input
|
<div className="text-center">
|
||||||
label="Title"
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
|
||||||
value={title}
|
<p className="text-gray-600">Loading QR code...</p>
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
</div>
|
||||||
placeholder="Enter QR code title"
|
</div>
|
||||||
required
|
);
|
||||||
/>
|
}
|
||||||
|
|
||||||
{qrCode.contentType === 'URL' && (
|
if (!qrCode) {
|
||||||
<Input
|
return null;
|
||||||
label="URL"
|
}
|
||||||
type="url"
|
|
||||||
value={content.url || ''}
|
// Static QR codes cannot be edited
|
||||||
onChange={(e) => setContent({ ...content, url: e.target.value })}
|
if (qrCode.type === 'STATIC') {
|
||||||
placeholder="https://example.com"
|
return (
|
||||||
required
|
<div className="max-w-2xl mx-auto mt-12">
|
||||||
/>
|
<Card>
|
||||||
)}
|
<CardContent className="p-12 text-center">
|
||||||
|
<div className="w-20 h-20 bg-warning-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
{qrCode.contentType === 'PHONE' && (
|
<svg className="w-10 h-10 text-warning-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<Input
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
label="Phone Number"
|
</svg>
|
||||||
type="tel"
|
</div>
|
||||||
value={content.phone || ''}
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">Static QR Code</h2>
|
||||||
onChange={(e) => setContent({ ...content, phone: e.target.value })}
|
<p className="text-gray-600 mb-8">
|
||||||
placeholder="+1234567890"
|
Static QR codes cannot be edited because their content is embedded directly in the QR code image.
|
||||||
required
|
</p>
|
||||||
/>
|
<Button onClick={() => router.push('/dashboard')}>
|
||||||
)}
|
Back to Dashboard
|
||||||
|
</Button>
|
||||||
{qrCode.contentType === 'VCARD' && (
|
</CardContent>
|
||||||
<>
|
</Card>
|
||||||
<Input
|
</div>
|
||||||
label="First Name"
|
);
|
||||||
value={content.firstName || ''}
|
}
|
||||||
onChange={(e) => setContent({ ...content, firstName: e.target.value })}
|
|
||||||
placeholder="John"
|
return (
|
||||||
required
|
<div className="max-w-3xl mx-auto">
|
||||||
/>
|
<div className="mb-8">
|
||||||
<Input
|
<h1 className="text-3xl font-bold text-gray-900">Edit QR Code</h1>
|
||||||
label="Last Name"
|
<p className="text-gray-600 mt-2">Update your dynamic QR code content</p>
|
||||||
value={content.lastName || ''}
|
</div>
|
||||||
onChange={(e) => setContent({ ...content, lastName: e.target.value })}
|
|
||||||
placeholder="Doe"
|
<Card>
|
||||||
required
|
<CardHeader>
|
||||||
/>
|
<CardTitle>QR Code Details</CardTitle>
|
||||||
<Input
|
</CardHeader>
|
||||||
label="Email"
|
<CardContent className="space-y-6">
|
||||||
type="email"
|
<Input
|
||||||
value={content.email || ''}
|
label="Title"
|
||||||
onChange={(e) => setContent({ ...content, email: e.target.value })}
|
value={title}
|
||||||
placeholder="john@example.com"
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
/>
|
placeholder="Enter QR code title"
|
||||||
<Input
|
required
|
||||||
label="Phone"
|
/>
|
||||||
value={content.phone || ''}
|
|
||||||
onChange={(e) => setContent({ ...content, phone: e.target.value })}
|
{qrCode.contentType === 'URL' && (
|
||||||
placeholder="+1234567890"
|
<Input
|
||||||
/>
|
label="URL"
|
||||||
<Input
|
type="url"
|
||||||
label="Organization"
|
value={content.url || ''}
|
||||||
value={content.organization || ''}
|
onChange={(e) => setContent({ ...content, url: e.target.value })}
|
||||||
onChange={(e) => setContent({ ...content, organization: e.target.value })}
|
placeholder="https://example.com"
|
||||||
placeholder="Company Name"
|
required
|
||||||
/>
|
/>
|
||||||
<Input
|
)}
|
||||||
label="Job Title"
|
|
||||||
value={content.title || ''}
|
{qrCode.contentType === 'PHONE' && (
|
||||||
onChange={(e) => setContent({ ...content, title: e.target.value })}
|
<Input
|
||||||
placeholder="CEO"
|
label="Phone Number"
|
||||||
/>
|
type="tel"
|
||||||
</>
|
value={content.phone || ''}
|
||||||
)}
|
onChange={(e) => setContent({ ...content, phone: e.target.value })}
|
||||||
|
placeholder="+1234567890"
|
||||||
{qrCode.contentType === 'GEO' && (
|
required
|
||||||
<>
|
/>
|
||||||
<Input
|
)}
|
||||||
label="Latitude"
|
|
||||||
type="number"
|
{qrCode.contentType === 'VCARD' && (
|
||||||
step="any"
|
<>
|
||||||
value={content.latitude || ''}
|
<Input
|
||||||
onChange={(e) => setContent({ ...content, latitude: parseFloat(e.target.value) || 0 })}
|
label="First Name"
|
||||||
placeholder="37.7749"
|
value={content.firstName || ''}
|
||||||
required
|
onChange={(e) => setContent({ ...content, firstName: e.target.value })}
|
||||||
/>
|
placeholder="John"
|
||||||
<Input
|
required
|
||||||
label="Longitude"
|
/>
|
||||||
type="number"
|
<Input
|
||||||
step="any"
|
label="Last Name"
|
||||||
value={content.longitude || ''}
|
value={content.lastName || ''}
|
||||||
onChange={(e) => setContent({ ...content, longitude: parseFloat(e.target.value) || 0 })}
|
onChange={(e) => setContent({ ...content, lastName: e.target.value })}
|
||||||
placeholder="-122.4194"
|
placeholder="Doe"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Location Label (Optional)"
|
label="Email"
|
||||||
value={content.label || ''}
|
type="email"
|
||||||
onChange={(e) => setContent({ ...content, label: e.target.value })}
|
value={content.email || ''}
|
||||||
placeholder="Golden Gate Bridge"
|
onChange={(e) => setContent({ ...content, email: e.target.value })}
|
||||||
/>
|
placeholder="john@example.com"
|
||||||
</>
|
/>
|
||||||
)}
|
<Input
|
||||||
|
label="Phone"
|
||||||
{qrCode.contentType === 'TEXT' && (
|
value={content.phone || ''}
|
||||||
<div>
|
onChange={(e) => setContent({ ...content, phone: e.target.value })}
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
placeholder="+1234567890"
|
||||||
Text Content
|
/>
|
||||||
</label>
|
<Input
|
||||||
<textarea
|
label="Organization"
|
||||||
value={content.text || ''}
|
value={content.organization || ''}
|
||||||
onChange={(e) => setContent({ ...content, text: e.target.value })}
|
onChange={(e) => setContent({ ...content, organization: e.target.value })}
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
placeholder="Company Name"
|
||||||
rows={4}
|
/>
|
||||||
placeholder="Enter your text content"
|
<Input
|
||||||
required
|
label="Job Title"
|
||||||
/>
|
value={content.title || ''}
|
||||||
</div>
|
onChange={(e) => setContent({ ...content, title: e.target.value })}
|
||||||
)}
|
placeholder="CEO"
|
||||||
|
/>
|
||||||
<div className="flex justify-end space-x-4 pt-4">
|
</>
|
||||||
<Button
|
)}
|
||||||
variant="outline"
|
|
||||||
onClick={() => router.push('/dashboard')}
|
{qrCode.contentType === 'GEO' && (
|
||||||
>
|
<>
|
||||||
Cancel
|
<Input
|
||||||
</Button>
|
label="Latitude"
|
||||||
<Button
|
type="number"
|
||||||
onClick={handleSave}
|
step="any"
|
||||||
loading={saving}
|
value={content.latitude || ''}
|
||||||
disabled={csrfLoading || saving}
|
onChange={(e) => setContent({ ...content, latitude: parseFloat(e.target.value) || 0 })}
|
||||||
>
|
placeholder="37.7749"
|
||||||
{csrfLoading ? 'Loading...' : 'Save Changes'}
|
required
|
||||||
</Button>
|
/>
|
||||||
</div>
|
<Input
|
||||||
</CardContent>
|
label="Longitude"
|
||||||
</Card>
|
type="number"
|
||||||
</div>
|
step="any"
|
||||||
);
|
value={content.longitude || ''}
|
||||||
}
|
onChange={(e) => setContent({ ...content, longitude: parseFloat(e.target.value) || 0 })}
|
||||||
|
placeholder="-122.4194"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Location Label (Optional)"
|
||||||
|
value={content.label || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, label: e.target.value })}
|
||||||
|
placeholder="Golden Gate Bridge"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{qrCode.contentType === 'TEXT' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Text Content
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={content.text || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, text: e.target.value })}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
rows={4}
|
||||||
|
placeholder="Enter your text content"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{qrCode.contentType === 'PDF' && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center mb-1">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Upload Menu / PDF</label>
|
||||||
|
<Tooltip text="Upload your menu PDF (Max 10MB). Hosted securely." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-lg hover:bg-gray-50 transition-colors relative">
|
||||||
|
<div className="space-y-1 text-center">
|
||||||
|
{uploading ? (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500 mb-2"></div>
|
||||||
|
<p className="text-sm text-gray-500">Uploading...</p>
|
||||||
|
</div>
|
||||||
|
) : content.fileUrl ? (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="mx-auto h-12 w-12 text-primary-500 bg-primary-50 rounded-full flex items-center justify-center mb-2">
|
||||||
|
<FileText className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-green-600 font-medium mb-1">Upload Complete!</p>
|
||||||
|
<a href={content.fileUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-primary-500 hover:underline break-all max-w-xs mb-3 block">
|
||||||
|
{content.fileName || 'View File'}
|
||||||
|
</a>
|
||||||
|
<label htmlFor="file-upload" className="cursor-pointer bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
|
||||||
|
<span>Replace File</span>
|
||||||
|
<input id="file-upload" name="file-upload" type="file" className="sr-only" accept=".pdf,image/*" onChange={handleFileUpload} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload className="mx-auto h-12 w-12 text-gray-400" />
|
||||||
|
<div className="flex text-sm text-gray-600 justify-center">
|
||||||
|
<label htmlFor="file-upload" className="relative cursor-pointer bg-white rounded-md font-medium text-primary-600 hover:text-primary-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-primary-500">
|
||||||
|
<span>Upload a file</span>
|
||||||
|
<input id="file-upload" name="file-upload" type="file" className="sr-only" accept=".pdf,image/*" onChange={handleFileUpload} />
|
||||||
|
</label>
|
||||||
|
<p className="pl-1">or drag and drop</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">PDF, PNG, JPG up to 10MB</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{content.fileUrl && (
|
||||||
|
<Input
|
||||||
|
label="File Name / Menu Title"
|
||||||
|
value={content.fileName || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, fileName: e.target.value })}
|
||||||
|
placeholder="Product Catalog 2026"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{qrCode.contentType === 'APP' && (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
label="iOS App Store URL"
|
||||||
|
value={content.iosUrl || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, iosUrl: e.target.value })}
|
||||||
|
placeholder="https://apps.apple.com/app/..."
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Android Play Store URL"
|
||||||
|
value={content.androidUrl || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, androidUrl: e.target.value })}
|
||||||
|
placeholder="https://play.google.com/store/apps/..."
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Fallback URL (Desktop)"
|
||||||
|
value={content.fallbackUrl || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, fallbackUrl: e.target.value })}
|
||||||
|
placeholder="https://yourapp.com"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{qrCode.contentType === 'COUPON' && (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
label="Coupon Code"
|
||||||
|
value={content.code || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, code: e.target.value })}
|
||||||
|
placeholder="SUMMER20"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Discount"
|
||||||
|
value={content.discount || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, discount: e.target.value })}
|
||||||
|
placeholder="20% OFF"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Title"
|
||||||
|
value={content.title || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, title: e.target.value })}
|
||||||
|
placeholder="Summer Sale 2026"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Description (optional)"
|
||||||
|
value={content.description || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, description: e.target.value })}
|
||||||
|
placeholder="Valid on all products"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Expiry Date (optional)"
|
||||||
|
type="date"
|
||||||
|
value={content.expiryDate || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, expiryDate: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Redeem URL (optional)"
|
||||||
|
value={content.redeemUrl || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, redeemUrl: e.target.value })}
|
||||||
|
placeholder="https://shop.example.com"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{qrCode.contentType === 'FEEDBACK' && (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
label="Business Name"
|
||||||
|
value={content.businessName || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, businessName: e.target.value })}
|
||||||
|
placeholder="Your Restaurant Name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Google Review URL (optional)"
|
||||||
|
value={content.googleReviewUrl || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, googleReviewUrl: e.target.value })}
|
||||||
|
placeholder="https://search.google.com/local/writereview?placeid=..."
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Thank You Message"
|
||||||
|
value={content.thankYouMessage || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, thankYouMessage: e.target.value })}
|
||||||
|
placeholder="Thanks for your feedback!"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-4 pt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push('/dashboard')}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
loading={saving}
|
||||||
|
disabled={csrfLoading || saving}
|
||||||
|
>
|
||||||
|
{csrfLoading ? 'Loading...' : 'Save Changes'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
196
src/app/(app)/qr/[id]/feedback/page.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Star, ArrowLeft, ChevronLeft, ChevronRight, MessageSquare } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Feedback {
|
||||||
|
id: string;
|
||||||
|
rating: number;
|
||||||
|
comment: string;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FeedbackStats {
|
||||||
|
total: number;
|
||||||
|
avgRating: number;
|
||||||
|
distribution: { [key: number]: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Pagination {
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FeedbackListPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const qrId = params.id as string;
|
||||||
|
|
||||||
|
const [feedbacks, setFeedbacks] = useState<Feedback[]>([]);
|
||||||
|
const [stats, setStats] = useState<FeedbackStats | null>(null);
|
||||||
|
const [pagination, setPagination] = useState<Pagination>({ page: 1, totalPages: 1, hasMore: false });
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchFeedback(currentPage);
|
||||||
|
}, [qrId, currentPage]);
|
||||||
|
|
||||||
|
const fetchFeedback = async (page: number) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/qrs/${qrId}/feedback?page=${page}&limit=20`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setFeedbacks(data.feedbacks);
|
||||||
|
setStats(data.stats);
|
||||||
|
setPagination(data.pagination);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching feedback:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStars = (rating: number) => (
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<Star
|
||||||
|
key={star}
|
||||||
|
className={`w-4 h-4 ${star <= rating ? 'text-amber-400 fill-amber-400' : 'text-gray-200'}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading && !stats) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<Link href={`/qr/${qrId}`} className="inline-flex items-center text-gray-500 hover:text-gray-700 mb-4">
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Back to QR Code
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Customer Feedback</h1>
|
||||||
|
<p className="text-gray-600 mt-1">{stats?.total || 0} total responses</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Overview */}
|
||||||
|
{stats && (
|
||||||
|
<Card className="mb-8">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center gap-8">
|
||||||
|
{/* Average Rating */}
|
||||||
|
<div className="text-center md:text-left">
|
||||||
|
<div className="text-5xl font-bold text-gray-900 mb-1">{stats.avgRating}</div>
|
||||||
|
<div className="flex justify-center md:justify-start mb-1">
|
||||||
|
{renderStars(Math.round(stats.avgRating))}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500">{stats.total} reviews</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Distribution */}
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
{[5, 4, 3, 2, 1].map((rating) => {
|
||||||
|
const count = stats.distribution[rating] || 0;
|
||||||
|
const percentage = stats.total > 0 ? (count / stats.total) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div key={rating} className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-gray-600 w-12">{rating} stars</span>
|
||||||
|
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-amber-400 rounded-full transition-all"
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-500 w-12 text-right">{count}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Feedback List */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<MessageSquare className="w-5 h-5" />
|
||||||
|
All Reviews
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{feedbacks.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-gray-500">
|
||||||
|
<Star className="w-12 h-12 mx-auto mb-4 text-gray-300" />
|
||||||
|
<p>No feedback received yet</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{feedbacks.map((feedback) => (
|
||||||
|
<div key={feedback.id} className="py-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
{renderStars(feedback.rating)}
|
||||||
|
<span className="text-sm text-gray-400">
|
||||||
|
{new Date(feedback.date).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{feedback.comment && (
|
||||||
|
<p className="text-gray-700">{feedback.comment}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{pagination.totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between mt-6 pt-6 border-t">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
Page {currentPage} of {pagination.totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentPage((p) => p + 1)}
|
||||||
|
disabled={!pagination.hasMore}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="w-4 h-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
287
src/app/(app)/qr/[id]/page.tsx
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import {
|
||||||
|
ArrowLeft, Edit, ExternalLink, Star, MessageSquare,
|
||||||
|
BarChart3, Copy, Check, Pause, Play
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { showToast } from '@/components/ui/Toast';
|
||||||
|
import { useCsrf } from '@/hooks/useCsrf';
|
||||||
|
|
||||||
|
interface QRCode {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
type: 'STATIC' | 'DYNAMIC';
|
||||||
|
contentType: string;
|
||||||
|
content: any;
|
||||||
|
slug: string;
|
||||||
|
status: 'ACTIVE' | 'PAUSED';
|
||||||
|
style: any;
|
||||||
|
createdAt: string;
|
||||||
|
_count?: { scans: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FeedbackStats {
|
||||||
|
total: number;
|
||||||
|
avgRating: number;
|
||||||
|
distribution: { [key: number]: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QRDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const qrId = params.id as string;
|
||||||
|
const { fetchWithCsrf } = useCsrf();
|
||||||
|
|
||||||
|
const [qrCode, setQrCode] = useState<QRCode | null>(null);
|
||||||
|
const [feedbackStats, setFeedbackStats] = useState<FeedbackStats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchQRCode();
|
||||||
|
}, [qrId]);
|
||||||
|
|
||||||
|
const fetchQRCode = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/qrs/${qrId}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setQrCode(data);
|
||||||
|
|
||||||
|
// Fetch feedback stats if it's a feedback QR
|
||||||
|
if (data.contentType === 'FEEDBACK') {
|
||||||
|
const feedbackRes = await fetch(`/api/qrs/${qrId}/feedback?limit=1`);
|
||||||
|
if (feedbackRes.ok) {
|
||||||
|
const feedbackData = await feedbackRes.json();
|
||||||
|
setFeedbackStats(feedbackData.stats);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast('QR code not found', 'error');
|
||||||
|
router.push('/dashboard');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching QR code:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyLink = async () => {
|
||||||
|
const url = `${window.location.origin}/r/${qrCode?.slug}`;
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
showToast('Link copied!', 'success');
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleStatus = async () => {
|
||||||
|
if (!qrCode) return;
|
||||||
|
const newStatus = qrCode.status === 'ACTIVE' ? 'PAUSED' : 'ACTIVE';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetchWithCsrf(`/api/qrs/${qrId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ status: newStatus }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setQrCode({ ...qrCode, status: newStatus });
|
||||||
|
showToast(`QR code ${newStatus === 'ACTIVE' ? 'activated' : 'paused'}`, 'success');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Failed to update status', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStars = (rating: number) => (
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<Star
|
||||||
|
key={star}
|
||||||
|
className={`w-4 h-4 ${star <= rating ? 'text-amber-400 fill-amber-400' : 'text-gray-200'}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!qrCode) return null;
|
||||||
|
|
||||||
|
const qrUrl = `${typeof window !== 'undefined' ? window.location.origin : ''}/r/${qrCode.slug}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<Link href="/dashboard" className="inline-flex items-center text-gray-500 hover:text-gray-700 mb-4">
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Back to Dashboard
|
||||||
|
</Link>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">{qrCode.title}</h1>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<Badge variant={qrCode.type === 'DYNAMIC' ? 'info' : 'default'}>
|
||||||
|
{qrCode.type}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant={qrCode.status === 'ACTIVE' ? 'success' : 'warning'}>
|
||||||
|
{qrCode.status}
|
||||||
|
</Badge>
|
||||||
|
<Badge>{qrCode.contentType}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{qrCode.type === 'DYNAMIC' && (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" size="sm" onClick={toggleStatus}>
|
||||||
|
{qrCode.status === 'ACTIVE' ? <Pause className="w-4 h-4 mr-1" /> : <Play className="w-4 h-4 mr-1" />}
|
||||||
|
{qrCode.status === 'ACTIVE' ? 'Pause' : 'Activate'}
|
||||||
|
</Button>
|
||||||
|
<Link href={`/qr/${qrId}/edit`}>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Edit className="w-4 h-4 mr-1" /> Edit
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid lg:grid-cols-3 gap-8">
|
||||||
|
{/* Left: QR Code */}
|
||||||
|
<div>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6 flex flex-col items-center">
|
||||||
|
<div className="bg-white p-4 rounded-xl shadow-sm mb-4">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={qrUrl}
|
||||||
|
size={200}
|
||||||
|
fgColor={qrCode.style?.foregroundColor || '#000000'}
|
||||||
|
bgColor={qrCode.style?.backgroundColor || '#FFFFFF'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full space-y-2">
|
||||||
|
<Button variant="outline" className="w-full" onClick={copyLink}>
|
||||||
|
{copied ? <Check className="w-4 h-4 mr-2" /> : <Copy className="w-4 h-4 mr-2" />}
|
||||||
|
{copied ? 'Copied!' : 'Copy Link'}
|
||||||
|
</Button>
|
||||||
|
<a href={qrUrl} target="_blank" rel="noopener noreferrer" className="block">
|
||||||
|
<Button variant="outline" className="w-full">
|
||||||
|
<ExternalLink className="w-4 h-4 mr-2" /> Open Link
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Stats & Info */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Quick Stats */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4 text-center">
|
||||||
|
<BarChart3 className="w-6 h-6 mx-auto mb-2 text-indigo-500" />
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{qrCode._count?.scans || 0}</p>
|
||||||
|
<p className="text-sm text-gray-500">Total Scans</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4 text-center">
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{qrCode.type}</p>
|
||||||
|
<p className="text-sm text-gray-500">QR Type</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4 text-center">
|
||||||
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
|
{new Date(qrCode.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">Created</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feedback Summary (only for FEEDBACK type) */}
|
||||||
|
{qrCode.contentType === 'FEEDBACK' && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Star className="w-5 h-5 text-amber-400" />
|
||||||
|
Customer Feedback
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{feedbackStats && feedbackStats.total > 0 ? (
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-6 mb-4">
|
||||||
|
{/* Average */}
|
||||||
|
<div className="text-center sm:text-left">
|
||||||
|
<div className="text-4xl font-bold text-gray-900">{feedbackStats.avgRating}</div>
|
||||||
|
{renderStars(Math.round(feedbackStats.avgRating))}
|
||||||
|
<p className="text-sm text-gray-500 mt-1">{feedbackStats.total} reviews</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Distribution */}
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
{[5, 4, 3, 2, 1].map((rating) => {
|
||||||
|
const count = feedbackStats.distribution[rating] || 0;
|
||||||
|
const pct = feedbackStats.total > 0 ? (count / feedbackStats.total) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div key={rating} className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="w-8 text-gray-500">{rating}★</span>
|
||||||
|
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-amber-400 rounded-full" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="w-8 text-gray-400 text-right">{count}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500 mb-4">No feedback received yet. Share your QR code to collect reviews!</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link href={`/qr/${qrId}/feedback`} className="block">
|
||||||
|
<Button variant="outline" className="w-full">
|
||||||
|
<MessageSquare className="w-4 h-4 mr-2" />
|
||||||
|
View All Feedback
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content Info */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Content Details</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<pre className="bg-gray-50 p-4 rounded-lg text-sm overflow-auto">
|
||||||
|
{JSON.stringify(qrCode.content, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
export default function AuthLayout({
|
export default function AuthLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white">
|
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,187 +1,187 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { useCsrf } from '@/hooks/useCsrf';
|
import { useCsrf } from '@/hooks/useCsrf';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { fetchWithCsrf, loading: csrfLoading } = useCsrf();
|
const { fetchWithCsrf, loading: csrfLoading } = useCsrf();
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetchWithCsrf('/api/auth/simple-login', {
|
const response = await fetchWithCsrf('/api/auth/simple-login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (response.ok && data.success) {
|
if (response.ok && data.success) {
|
||||||
// Store user in localStorage for client-side
|
// Store user in localStorage for client-side
|
||||||
localStorage.setItem('user', JSON.stringify(data.user));
|
localStorage.setItem('user', JSON.stringify(data.user));
|
||||||
|
|
||||||
// Track successful login with PostHog
|
// Track successful login with PostHog
|
||||||
try {
|
try {
|
||||||
const { identifyUser, trackEvent } = await import('@/components/PostHogProvider');
|
const { identifyUser, trackEvent } = await import('@/components/PostHogProvider');
|
||||||
identifyUser(data.user.id, {
|
identifyUser(data.user.id, {
|
||||||
email: data.user.email,
|
email: data.user.email,
|
||||||
name: data.user.name,
|
name: data.user.name,
|
||||||
plan: data.user.plan || 'FREE',
|
plan: data.user.plan || 'FREE',
|
||||||
});
|
});
|
||||||
trackEvent('user_login', {
|
trackEvent('user_login', {
|
||||||
method: 'email',
|
method: 'email',
|
||||||
email: data.user.email,
|
email: data.user.email,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('PostHog tracking error:', error);
|
console.error('PostHog tracking error:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for redirect parameter
|
// Check for redirect parameter
|
||||||
const redirectUrl = searchParams.get('redirect') || '/dashboard';
|
const redirectUrl = searchParams.get('redirect') || '/dashboard';
|
||||||
router.push(redirectUrl);
|
router.push(redirectUrl);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
setError(data.error || 'Invalid email or password');
|
setError(data.error || 'Invalid email or password');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('An error occurred. Please try again.');
|
setError('An error occurred. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGoogleSignIn = () => {
|
const handleGoogleSignIn = () => {
|
||||||
// Redirect to Google OAuth API route
|
// Redirect to Google OAuth API route
|
||||||
window.location.href = '/api/auth/google';
|
window.location.href = '/api/auth/google';
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
|
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
|
||||||
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
|
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
|
||||||
<span className="text-2xl font-bold text-gray-900">QR Master</span>
|
<span className="text-2xl font-bold text-gray-900">QR Master</span>
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Welcome Back</h1>
|
<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>
|
<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">
|
<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
|
← Back to Home
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
|
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="Email"
|
label="Email"
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="Password"
|
label="Password"
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="flex items-center">
|
<label className="flex items-center">
|
||||||
<input type="checkbox" className="mr-2" />
|
<input type="checkbox" className="mr-2" />
|
||||||
<span className="text-sm text-gray-600">Remember me</span>
|
<span className="text-sm text-gray-600">Remember me</span>
|
||||||
</label>
|
</label>
|
||||||
<Link href="/forgot-password" className="text-sm text-primary-600 hover:text-primary-700">
|
<Link href="/forgot-password" className="text-sm text-primary-600 hover:text-primary-700">
|
||||||
Forgot password?
|
Forgot password?
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" className="w-full" loading={loading} disabled={csrfLoading || loading}>
|
<Button type="submit" className="w-full" loading={loading} disabled={csrfLoading || loading}>
|
||||||
{csrfLoading ? 'Loading...' : 'Sign In'}
|
{csrfLoading ? 'Loading...' : 'Sign In'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="relative my-6">
|
<div className="relative my-6">
|
||||||
<div className="absolute inset-0 flex items-center">
|
<div className="absolute inset-0 flex items-center">
|
||||||
<div className="w-full border-t border-gray-300"></div>
|
<div className="w-full border-t border-gray-300"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex justify-center text-sm">
|
<div className="relative flex justify-center text-sm">
|
||||||
<span className="px-2 bg-white text-gray-500">Or continue with</span>
|
<span className="px-2 bg-white text-gray-500">Or continue with</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={handleGoogleSignIn}
|
onClick={handleGoogleSignIn}
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
fill="#4285F4"
|
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"
|
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
|
<path
|
||||||
fill="#34A853"
|
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"
|
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
|
<path
|
||||||
fill="#FBBC05"
|
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"
|
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
|
<path
|
||||||
fill="#EA4335"
|
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"
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Sign in with Google
|
Sign in with Google
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-6 text-center">
|
<div className="mt-6 text-center">
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
Don't have an account?{' '}
|
Don't have an account?{' '}
|
||||||
<Link href="/signup" className="text-primary-600 hover:text-primary-700 font-medium">
|
<Link href="/signup" className="text-primary-600 hover:text-primary-700 font-medium">
|
||||||
Sign up
|
Sign up
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<p className="text-center text-sm text-gray-500 mt-6">
|
<p className="text-center text-sm text-gray-500 mt-6">
|
||||||
By signing in, you agree to our{' '}
|
By signing in, you agree to our{' '}
|
||||||
<Link href="/privacy" className="text-primary-600 hover:text-primary-700">
|
<Link href="/privacy" className="text-primary-600 hover:text-primary-700">
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,208 +1,208 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useSearchParams, useRouter } from 'next/navigation';
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
import { Card, CardContent } from '@/components/ui/Card';
|
import { Card, CardContent } from '@/components/ui/Card';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { useCsrf } from '@/hooks/useCsrf';
|
import { useCsrf } from '@/hooks/useCsrf';
|
||||||
|
|
||||||
export default function ResetPasswordPage() {
|
export default function ResetPasswordPage() {
|
||||||
const { fetchWithCsrf, loading: csrfLoading } = useCsrf();
|
const { fetchWithCsrf, loading: csrfLoading } = useCsrf();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [token, setToken] = useState('');
|
const [token, setToken] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const tokenParam = searchParams.get('token');
|
const tokenParam = searchParams.get('token');
|
||||||
if (!tokenParam) {
|
if (!tokenParam) {
|
||||||
setError('Invalid or missing reset token. Please request a new password reset link.');
|
setError('Invalid or missing reset token. Please request a new password reset link.');
|
||||||
} else {
|
} else {
|
||||||
setToken(tokenParam);
|
setToken(tokenParam);
|
||||||
}
|
}
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
// Validate passwords match
|
// Validate passwords match
|
||||||
if (password !== confirmPassword) {
|
if (password !== confirmPassword) {
|
||||||
setError('Passwords do not match');
|
setError('Passwords do not match');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate password length
|
// Validate password length
|
||||||
if (password.length < 8) {
|
if (password.length < 8) {
|
||||||
setError('Password must be at least 8 characters long');
|
setError('Password must be at least 8 characters long');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetchWithCsrf('/api/auth/reset-password', {
|
const response = await fetchWithCsrf('/api/auth/reset-password', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ token, password }),
|
body: JSON.stringify({ token, password }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
// Redirect to login after 3 seconds
|
// Redirect to login after 3 seconds
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
}, 3000);
|
}, 3000);
|
||||||
} else {
|
} else {
|
||||||
setError(data.error || 'Failed to reset password');
|
setError(data.error || 'Failed to reset password');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('An error occurred. Please try again.');
|
setError('An error occurred. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
|
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
|
||||||
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
|
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
|
||||||
<span className="text-2xl font-bold text-gray-900">QR Master</span>
|
<span className="text-2xl font-bold text-gray-900">QR Master</span>
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Password Reset Successful</h1>
|
<h1 className="text-3xl font-bold text-gray-900">Password Reset Successful</h1>
|
||||||
<p className="text-gray-600 mt-2">Your password has been updated</p>
|
<p className="text-gray-600 mt-2">Your password has been updated</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-green-100 rounded-full mb-4">
|
<div className="inline-flex items-center justify-center w-16 h-16 bg-green-100 rounded-full mb-4">
|
||||||
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-gray-700 mb-4">
|
<p className="text-gray-700 mb-4">
|
||||||
Your password has been successfully reset!
|
Your password has been successfully reset!
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-sm text-gray-600 mb-6">
|
<p className="text-sm text-gray-600 mb-6">
|
||||||
Redirecting you to the login page in 3 seconds...
|
Redirecting you to the login page in 3 seconds...
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Link href="/login" className="block">
|
<Link href="/login" className="block">
|
||||||
<Button variant="primary" className="w-full">
|
<Button variant="primary" className="w-full">
|
||||||
Go to Login
|
Go to Login
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
|
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
|
||||||
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
|
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
|
||||||
<span className="text-2xl font-bold text-gray-900">QR Master</span>
|
<span className="text-2xl font-bold text-gray-900">QR Master</span>
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Reset Your Password</h1>
|
<h1 className="text-3xl font-bold text-gray-900">Reset Your Password</h1>
|
||||||
<p className="text-gray-600 mt-2">Enter your new password below</p>
|
<p className="text-gray-600 mt-2">Enter your new password below</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
{!token ? (
|
{!token ? (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-red-100 rounded-full mb-4">
|
<div className="inline-flex items-center justify-center w-16 h-16 bg-red-100 rounded-full mb-4">
|
||||||
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-red-600 mb-4">{error}</p>
|
<p className="text-red-600 mb-4">{error}</p>
|
||||||
<Link href="/forgot-password" className="block">
|
<Link href="/forgot-password" className="block">
|
||||||
<Button variant="primary" className="w-full">
|
<Button variant="primary" className="w-full">
|
||||||
Request New Reset Link
|
Request New Reset Link
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
|
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="New Password"
|
label="New Password"
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="Enter new password"
|
placeholder="Enter new password"
|
||||||
required
|
required
|
||||||
disabled={loading || csrfLoading}
|
disabled={loading || csrfLoading}
|
||||||
minLength={8}
|
minLength={8}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="Confirm Password"
|
label="Confirm Password"
|
||||||
type="password"
|
type="password"
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
placeholder="Confirm new password"
|
placeholder="Confirm new password"
|
||||||
required
|
required
|
||||||
disabled={loading || csrfLoading}
|
disabled={loading || csrfLoading}
|
||||||
minLength={8}
|
minLength={8}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-gray-500">
|
||||||
Password must be at least 8 characters long
|
Password must be at least 8 characters long
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={csrfLoading || loading}
|
disabled={csrfLoading || loading}
|
||||||
>
|
>
|
||||||
{csrfLoading ? 'Loading...' : 'Reset Password'}
|
{csrfLoading ? 'Loading...' : 'Reset Password'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Link href="/login" className="text-sm text-primary-600 hover:text-primary-700 font-medium">
|
<Link href="/login" className="text-sm text-primary-600 hover:text-primary-700 font-medium">
|
||||||
← Back to Login
|
← Back to Login
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<p className="text-center text-sm text-gray-500 mt-6">
|
<p className="text-center text-sm text-gray-500 mt-6">
|
||||||
Remember your password?{' '}
|
Remember your password?{' '}
|
||||||
<Link href="/login" className="text-primary-600 hover:text-primary-700 font-medium">
|
<Link href="/login" className="text-primary-600 hover:text-primary-700 font-medium">
|
||||||
Sign in
|
Sign in
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,208 +1,208 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { useCsrf } from '@/hooks/useCsrf';
|
import { useCsrf } from '@/hooks/useCsrf';
|
||||||
|
|
||||||
export default function SignupPage() {
|
export default function SignupPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { fetchWithCsrf } = useCsrf();
|
const { fetchWithCsrf } = useCsrf();
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
if (password !== confirmPassword) {
|
if (password !== confirmPassword) {
|
||||||
setError('Passwords do not match');
|
setError('Passwords do not match');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password.length < 8) {
|
if (password.length < 8) {
|
||||||
setError('Password must be at least 8 characters');
|
setError('Password must be at least 8 characters');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetchWithCsrf('/api/auth/signup', {
|
const response = await fetchWithCsrf('/api/auth/signup', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ name, email, password }),
|
body: JSON.stringify({ name, email, password }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (response.ok && data.success) {
|
if (response.ok && data.success) {
|
||||||
// Store user in localStorage for client-side
|
// Store user in localStorage for client-side
|
||||||
localStorage.setItem('user', JSON.stringify(data.user));
|
localStorage.setItem('user', JSON.stringify(data.user));
|
||||||
|
|
||||||
// Track successful signup with PostHog
|
// Track successful signup with PostHog
|
||||||
try {
|
try {
|
||||||
const { identifyUser, trackEvent } = await import('@/components/PostHogProvider');
|
const { identifyUser, trackEvent } = await import('@/components/PostHogProvider');
|
||||||
identifyUser(data.user.id, {
|
identifyUser(data.user.id, {
|
||||||
email: data.user.email,
|
email: data.user.email,
|
||||||
name: data.user.name,
|
name: data.user.name,
|
||||||
plan: data.user.plan || 'FREE',
|
plan: data.user.plan || 'FREE',
|
||||||
signupMethod: 'email',
|
signupMethod: 'email',
|
||||||
});
|
});
|
||||||
trackEvent('user_signup', {
|
trackEvent('user_signup', {
|
||||||
method: 'email',
|
method: 'email',
|
||||||
email: data.user.email,
|
email: data.user.email,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('PostHog tracking error:', error);
|
console.error('PostHog tracking error:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to dashboard
|
// Redirect to dashboard
|
||||||
router.push('/dashboard');
|
router.push('/dashboard');
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
setError(data.error || 'Failed to create account');
|
setError(data.error || 'Failed to create account');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('An error occurred. Please try again.');
|
setError('An error occurred. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGoogleSignIn = () => {
|
const handleGoogleSignIn = () => {
|
||||||
// Redirect to Google OAuth API route
|
// Redirect to Google OAuth API route
|
||||||
window.location.href = '/api/auth/google';
|
window.location.href = '/api/auth/google';
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
|
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
|
||||||
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
|
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
|
||||||
<span className="text-2xl font-bold text-gray-900">QR Master</span>
|
<span className="text-2xl font-bold text-gray-900">QR Master</span>
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Create Account</h1>
|
<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>
|
<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">
|
<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
|
← Back to Home
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
|
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="Full Name"
|
label="Full Name"
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="John Doe"
|
placeholder="John Doe"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="Email"
|
label="Email"
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="Password"
|
label="Password"
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="Confirm Password"
|
label="Confirm Password"
|
||||||
type="password"
|
type="password"
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button type="submit" className="w-full" loading={loading}>
|
<Button type="submit" className="w-full" loading={loading}>
|
||||||
Create Account
|
Create Account
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="relative my-6">
|
<div className="relative my-6">
|
||||||
<div className="absolute inset-0 flex items-center">
|
<div className="absolute inset-0 flex items-center">
|
||||||
<div className="w-full border-t border-gray-300"></div>
|
<div className="w-full border-t border-gray-300"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex justify-center text-sm">
|
<div className="relative flex justify-center text-sm">
|
||||||
<span className="px-2 bg-white text-gray-500">Or continue with</span>
|
<span className="px-2 bg-white text-gray-500">Or continue with</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={handleGoogleSignIn}
|
onClick={handleGoogleSignIn}
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
fill="#4285F4"
|
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"
|
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
|
<path
|
||||||
fill="#34A853"
|
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"
|
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
|
<path
|
||||||
fill="#FBBC05"
|
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"
|
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
|
<path
|
||||||
fill="#EA4335"
|
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"
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Sign up with Google
|
Sign up with Google
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-6 text-center">
|
<div className="mt-6 text-center">
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
Already have an account?{' '}
|
Already have an account?{' '}
|
||||||
<Link href="/login" className="text-primary-600 hover:text-primary-700 font-medium">
|
<Link href="/login" className="text-primary-600 hover:text-primary-700 font-medium">
|
||||||
Sign in
|
Sign in
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<p className="text-center text-sm text-gray-500 mt-6">
|
<p className="text-center text-sm text-gray-500 mt-6">
|
||||||
By signing up, you agree to our{' '}
|
By signing up, you agree to our{' '}
|
||||||
<Link href="/privacy" className="text-primary-600 hover:text-primary-700">
|
<Link href="/privacy" className="text-primary-600 hover:text-primary-700">
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,182 +1,182 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import SeoJsonLd from '@/components/SeoJsonLd';
|
import SeoJsonLd from '@/components/SeoJsonLd';
|
||||||
import { websiteSchema, breadcrumbSchema } from '@/lib/schema';
|
import { websiteSchema, breadcrumbSchema } from '@/lib/schema';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
import Breadcrumbs, { BreadcrumbItem } from '@/components/Breadcrumbs';
|
import Breadcrumbs, { BreadcrumbItem } from '@/components/Breadcrumbs';
|
||||||
|
|
||||||
function truncateAtWord(text: string, maxLength: number): string {
|
function truncateAtWord(text: string, maxLength: number): string {
|
||||||
if (text.length <= maxLength) return text;
|
if (text.length <= maxLength) return text;
|
||||||
const truncated = text.slice(0, maxLength);
|
const truncated = text.slice(0, maxLength);
|
||||||
const lastSpace = truncated.lastIndexOf(' ');
|
const lastSpace = truncated.lastIndexOf(' ');
|
||||||
return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated;
|
return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const title = truncateAtWord('QR Insights: Latest QR Strategies', 60);
|
const title = truncateAtWord('QR Insights: Latest QR Strategies', 60);
|
||||||
const description = truncateAtWord(
|
const description = truncateAtWord(
|
||||||
'Expert guides on QR analytics, dynamic codes & smart marketing uses.',
|
'Expert guides on QR analytics, dynamic codes & smart marketing uses.',
|
||||||
160
|
160
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: 'https://www.qrmaster.net/blog',
|
canonical: 'https://www.qrmaster.net/blog',
|
||||||
languages: {
|
languages: {
|
||||||
'x-default': 'https://www.qrmaster.net/blog',
|
'x-default': 'https://www.qrmaster.net/blog',
|
||||||
en: 'https://www.qrmaster.net/blog',
|
en: 'https://www.qrmaster.net/blog',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
url: 'https://www.qrmaster.net/blog',
|
url: 'https://www.qrmaster.net/blog',
|
||||||
type: 'website',
|
type: 'website',
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const blogPosts = [
|
const blogPosts = [
|
||||||
// NEW POSTS (January 2026)
|
// NEW POSTS (January 2026)
|
||||||
{
|
{
|
||||||
slug: 'qr-code-restaurant-menu',
|
slug: 'qr-code-restaurant-menu',
|
||||||
title: 'How to Create a QR Code for Restaurant Menu',
|
title: 'How to Create a QR Code for Restaurant Menu',
|
||||||
excerpt: 'Step-by-step guide to creating digital menu QR codes for your restaurant. Learn best practices for touchless menus, placement tips, and tracking.',
|
excerpt: 'Step-by-step guide to creating digital menu QR codes for your restaurant. Learn best practices for touchless menus, placement tips, and tracking.',
|
||||||
date: 'January 5, 2026',
|
date: 'January 5, 2026',
|
||||||
readTime: '12 Min',
|
readTime: '12 Min',
|
||||||
category: 'Restaurant',
|
category: 'Restaurant',
|
||||||
image: '/blog/restaurant-qr-menu.png',
|
image: '/blog/restaurant-qr-menu.png',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: 'vcard-qr-code-generator',
|
slug: 'vcard-qr-code-generator',
|
||||||
title: 'Free vCard QR Code Generator: Digital Business Cards',
|
title: 'Free vCard QR Code Generator: Digital Business Cards',
|
||||||
excerpt: 'Create professional vCard QR codes for digital business cards. Share contact info instantly with a scan—includes templates and best practices.',
|
excerpt: 'Create professional vCard QR codes for digital business cards. Share contact info instantly with a scan—includes templates and best practices.',
|
||||||
date: 'January 5, 2026',
|
date: 'January 5, 2026',
|
||||||
readTime: '10 Min',
|
readTime: '10 Min',
|
||||||
category: 'Business Cards',
|
category: 'Business Cards',
|
||||||
image: '/blog/vcard-qr-code.png',
|
image: '/blog/vcard-qr-code.png',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: 'qr-code-small-business',
|
slug: 'qr-code-small-business',
|
||||||
title: 'Best QR Code Generator for Small Business: 2025 Guide',
|
title: 'Best QR Code Generator for Small Business: 2025 Guide',
|
||||||
excerpt: 'Find the best QR code solution for your small business. Compare features, pricing, and use cases for marketing, payments, and operations.',
|
excerpt: 'Find the best QR code solution for your small business. Compare features, pricing, and use cases for marketing, payments, and operations.',
|
||||||
date: 'January 5, 2026',
|
date: 'January 5, 2026',
|
||||||
readTime: '14 Min',
|
readTime: '14 Min',
|
||||||
category: 'Business',
|
category: 'Business',
|
||||||
image: '/blog/small-business-qr.png',
|
image: '/blog/small-business-qr.png',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: 'qr-code-print-size-guide',
|
slug: 'qr-code-print-size-guide',
|
||||||
title: 'QR Code Print Size Guide: Minimum Sizes for Every Use Case',
|
title: 'QR Code Print Size Guide: Minimum Sizes for Every Use Case',
|
||||||
excerpt: 'Complete guide to QR code print sizes. Learn minimum dimensions for business cards, posters, banners, and more to ensure reliable scanning.',
|
excerpt: 'Complete guide to QR code print sizes. Learn minimum dimensions for business cards, posters, banners, and more to ensure reliable scanning.',
|
||||||
date: 'January 5, 2026',
|
date: 'January 5, 2026',
|
||||||
readTime: '8 Min',
|
readTime: '8 Min',
|
||||||
category: 'Printing',
|
category: 'Printing',
|
||||||
image: '/blog/qr-print-sizes.png',
|
image: '/blog/qr-print-sizes.png',
|
||||||
},
|
},
|
||||||
// EXISTING POSTS
|
// EXISTING POSTS
|
||||||
{
|
{
|
||||||
slug: 'qr-code-tracking-guide-2025',
|
slug: 'qr-code-tracking-guide-2025',
|
||||||
title: 'QR Code Tracking: Complete Guide 2025',
|
title: 'QR Code Tracking: Complete Guide 2025',
|
||||||
excerpt: 'Learn how to track QR code scans with real-time analytics. Compare free vs paid tracking tools, setup Google Analytics, and measure ROI.',
|
excerpt: 'Learn how to track QR code scans with real-time analytics. Compare free vs paid tracking tools, setup Google Analytics, and measure ROI.',
|
||||||
date: 'October 18, 2025',
|
date: 'October 18, 2025',
|
||||||
readTime: '12 Min',
|
readTime: '12 Min',
|
||||||
category: 'Tracking & Analytics',
|
category: 'Tracking & Analytics',
|
||||||
image: '/blog/1-hero.png',
|
image: '/blog/1-hero.png',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: 'dynamic-vs-static-qr-codes',
|
slug: 'dynamic-vs-static-qr-codes',
|
||||||
title: 'Dynamic vs Static QR Codes: Which Should You Use?',
|
title: 'Dynamic vs Static QR Codes: Which Should You Use?',
|
||||||
excerpt: 'Understand the difference between static and dynamic QR codes. Learn when to use each type, pros/cons, and how dynamic QR codes save money.',
|
excerpt: 'Understand the difference between static and dynamic QR codes. Learn when to use each type, pros/cons, and how dynamic QR codes save money.',
|
||||||
date: 'October 17, 2025',
|
date: 'October 17, 2025',
|
||||||
readTime: '10 Min',
|
readTime: '10 Min',
|
||||||
category: 'QR Code Basics',
|
category: 'QR Code Basics',
|
||||||
image: '/blog/2-hero.png',
|
image: '/blog/2-hero.png',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: 'bulk-qr-code-generator-excel',
|
slug: 'bulk-qr-code-generator-excel',
|
||||||
title: 'How to Generate Bulk QR Codes from Excel',
|
title: 'How to Generate Bulk QR Codes from Excel',
|
||||||
excerpt: 'Generate hundreds of QR codes from Excel or CSV files in minutes. Step-by-step guide with templates, best practices, and free tools.',
|
excerpt: 'Generate hundreds of QR codes from Excel or CSV files in minutes. Step-by-step guide with templates, best practices, and free tools.',
|
||||||
date: 'October 16, 2025',
|
date: 'October 16, 2025',
|
||||||
readTime: '13 Min',
|
readTime: '13 Min',
|
||||||
category: 'Bulk Generation',
|
category: 'Bulk Generation',
|
||||||
image: '/blog/3-hero.png',
|
image: '/blog/3-hero.png',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: 'qr-code-analytics',
|
slug: 'qr-code-analytics',
|
||||||
title: 'QR Code Analytics: Track, Measure & Optimize Campaigns',
|
title: 'QR Code Analytics: Track, Measure & Optimize Campaigns',
|
||||||
excerpt: 'Learn how to leverage scan analytics, campaign tracking, and dashboard insights to maximize QR code ROI.',
|
excerpt: 'Learn how to leverage scan analytics, campaign tracking, and dashboard insights to maximize QR code ROI.',
|
||||||
date: 'October 16, 2025',
|
date: 'October 16, 2025',
|
||||||
readTime: '15 Min',
|
readTime: '15 Min',
|
||||||
category: 'Analytics',
|
category: 'Analytics',
|
||||||
image: '/blog/4-hero.png',
|
image: '/blog/4-hero.png',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function BlogPage() {
|
export default function BlogPage() {
|
||||||
const breadcrumbItems: BreadcrumbItem[] = [
|
const breadcrumbItems: BreadcrumbItem[] = [
|
||||||
{ name: 'Home', url: '/' },
|
{ name: 'Home', url: '/' },
|
||||||
{ name: 'Blog', url: '/blog' },
|
{ name: 'Blog', url: '/blog' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SeoJsonLd data={[websiteSchema(), breadcrumbSchema(breadcrumbItems)]} />
|
<SeoJsonLd data={[websiteSchema(), breadcrumbSchema(breadcrumbItems)]} />
|
||||||
<div className="py-20 bg-gradient-to-b from-gray-50 to-white">
|
<div className="py-20 bg-gradient-to-b from-gray-50 to-white">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<Breadcrumbs items={breadcrumbItems} />
|
<Breadcrumbs items={breadcrumbItems} />
|
||||||
<div className="text-center mb-16">
|
<div className="text-center mb-16">
|
||||||
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-6">
|
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-6">
|
||||||
QR Code Insights
|
QR Code Insights
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||||
Expert guides on dynamic QR codes, campaign tracking, UTM analytics, and smart marketing use cases.
|
Expert guides on dynamic QR codes, campaign tracking, UTM analytics, and smart marketing use cases.
|
||||||
Discover how-to tutorials and best practices for QR code analytics.
|
Discover how-to tutorials and best practices for QR code analytics.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8 max-w-6xl mx-auto">
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8 max-w-6xl mx-auto">
|
||||||
{blogPosts.map((post) => (
|
{blogPosts.map((post) => (
|
||||||
<Link key={post.slug} href={`/blog/${post.slug}`}>
|
<Link key={post.slug} href={`/blog/${post.slug}`}>
|
||||||
<Card hover className="h-full overflow-hidden shadow-md hover:shadow-xl transition-all duration-300">
|
<Card hover className="h-full overflow-hidden shadow-md hover:shadow-xl transition-all duration-300">
|
||||||
<div className="relative h-56 overflow-hidden">
|
<div className="relative h-56 overflow-hidden">
|
||||||
<Image
|
<Image
|
||||||
src={post.image}
|
src={post.image}
|
||||||
alt={`${post.title} - QR code guide showing ${post.category.toLowerCase()} strategies`}
|
alt={`${post.title} - QR code guide showing ${post.category.toLowerCase()} strategies`}
|
||||||
width={800}
|
width={800}
|
||||||
height={600}
|
height={600}
|
||||||
className="w-full h-full object-cover transition-transform duration-500 hover:scale-110"
|
className="w-full h-full object-cover transition-transform duration-500 hover:scale-110"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<Badge variant="info">{post.category}</Badge>
|
<Badge variant="info">{post.category}</Badge>
|
||||||
<span className="text-sm text-gray-500 font-medium">{post.readTime} read</span>
|
<span className="text-sm text-gray-500 font-medium">{post.readTime} read</span>
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-xl leading-tight mb-3">{post.title}</CardTitle>
|
<CardTitle className="text-xl leading-tight mb-3">{post.title}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-0">
|
<CardContent className="pt-0">
|
||||||
<p className="text-gray-600 mb-4 leading-relaxed">{post.excerpt}</p>
|
<p className="text-gray-600 mb-4 leading-relaxed">{post.excerpt}</p>
|
||||||
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
|
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
|
||||||
<p className="text-sm text-gray-500">{post.date}</p>
|
<p className="text-sm text-gray-500">{post.date}</p>
|
||||||
<span className="text-primary-600 text-sm font-medium">Read more →</span>
|
<span className="text-primary-600 text-sm font-medium">Read more →</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,143 +1,143 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import SeoJsonLd from '@/components/SeoJsonLd';
|
import SeoJsonLd from '@/components/SeoJsonLd';
|
||||||
import { faqPageSchema } from '@/lib/schema';
|
import { faqPageSchema } from '@/lib/schema';
|
||||||
import { Card, CardContent } from '@/components/ui/Card';
|
import { Card, CardContent } from '@/components/ui/Card';
|
||||||
|
|
||||||
function truncateAtWord(text: string, maxLength: number): string {
|
function truncateAtWord(text: string, maxLength: number): string {
|
||||||
if (text.length <= maxLength) return text;
|
if (text.length <= maxLength) return text;
|
||||||
const truncated = text.slice(0, maxLength);
|
const truncated = text.slice(0, maxLength);
|
||||||
const lastSpace = truncated.lastIndexOf(' ');
|
const lastSpace = truncated.lastIndexOf(' ');
|
||||||
return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated;
|
return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const title = truncateAtWord('QR Master FAQ: Dynamic & Bulk QR', 60);
|
const title = truncateAtWord('QR Master FAQ: Dynamic & Bulk QR', 60);
|
||||||
const description = truncateAtWord(
|
const description = truncateAtWord(
|
||||||
'All answers: dynamic QR, security, analytics, bulk, events & print.',
|
'All answers: dynamic QR, security, analytics, bulk, events & print.',
|
||||||
160
|
160
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: 'https://www.qrmaster.net/faq',
|
canonical: 'https://www.qrmaster.net/faq',
|
||||||
languages: {
|
languages: {
|
||||||
'x-default': 'https://www.qrmaster.net/faq',
|
'x-default': 'https://www.qrmaster.net/faq',
|
||||||
en: 'https://www.qrmaster.net/faq',
|
en: 'https://www.qrmaster.net/faq',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
url: 'https://www.qrmaster.net/faq',
|
url: 'https://www.qrmaster.net/faq',
|
||||||
type: 'website',
|
type: 'website',
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const faqs = [
|
const faqs = [
|
||||||
{
|
{
|
||||||
question: 'What is a dynamic QR code?',
|
question: 'What is a dynamic QR code?',
|
||||||
answer: 'A dynamic QR code allows you to change the destination URL after the code has been created and printed. Unlike static QR codes, dynamic codes redirect through a short URL that you control, enabling real-time updates, scan analytics, and campaign tracking without reprinting the code.',
|
answer: 'A dynamic QR code allows you to change the destination URL after the code has been created and printed. Unlike static QR codes, dynamic codes redirect through a short URL that you control, enabling real-time updates, scan analytics, and campaign tracking without reprinting the code.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'How do I track QR scans?',
|
question: 'How do I track QR scans?',
|
||||||
answer: 'QR Master provides a comprehensive analytics dashboard that tracks every scan in real-time. You can monitor scan rates, geographic locations, device types, timestamps, and user behavior. Enable UTM parameters to integrate with Google Analytics for advanced campaign tracking and conversion attribution.',
|
answer: 'QR Master provides a comprehensive analytics dashboard that tracks every scan in real-time. You can monitor scan rates, geographic locations, device types, timestamps, and user behavior. Enable UTM parameters to integrate with Google Analytics for advanced campaign tracking and conversion attribution.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'What security features does QR Master offer?',
|
question: 'What security features does QR Master offer?',
|
||||||
answer: 'QR Master employs enterprise-grade security including SSL encryption, link validation to prevent malicious redirects, fraud detection, and GDPR-compliant data handling. All scan analytics are stored securely and access is protected with multi-factor authentication for business accounts.',
|
answer: 'QR Master employs enterprise-grade security including SSL encryption, link validation to prevent malicious redirects, fraud detection, and GDPR-compliant data handling. All scan analytics are stored securely and access is protected with multi-factor authentication for business accounts.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Can I generate bulk QR codes for print?',
|
question: 'Can I generate bulk QR codes for print?',
|
||||||
answer: 'Yes. Our bulk QR generation tool allows you to create thousands of QR codes at once by uploading a CSV file. Each code can be customized with unique URLs, UTM parameters, and branding. Download print-ready files in SVG, PNG, or PDF formats optimized for high-resolution printing.',
|
answer: 'Yes. Our bulk QR generation tool allows you to create thousands of QR codes at once by uploading a CSV file. Each code can be customized with unique URLs, UTM parameters, and branding. Download print-ready files in SVG, PNG, or PDF formats optimized for high-resolution printing.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'How do I brand my QR codes?',
|
question: 'How do I brand my QR codes?',
|
||||||
answer: 'QR Master offers customization options including custom colors, corner styles, and pattern designs. Branded QR codes maintain scannability while matching your brand identity. Choose your color palette and preview designs before downloading.',
|
answer: 'QR Master offers customization options including custom colors, corner styles, and pattern designs. Branded QR codes maintain scannability while matching your brand identity. Choose your color palette and preview designs before downloading.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Is scan analytics GDPR compliant?',
|
question: 'Is scan analytics GDPR compliant?',
|
||||||
answer: 'Yes. All QR Master analytics are fully GDPR compliant. We collect only necessary data, provide transparent privacy policies, allow users to opt out, and store data securely in EU-compliant data centers. You maintain full control over data retention and deletion.',
|
answer: 'Yes. All QR Master analytics are fully GDPR compliant. We collect only necessary data, provide transparent privacy policies, allow users to opt out, and store data securely in EU-compliant data centers. You maintain full control over data retention and deletion.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Can QR Master track campaigns with UTM?',
|
question: 'Can QR Master track campaigns with UTM?',
|
||||||
answer: 'Absolutely. QR Master supports UTM parameter integration for all dynamic QR codes. Automatically append source, medium, campaign, term, and content parameters to track QR performance in Google Analytics, Adobe Analytics, and other marketing platforms. UTM tracking enables multi-channel attribution and ROI measurement.',
|
answer: 'Absolutely. QR Master supports UTM parameter integration for all dynamic QR codes. Automatically append source, medium, campaign, term, and content parameters to track QR performance in Google Analytics, Adobe Analytics, and other marketing platforms. UTM tracking enables multi-channel attribution and ROI measurement.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Difference between static and dynamic QR codes?',
|
question: 'Difference between static and dynamic QR codes?',
|
||||||
answer: 'Static QR codes encode the destination URL directly in the code pattern and cannot be changed after creation. Dynamic QR codes use a short redirect URL, allowing you to update destinations, track scans, enable/disable codes, and gather analytics—all without reprinting. Dynamic codes are essential for professional marketing campaigns.',
|
answer: 'Static QR codes encode the destination URL directly in the code pattern and cannot be changed after creation. Dynamic QR codes use a short redirect URL, allowing you to update destinations, track scans, enable/disable codes, and gather analytics—all without reprinting. Dynamic codes are essential for professional marketing campaigns.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'How are QR codes used for events?',
|
question: 'How are QR codes used for events?',
|
||||||
answer: 'QR codes streamline event check-ins, ticket validation, attendee tracking, and engagement measurement. Generate unique codes for each ticket, track scan times and locations, enable contactless entry, and analyze attendee behavior. Event organizers use QR analytics to measure session popularity and optimize future events.',
|
answer: 'QR codes streamline event check-ins, ticket validation, attendee tracking, and engagement measurement. Generate unique codes for each ticket, track scan times and locations, enable contactless entry, and analyze attendee behavior. Event organizers use QR analytics to measure session popularity and optimize future events.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Can I make QR codes for business cards?',
|
question: 'Can I make QR codes for business cards?',
|
||||||
answer: 'Yes. QR codes on business cards provide instant contact sharing via vCard format, link to your portfolio or LinkedIn profile, and track networking effectiveness. Use branded QR codes that match your card design, and leverage scan analytics to see how many contacts engage and when they follow up.',
|
answer: 'Yes. QR codes on business cards provide instant contact sharing via vCard format, link to your portfolio or LinkedIn profile, and track networking effectiveness. Use branded QR codes that match your card design, and leverage scan analytics to see how many contacts engage and when they follow up.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'How do I use QR codes for bulk marketing?',
|
question: 'How do I use QR codes for bulk marketing?',
|
||||||
answer: 'Bulk QR codes enable scalable campaigns across print ads, packaging, direct mail, and retail displays. Generate thousands of codes with unique tracking URLs, distribute them across channels, and use analytics to measure which placements drive the highest engagement. Bulk generation supports CSV upload, API integration, and automated workflows.',
|
answer: 'Bulk QR codes enable scalable campaigns across print ads, packaging, direct mail, and retail displays. Generate thousands of codes with unique tracking URLs, distribute them across channels, and use analytics to measure which placements drive the highest engagement. Bulk generation supports CSV upload, API integration, and automated workflows.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Is API access available for bulk QR generation?',
|
question: 'Is API access available for bulk QR generation?',
|
||||||
answer: 'Yes. QR Master offers a developer-friendly REST API for programmatic QR code generation, URL management, and analytics retrieval. Integrate QR creation into your CRM, marketing automation platform, or e-commerce system. API access is included in Business plans and supports bulk operations, webhooks, and real-time updates.',
|
answer: 'Yes. QR Master offers a developer-friendly REST API for programmatic QR code generation, URL management, and analytics retrieval. Integrate QR creation into your CRM, marketing automation platform, or e-commerce system. API access is included in Business plans and supports bulk operations, webhooks, and real-time updates.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function FAQPage() {
|
export default function FAQPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SeoJsonLd data={faqPageSchema(faqs)} />
|
<SeoJsonLd data={faqPageSchema(faqs)} />
|
||||||
<div className="py-20 bg-gradient-to-b from-gray-50 to-white">
|
<div className="py-20 bg-gradient-to-b from-gray-50 to-white">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
<div className="text-center mb-16">
|
<div className="text-center mb-16">
|
||||||
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-6">
|
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-6">
|
||||||
Frequently Asked Questions
|
Frequently Asked Questions
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl text-gray-600">
|
<p className="text-xl text-gray-600">
|
||||||
Everything you need to know about dynamic QR codes, security, analytics, bulk generation, events, and print quality.
|
Everything you need to know about dynamic QR codes, security, analytics, bulk generation, events, and print quality.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{faqs.map((faq, index) => (
|
{faqs.map((faq, index) => (
|
||||||
<Card key={index} className="border-l-4 border-blue-500">
|
<Card key={index} className="border-l-4 border-blue-500">
|
||||||
<CardContent className="p-8">
|
<CardContent className="p-8">
|
||||||
<h2 className="text-2xl font-semibold mb-4 text-gray-900">
|
<h2 className="text-2xl font-semibold mb-4 text-gray-900">
|
||||||
{faq.question}
|
{faq.question}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-lg text-gray-700 leading-relaxed">
|
<p className="text-lg text-gray-700 leading-relaxed">
|
||||||
{faq.answer}
|
{faq.answer}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-16 bg-blue-50 border-l-4 border-blue-500 p-8 rounded-r-lg">
|
<div className="mt-16 bg-blue-50 border-l-4 border-blue-500 p-8 rounded-r-lg">
|
||||||
<h2 className="text-2xl font-bold mb-4 text-gray-900">
|
<h2 className="text-2xl font-bold mb-4 text-gray-900">
|
||||||
Still have questions?
|
Still have questions?
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-lg text-gray-700 mb-6 leading-relaxed">
|
<p className="text-lg text-gray-700 mb-6 leading-relaxed">
|
||||||
Our support team is here to help. Contact us at{' '}
|
Our support team is here to help. Contact us at{' '}
|
||||||
<a href="mailto:support@qrmaster.net" className="text-blue-600 hover:text-blue-700 font-semibold">
|
<a href="mailto:support@qrmaster.net" className="text-blue-600 hover:text-blue-700 font-semibold">
|
||||||
support@qrmaster.net
|
support@qrmaster.net
|
||||||
</a>{' '}
|
</a>{' '}
|
||||||
or reach out through our live chat.
|
or reach out through our live chat.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Footer } from '@/components/ui/Footer';
|
||||||
import en from '@/i18n/en.json';
|
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({
|
export default function MarketingLayout({
|
||||||
children,
|
children,
|
||||||
@@ -11,154 +16,233 @@ export default function MarketingLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
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 use English for marketing pages
|
// Always use English for marketing pages
|
||||||
const t = en;
|
const t = en;
|
||||||
|
|
||||||
const navigation = [
|
const tools = [
|
||||||
{ name: t.nav.features, href: '/#features' },
|
{ name: 'URL / Link', description: 'Link to any website', href: '/tools/url-qr-code', icon: Link2, color: 'text-blue-500', bgColor: 'bg-blue-50' },
|
||||||
{ name: t.nav.pricing, href: '/#pricing' },
|
{ name: 'Text', description: 'Plain text message', href: '/tools/text-qr-code', icon: Type, color: 'text-slate-500', bgColor: 'bg-slate-50' },
|
||||||
{ name: t.nav.faq, href: '/#faq' },
|
{ name: 'WiFi', description: 'Share WiFi credentials', href: '/tools/wifi-qr-code', icon: Wifi, color: 'text-indigo-500', bgColor: 'bg-indigo-50' },
|
||||||
{ name: t.nav.blog, href: '/blog' },
|
{ 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 (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-white">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="sticky top-0 z-50 bg-white border-b border-gray-200">
|
<header
|
||||||
<nav className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl py-4">
|
className="fixed top-0 left-0 right-0 z-50 bg-white/80 backdrop-blur-md border-b border-slate-200 shadow-sm"
|
||||||
<div className="flex items-center justify-between">
|
>
|
||||||
{/* Logo */}
|
<nav className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl h-20 flex items-center justify-between">
|
||||||
<Link href="/" className="flex items-center space-x-2">
|
{/* Logo */}
|
||||||
<img src="/favicon.svg" alt="QR Master" className="w-8 h-8" />
|
<Link href="/" className="flex items-center space-x-2.5 group">
|
||||||
<span className="text-xl font-bold text-gray-900">QR Master</span>
|
<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">
|
||||||
</Link>
|
<QrCode className="w-5 h-5 text-white" />
|
||||||
|
|
||||||
{/* Desktop Navigation */}
|
|
||||||
<div className="hidden md:flex items-center space-x-8">
|
|
||||||
{navigation.map((item) => (
|
|
||||||
<Link
|
|
||||||
key={item.name}
|
|
||||||
href={item.href}
|
|
||||||
className="text-gray-600 hover:text-gray-900 font-medium transition-colors"
|
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
<span className="text-xl font-bold text-slate-900 tracking-tight group-hover:text-indigo-600 transition-colors">QR Master</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
{/* Right Actions */}
|
|
||||||
<div className="hidden md:flex items-center space-x-4">
|
|
||||||
<Link href="/login">
|
|
||||||
<Button variant="outline">{t.nav.login}</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link href="/signup">
|
{/* Desktop Navigation */}
|
||||||
<Button>Get Started Free</Button>
|
<div className="hidden md:flex items-center space-x-1">
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Menu Button */}
|
{/* Tools Dropdown */}
|
||||||
<button
|
<div
|
||||||
className="md:hidden text-gray-900"
|
className="relative group px-3 py-2"
|
||||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
onMouseEnter={() => setToolsOpen(true)}
|
||||||
aria-label={mobileMenuOpen ? 'Close menu' : 'Open menu'}
|
onMouseLeave={() => setToolsOpen(false)}
|
||||||
aria-expanded={mobileMenuOpen}
|
|
||||||
>
|
>
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
<button className="flex items-center space-x-1 text-sm font-medium text-slate-600 group-hover:text-slate-900 transition-colors">
|
||||||
{mobileMenuOpen ? (
|
<span>Free Tools</span>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<ChevronDown className={cn("w-4 h-4 transition-transform duration-200", toolsOpen && "rotate-180")} />
|
||||||
) : (
|
</button>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
|
||||||
|
<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">All generators are 100% free</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</svg>
|
</AnimatePresence>
|
||||||
</button>
|
</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">
|
||||||
|
FAQ
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Menu */}
|
<div className="hidden md:flex items-center space-x-4">
|
||||||
{mobileMenuOpen && (
|
<Link href="/login" className="text-sm font-medium text-slate-600 hover:text-slate-900 transition-colors">
|
||||||
<div className="md:hidden mt-4 pb-4 border-t border-gray-200 pt-4">
|
{t.nav.login}
|
||||||
<div className="flex flex-col space-y-4">
|
</Link>
|
||||||
{navigation.map((item) => (
|
|
||||||
<Link
|
<Link href="/signup">
|
||||||
key={item.name}
|
<Button className={cn(
|
||||||
href={item.href}
|
"font-semibold shadow-lg shadow-indigo-500/20 transition-all hover:scale-105",
|
||||||
className="text-gray-600 hover:text-gray-900 font-medium"
|
scrolled ? "bg-blue-600 text-white hover:bg-blue-700" : "bg-blue-600 text-white hover:bg-blue-700"
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
)}>
|
||||||
>
|
{t.nav.cta || "Get Started Free"}
|
||||||
{item.name}
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
</div>
|
||||||
<Link href="/login" onClick={() => setMobileMenuOpen(false)}>
|
|
||||||
<Button variant="outline" className="w-full">{t.nav.login}</Button>
|
{/* Mobile Menu Button - Always dark */}
|
||||||
</Link>
|
<button
|
||||||
<Link href="/signup" onClick={() => setMobileMenuOpen(false)}>
|
className="md:hidden p-2 text-slate-900"
|
||||||
<Button className="w-full">Get Started Free</Button>
|
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||||
</Link>
|
aria-label="Toggle menu"
|
||||||
</div>
|
>
|
||||||
</div>
|
<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>
|
</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>Free 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)}>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">Log in</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/signup" onClick={() => setMobileMenuOpen(false)}>
|
||||||
|
<Button className="w-full justify-center bg-indigo-600 hover:bg-indigo-700">Get Started</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main>{children}</main>
|
<main className="pt-20">{children}</main>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="bg-gray-900 text-white py-12 mt-20">
|
<Footer />
|
||||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
</div >
|
||||||
<div className="grid md:grid-cols-4 gap-8">
|
|
||||||
<div>
|
|
||||||
<Link href="/" className="flex items-center space-x-2 mb-4 hover:opacity-80 transition-opacity">
|
|
||||||
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
|
|
||||||
<span className="text-xl font-bold">QR Master</span>
|
|
||||||
</Link>
|
|
||||||
<p className="text-gray-400">
|
|
||||||
Create custom QR codes in seconds with advanced tracking and analytics.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold mb-4">Product</h3>
|
|
||||||
<ul className="space-y-2 text-gray-400">
|
|
||||||
<li><Link href="/#features" className="hover:text-white">Features</Link></li>
|
|
||||||
<li><Link href="/#pricing" className="hover:text-white">Pricing</Link></li>
|
|
||||||
<li><Link href="/#faq" className="hover:text-white">FAQ</Link></li>
|
|
||||||
<li><Link href="/blog" className="hover:text-white">Blog</Link></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold mb-4">Resources</h3>
|
|
||||||
<ul className="space-y-2 text-gray-400">
|
|
||||||
<li><Link href="/#pricing" className="hover:text-white">Full Pricing</Link></li>
|
|
||||||
<li><Link href="/faq" className="hover:text-white">All Questions</Link></li>
|
|
||||||
<li><Link href="/blog" className="hover:text-white">Blog</Link></li>
|
|
||||||
<li><Link href="/signup" className="hover:text-white">Get Started</Link></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold mb-4">Legal</h3>
|
|
||||||
<ul className="space-y-2 text-gray-400">
|
|
||||||
<li><Link href="/privacy" className="hover:text-white">Privacy Policy</Link></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-gray-800 mt-8 pt-8 flex items-center justify-between text-gray-400">
|
|
||||||
<Link
|
|
||||||
href="/newsletter"
|
|
||||||
className="text-[6px] text-gray-700 opacity-[0.25] hover:opacity-100 hover:text-white transition-opacity duration-300"
|
|
||||||
>
|
|
||||||
•
|
|
||||||
</Link>
|
|
||||||
<p>© 2025 QR Master. All rights reserved.</p>
|
|
||||||
<div className="w-12"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,71 +1,71 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import SeoJsonLd from '@/components/SeoJsonLd';
|
import SeoJsonLd from '@/components/SeoJsonLd';
|
||||||
import { organizationSchema, websiteSchema } from '@/lib/schema';
|
import { organizationSchema, websiteSchema } from '@/lib/schema';
|
||||||
import HomePageClient from '@/components/marketing/HomePageClient';
|
import HomePageClient from '@/components/marketing/HomePageClient';
|
||||||
|
|
||||||
function truncateAtWord(text: string, maxLength: number): string {
|
function truncateAtWord(text: string, maxLength: number): string {
|
||||||
if (text.length <= maxLength) return text;
|
if (text.length <= maxLength) return text;
|
||||||
const truncated = text.slice(0, maxLength);
|
const truncated = text.slice(0, maxLength);
|
||||||
const lastSpace = truncated.lastIndexOf(' ');
|
const lastSpace = truncated.lastIndexOf(' ');
|
||||||
return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated;
|
return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const title = truncateAtWord('QR Master: Dynamic QR Generator', 60);
|
const title = truncateAtWord('QR Master: Dynamic QR Generator', 60);
|
||||||
const description = truncateAtWord(
|
const description = truncateAtWord(
|
||||||
'Dynamic QR, branding, bulk generation & analytics for all campaigns.',
|
'Dynamic QR, branding, bulk generation & analytics for all campaigns.',
|
||||||
160
|
160
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: 'https://www.qrmaster.net/',
|
canonical: 'https://www.qrmaster.net/',
|
||||||
languages: {
|
languages: {
|
||||||
'x-default': 'https://www.qrmaster.net/',
|
'x-default': 'https://www.qrmaster.net/',
|
||||||
en: 'https://www.qrmaster.net/',
|
en: 'https://www.qrmaster.net/',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
url: 'https://www.qrmaster.net/',
|
url: 'https://www.qrmaster.net/',
|
||||||
type: 'website',
|
type: 'website',
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SeoJsonLd data={[organizationSchema(), websiteSchema()]} />
|
<SeoJsonLd data={[organizationSchema(), websiteSchema()]} />
|
||||||
|
|
||||||
{/* Server-rendered SEO content for crawlers */}
|
{/* Server-rendered SEO content for crawlers */}
|
||||||
<div className="sr-only" aria-hidden="false">
|
<div className="sr-only" aria-hidden="false">
|
||||||
<h1>QR Master: Free Dynamic QR Code Generator with Tracking & Analytics</h1>
|
<h1>QR Master: Free Dynamic QR Code Generator with Tracking & Analytics</h1>
|
||||||
<p>
|
<p>
|
||||||
Create professional QR codes for your business with QR Master. Our dynamic QR code generator
|
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.
|
lets you create trackable QR codes, edit destinations anytime, and view detailed analytics.
|
||||||
Perfect for restaurants, retail, events, and marketing campaigns.
|
Perfect for restaurants, retail, events, and marketing campaigns.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Features include: Dynamic QR codes with real-time tracking, bulk QR code generation from Excel/CSV,
|
Features include: Dynamic QR codes with real-time tracking, bulk QR code generation from Excel/CSV,
|
||||||
custom branding with colors and logos, advanced scan analytics showing device types and locations,
|
custom branding with colors and logos, advanced scan analytics showing device types and locations,
|
||||||
vCard QR codes for digital business cards, and restaurant menu QR codes.
|
vCard QR codes for digital business cards, and restaurant menu QR codes.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Start free with 3 dynamic QR codes and unlimited static codes. Upgrade to Pro for 50 codes
|
Start free with 3 dynamic QR codes and unlimited static codes. Upgrade to Pro for 50 codes
|
||||||
with advanced analytics, or Business for 500 codes with bulk creation and priority support.
|
with advanced analytics, or Business for 500 codes with bulk creation and priority support.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<HomePageClient />
|
<HomePageClient />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,133 +1,133 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Privacy Policy | QR Master',
|
title: 'Privacy Policy | QR Master',
|
||||||
description: 'Privacy Policy and data protection information for QR Master',
|
description: 'Privacy Policy and data protection information for QR Master',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PrivacyPage() {
|
export default function PrivacyPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white py-12">
|
<div className="min-h-screen bg-white py-12">
|
||||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-4xl">
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-4xl">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<Link href="/" className="text-primary-600 hover:text-primary-700 font-medium">
|
<Link href="/" className="text-primary-600 hover:text-primary-700 font-medium">
|
||||||
← Back to Home
|
← Back to Home
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">Privacy Policy</h1>
|
<h1 className="text-4xl font-bold text-gray-900 mb-4">Privacy Policy</h1>
|
||||||
<p className="text-gray-600 mb-8">Last updated: January 2025</p>
|
<p className="text-gray-600 mb-8">Last updated: January 2025</p>
|
||||||
|
|
||||||
<div className="prose prose-lg max-w-none">
|
<div className="prose prose-lg max-w-none">
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">1. Introduction</h2>
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">1. Introduction</h2>
|
||||||
<p className="text-gray-700 mb-4">
|
<p className="text-gray-700 mb-4">
|
||||||
Welcome to QR Master ("we," "our," or "us"). We respect your privacy and are committed to protecting your personal data.
|
Welcome to QR Master ("we," "our," or "us"). We respect your privacy and are committed to protecting your personal data.
|
||||||
This privacy policy explains how we collect, use, and protect your information when you use our services.
|
This privacy policy explains how we collect, use, and protect your information when you use our services.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-700 mb-4">
|
<p className="text-gray-700 mb-4">
|
||||||
We implement appropriate security measures including secure HTTPS transmission, password hashing, database access controls,
|
We implement appropriate security measures including secure HTTPS transmission, password hashing, database access controls,
|
||||||
and CSRF protection to keep your data safe.
|
and CSRF protection to keep your data safe.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">2. Information We Collect</h2>
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">2. Information We Collect</h2>
|
||||||
|
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">Information You Provide</h3>
|
<h3 className="text-xl font-semibold text-gray-900 mb-3">Information You Provide</h3>
|
||||||
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
||||||
<li><strong>Account Information:</strong> Name, email address, and password</li>
|
<li><strong>Account Information:</strong> Name, email address, and password</li>
|
||||||
<li><strong>Payment Information:</strong> Processed securely through Stripe (we do not store credit card information)</li>
|
<li><strong>Payment Information:</strong> Processed securely through Stripe (we do not store credit card information)</li>
|
||||||
<li><strong>QR Code Content:</strong> URLs, text, and customization settings for your QR codes</li>
|
<li><strong>QR Code Content:</strong> URLs, text, and customization settings for your QR codes</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">Information Collected Automatically</h3>
|
<h3 className="text-xl font-semibold text-gray-900 mb-3">Information Collected Automatically</h3>
|
||||||
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
||||||
<li><strong>Usage Data:</strong> QR code scans and analytics</li>
|
<li><strong>Usage Data:</strong> QR code scans and analytics</li>
|
||||||
<li><strong>Technical Data:</strong> IP address, browser type, and device information</li>
|
<li><strong>Technical Data:</strong> IP address, browser type, and device information</li>
|
||||||
<li><strong>Cookies:</strong> Essential cookies for authentication and optional analytics cookies (PostHog) with your consent</li>
|
<li><strong>Cookies:</strong> Essential cookies for authentication and optional analytics cookies (PostHog) with your consent</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">3. How We Use Your Information</h2>
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">3. How We Use Your Information</h2>
|
||||||
<p className="text-gray-700 mb-4">We use your data to:</p>
|
<p className="text-gray-700 mb-4">We use your data to:</p>
|
||||||
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
||||||
<li>Provide and maintain our QR code services</li>
|
<li>Provide and maintain our QR code services</li>
|
||||||
<li>Process payments and manage subscriptions</li>
|
<li>Process payments and manage subscriptions</li>
|
||||||
<li>Provide customer support</li>
|
<li>Provide customer support</li>
|
||||||
<li>Improve our services and develop new features</li>
|
<li>Improve our services and develop new features</li>
|
||||||
<li>Detect and prevent fraud</li>
|
<li>Detect and prevent fraud</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-gray-700 mb-4">
|
<p className="text-gray-700 mb-4">
|
||||||
We retain your data while your account is active. Upon account deletion, most data is removed immediately,
|
We retain your data while your account is active. Upon account deletion, most data is removed immediately,
|
||||||
though some may be retained for legal compliance. Aggregated, anonymized analytics may be kept indefinitely.
|
though some may be retained for legal compliance. Aggregated, anonymized analytics may be kept indefinitely.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">4. Data Sharing</h2>
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">4. Data Sharing</h2>
|
||||||
<p className="text-gray-700 mb-4">We may share your data with:</p>
|
<p className="text-gray-700 mb-4">We may share your data with:</p>
|
||||||
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
||||||
<li><strong>Stripe:</strong> Payment processing</li>
|
<li><strong>Stripe:</strong> Payment processing</li>
|
||||||
<li><strong>PostHog:</strong> Analytics (only with your consent, respects Do Not Track)</li>
|
<li><strong>PostHog:</strong> Analytics (only with your consent, respects Do Not Track)</li>
|
||||||
<li><strong>Vercel:</strong> Cloud hosting provider</li>
|
<li><strong>Vercel:</strong> Cloud hosting provider</li>
|
||||||
<li><strong>Legal Requirements:</strong> When required by law</li>
|
<li><strong>Legal Requirements:</strong> When required by law</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-gray-700 mb-4">
|
<p className="text-gray-700 mb-4">
|
||||||
We do not sell your personal data. Analytics are only activated if you accept optional cookies.
|
We do not sell your personal data. Analytics are only activated if you accept optional cookies.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">5. Your Rights (GDPR)</h2>
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">5. Your Rights (GDPR)</h2>
|
||||||
<p className="text-gray-700 mb-4">You have the right to:</p>
|
<p className="text-gray-700 mb-4">You have the right to:</p>
|
||||||
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
||||||
<li><strong>Access:</strong> Request a copy of your personal data</li>
|
<li><strong>Access:</strong> Request a copy of your personal data</li>
|
||||||
<li><strong>Rectification:</strong> Correct inaccurate data (update in account settings)</li>
|
<li><strong>Rectification:</strong> Correct inaccurate data (update in account settings)</li>
|
||||||
<li><strong>Erasure:</strong> Delete your data (account deletion available in settings)</li>
|
<li><strong>Erasure:</strong> Delete your data (account deletion available in settings)</li>
|
||||||
<li><strong>Data Portability:</strong> Receive your data in a portable format</li>
|
<li><strong>Data Portability:</strong> Receive your data in a portable format</li>
|
||||||
<li><strong>Object:</strong> Object to processing based on legitimate interests</li>
|
<li><strong>Object:</strong> Object to processing based on legitimate interests</li>
|
||||||
<li><strong>Withdraw Consent:</strong> Withdraw cookie consent at any time</li>
|
<li><strong>Withdraw Consent:</strong> Withdraw cookie consent at any time</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-gray-700 mb-4">
|
<p className="text-gray-700 mb-4">
|
||||||
To exercise these rights, contact us at{' '}
|
To exercise these rights, contact us at{' '}
|
||||||
<a href="mailto:support@qrmaster.net" className="text-primary-600 hover:text-primary-700">
|
<a href="mailto:support@qrmaster.net" className="text-primary-600 hover:text-primary-700">
|
||||||
support@qrmaster.net
|
support@qrmaster.net
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-700 mb-4">
|
<p className="text-gray-700 mb-4">
|
||||||
Our service is for users 16 years and older. If you're in the EEA and have concerns,
|
Our service is for users 16 years and older. If you're in the EEA and have concerns,
|
||||||
you may lodge a complaint with your local data protection authority.
|
you may lodge a complaint with your local data protection authority.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">6. Contact Us</h2>
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">6. Contact Us</h2>
|
||||||
<p className="text-gray-700 mb-4">
|
<p className="text-gray-700 mb-4">
|
||||||
If you have questions about this privacy policy, please contact us:
|
If you have questions about this privacy policy, please contact us:
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-gray-50 p-6 rounded-lg">
|
<div className="bg-gray-50 p-6 rounded-lg">
|
||||||
<p className="text-gray-700 mb-2">
|
<p className="text-gray-700 mb-2">
|
||||||
<strong>Email:</strong>{' '}
|
<strong>Email:</strong>{' '}
|
||||||
<a href="mailto:support@qrmaster.net" className="text-primary-600 hover:text-primary-700">
|
<a href="mailto:support@qrmaster.net" className="text-primary-600 hover:text-primary-700">
|
||||||
support@qrmaster.net
|
support@qrmaster.net
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-700 mb-2"><strong>Website:</strong> <a href="/" className="text-primary-600 hover:text-primary-700">qrmaster.net</a></p>
|
<p className="text-gray-700 mb-2"><strong>Website:</strong> <a href="/" className="text-primary-600 hover:text-primary-700">qrmaster.net</a></p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12 pt-8 border-t border-gray-200">
|
<div className="mt-12 pt-8 border-t border-gray-200">
|
||||||
<p className="text-gray-600 text-center">
|
<p className="text-gray-600 text-center">
|
||||||
<Link href="/" className="text-primary-600 hover:text-primary-700">
|
<Link href="/" className="text-primary-600 hover:text-primary-700">
|
||||||
Back to Home
|
Back to Home
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,398 +1,398 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import SeoJsonLd from '@/components/SeoJsonLd';
|
import SeoJsonLd from '@/components/SeoJsonLd';
|
||||||
import Breadcrumbs, { BreadcrumbItem } from '@/components/Breadcrumbs';
|
import Breadcrumbs, { BreadcrumbItem } from '@/components/Breadcrumbs';
|
||||||
import { breadcrumbSchema } from '@/lib/schema';
|
import { breadcrumbSchema } from '@/lib/schema';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'QR Code Tracking & Analytics - Track Every Scan | QR Master',
|
title: 'QR Code Tracking & Analytics - Track Every Scan | QR Master',
|
||||||
description: 'Track QR code scans with real-time analytics. Monitor location, device, time, and user behavior. Free QR code tracking software with detailed reports.',
|
description: 'Track QR code scans with real-time analytics. Monitor location, device, time, and user behavior. Free QR code tracking software with detailed reports.',
|
||||||
keywords: 'qr code tracking, qr code analytics, track qr scans, qr code statistics, free qr tracking, qr code monitoring',
|
keywords: 'qr code tracking, qr code analytics, track qr scans, qr code statistics, free qr tracking, qr code monitoring',
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: 'https://www.qrmaster.net/qr-code-tracking',
|
canonical: 'https://www.qrmaster.net/qr-code-tracking',
|
||||||
languages: {
|
languages: {
|
||||||
'x-default': 'https://www.qrmaster.net/qr-code-tracking',
|
'x-default': 'https://www.qrmaster.net/qr-code-tracking',
|
||||||
en: 'https://www.qrmaster.net/qr-code-tracking',
|
en: 'https://www.qrmaster.net/qr-code-tracking',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: 'QR Code Tracking & Analytics - Track Every Scan | QR Master',
|
title: 'QR Code Tracking & Analytics - Track Every Scan | QR Master',
|
||||||
description: 'Track QR code scans with real-time analytics. Monitor location, device, time, and user behavior.',
|
description: 'Track QR code scans with real-time analytics. Monitor location, device, time, and user behavior.',
|
||||||
url: 'https://www.qrmaster.net/qr-code-tracking',
|
url: 'https://www.qrmaster.net/qr-code-tracking',
|
||||||
type: 'website',
|
type: 'website',
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
title: 'QR Code Tracking & Analytics - Track Every Scan | QR Master',
|
title: 'QR Code Tracking & Analytics - Track Every Scan | QR Master',
|
||||||
description: 'Track QR code scans with real-time analytics. Monitor location, device, time, and user behavior.',
|
description: 'Track QR code scans with real-time analytics. Monitor location, device, time, and user behavior.',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function QRCodeTrackingPage() {
|
export default function QRCodeTrackingPage() {
|
||||||
const trackingFeatures = [
|
const trackingFeatures = [
|
||||||
{
|
{
|
||||||
icon: '📊',
|
icon: '📊',
|
||||||
title: 'Real-Time Analytics',
|
title: 'Real-Time Analytics',
|
||||||
description: 'See scan data instantly as it happens. Monitor your QR code performance in real-time with live dashboards.',
|
description: 'See scan data instantly as it happens. Monitor your QR code performance in real-time with live dashboards.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '🌍',
|
icon: '🌍',
|
||||||
title: 'Location Tracking',
|
title: 'Location Tracking',
|
||||||
description: 'Know exactly where your QR codes are being scanned. Track by country, city, and region.',
|
description: 'Know exactly where your QR codes are being scanned. Track by country, city, and region.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '📱',
|
icon: '📱',
|
||||||
title: 'Device Detection',
|
title: 'Device Detection',
|
||||||
description: 'Identify which devices scan your codes. Track iOS, Android, desktop, and browser types.',
|
description: 'Identify which devices scan your codes. Track iOS, Android, desktop, and browser types.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '🕐',
|
icon: '🕐',
|
||||||
title: 'Time-Based Reports',
|
title: 'Time-Based Reports',
|
||||||
description: 'Analyze scan patterns by hour, day, week, or month. Optimize your campaigns with timing insights.',
|
description: 'Analyze scan patterns by hour, day, week, or month. Optimize your campaigns with timing insights.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '👥',
|
icon: '👥',
|
||||||
title: 'Unique vs Total Scans',
|
title: 'Unique vs Total Scans',
|
||||||
description: 'Distinguish between unique users and repeat scans. Measure true reach and engagement.',
|
description: 'Distinguish between unique users and repeat scans. Measure true reach and engagement.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '📈',
|
icon: '📈',
|
||||||
title: 'Campaign Performance',
|
title: 'Campaign Performance',
|
||||||
description: 'Track ROI with UTM parameters. Measure conversion rates and campaign effectiveness.',
|
description: 'Track ROI with UTM parameters. Measure conversion rates and campaign effectiveness.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const useCases = [
|
const useCases = [
|
||||||
{
|
{
|
||||||
title: 'Marketing Campaigns',
|
title: 'Marketing Campaigns',
|
||||||
description: 'Track print ads, billboards, and product packaging to measure marketing ROI.',
|
description: 'Track print ads, billboards, and product packaging to measure marketing ROI.',
|
||||||
benefits: ['Measure ad performance', 'A/B test campaigns', 'Track conversions'],
|
benefits: ['Measure ad performance', 'A/B test campaigns', 'Track conversions'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Event Management',
|
title: 'Event Management',
|
||||||
description: 'Monitor event check-ins, booth visits, and attendee engagement in real-time.',
|
description: 'Monitor event check-ins, booth visits, and attendee engagement in real-time.',
|
||||||
benefits: ['Live attendance tracking', 'Booth analytics', 'Engagement metrics'],
|
benefits: ['Live attendance tracking', 'Booth analytics', 'Engagement metrics'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Product Labels',
|
title: 'Product Labels',
|
||||||
description: 'Track product authenticity scans, manual downloads, and warranty registrations.',
|
description: 'Track product authenticity scans, manual downloads, and warranty registrations.',
|
||||||
benefits: ['Anti-counterfeiting', 'User registration tracking', 'Product analytics'],
|
benefits: ['Anti-counterfeiting', 'User registration tracking', 'Product analytics'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Restaurant Menus',
|
title: 'Restaurant Menus',
|
||||||
description: 'See how many customers scan your menu QR codes and when peak times occur.',
|
description: 'See how many customers scan your menu QR codes and when peak times occur.',
|
||||||
benefits: ['Customer insights', 'Peak time analysis', 'Menu engagement'],
|
benefits: ['Customer insights', 'Peak time analysis', 'Menu engagement'],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const comparisonData = [
|
const comparisonData = [
|
||||||
{ feature: 'Real-Time Analytics', free: true, qrMaster: true },
|
{ feature: 'Real-Time Analytics', free: true, qrMaster: true },
|
||||||
{ feature: 'Location Tracking', free: false, qrMaster: true },
|
{ feature: 'Location Tracking', free: false, qrMaster: true },
|
||||||
{ feature: 'Device Detection', free: false, qrMaster: true },
|
{ feature: 'Device Detection', free: false, qrMaster: true },
|
||||||
{ feature: 'Unlimited Scans', free: false, qrMaster: true },
|
{ feature: 'Unlimited Scans', free: false, qrMaster: true },
|
||||||
{ feature: 'Historical Data', free: '7 days', qrMaster: 'Unlimited' },
|
{ feature: 'Historical Data', free: '7 days', qrMaster: 'Unlimited' },
|
||||||
{ feature: 'Export Reports', free: false, qrMaster: true },
|
{ feature: 'Export Reports', free: false, qrMaster: true },
|
||||||
{ feature: 'API Access', free: false, qrMaster: true },
|
{ feature: 'API Access', free: false, qrMaster: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
const softwareSchema = {
|
const softwareSchema = {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'SoftwareApplication',
|
'@type': 'SoftwareApplication',
|
||||||
'@id': 'https://www.qrmaster.net/qr-code-tracking#software',
|
'@id': 'https://www.qrmaster.net/qr-code-tracking#software',
|
||||||
name: 'QR Master - QR Code Tracking & Analytics',
|
name: 'QR Master - QR Code Tracking & Analytics',
|
||||||
applicationCategory: 'BusinessApplication',
|
applicationCategory: 'BusinessApplication',
|
||||||
operatingSystem: 'Web Browser, iOS, Android',
|
operatingSystem: 'Web Browser, iOS, Android',
|
||||||
offers: {
|
offers: {
|
||||||
'@type': 'Offer',
|
'@type': 'Offer',
|
||||||
price: '0',
|
price: '0',
|
||||||
priceCurrency: 'USD',
|
priceCurrency: 'USD',
|
||||||
availability: 'https://schema.org/InStock',
|
availability: 'https://schema.org/InStock',
|
||||||
},
|
},
|
||||||
aggregateRating: {
|
aggregateRating: {
|
||||||
'@type': 'AggregateRating',
|
'@type': 'AggregateRating',
|
||||||
ratingValue: '4.8',
|
ratingValue: '4.8',
|
||||||
ratingCount: '1250',
|
ratingCount: '1250',
|
||||||
},
|
},
|
||||||
description: 'Track QR code scans with real-time analytics. Monitor location, device, time, and user behavior with our free QR code tracking software.',
|
description: 'Track QR code scans with real-time analytics. Monitor location, device, time, and user behavior with our free QR code tracking software.',
|
||||||
features: [
|
features: [
|
||||||
'Real-time analytics dashboard',
|
'Real-time analytics dashboard',
|
||||||
'Location tracking by country and city',
|
'Location tracking by country and city',
|
||||||
'Device detection (iOS, Android, Desktop)',
|
'Device detection (iOS, Android, Desktop)',
|
||||||
'Time-based scan reports',
|
'Time-based scan reports',
|
||||||
'Unique vs total scan tracking',
|
'Unique vs total scan tracking',
|
||||||
'Campaign performance metrics',
|
'Campaign performance metrics',
|
||||||
'Unlimited scans',
|
'Unlimited scans',
|
||||||
'Export detailed reports',
|
'Export detailed reports',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const howToSchema = {
|
const howToSchema = {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'HowTo',
|
'@type': 'HowTo',
|
||||||
'@id': 'https://www.qrmaster.net/qr-code-tracking#howto',
|
'@id': 'https://www.qrmaster.net/qr-code-tracking#howto',
|
||||||
name: 'How to Track QR Code Scans',
|
name: 'How to Track QR Code Scans',
|
||||||
description: 'Learn how to track and analyze QR code scans with real-time analytics',
|
description: 'Learn how to track and analyze QR code scans with real-time analytics',
|
||||||
totalTime: 'PT5M',
|
totalTime: 'PT5M',
|
||||||
step: [
|
step: [
|
||||||
{
|
{
|
||||||
'@type': 'HowToStep',
|
'@type': 'HowToStep',
|
||||||
position: 1,
|
position: 1,
|
||||||
name: 'Create QR Code',
|
name: 'Create QR Code',
|
||||||
text: 'Sign up for free and create a dynamic QR code with tracking enabled',
|
text: 'Sign up for free and create a dynamic QR code with tracking enabled',
|
||||||
url: 'https://www.qrmaster.net/signup',
|
url: 'https://www.qrmaster.net/signup',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'@type': 'HowToStep',
|
'@type': 'HowToStep',
|
||||||
position: 2,
|
position: 2,
|
||||||
name: 'Deploy QR Code',
|
name: 'Deploy QR Code',
|
||||||
text: 'Download and place your QR code on marketing materials, products, or digital platforms',
|
text: 'Download and place your QR code on marketing materials, products, or digital platforms',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'@type': 'HowToStep',
|
'@type': 'HowToStep',
|
||||||
position: 3,
|
position: 3,
|
||||||
name: 'Monitor Analytics',
|
name: 'Monitor Analytics',
|
||||||
text: 'View real-time scan data including location, device, and time patterns in your dashboard',
|
text: 'View real-time scan data including location, device, and time patterns in your dashboard',
|
||||||
url: 'https://www.qrmaster.net/analytics',
|
url: 'https://www.qrmaster.net/analytics',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'@type': 'HowToStep',
|
'@type': 'HowToStep',
|
||||||
position: 4,
|
position: 4,
|
||||||
name: 'Optimize Campaigns',
|
name: 'Optimize Campaigns',
|
||||||
text: 'Use insights to optimize placement, timing, and targeting of your QR code campaigns',
|
text: 'Use insights to optimize placement, timing, and targeting of your QR code campaigns',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const breadcrumbItems: BreadcrumbItem[] = [
|
const breadcrumbItems: BreadcrumbItem[] = [
|
||||||
{ name: 'Home', url: '/' },
|
{ name: 'Home', url: '/' },
|
||||||
{ name: 'QR Code Tracking', url: '/qr-code-tracking' },
|
{ name: 'QR Code Tracking', url: '/qr-code-tracking' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SeoJsonLd data={[softwareSchema, howToSchema, breadcrumbSchema(breadcrumbItems)]} />
|
<SeoJsonLd data={[softwareSchema, howToSchema, breadcrumbSchema(breadcrumbItems)]} />
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-white">
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="relative overflow-hidden bg-gradient-to-br from-blue-50 via-white to-purple-50 py-20">
|
<section className="relative overflow-hidden bg-gradient-to-br from-blue-50 via-white to-purple-50 py-20">
|
||||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||||
<Breadcrumbs items={breadcrumbItems} />
|
<Breadcrumbs items={breadcrumbItems} />
|
||||||
<div className="grid lg:grid-cols-2 gap-12 items-center">
|
<div className="grid lg:grid-cols-2 gap-12 items-center">
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="inline-flex items-center space-x-2 bg-blue-100 text-blue-800 px-4 py-2 rounded-full text-sm font-semibold">
|
<div className="inline-flex items-center space-x-2 bg-blue-100 text-blue-800 px-4 py-2 rounded-full text-sm font-semibold">
|
||||||
<span>📊</span>
|
<span>📊</span>
|
||||||
<span>Free QR Code Tracking</span>
|
<span>Free QR Code Tracking</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-5xl lg:text-6xl font-bold text-gray-900 leading-tight">
|
<h1 className="text-5xl lg:text-6xl font-bold text-gray-900 leading-tight">
|
||||||
Track Every QR Code Scan with Powerful Analytics
|
Track Every QR Code Scan with Powerful Analytics
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="text-xl text-gray-600 leading-relaxed">
|
<p className="text-xl text-gray-600 leading-relaxed">
|
||||||
Monitor your QR code performance in real-time. Get detailed insights on location, device, time, and user behavior. Make data-driven decisions with our free tracking software.
|
Monitor your QR code performance in real-time. Get detailed insights on location, device, time, and user behavior. Make data-driven decisions with our free tracking software.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
<Link href="/signup">
|
<Link href="/signup">
|
||||||
<Button size="lg" className="text-lg px-8 py-4 w-full sm:w-auto">
|
<Button size="lg" className="text-lg px-8 py-4 w-full sm:w-auto">
|
||||||
Start Tracking Free
|
Start Tracking Free
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/create">
|
<Link href="/create">
|
||||||
<Button variant="outline" size="lg" className="text-lg px-8 py-4 w-full sm:w-auto">
|
<Button variant="outline" size="lg" className="text-lg px-8 py-4 w-full sm:w-auto">
|
||||||
Create Trackable QR Code
|
Create Trackable QR Code
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-6 text-sm text-gray-600">
|
<div className="flex items-center space-x-6 text-sm text-gray-600">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<svg className="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
<svg className="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>No credit card required</span>
|
<span>No credit card required</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<svg className="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
<svg className="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>Unlimited scans</span>
|
<span>Unlimited scans</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Analytics Preview */}
|
{/* Analytics Preview */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Card className="p-6 shadow-2xl">
|
<Card className="p-6 shadow-2xl">
|
||||||
<h3 className="font-semibold text-lg mb-4">Live Analytics Dashboard</h3>
|
<h3 className="font-semibold text-lg mb-4">Live Analytics Dashboard</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-between items-center pb-3 border-b">
|
<div className="flex justify-between items-center pb-3 border-b">
|
||||||
<span className="text-gray-600">Total Scans</span>
|
<span className="text-gray-600">Total Scans</span>
|
||||||
<span className="text-2xl font-bold text-primary-600">12,547</span>
|
<span className="text-2xl font-bold text-primary-600">12,547</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center pb-3 border-b">
|
<div className="flex justify-between items-center pb-3 border-b">
|
||||||
<span className="text-gray-600">Unique Users</span>
|
<span className="text-gray-600">Unique Users</span>
|
||||||
<span className="text-2xl font-bold text-primary-600">8,392</span>
|
<span className="text-2xl font-bold text-primary-600">8,392</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center pb-3 border-b">
|
<div className="flex justify-between items-center pb-3 border-b">
|
||||||
<span className="text-gray-600">Top Location</span>
|
<span className="text-gray-600">Top Location</span>
|
||||||
<span className="font-semibold">🇩🇪 Germany</span>
|
<span className="font-semibold">🇩🇪 Germany</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-gray-600">Top Device</span>
|
<span className="text-gray-600">Top Device</span>
|
||||||
<span className="font-semibold">📱 iPhone</span>
|
<span className="font-semibold">📱 iPhone</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<div className="absolute -top-4 -right-4 bg-green-500 text-white px-4 py-2 rounded-full text-sm font-semibold shadow-lg animate-pulse">
|
<div className="absolute -top-4 -right-4 bg-green-500 text-white px-4 py-2 rounded-full text-sm font-semibold shadow-lg animate-pulse">
|
||||||
Live Updates
|
Live Updates
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Tracking Features */}
|
{/* Tracking Features */}
|
||||||
<section className="py-20 bg-gray-50">
|
<section className="py-20 bg-gray-50">
|
||||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||||
<div className="text-center mb-16">
|
<div className="text-center mb-16">
|
||||||
<h2 className="text-4xl font-bold text-gray-900 mb-4">
|
<h2 className="text-4xl font-bold text-gray-900 mb-4">
|
||||||
Powerful QR Code Tracking Features
|
Powerful QR Code Tracking Features
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
|
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||||
Get complete visibility into your QR code performance with our comprehensive analytics suite
|
Get complete visibility into your QR code performance with our comprehensive analytics suite
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
{trackingFeatures.map((feature, index) => (
|
{trackingFeatures.map((feature, index) => (
|
||||||
<Card key={index} className="p-6 hover:shadow-lg transition-shadow">
|
<Card key={index} className="p-6 hover:shadow-lg transition-shadow">
|
||||||
<div className="text-4xl mb-4">{feature.icon}</div>
|
<div className="text-4xl mb-4">{feature.icon}</div>
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||||
{feature.title}
|
{feature.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
{feature.description}
|
{feature.description}
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Use Cases */}
|
{/* Use Cases */}
|
||||||
<section className="py-20">
|
<section className="py-20">
|
||||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||||
<div className="text-center mb-16">
|
<div className="text-center mb-16">
|
||||||
<h2 className="text-4xl font-bold text-gray-900 mb-4">
|
<h2 className="text-4xl font-bold text-gray-900 mb-4">
|
||||||
QR Code Tracking Use Cases
|
QR Code Tracking Use Cases
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
|
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||||
See how businesses use QR code tracking to improve their operations
|
See how businesses use QR code tracking to improve their operations
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 gap-8">
|
<div className="grid md:grid-cols-2 gap-8">
|
||||||
{useCases.map((useCase, index) => (
|
{useCases.map((useCase, index) => (
|
||||||
<Card key={index} className="p-8">
|
<Card key={index} className="p-8">
|
||||||
<h3 className="text-2xl font-bold text-gray-900 mb-3">
|
<h3 className="text-2xl font-bold text-gray-900 mb-3">
|
||||||
{useCase.title}
|
{useCase.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 mb-6">
|
<p className="text-gray-600 mb-6">
|
||||||
{useCase.description}
|
{useCase.description}
|
||||||
</p>
|
</p>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{useCase.benefits.map((benefit, idx) => (
|
{useCase.benefits.map((benefit, idx) => (
|
||||||
<li key={idx} className="flex items-center space-x-2">
|
<li key={idx} className="flex items-center space-x-2">
|
||||||
<svg className="w-5 h-5 text-green-500 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
<svg className="w-5 h-5 text-green-500 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-gray-700">{benefit}</span>
|
<span className="text-gray-700">{benefit}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Comparison Table */}
|
{/* Comparison Table */}
|
||||||
<section className="py-20 bg-gray-50">
|
<section className="py-20 bg-gray-50">
|
||||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-5xl">
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-5xl">
|
||||||
<div className="text-center mb-16">
|
<div className="text-center mb-16">
|
||||||
<h2 className="text-4xl font-bold text-gray-900 mb-4">
|
<h2 className="text-4xl font-bold text-gray-900 mb-4">
|
||||||
QR Master vs Free Tools
|
QR Master vs Free Tools
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-gray-600">
|
<p className="text-xl text-gray-600">
|
||||||
See why businesses choose QR Master for QR code tracking
|
See why businesses choose QR Master for QR code tracking
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-gray-100">
|
<thead className="bg-gray-100">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-4 text-left text-gray-900 font-semibold">Feature</th>
|
<th className="px-6 py-4 text-left text-gray-900 font-semibold">Feature</th>
|
||||||
<th className="px-6 py-4 text-center text-gray-900 font-semibold">Free Tools</th>
|
<th className="px-6 py-4 text-center text-gray-900 font-semibold">Free Tools</th>
|
||||||
<th className="px-6 py-4 text-center text-primary-600 font-semibold">QR Master</th>
|
<th className="px-6 py-4 text-center text-primary-600 font-semibold">QR Master</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200">
|
<tbody className="divide-y divide-gray-200">
|
||||||
{comparisonData.map((row, index) => (
|
{comparisonData.map((row, index) => (
|
||||||
<tr key={index}>
|
<tr key={index}>
|
||||||
<td className="px-6 py-4 text-gray-900 font-medium">{row.feature}</td>
|
<td className="px-6 py-4 text-gray-900 font-medium">{row.feature}</td>
|
||||||
<td className="px-6 py-4 text-center">
|
<td className="px-6 py-4 text-center">
|
||||||
{typeof row.free === 'boolean' ? (
|
{typeof row.free === 'boolean' ? (
|
||||||
row.free ? (
|
row.free ? (
|
||||||
<span className="text-green-500 text-2xl">✓</span>
|
<span className="text-green-500 text-2xl">✓</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-red-500 text-2xl">✗</span>
|
<span className="text-red-500 text-2xl">✗</span>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-600">{row.free}</span>
|
<span className="text-gray-600">{row.free}</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-center">
|
<td className="px-6 py-4 text-center">
|
||||||
{typeof row.qrMaster === 'boolean' ? (
|
{typeof row.qrMaster === 'boolean' ? (
|
||||||
<span className="text-green-500 text-2xl">✓</span>
|
<span className="text-green-500 text-2xl">✓</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-primary-600 font-semibold">{row.qrMaster}</span>
|
<span className="text-primary-600 font-semibold">{row.qrMaster}</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* CTA Section */}
|
{/* CTA Section */}
|
||||||
<section className="py-20 bg-gradient-to-r from-primary-600 to-purple-600 text-white">
|
<section className="py-20 bg-gradient-to-r from-primary-600 to-purple-600 text-white">
|
||||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-4xl text-center">
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-4xl text-center">
|
||||||
<h2 className="text-4xl font-bold mb-6">
|
<h2 className="text-4xl font-bold mb-6">
|
||||||
Start Tracking Your QR Codes Today
|
Start Tracking Your QR Codes Today
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl mb-8 text-primary-100">
|
<p className="text-xl mb-8 text-primary-100">
|
||||||
Join thousands of businesses using QR Master to track and optimize their QR code campaigns
|
Join thousands of businesses using QR Master to track and optimize their QR code campaigns
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
<Link href="/signup">
|
<Link href="/signup">
|
||||||
<Button size="lg" variant="secondary" className="text-lg px-8 py-4 w-full sm:w-auto bg-white text-primary-600 hover:bg-gray-100">
|
<Button size="lg" variant="secondary" className="text-lg px-8 py-4 w-full sm:w-auto bg-white text-primary-600 hover:bg-gray-100">
|
||||||
Create Free Account
|
Create Free Account
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/pricing">
|
<Link href="/pricing">
|
||||||
<Button size="lg" variant="outline" className="text-lg px-8 py-4 w-full sm:w-auto border-white text-white hover:bg-white/10">
|
<Button size="lg" variant="outline" className="text-lg px-8 py-4 w-full sm:w-auto border-white text-white hover:bg-white/10">
|
||||||
View Pricing
|
View Pricing
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
371
src/app/(marketing)/tools/crypto-qr-code/CryptoGenerator.tsx
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import {
|
||||||
|
Bitcoin,
|
||||||
|
Download,
|
||||||
|
Check,
|
||||||
|
Sparkles,
|
||||||
|
Wallet,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Select } from '@/components/ui/Select';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// Brand Colors
|
||||||
|
const BRAND = {
|
||||||
|
paleGrey: '#EBEBDF',
|
||||||
|
richBlue: '#1A1265',
|
||||||
|
richBlueLight: '#2A2275',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Crypto Options
|
||||||
|
const CRYPTO_CURRENCIES = [
|
||||||
|
{ value: 'bitcoin', label: 'Bitcoin (BTC)', color: '#F7931A', prefix: 'bitcoin:' },
|
||||||
|
{ value: 'ethereum', label: 'Ethereum (ETH)', color: '#627EEA', prefix: 'ethereum:' },
|
||||||
|
{ value: 'usdt', label: 'Tether (USDT)', color: '#26A17B', prefix: '' }, // Commonly ERC20/TRC20 - keeping raw for safety
|
||||||
|
{ value: 'solana', label: 'Solana (SOL)', color: '#14F195', prefix: 'solana:' },
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
// QR Color Options
|
||||||
|
const QR_COLORS = [
|
||||||
|
{ name: 'Bitcoin Orange', value: '#F7931A' },
|
||||||
|
{ name: 'Ethereum Blue', value: '#627EEA' },
|
||||||
|
{ name: 'Tether Green', value: '#26A17B' },
|
||||||
|
{ name: 'Classic Black', value: '#000000' },
|
||||||
|
{ name: 'Dark Blue', value: '#1A1265' },
|
||||||
|
{ name: 'Emerald', value: '#10B981' },
|
||||||
|
{ name: 'Rose', value: '#F43F5E' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Frame Options
|
||||||
|
const FRAME_OPTIONS = [
|
||||||
|
{ id: 'none', label: 'No Frame' },
|
||||||
|
{ id: 'scanme', label: 'Scan Me' },
|
||||||
|
{ id: 'pay', label: 'Pay Now' },
|
||||||
|
{ id: 'donate', label: 'Donate' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function CryptoGenerator() {
|
||||||
|
const [currency, setCurrency] = useState('bitcoin');
|
||||||
|
const [address, setAddress] = useState('');
|
||||||
|
const [amount, setAmount] = useState('');
|
||||||
|
const [qrMode, setQrMode] = useState<'universal' | 'wallet'>('universal');
|
||||||
|
const [qrColor, setQrColor] = useState('#F7931A');
|
||||||
|
const [frameType, setFrameType] = useState('none');
|
||||||
|
|
||||||
|
const qrRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Generate URL based on selected mode
|
||||||
|
const getUrl = () => {
|
||||||
|
if (!address.trim()) return 'https://www.qrmaster.net';
|
||||||
|
|
||||||
|
const cleanAddr = address.trim();
|
||||||
|
|
||||||
|
if (qrMode === 'wallet') {
|
||||||
|
// Wallet Direct Mode - Uses crypto URI scheme
|
||||||
|
// Only works when scanning FROM a wallet app (Coinbase, Trust Wallet, etc.)
|
||||||
|
const prefixes: Record<string, string> = {
|
||||||
|
bitcoin: 'bitcoin:',
|
||||||
|
ethereum: 'ethereum:',
|
||||||
|
solana: 'solana:',
|
||||||
|
usdt: '', // USDT doesn't have a standard URI
|
||||||
|
};
|
||||||
|
const prefix = prefixes[currency] || '';
|
||||||
|
if (!prefix) return cleanAddr; // USDT fallback
|
||||||
|
let uri = `${prefix}${cleanAddr}`;
|
||||||
|
if (amount) uri += `?amount=${amount}`;
|
||||||
|
return uri;
|
||||||
|
} else {
|
||||||
|
// Universal Mode - Blockchain explorer links
|
||||||
|
// Works with ANY phone camera
|
||||||
|
switch (currency) {
|
||||||
|
case 'bitcoin':
|
||||||
|
return `https://blockchair.com/bitcoin/address/${cleanAddr}`;
|
||||||
|
case 'ethereum':
|
||||||
|
return `https://etherscan.io/address/${cleanAddr}`;
|
||||||
|
case 'solana':
|
||||||
|
return `https://solscan.io/account/${cleanAddr}`;
|
||||||
|
case 'usdt':
|
||||||
|
return `https://etherscan.io/token/0xdac17f958d2ee523a2206206994597c13d831ec7?a=${cleanAddr}`;
|
||||||
|
default:
|
||||||
|
return cleanAddr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async (format: 'png' | 'svg') => {
|
||||||
|
if (!qrRef.current) return;
|
||||||
|
try {
|
||||||
|
if (format === 'png') {
|
||||||
|
const { toPng } = await import('html-to-image');
|
||||||
|
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3 });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = `${currency}-qr-code.png`;
|
||||||
|
link.href = dataUrl;
|
||||||
|
link.click();
|
||||||
|
} else {
|
||||||
|
const svgData = qrRef.current.querySelector('svg')?.outerHTML;
|
||||||
|
if (svgData) {
|
||||||
|
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `${currency}-qr-code.svg`;
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Download failed', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFrameLabel = () => {
|
||||||
|
const frame = FRAME_OPTIONS.find(f => f.id === frameType);
|
||||||
|
return frame?.id !== 'none' ? frame?.label : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-5xl mx-auto px-4 md:px-6">
|
||||||
|
|
||||||
|
{/* Main Generator Card */}
|
||||||
|
<div className="bg-white rounded-3xl shadow-2xl shadow-slate-900/10 overflow-hidden border border-slate-100">
|
||||||
|
<div className="grid lg:grid-cols-2">
|
||||||
|
|
||||||
|
{/* LEFT: Input Section */}
|
||||||
|
<div className="p-8 lg:p-10 space-y-8 border-r border-slate-100">
|
||||||
|
|
||||||
|
{/* Crypto Details */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Wallet className="w-5 h-5 text-slate-900" />
|
||||||
|
Wallet Details
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Currency</label>
|
||||||
|
<Select
|
||||||
|
value={currency}
|
||||||
|
options={CRYPTO_CURRENCIES}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setCurrency(val);
|
||||||
|
const col = CRYPTO_CURRENCIES.find(c => c.value === val)?.color;
|
||||||
|
if (col) setQrColor(col);
|
||||||
|
}}
|
||||||
|
className="h-12 w-full rounded-xl border-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Wallet Address</label>
|
||||||
|
<Input
|
||||||
|
placeholder={`Enter ${currency} address`}
|
||||||
|
value={address}
|
||||||
|
onChange={(e) => setAddress(e.target.value)}
|
||||||
|
className="h-12 text-base rounded-xl border-slate-200 focus:border-slate-900 focus:ring-slate-900 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Amount (Optional)</label>
|
||||||
|
<Input
|
||||||
|
placeholder="0.00"
|
||||||
|
type="number"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
className="h-12 text-base rounded-xl border-slate-200 focus:border-slate-900 focus:ring-slate-900"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* QR Mode Toggle */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">QR Code Mode</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setQrMode('universal')}
|
||||||
|
className={cn(
|
||||||
|
"py-3 px-4 rounded-xl text-sm font-medium transition-all border",
|
||||||
|
qrMode === 'universal'
|
||||||
|
? "bg-slate-900 text-white border-slate-900"
|
||||||
|
: "bg-slate-50 text-slate-600 border-slate-200 hover:border-slate-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Universal (Web)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setQrMode('wallet')}
|
||||||
|
className={cn(
|
||||||
|
"py-3 px-4 rounded-xl text-sm font-medium transition-all border",
|
||||||
|
qrMode === 'wallet'
|
||||||
|
? "bg-slate-900 text-white border-slate-900"
|
||||||
|
: "bg-slate-50 text-slate-600 border-slate-200 hover:border-slate-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Wallet Direct
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 mt-2">
|
||||||
|
{qrMode === 'universal'
|
||||||
|
? "Works with any phone camera. Opens blockchain explorer."
|
||||||
|
: "Requires scanning from a wallet app. Enables direct payment."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-100"></div>
|
||||||
|
|
||||||
|
{/* Design Options */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Sparkles className="w-5 h-5 text-slate-900" />
|
||||||
|
Design Options
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Color Picker */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">QR Code Color</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{QR_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.name}
|
||||||
|
onClick={() => setQrColor(c.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-9 h-9 rounded-full border-2 flex items-center justify-center transition-all hover:scale-110",
|
||||||
|
qrColor === c.value ? "border-slate-900 ring-2 ring-offset-2 ring-slate-200" : "border-white shadow-md"
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: c.value }}
|
||||||
|
aria-label={`Select ${c.name}`}
|
||||||
|
title={c.name}
|
||||||
|
>
|
||||||
|
{qrColor === c.value && <Check className="w-4 h-4 text-white" strokeWidth={3} />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Frame Selector */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">Frame Label</label>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{FRAME_OPTIONS.map((frame) => (
|
||||||
|
<button
|
||||||
|
key={frame.id}
|
||||||
|
onClick={() => setFrameType(frame.id)}
|
||||||
|
className={cn(
|
||||||
|
"py-2.5 px-3 rounded-lg text-sm font-medium transition-all border",
|
||||||
|
frameType === frame.id
|
||||||
|
? "bg-slate-900 text-white border-slate-900"
|
||||||
|
: "bg-slate-50 text-slate-600 border-slate-200 hover:border-slate-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{frame.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT: Preview Section */}
|
||||||
|
<div className="p-8 lg:p-10 flex flex-col items-center justify-center" style={{ backgroundColor: BRAND.paleGrey }}>
|
||||||
|
|
||||||
|
{/* QR Card with Frame */}
|
||||||
|
<div
|
||||||
|
ref={qrRef}
|
||||||
|
className="bg-white rounded-3xl shadow-xl p-8 flex flex-col items-center"
|
||||||
|
style={{ minWidth: '320px' }}
|
||||||
|
>
|
||||||
|
{/* Frame Label */}
|
||||||
|
{getFrameLabel() && (
|
||||||
|
<div
|
||||||
|
className="mb-5 px-8 py-2.5 rounded-full text-white font-bold text-sm tracking-widest uppercase shadow-md"
|
||||||
|
style={{ backgroundColor: qrColor }}
|
||||||
|
>
|
||||||
|
{getFrameLabel()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* QR Code */}
|
||||||
|
<div className="bg-white">
|
||||||
|
{address.trim() ? (
|
||||||
|
<QRCodeSVG
|
||||||
|
value={getUrl()}
|
||||||
|
size={240}
|
||||||
|
level="Q"
|
||||||
|
includeMargin={false}
|
||||||
|
fgColor={qrColor}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center border-2 border-dashed border-slate-200 rounded-xl"
|
||||||
|
style={{ width: 240, height: 240 }}
|
||||||
|
>
|
||||||
|
<div className="text-center text-slate-400 p-6">
|
||||||
|
<Wallet className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||||
|
<p className="text-sm font-medium">Enter wallet address</p>
|
||||||
|
<p className="text-xs mt-1">to generate QR code</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Preview */}
|
||||||
|
<div className="mt-6 text-center max-w-[260px]">
|
||||||
|
<h3 className="font-bold text-slate-900 text-lg flex items-center justify-center gap-2 truncate">
|
||||||
|
<Bitcoin className="w-4 h-4 text-slate-400 shrink-0" />
|
||||||
|
<span className="truncate capitalize">{currency}</span>
|
||||||
|
</h3>
|
||||||
|
<div className="text-xs text-slate-500 mt-1 truncate px-2">
|
||||||
|
{address || 'Wallet Address'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Download Buttons */}
|
||||||
|
<div className="flex items-center gap-3 mt-8">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('png')}
|
||||||
|
className="bg-slate-900 hover:bg-black text-white shadow-lg"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Download PNG
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('svg')}
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-300 hover:bg-white"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
SVG
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500 mt-4 text-center">
|
||||||
|
Scanning copies the wallet address or opens a crypto app.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upsell Banner */}
|
||||||
|
<div className="mt-8 bg-gradient-to-r from-slate-900 to-slate-700 rounded-2xl p-6 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="text-white text-center sm:text-left">
|
||||||
|
<h3 className="font-bold text-lg">Accept Crypto for Business?</h3>
|
||||||
|
<p className="text-white/80 text-sm mt-1">
|
||||||
|
Create professional, branded payment pages for your store.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/signup">
|
||||||
|
<Button className="bg-white text-slate-900 hover:bg-slate-100 shrink-0 shadow-lg">
|
||||||
|
Get Business Tools
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
362
src/app/(marketing)/tools/crypto-qr-code/page.tsx
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
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';
|
||||||
|
|
||||||
|
// SEO Optimized Metadata
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Free Crypto QR Code Generator | Bitcoin, Ethereum & USDT | QR Master',
|
||||||
|
description: 'Create a QR code for your Crypto wallet address. Supports Bitcoin (BTC), Ethereum (ETH), USDT, and more. Essential for easy payments and donations.',
|
||||||
|
keywords: ['crypto qr code', 'bitcoin qr generator', 'ethereum qr code', 'crypto wallet qr', 'donation qr code'],
|
||||||
|
alternates: {
|
||||||
|
canonical: 'https://qrmaster.io/tools/crypto-qr-code',
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: 'Free Crypto QR Code Generator | QR Master',
|
||||||
|
description: 'Generate QR codes to accept Crypto payments securely. Supports BTC, ETH, SOL.',
|
||||||
|
type: 'website',
|
||||||
|
url: 'https://qrmaster.io/tools/crypto-qr-code',
|
||||||
|
images: [{ url: '/og-crypto-generator.png', width: 1200, height: 630 }],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: 'Free Crypto QR Code Generator',
|
||||||
|
description: 'Create secure QR codes for your crypto wallet.',
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// JSON-LD Structured Data
|
||||||
|
const jsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@graph': [
|
||||||
|
{
|
||||||
|
'@type': 'SoftwareApplication',
|
||||||
|
name: 'Crypto QR Code Generator',
|
||||||
|
applicationCategory: 'FinanceApplication',
|
||||||
|
operatingSystem: 'Web Browser',
|
||||||
|
offers: {
|
||||||
|
'@type': 'Offer',
|
||||||
|
price: '0',
|
||||||
|
priceCurrency: 'USD',
|
||||||
|
},
|
||||||
|
aggregateRating: {
|
||||||
|
'@type': 'AggregateRating',
|
||||||
|
ratingValue: '4.9',
|
||||||
|
ratingCount: '870',
|
||||||
|
},
|
||||||
|
description: 'Generate QR codes that contain your cryptocurrency wallet address for easy payments.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowTo',
|
||||||
|
name: 'How to Create a Crypto QR Code',
|
||||||
|
description: 'Create a QR code for your Bitcoin or Ethereum wallet.',
|
||||||
|
step: [
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 1,
|
||||||
|
name: 'Select Currency',
|
||||||
|
text: 'Choose your cryptocurrency from the list (Bitcoin, Ethereum, USDT, etc.).',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 2,
|
||||||
|
name: 'Enter Address',
|
||||||
|
text: 'Copy your public wallet address from your crypto app and paste it into the "Wallet Address" field.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 3,
|
||||||
|
name: 'Add Amount (Optional)',
|
||||||
|
text: 'If you are requesting a specific payment, enter the amount to pre-fill the transaction.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 4,
|
||||||
|
name: 'Customize QR',
|
||||||
|
text: 'Select a brand color (like Bitcoin Orange or Ethereum Blue) and add a frame like "Pay Now".',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 5,
|
||||||
|
name: 'Download',
|
||||||
|
text: 'Download the QR code image and share it to receive funds securely.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalTime: 'PT30S',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: [
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Is it safe to share my wallet address?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes. Your public wallet address is designed to be shared so you can receive funds. Never share your private key.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Which currencies are supported?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Our generator supports standard URI schemes for Bitcoin, Ethereum, Solana, and can generally store any wallet string for other coins.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Can I add a specific amount?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes, you can pre-fill an amount so when the user scans, their wallet app automatically suggests the correct payment value.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Does it work with all wallets?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes, standard crypto QR codes are universally readable by almost all modern wallet apps (Coinbase, MetaMask, Trust Wallet, etc.).',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Are there any fees?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'No. This generator is completely free. We do not charge any fees for generating codes or for the transactions made using them.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CryptoQRCodePage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
/>
|
||||||
|
<ToolBreadcrumb toolName="Crypto QR Code Generator" toolSlug="crypto-qr-code" />
|
||||||
|
|
||||||
|
<div className="min-h-screen" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
|
||||||
|
{/* HERO SECTION */}
|
||||||
|
<section className="relative pt-20 pb-20 lg:pt-32 lg:pb-32 px-4 sm:px-6 lg:px-8 overflow-hidden bg-slate-900">
|
||||||
|
<div className="absolute inset-0 opacity-20">
|
||||||
|
{/* Circuit Pattern */}
|
||||||
|
<svg className="w-full h-full" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<pattern id="circuit_pattern" width="100" height="100" patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M10 10 H 90 V 90 H 10 Z" stroke="none" fill="none" />
|
||||||
|
<circle cx="20" cy="20" r="2" fill="#F7931A" />
|
||||||
|
<circle cx="80" cy="80" r="2" fill="#627EEA" />
|
||||||
|
<path d="M20 20 L 50 20 L 50 50" stroke="white" strokeWidth="1" strokeOpacity="0.1" />
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="url(#circuit_pattern)" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto grid lg:grid-cols-2 gap-12 items-center relative z-10">
|
||||||
|
<div className="text-center lg:text-left">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/10 text-white/90 text-sm font-medium mb-6 backdrop-blur-sm border border-white/10 hover:bg-white/20 transition-colors cursor-default">
|
||||||
|
<span className="flex h-2 w-2 relative">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-orange-400 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-orange-400"></span>
|
||||||
|
</span>
|
||||||
|
Free Tool — Secure & Private
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl md:text-5xl lg:text-6xl font-extrabold text-white tracking-tight leading-tight mb-6">
|
||||||
|
Accept Payments with <br className="hidden lg:block" />
|
||||||
|
<span className="text-transparent bg-clip-text bg-gradient-to-r from-[#F7931A] to-[#F2A900]">Crypto QR Codes</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-lg md:text-xl text-slate-400 max-w-2xl mx-auto lg:mx-0 mb-8 leading-relaxed">
|
||||||
|
Share your wallet address securely. Supports Bitcoin, Ethereum, USDT, and more.
|
||||||
|
<strong className="text-white block sm:inline mt-2 sm:mt-0"> Error-free transfers.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center lg:justify-start gap-4 text-sm font-medium text-white/80">
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/10 backdrop-blur-sm">
|
||||||
|
<Bitcoin className="w-4 h-4 text-[#F7931A]" />
|
||||||
|
Bitcoin
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/10 backdrop-blur-sm">
|
||||||
|
<Coins className="w-4 h-4 text-[#627EEA]" />
|
||||||
|
Ethereum & Altcoins
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/10 backdrop-blur-sm">
|
||||||
|
<Wallet className="w-4 h-4 text-white" />
|
||||||
|
Wallet Connect
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visual Abstract */}
|
||||||
|
<div className="hidden lg:flex relative items-center justify-center min-h-[400px]">
|
||||||
|
<div className="absolute w-[500px] h-[500px] bg-orange-500/10 rounded-full blur-[100px] -top-20 -right-20 animate-pulse" />
|
||||||
|
|
||||||
|
<div className="relative w-80 h-96 bg-white/5 backdrop-blur-xl border border-white/20 rounded-3xl shadow-2xl flex flex-col items-center justify-center p-8 transform rotate-2 hover:-rotate-1 transition-all duration-700 group">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-white/5 to-transparent rounded-3xl" />
|
||||||
|
|
||||||
|
<div className="w-full bg-gradient-to-br from-orange-400 to-orange-600 rounded-xl shadow-lg p-5 mb-6 relative overflow-hidden text-white">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<Bitcoin className="w-8 h-8 opacity-80" />
|
||||||
|
<div className="bg-white/20 px-2 py-1 rounded text-xs">BTC</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold tracking-wider mb-1">0.05 BTC</div>
|
||||||
|
<div className="text-xs opacity-70">$3,450.25 USD</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-48 h-48 bg-white rounded-xl p-2 shadow-inner relative overflow-hidden flex items-center justify-center">
|
||||||
|
<QRCodeSVG value="https://www.qrmaster.net" size={170} fgColor="#333" level="Q" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Badge */}
|
||||||
|
<div className="absolute -bottom-6 -right-6 bg-slate-900 border border-white/10 py-3 px-5 rounded-xl shadow-xl flex items-center gap-3 transform transition-transform hover:scale-105 duration-300">
|
||||||
|
<div className="bg-orange-500/20 p-2 rounded-full">
|
||||||
|
<Wallet className="w-5 h-5 text-orange-500" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-[10px] text-slate-400 font-bold uppercase tracking-wider">Payment</div>
|
||||||
|
<div className="text-sm font-bold text-white">Receive Crypto</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* GENERATOR SECTION */}
|
||||||
|
<section className="py-12 px-4 sm:px-6 lg:px-8 -mt-8">
|
||||||
|
<CryptoGenerator />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* HOW IT WORKS */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8 bg-white">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-12">
|
||||||
|
How Crypto QR Codes Work
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 lg:grid-cols-5 gap-8">
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-black flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Coins className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">1. Select</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Choose your crypto currency (BTC, ETH, etc.).
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-black flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Wallet className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">2. Paste</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Enter your public wallet address.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-black flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Zap className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">3. Amount</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Optionally specify an amount to request.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-black flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Sparkles className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">4. Style</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Customize colors and add a 'Pay' frame.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-black flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Download className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">5. Download</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Save your secure QR code image.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* FAQ SECTION */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-4">
|
||||||
|
Frequently Asked Questions
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-600 text-center mb-10">
|
||||||
|
Common questions about Crypto QR codes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FaqItem
|
||||||
|
question="Is it safe to share my wallet address?"
|
||||||
|
answer="Yes. Your public wallet address is designed to be shared so you can receive funds. Never share your private key."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Which currencies are supported?"
|
||||||
|
answer="Our generator supports standard URI schemes for Bitcoin, Ethereum, Solana, and can generally store any wallet string for other coins."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Can I add a specific amount?"
|
||||||
|
answer="Yes, you can pre-fill an amount so when the user scans, their wallet app automatically suggests the correct payment value."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Does it work with all wallets?"
|
||||||
|
answer="Yes, standard crypto QR codes are universally readable by almost all modern wallet apps (Coinbase, MetaMask, Trust Wallet, etc.)."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Are there any fees?"
|
||||||
|
answer="No. This generator is completely free. We do not charge any fees for generating codes or for the transactions made using them."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FaqItem({ question, answer }: { question: string; answer: string }) {
|
||||||
|
return (
|
||||||
|
<details className="group bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||||
|
<summary className="flex items-center justify-between p-5 cursor-pointer list-none text-slate-900 font-semibold hover:bg-slate-50 transition-colors">
|
||||||
|
{question}
|
||||||
|
<span className="transition group-open:rotate-180 text-slate-400">
|
||||||
|
<svg fill="none" height="20" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width="20">
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div className="text-slate-600 px-5 pb-5 pt-0 leading-relaxed text-sm">
|
||||||
|
{answer}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
297
src/app/(marketing)/tools/email-qr-code/EmailGenerator.tsx
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import {
|
||||||
|
Mail,
|
||||||
|
Download,
|
||||||
|
Check,
|
||||||
|
Sparkles,
|
||||||
|
Type,
|
||||||
|
FileText
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// Brand Colors
|
||||||
|
const BRAND = {
|
||||||
|
paleGrey: '#EBEBDF',
|
||||||
|
richRed: '#dc2626',
|
||||||
|
};
|
||||||
|
|
||||||
|
// QR Color Options
|
||||||
|
const QR_COLORS = [
|
||||||
|
{ name: 'Classic Black', value: '#000000' },
|
||||||
|
{ name: 'Email Red', value: '#dc2626' },
|
||||||
|
{ name: 'Deep Blue', value: '#1E40AF' },
|
||||||
|
{ name: 'Violet', value: '#7C3AED' },
|
||||||
|
{ name: 'Teal', value: '#0D9488' },
|
||||||
|
{ name: 'Coral', value: '#F43F5E' },
|
||||||
|
{ name: 'Emerald', value: '#10B981' },
|
||||||
|
{ name: 'Rose', value: '#F43F5E' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Frame Options
|
||||||
|
const FRAME_OPTIONS = [
|
||||||
|
{ id: 'none', label: 'No Frame' },
|
||||||
|
{ id: 'email', label: 'Email Me' },
|
||||||
|
{ id: 'contact', label: 'Contact' },
|
||||||
|
{ id: 'send', label: 'Send Mail' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function EmailGenerator() {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
email: '',
|
||||||
|
subject: '',
|
||||||
|
body: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const [qrColor, setQrColor] = useState('#dc2626');
|
||||||
|
const [frameType, setFrameType] = useState('none');
|
||||||
|
|
||||||
|
const qrRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Generate Mailto Link
|
||||||
|
// Format: mailto:email?subject=...&body=...
|
||||||
|
const getMailtoUrl = () => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (formData.subject) params.append('subject', formData.subject);
|
||||||
|
if (formData.body) params.append('body', formData.body);
|
||||||
|
|
||||||
|
const queryString = params.toString();
|
||||||
|
return `mailto:${formData.email}${queryString ? `?${queryString}` : ''}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async (format: 'png' | 'svg') => {
|
||||||
|
if (!qrRef.current) return;
|
||||||
|
try {
|
||||||
|
if (format === 'png') {
|
||||||
|
const { toPng } = await import('html-to-image');
|
||||||
|
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3 });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = `email-qr-code.png`;
|
||||||
|
link.href = dataUrl;
|
||||||
|
link.click();
|
||||||
|
} else {
|
||||||
|
const svgData = qrRef.current.querySelector('svg')?.outerHTML;
|
||||||
|
if (svgData) {
|
||||||
|
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
||||||
|
const urlBlob = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = urlBlob;
|
||||||
|
link.download = `email-qr-code.svg`;
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Download failed', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFrameLabel = () => {
|
||||||
|
const frame = FRAME_OPTIONS.find(f => f.id === frameType);
|
||||||
|
return frame?.id !== 'none' ? frame?.label : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
setFormData({ ...formData, [e.target.name]: e.target.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-5xl mx-auto px-4 md:px-6">
|
||||||
|
|
||||||
|
{/* Main Generator Card */}
|
||||||
|
<div className="bg-white rounded-3xl shadow-2xl shadow-slate-900/10 overflow-hidden border border-slate-100">
|
||||||
|
<div className="grid lg:grid-cols-2">
|
||||||
|
|
||||||
|
{/* LEFT: Input Section */}
|
||||||
|
<div className="p-8 lg:p-10 space-y-8 border-r border-slate-100">
|
||||||
|
|
||||||
|
{/* Input Fields */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Mail className="w-5 h-5 text-red-600" />
|
||||||
|
Email Details
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Recipient Email</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="absolute left-3 top-3 w-4 h-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
name="email"
|
||||||
|
placeholder="recipient@example.com"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="h-11 rounded-xl pl-9"
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Subject Line</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Type className="absolute left-3 top-3 w-4 h-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
name="subject"
|
||||||
|
placeholder="e.g. Inquiry about services"
|
||||||
|
value={formData.subject}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="h-11 rounded-xl pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Body Message (Optional)</label>
|
||||||
|
<textarea
|
||||||
|
name="body"
|
||||||
|
placeholder="Hi there, I would like to know more about..."
|
||||||
|
value={formData.body}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full h-32 p-3 rounded-xl border border-slate-200 focus:border-red-600 focus:ring-1 focus:ring-red-600 focus:outline-none resize-none text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="border-t border-slate-100"></div>
|
||||||
|
|
||||||
|
{/* Design Options */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Sparkles className="w-5 h-5 text-red-600" />
|
||||||
|
Design Options
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Color Picker */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">QR Code Color</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{QR_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.name}
|
||||||
|
onClick={() => setQrColor(c.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-9 h-9 rounded-full border-2 flex items-center justify-center transition-all hover:scale-110",
|
||||||
|
qrColor === c.value ? "border-slate-900 ring-2 ring-offset-2 ring-slate-200" : "border-white shadow-md"
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: c.value }}
|
||||||
|
aria-label={`Select ${c.name}`}
|
||||||
|
title={c.name}
|
||||||
|
>
|
||||||
|
{qrColor === c.value && <Check className="w-4 h-4 text-white" strokeWidth={3} />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Frame Selector */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">Frame Label</label>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{FRAME_OPTIONS.map((frame) => (
|
||||||
|
<button
|
||||||
|
key={frame.id}
|
||||||
|
onClick={() => setFrameType(frame.id)}
|
||||||
|
className={cn(
|
||||||
|
"py-2.5 px-3 rounded-lg text-sm font-medium transition-all border",
|
||||||
|
frameType === frame.id
|
||||||
|
? "bg-red-600 text-white border-red-600"
|
||||||
|
: "bg-slate-50 text-slate-600 border-slate-200 hover:border-slate-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{frame.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT: Preview Section */}
|
||||||
|
<div className="p-8 lg:p-10 flex flex-col items-center justify-center" style={{ backgroundColor: BRAND.paleGrey }}>
|
||||||
|
|
||||||
|
{/* QR Card with Frame */}
|
||||||
|
<div
|
||||||
|
ref={qrRef}
|
||||||
|
className="bg-white rounded-3xl shadow-xl p-8 flex flex-col items-center"
|
||||||
|
style={{ minWidth: '320px' }}
|
||||||
|
>
|
||||||
|
{/* Frame Label */}
|
||||||
|
{getFrameLabel() && (
|
||||||
|
<div
|
||||||
|
className="mb-5 px-8 py-2.5 rounded-full text-white font-bold text-sm tracking-widest uppercase shadow-md"
|
||||||
|
style={{ backgroundColor: qrColor }}
|
||||||
|
>
|
||||||
|
{getFrameLabel()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* QR Code */}
|
||||||
|
<div className="bg-white">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={getMailtoUrl() || 'mailto:hello@example.com'}
|
||||||
|
size={240}
|
||||||
|
level="M"
|
||||||
|
includeMargin={false}
|
||||||
|
fgColor={qrColor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-red-50 mx-auto mb-3">
|
||||||
|
<Mail className="w-6 h-6 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 text-lg truncate max-w-[260px] mx-auto">
|
||||||
|
{formData.email || 'Email QR Code'}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Download Buttons */}
|
||||||
|
<div className="flex items-center gap-3 mt-8">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('png')}
|
||||||
|
className="bg-red-600 hover:bg-red-700 text-white shadow-lg"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Download PNG
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('svg')}
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-300 hover:bg-white"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
SVG
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500 mt-4 text-center">
|
||||||
|
100% free. No signup required.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upsell Banner */}
|
||||||
|
<div className="mt-8 bg-gradient-to-r from-red-600 to-rose-700 rounded-2xl p-6 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="text-white text-center sm:text-left">
|
||||||
|
<h3 className="font-bold text-lg">Change your email address often?</h3>
|
||||||
|
<p className="text-white/80 text-sm mt-1">Dynamic QR Codes allow you to update the recipient without reprinting.</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/signup">
|
||||||
|
<Button className="bg-white text-red-700 hover:bg-slate-100 shrink-0 shadow-lg">
|
||||||
|
Go Dynamic
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
273
src/app/(marketing)/tools/email-qr-code/page.tsx
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
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';
|
||||||
|
|
||||||
|
// SEO Optimized Metadata
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Free Email QR Code Generator | Mailto QR | QR Master',
|
||||||
|
description: 'Create an Email QR code to send emails instantly. Pre-fill subject and body. 100% free and client-side secure.',
|
||||||
|
keywords: ['email qr code', 'mailto qr', 'email generator', 'free qr code'],
|
||||||
|
alternates: {
|
||||||
|
canonical: 'https://qrmaster.io/tools/email-qr-code',
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: 'Free Email QR Code Generator | QR Master',
|
||||||
|
description: 'Send emails instantly with a custom QR code. Add recipient, subject, and body.',
|
||||||
|
type: 'website',
|
||||||
|
url: 'https://qrmaster.io/tools/email-qr-code',
|
||||||
|
images: [{ url: '/og-email-generator.png', width: 1200, height: 630 }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// JSON-LD
|
||||||
|
const jsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@graph': [
|
||||||
|
{
|
||||||
|
'@type': 'SoftwareApplication',
|
||||||
|
name: 'Email QR Code Generator',
|
||||||
|
applicationCategory: 'UtilitiesApplication',
|
||||||
|
operatingSystem: 'Web Browser',
|
||||||
|
offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
|
||||||
|
description: 'Generate Email QR codes for mailto links with subject and body.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowTo',
|
||||||
|
name: 'How to Create an Email QR Code',
|
||||||
|
step: [
|
||||||
|
{ '@type': 'HowToStep', position: 1, name: 'Enter Recipient', text: 'Type the email address you want to receive emails at.' },
|
||||||
|
{ '@type': 'HowToStep', position: 2, name: 'Add Details', text: 'Optional: Add a pre-filled subject line and body text.' },
|
||||||
|
{ '@type': 'HowToStep', position: 3, name: 'Customize', text: 'Choose a brand color and add a call-to-action frame.' },
|
||||||
|
{ '@type': 'HowToStep', position: 4, name: 'Download', text: 'Download your QR code in PNG or SVG.' },
|
||||||
|
{ '@type': 'HowToStep', position: 5, name: 'Share', text: 'Add to business cards or flyers.' },
|
||||||
|
],
|
||||||
|
totalTime: 'PT30S',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: [
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'How does it work?',
|
||||||
|
acceptedAnswer: { '@type': 'Answer', text: 'When scanned, it opens the user\'s default email app (like Gmail or Outlook) with a new draft composed to your address.' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Can I add a subject line?',
|
||||||
|
acceptedAnswer: { '@type': 'Answer', text: 'Yes! You can pre-fill the subject line and the body content so the sender just has to hit send.' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Is it free?',
|
||||||
|
acceptedAnswer: { '@type': 'Answer', text: 'Yes, 100% free with unlimited scans.' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Does it work with attachments?',
|
||||||
|
acceptedAnswer: { '@type': 'Answer', text: 'No. The standard mailto format does not support attaching files automatically. Users will have to attach files manually.' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Is it private?',
|
||||||
|
acceptedAnswer: { '@type': 'Answer', text: 'Yes. The data is encoded directly into the QR code. We do not store your email or message data.' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EmailPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
/>
|
||||||
|
<ToolBreadcrumb toolName="Email QR Code Generator" toolSlug="email-qr-code" />
|
||||||
|
|
||||||
|
<div className="min-h-screen" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
|
||||||
|
{/* HERO SECTION */}
|
||||||
|
<section className="relative pt-20 pb-20 lg:pt-32 lg:pb-32 px-4 sm:px-6 lg:px-8 overflow-hidden" style={{ backgroundColor: '#dc2626' }}>
|
||||||
|
|
||||||
|
{/* Background Pattern */}
|
||||||
|
<div className="absolute inset-0 opacity-10">
|
||||||
|
<svg className="w-full h-full" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||||
|
<path d="M0 100 C 20 0 50 0 100 100 Z" fill="url(#grad1)" />
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" style={{ stopColor: 'white', stopOpacity: 1 }} />
|
||||||
|
<stop offset="100%" style={{ stopColor: 'white', stopOpacity: 0 }} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto grid lg:grid-cols-2 gap-12 items-center relative z-10">
|
||||||
|
|
||||||
|
{/* Left: Text Content */}
|
||||||
|
<div className="text-center lg:text-left">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/10 text-white/90 text-sm font-medium mb-6 backdrop-blur-sm border border-white/10 hover:bg-white/20 transition-colors cursor-default">
|
||||||
|
<span className="flex h-2 w-2 relative">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-300 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-300"></span>
|
||||||
|
</span>
|
||||||
|
Free Tool — No Signup Required
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl md:text-5xl lg:text-6xl font-extrabold text-white tracking-tight leading-tight mb-6">
|
||||||
|
The Smartest Way to <br className="hidden lg:block" />
|
||||||
|
<span className="text-transparent bg-clip-text bg-gradient-to-r from-red-200 to-rose-200">Receive Emails</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-lg md:text-xl text-red-50 max-w-2xl mx-auto lg:mx-0 mb-8 leading-relaxed">
|
||||||
|
Create a QR code that opens a pre-composed email instantly.
|
||||||
|
<strong className="text-white block sm:inline mt-2 sm:mt-0"> Perfect for feedback & inquiries.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center lg:justify-start gap-4 text-sm font-medium text-white/80">
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/5 backdrop-blur-sm">
|
||||||
|
<Mail className="w-4 h-4 text-red-300" />
|
||||||
|
Instant Draft
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/5 backdrop-blur-sm">
|
||||||
|
<Zap className="w-4 h-4 text-yellow-300" />
|
||||||
|
Pre-filled Content
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/5 backdrop-blur-sm">
|
||||||
|
<Smartphone className="w-4 h-4 text-red-300" />
|
||||||
|
Mobile Ready
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Visual Abstract Composition */}
|
||||||
|
<div className="hidden lg:flex relative items-center justify-center min-h-[400px]">
|
||||||
|
{/* Decorative Glow */}
|
||||||
|
<div className="absolute w-[500px] h-[500px] bg-red-500/30 rounded-full blur-[100px] -top-20 -right-20 animate-pulse" />
|
||||||
|
|
||||||
|
{/* Floating Glass Card */}
|
||||||
|
<div className="relative w-80 h-96 bg-white/5 backdrop-blur-xl border border-white/20 rounded-3xl shadow-2xl flex flex-col items-center justify-center p-8 transform rotate-6 hover:rotate-3 transition-all duration-700 group">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-white/10 to-transparent rounded-3xl" />
|
||||||
|
|
||||||
|
{/* Mock QR */}
|
||||||
|
<div className="w-48 h-48 bg-white rounded-xl p-2 shadow-inner mb-6 relative overflow-hidden flex items-center justify-center">
|
||||||
|
<QRCodeSVG value="https://www.qrmaster.net" size={170} fgColor="#b91c1c" level="Q" />
|
||||||
|
{/* Scan Line */}
|
||||||
|
<div className="absolute top-1/2 left-0 w-full h-1 bg-red-500 shadow-[0_0_20px_rgba(220,38,38,1)] animate-pulse" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full space-y-3">
|
||||||
|
<div className="h-2 w-32 bg-white/20 rounded-full mx-auto" />
|
||||||
|
<div className="h-2 w-20 bg-white/10 rounded-full mx-auto" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Badge */}
|
||||||
|
<div className="absolute -bottom-6 -left-6 bg-white py-3 px-5 rounded-xl shadow-xl flex items-center gap-3 transform transition-transform hover:scale-105 duration-300">
|
||||||
|
<div className="bg-red-100 p-2 rounded-full">
|
||||||
|
<Mail className="w-5 h-5 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-[10px] text-slate-400 font-bold uppercase tracking-wider">Status</div>
|
||||||
|
<div className="text-sm font-bold text-slate-900">Live</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* GENERATOR SECTION */}
|
||||||
|
<section className="py-12 px-4 sm:px-6 lg:px-8 -mt-8">
|
||||||
|
<EmailGenerator />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* HOW IT WORKS */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8 bg-white">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-12">
|
||||||
|
How Email QR Codes Work
|
||||||
|
</h2>
|
||||||
|
<div className="grid md:grid-cols-3 lg:grid-cols-5 gap-8">
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#1A1265]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Mail className="w-6 h-6 text-[#1A1265]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">1. Add Email</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">Enter the address and subject.</p>
|
||||||
|
</article>
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#1A1265]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Sparkles className="w-6 h-6 text-[#1A1265]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">2. Customize</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">Pick a brand color.</p>
|
||||||
|
</article>
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#1A1265]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Zap className="w-6 h-6 text-[#1A1265]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">3. Style</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">Add a cool frame.</p>
|
||||||
|
</article>
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#1A1265]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Download className="w-6 h-6 text-[#1A1265]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">4. Download</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">Save your QR code.</p>
|
||||||
|
</article>
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#1A1265]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Smartphone className="w-6 h-6 text-[#1A1265]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">5. Share</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">Print and get emails.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* FAQ SECTION */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-4">
|
||||||
|
Frequently Asked Questions
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-600 text-center mb-10">Common questions about Email QR codes.</p>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FaqItem question="Does it work with Gmail?" answer="Yes, and Outlook, Apple Mail, Yahoo, etc. It opens the default mail app on the user's device." />
|
||||||
|
<FaqItem question="Is it reversible?" answer="Yes, if you made a mistake you would need to generate a new code, as static QR codes cannot be edited after creation." />
|
||||||
|
<FaqItem question="Is this tool free?" answer="Yes, completely free to use." />
|
||||||
|
<FaqItem question="Can I attach files?" answer="No. The mailto standard does not support automatic attachment of files. Users must attach them manually." />
|
||||||
|
<FaqItem question="Is it private?" answer="Yes. The data is encoded directly into the QR code. We do not store your email or message data." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// FAQ Item Component
|
||||||
|
function FaqItem({ question, answer }: { question: string; answer: string }) {
|
||||||
|
return (
|
||||||
|
<details className="group bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||||
|
<summary className="flex items-center justify-between p-5 cursor-pointer list-none text-slate-900 font-semibold hover:bg-slate-50 transition-colors">
|
||||||
|
{question}
|
||||||
|
<span className="transition group-open:rotate-180 text-slate-400">
|
||||||
|
<svg fill="none" height="20" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width="20">
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div className="text-slate-600 px-5 pb-5 pt-0 leading-relaxed text-sm">
|
||||||
|
{answer}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
331
src/app/(marketing)/tools/event-qr-code/EventGenerator.tsx
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import {
|
||||||
|
Calendar,
|
||||||
|
Download,
|
||||||
|
Check,
|
||||||
|
Sparkles,
|
||||||
|
Clock,
|
||||||
|
MapPin,
|
||||||
|
AlignLeft
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// Brand Colors
|
||||||
|
const BRAND = {
|
||||||
|
paleGrey: '#F5F3FF', // Violet-50
|
||||||
|
primary: '#7C3AED', // Violet-600
|
||||||
|
primaryDark: '#6D28D9', // Violet-700
|
||||||
|
};
|
||||||
|
|
||||||
|
// QR Color Options
|
||||||
|
const QR_COLORS = [
|
||||||
|
{ name: 'Violet', value: '#7C3AED' },
|
||||||
|
{ name: 'Purple', value: '#9333EA' },
|
||||||
|
{ name: 'Classic Black', value: '#000000' },
|
||||||
|
{ name: 'Deep Blue', value: '#1E40AF' },
|
||||||
|
{ name: 'Pink', value: '#DB2777' },
|
||||||
|
{ name: 'Emerald', value: '#10B981' },
|
||||||
|
{ name: 'Rose', value: '#F43F5E' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Frame Options
|
||||||
|
const FRAME_OPTIONS = [
|
||||||
|
{ id: 'none', label: 'No Frame' },
|
||||||
|
{ id: 'scanme', label: 'Scan Me' },
|
||||||
|
{ id: 'event', label: 'Event' },
|
||||||
|
{ id: 'save', label: 'Save Date' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function EventGenerator() {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [location, setLocation] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [startDate, setStartDate] = useState('');
|
||||||
|
const [endDate, setEndDate] = useState('');
|
||||||
|
|
||||||
|
const [qrColor, setQrColor] = useState(BRAND.primary);
|
||||||
|
const [frameType, setFrameType] = useState('none');
|
||||||
|
|
||||||
|
const qrRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Format Date for iCal: YYYYMMDDTHHMMSS
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
if (!dateString) return '';
|
||||||
|
const d = new Date(dateString);
|
||||||
|
// Basic formatting, assumes local time for simplicity in this static tool
|
||||||
|
const year = d.getFullYear();
|
||||||
|
const month = ('0' + (d.getMonth() + 1)).slice(-2);
|
||||||
|
const day = ('0' + d.getDate()).slice(-2);
|
||||||
|
const hours = ('0' + d.getHours()).slice(-2);
|
||||||
|
const minutes = ('0' + d.getMinutes()).slice(-2);
|
||||||
|
const seconds = ('0' + d.getSeconds()).slice(-2);
|
||||||
|
return `${year}${month}${day}T${hours}${minutes}${seconds}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const qrValue = [
|
||||||
|
'BEGIN:VEVENT',
|
||||||
|
`SUMMARY:${title}`,
|
||||||
|
`LOCATION:${location}`,
|
||||||
|
`DESCRIPTION:${description}`,
|
||||||
|
`DTSTART:${formatDate(startDate)}`,
|
||||||
|
`DTEND:${formatDate(endDate)}`,
|
||||||
|
'END:VEVENT'
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const handleDownload = async (format: 'png' | 'svg') => {
|
||||||
|
if (!qrRef.current) return;
|
||||||
|
try {
|
||||||
|
if (format === 'png') {
|
||||||
|
const { toPng } = await import('html-to-image');
|
||||||
|
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3 });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = `event-qr-code.png`;
|
||||||
|
link.href = dataUrl;
|
||||||
|
link.click();
|
||||||
|
} else {
|
||||||
|
const svgData = qrRef.current.querySelector('svg')?.outerHTML;
|
||||||
|
if (svgData) {
|
||||||
|
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `event-qr-code.svg`;
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Download failed', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFrameLabel = () => {
|
||||||
|
const frame = FRAME_OPTIONS.find(f => f.id === frameType);
|
||||||
|
return frame?.id !== 'none' ? frame?.label : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-5xl mx-auto px-4 md:px-6">
|
||||||
|
|
||||||
|
{/* Main Generator Card */}
|
||||||
|
<div className="bg-white rounded-3xl shadow-2xl shadow-slate-900/10 overflow-hidden border border-slate-100">
|
||||||
|
<div className="grid lg:grid-cols-2">
|
||||||
|
|
||||||
|
{/* LEFT: Input Section */}
|
||||||
|
<div className="p-8 lg:p-10 space-y-8 border-r border-slate-100">
|
||||||
|
|
||||||
|
{/* Event Details */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Calendar className="w-5 h-5 text-[#7C3AED]" />
|
||||||
|
Event Details
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Event Title</label>
|
||||||
|
<Input
|
||||||
|
placeholder="Summer Party"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className="h-12 text-base rounded-xl border-slate-200 focus:border-[#7C3AED] focus:ring-[#7C3AED]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Start Time</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
className="h-12 text-sm rounded-xl border-slate-200 focus:border-[#1A1265] focus:ring-[#1A1265]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">End Time</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
className="h-12 text-sm rounded-xl border-slate-200 focus:border-[#7C3AED] focus:ring-[#7C3AED]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Location</label>
|
||||||
|
<div className="relative">
|
||||||
|
<MapPin className="absolute left-3 top-3.5 w-5 h-5 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
placeholder="123 Main St, New York"
|
||||||
|
value={location}
|
||||||
|
onChange={(e) => setLocation(e.target.value)}
|
||||||
|
className="pl-10 h-12 text-base rounded-xl border-slate-200 focus:border-[#1A1265] focus:ring-[#1A1265]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Description</label>
|
||||||
|
<textarea
|
||||||
|
className="w-full h-24 p-4 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-[#1A1265] resize-none text-slate-800 placeholder:text-slate-400"
|
||||||
|
placeholder="Join us for a celebration..."
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-100"></div>
|
||||||
|
|
||||||
|
{/* Design Options */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Sparkles className="w-5 h-5 text-[#7C3AED]" />
|
||||||
|
Design Options
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Color Picker */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">QR Code Color</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{QR_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.name}
|
||||||
|
onClick={() => setQrColor(c.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-9 h-9 rounded-full border-2 flex items-center justify-center transition-all hover:scale-110",
|
||||||
|
qrColor === c.value ? "border-slate-900 ring-2 ring-offset-2 ring-slate-200" : "border-white shadow-md"
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: c.value }}
|
||||||
|
aria-label={`Select ${c.name}`}
|
||||||
|
title={c.name}
|
||||||
|
>
|
||||||
|
{qrColor === c.value && <Check className="w-4 h-4 text-white" strokeWidth={3} />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Frame Selector */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">Frame Label</label>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{FRAME_OPTIONS.map((frame) => (
|
||||||
|
<button
|
||||||
|
key={frame.id}
|
||||||
|
onClick={() => setFrameType(frame.id)}
|
||||||
|
className={cn(
|
||||||
|
"py-2.5 px-3 rounded-lg text-sm font-medium transition-all border",
|
||||||
|
frameType === frame.id
|
||||||
|
? "bg-[#7C3AED] text-white border-[#7C3AED]"
|
||||||
|
: "bg-slate-50 text-slate-600 border-slate-200 hover:border-slate-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{frame.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT: Preview Section */}
|
||||||
|
<div className="p-8 lg:p-10 flex flex-col items-center justify-center" style={{ backgroundColor: BRAND.paleGrey }}>
|
||||||
|
|
||||||
|
{/* QR Card with Frame */}
|
||||||
|
<div
|
||||||
|
ref={qrRef}
|
||||||
|
className="bg-white rounded-3xl shadow-xl p-8 flex flex-col items-center"
|
||||||
|
style={{ minWidth: '320px' }}
|
||||||
|
>
|
||||||
|
{/* Frame Label */}
|
||||||
|
{getFrameLabel() && (
|
||||||
|
<div
|
||||||
|
className="mb-5 px-8 py-2.5 rounded-full text-white font-bold text-sm tracking-widest uppercase shadow-md"
|
||||||
|
style={{ backgroundColor: qrColor }}
|
||||||
|
>
|
||||||
|
{getFrameLabel()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* QR Code */}
|
||||||
|
<div className="bg-white">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={(title || startDate) ? qrValue : "Title"}
|
||||||
|
size={240}
|
||||||
|
level="M"
|
||||||
|
includeMargin={false}
|
||||||
|
fgColor={qrColor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Preview */}
|
||||||
|
<div className="mt-6 text-center max-w-[260px]">
|
||||||
|
<h3 className="font-bold text-slate-900 text-lg flex items-center justify-center gap-2 truncate">
|
||||||
|
<Calendar className="w-4 h-4 text-[#7C3AED] shrink-0" />
|
||||||
|
<span className="truncate">{title || 'Event Title'}</span>
|
||||||
|
</h3>
|
||||||
|
{(startDate) && (
|
||||||
|
<div className="text-xs text-slate-500 mt-1 flex items-center justify-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{new Date(startDate).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Download Buttons */}
|
||||||
|
<div className="flex items-center gap-3 mt-8">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('png')}
|
||||||
|
className="bg-[#7C3AED] hover:bg-[#6D28D9] text-white shadow-lg"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Download PNG
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('svg')}
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-300 hover:bg-white"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
SVG
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500 mt-4 text-center">
|
||||||
|
Scanning adds the event to the user's calendar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upsell Banner */}
|
||||||
|
<div className="mt-8 bg-gradient-to-r from-[#7C3AED] to-[#6D28D9] rounded-2xl p-6 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="text-white text-center sm:text-left">
|
||||||
|
<h3 className="font-bold text-lg">Planning a big event?</h3>
|
||||||
|
<p className="text-white/80 text-sm mt-1">
|
||||||
|
Use a Dynamic QR Code to track RSVPs and update event details if the schedule changes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/signup">
|
||||||
|
<Button className="bg-white text-[#7C3AED] hover:bg-slate-100 shrink-0 shadow-lg">
|
||||||
|
Get Dynamic Events
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
353
src/app/(marketing)/tools/event-qr-code/page.tsx
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
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';
|
||||||
|
|
||||||
|
// SEO Optimized Metadata
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Free Event QR Code Generator | Add to Calendar | QR Master',
|
||||||
|
description: 'Create a QR code for your event. Scanners can instantly save the date, time, and location to their phone calendar. Perfect for invitations and flyers.',
|
||||||
|
keywords: ['event qr code', 'calendar qr code', 'save the date qr', 'ical qr generator', 'invitation qr code'],
|
||||||
|
alternates: {
|
||||||
|
canonical: 'https://qrmaster.io/tools/event-qr-code',
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: 'Free Event QR Code Generator | QR Master',
|
||||||
|
description: 'Generate QR codes to save events to calendars. Share dates easily.',
|
||||||
|
type: 'website',
|
||||||
|
url: 'https://qrmaster.io/tools/event-qr-code',
|
||||||
|
images: [{ url: '/og-event-generator.png', width: 1200, height: 630 }],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: 'Free Event QR Code Generator',
|
||||||
|
description: 'Create QR codes for events. Instant save-to-calendar.',
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// JSON-LD Structured Data
|
||||||
|
const jsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@graph': [
|
||||||
|
{
|
||||||
|
'@type': 'SoftwareApplication',
|
||||||
|
name: 'Event QR Code Generator',
|
||||||
|
applicationCategory: 'UtilitiesApplication',
|
||||||
|
operatingSystem: 'Web Browser',
|
||||||
|
offers: {
|
||||||
|
'@type': 'Offer',
|
||||||
|
price: '0',
|
||||||
|
priceCurrency: 'USD',
|
||||||
|
},
|
||||||
|
aggregateRating: {
|
||||||
|
'@type': 'AggregateRating',
|
||||||
|
ratingValue: '4.8',
|
||||||
|
ratingCount: '760',
|
||||||
|
},
|
||||||
|
description: 'Generate QR codes that add event details to the user\'s digital calendar.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowTo',
|
||||||
|
name: 'How to Create an Event QR Code',
|
||||||
|
description: 'Create a QR code that saves an event to a calendar.',
|
||||||
|
step: [
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 1,
|
||||||
|
name: 'Enter Event Details',
|
||||||
|
text: 'Fill in the Event Title, Location, Description, Start Time, and End Time.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 2,
|
||||||
|
name: 'Customize',
|
||||||
|
text: 'Choose a color and frame style like "Save the Date".',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 3,
|
||||||
|
name: 'Download',
|
||||||
|
text: 'Save the QR code and add it to your invitations.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 4,
|
||||||
|
name: 'Test',
|
||||||
|
text: 'Scan the code to ensure the event details and times are correct.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 5,
|
||||||
|
name: 'Share',
|
||||||
|
text: 'Distribute it via email, flyers, or social media.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalTime: 'PT45S',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: [
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Which calendars does it support?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'The QR code uses the standard iCalendar (ICS) format. It works with Apple Calendar, Google Calendar, Outlook, and most other mobile calendar apps.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Can I change the date after printing?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'No. This is a static QR code, meaning the event details are permanently embedded in the image. If the date changes, you must create a new QR code. Use our Dynamic QR Code to edit events anytime.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Is there a limit to the description length?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes, because the data is stored in the QR code pattern. We recommend keeping descriptions concise (under 300 characters) to ensure the code remains easy to scan.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Do users need an app?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'No special app is needed. Standard camera apps on iOS and Android can read the code and will prompt the user to "Add to Calendar".',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Is it free?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes. Creating and scanning the code is completely free and requires no signup.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EventQRCodePage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
/>
|
||||||
|
<ToolBreadcrumb toolName="Event QR Code Generator" toolSlug="event-qr-code" />
|
||||||
|
|
||||||
|
<div className="min-h-screen" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
|
||||||
|
{/* HERO SECTION */}
|
||||||
|
<section className="relative pt-20 pb-20 lg:pt-32 lg:pb-32 px-4 sm:px-6 lg:px-8 overflow-hidden" style={{ backgroundColor: '#5B21B6' }}>
|
||||||
|
<div className="absolute inset-0 opacity-10">
|
||||||
|
<svg className="w-full h-full" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||||
|
<path d="M0 100 C 20 0 50 0 100 100 Z" fill="url(#grad1)" />
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" style={{ stopColor: 'white', stopOpacity: 1 }} />
|
||||||
|
<stop offset="100%" style={{ stopColor: 'white', stopOpacity: 0 }} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto grid lg:grid-cols-2 gap-12 items-center relative z-10">
|
||||||
|
<div className="text-center lg:text-left">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/10 text-white/90 text-sm font-medium mb-6 backdrop-blur-sm border border-white/10 hover:bg-white/20 transition-colors cursor-default">
|
||||||
|
<span className="flex h-2 w-2 relative">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-violet-400 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-violet-400"></span>
|
||||||
|
</span>
|
||||||
|
Free Tool — No Signup Required
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl md:text-5xl lg:text-6xl font-extrabold text-white tracking-tight leading-tight mb-6">
|
||||||
|
Create Scannable <br className="hidden lg:block" />
|
||||||
|
<span className="text-transparent bg-clip-text bg-gradient-to-r from-violet-300 to-fuchsia-300">Calendar Invites</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-lg md:text-xl text-indigo-100 max-w-2xl mx-auto lg:mx-0 mb-8 leading-relaxed">
|
||||||
|
Share your event details instantly. Visitors scan to "Save the Date" directly to their phone calendar.
|
||||||
|
<strong className="text-white block sm:inline mt-2 sm:mt-0"> Perfect for invitations.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center lg:justify-start gap-4 text-sm font-medium text-white/80">
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/5 backdrop-blur-sm">
|
||||||
|
<Calendar className="w-4 h-4 text-violet-300" />
|
||||||
|
Instant Save
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/5 backdrop-blur-sm">
|
||||||
|
<Clock className="w-4 h-4 text-amber-400" />
|
||||||
|
Timezone Smart
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/5 backdrop-blur-sm">
|
||||||
|
<UserCheck className="w-4 h-4 text-purple-400" />
|
||||||
|
Native Support
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visual Abstract */}
|
||||||
|
<div className="hidden lg:flex relative items-center justify-center min-h-[400px]">
|
||||||
|
<div className="absolute w-[500px] h-[500px] bg-indigo-500/30 rounded-full blur-[100px] -top-20 -right-20 animate-pulse" />
|
||||||
|
|
||||||
|
<div className="relative w-80 h-96 bg-white/5 backdrop-blur-xl border border-white/20 rounded-3xl shadow-2xl flex flex-col items-center justify-center p-8 transform -rotate-2 hover:rotate-1 transition-all duration-700 group">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-white/10 to-transparent rounded-3xl" />
|
||||||
|
|
||||||
|
<div className="w-full bg-white rounded-xl shadow-lg p-4 mb-6 relative overflow-hidden flex flex-col items-center text-center">
|
||||||
|
<div className="w-full h-2 bg-red-500 rounded-full mb-3" />
|
||||||
|
<div className="text-xs uppercase font-bold text-red-500 tracking-widest mb-1">DECEMBER</div>
|
||||||
|
<div className="text-4xl font-black text-slate-900 leading-none mb-1">25</div>
|
||||||
|
<div className="text-xs text-slate-400">Saturday • 8:00 PM</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-44 h-44 bg-white rounded-xl p-2 shadow-inner relative overflow-hidden flex items-center justify-center">
|
||||||
|
<QRCodeSVG value="https://www.qrmaster.net" size={160} fgColor="#0f172a" level="Q" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Badge */}
|
||||||
|
<div className="absolute -bottom-6 -left-6 bg-white py-3 px-5 rounded-xl shadow-xl flex items-center gap-3 transform transition-transform hover:scale-105 duration-300">
|
||||||
|
<div className="bg-emerald-100 p-2 rounded-full">
|
||||||
|
<Check className="w-5 h-5 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-[10px] text-slate-400 font-bold uppercase tracking-wider">Event</div>
|
||||||
|
<div className="text-sm font-bold text-slate-900">Added to Cal</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* GENERATOR SECTION */}
|
||||||
|
<section className="py-12 px-4 sm:px-6 lg:px-8 -mt-8">
|
||||||
|
<EventGenerator />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* HOW IT WORKS */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8 bg-white">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-12">
|
||||||
|
How Event QR Codes Work
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 lg:grid-cols-5 gap-8">
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-[#1A1265]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Calendar className="w-7 h-7 text-[#1A1265]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">1. Set Details</h3>
|
||||||
|
<p className="text-slate-600 text-sm">
|
||||||
|
Enter the event name, location, and start/end times.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-[#1A1265]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Smartphone className="w-7 h-7 text-[#1A1265]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">2. Scan</h3>
|
||||||
|
<p className="text-slate-600 text-sm">
|
||||||
|
Guests scan the code from your invite, poster, or flyer.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#1A1265]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Clock className="w-6 h-6 text-[#1A1265]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">3. Download</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Save your event QR code.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#1A1265]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Smartphone className="w-6 h-6 text-[#1A1265]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">4. Scan</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Guests scan the code.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#1A1265]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<UserCheck className="w-6 h-6 text-[#1A1265]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">5. Save</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
They tap "Add to Calendar."
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* FAQ SECTION */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-4">
|
||||||
|
Frequently Asked Questions
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-600 text-center mb-10">
|
||||||
|
Common questions about Event QR codes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FaqItem
|
||||||
|
question="Does this work with Google Calendar?"
|
||||||
|
answer="Yes, the generated QR code creates a standard .ics file event, which is compatible with Google Calendar, Apple Calendar, Outlook, and most others."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Is the QR code reusable?"
|
||||||
|
answer="No. Because the specific date and time are embedded in the code, you cannot change them later. If the event is rescheduled, you must generate a new QR code."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="What happens if the event is in a different time zone?"
|
||||||
|
answer="The user's calendar will usually convert the time to their local time zone automatically when they save it."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Is it free?"
|
||||||
|
answer="Yes. Creating and scanning the code is completely free."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FaqItem({ question, answer }: { question: string; answer: string }) {
|
||||||
|
return (
|
||||||
|
<details className="group bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||||
|
<summary className="flex items-center justify-between p-5 cursor-pointer list-none text-slate-900 font-semibold hover:bg-slate-50 transition-colors">
|
||||||
|
{question}
|
||||||
|
<span className="transition group-open:rotate-180 text-slate-400">
|
||||||
|
<svg fill="none" height="20" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width="20">
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div className="text-slate-600 px-5 pb-5 pt-0 leading-relaxed text-sm">
|
||||||
|
{answer}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
248
src/app/(marketing)/tools/facebook-qr-code/FacebookGenerator.tsx
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import {
|
||||||
|
Facebook,
|
||||||
|
Download,
|
||||||
|
Check,
|
||||||
|
Sparkles,
|
||||||
|
ThumbsUp,
|
||||||
|
Globe
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// Brand Colors
|
||||||
|
const BRAND = {
|
||||||
|
paleGrey: '#EBEBDF',
|
||||||
|
richBlue: '#1A1265',
|
||||||
|
richBlueLight: '#2A2275',
|
||||||
|
};
|
||||||
|
|
||||||
|
// QR Color Options - Facebook Theme
|
||||||
|
const QR_COLORS = [
|
||||||
|
{ name: 'Facebook Blue', value: '#1877F2' },
|
||||||
|
{ name: 'Classic Black', value: '#000000' },
|
||||||
|
{ name: 'Dark Blue', value: '#1A1265' },
|
||||||
|
{ name: 'Teal', value: '#0D9488' },
|
||||||
|
{ name: 'Coral', value: '#F43F5E' },
|
||||||
|
{ name: 'Purple', value: '#7C3AED' },
|
||||||
|
{ name: 'Emerald', value: '#10B981' },
|
||||||
|
{ name: 'Rose', value: '#F43F5E' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Frame Options
|
||||||
|
const FRAME_OPTIONS = [
|
||||||
|
{ id: 'none', label: 'No Frame' },
|
||||||
|
{ id: 'scanme', label: 'Scan Me' },
|
||||||
|
{ id: 'follow', label: 'Follow Us' },
|
||||||
|
{ id: 'like', label: 'Like Us' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function FacebookGenerator() {
|
||||||
|
const [url, setUrl] = useState('');
|
||||||
|
const [qrColor, setQrColor] = useState('#1877F2'); // Default to FB Blue
|
||||||
|
const [frameType, setFrameType] = useState('none');
|
||||||
|
|
||||||
|
const qrRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const handleDownload = async (format: 'png' | 'svg') => {
|
||||||
|
if (!qrRef.current) return;
|
||||||
|
try {
|
||||||
|
if (format === 'png') {
|
||||||
|
const { toPng } = await import('html-to-image');
|
||||||
|
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3 });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = `facebook-qr-code.png`;
|
||||||
|
link.href = dataUrl;
|
||||||
|
link.click();
|
||||||
|
} else {
|
||||||
|
const svgData = qrRef.current.querySelector('svg')?.outerHTML;
|
||||||
|
if (svgData) {
|
||||||
|
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `facebook-qr-code.svg`;
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Download failed', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFrameLabel = () => {
|
||||||
|
const frame = FRAME_OPTIONS.find(f => f.id === frameType);
|
||||||
|
return frame?.id !== 'none' ? frame?.label : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-5xl mx-auto px-4 md:px-6">
|
||||||
|
|
||||||
|
{/* Main Generator Card */}
|
||||||
|
<div className="bg-white rounded-3xl shadow-2xl shadow-slate-900/10 overflow-hidden border border-slate-100">
|
||||||
|
<div className="grid lg:grid-cols-2">
|
||||||
|
|
||||||
|
{/* LEFT: Input Section */}
|
||||||
|
<div className="p-8 lg:p-10 space-y-8 border-r border-slate-100">
|
||||||
|
|
||||||
|
{/* Facebook Details */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Facebook className="w-5 h-5 text-[#1877F2]" />
|
||||||
|
Facebook Page or Profile
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Facebook URL</label>
|
||||||
|
<Input
|
||||||
|
placeholder="https://facebook.com/yourpage"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
className="h-12 text-base rounded-xl border-slate-200 focus:border-[#1877F2] focus:ring-[#1877F2]"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500 mt-2">Paste the full link to your profile, page, group, or post.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-100"></div>
|
||||||
|
|
||||||
|
{/* Design Options */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Sparkles className="w-5 h-5 text-[#1877F2]" />
|
||||||
|
Design Options
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Color Picker */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">QR Code Color</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{QR_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.name}
|
||||||
|
onClick={() => setQrColor(c.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-9 h-9 rounded-full border-2 flex items-center justify-center transition-all hover:scale-110",
|
||||||
|
qrColor === c.value ? "border-slate-900 ring-2 ring-offset-2 ring-slate-200" : "border-white shadow-md"
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: c.value }}
|
||||||
|
aria-label={`Select ${c.name}`}
|
||||||
|
title={c.name}
|
||||||
|
>
|
||||||
|
{qrColor === c.value && <Check className="w-4 h-4 text-white" strokeWidth={3} />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Frame Selector */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">Frame Label</label>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{FRAME_OPTIONS.map((frame) => (
|
||||||
|
<button
|
||||||
|
key={frame.id}
|
||||||
|
onClick={() => setFrameType(frame.id)}
|
||||||
|
className={cn(
|
||||||
|
"py-2.5 px-3 rounded-lg text-sm font-medium transition-all border",
|
||||||
|
frameType === frame.id
|
||||||
|
? "bg-[#1877F2] text-white border-[#1877F2]"
|
||||||
|
: "bg-slate-50 text-slate-600 border-slate-200 hover:border-slate-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{frame.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT: Preview Section */}
|
||||||
|
<div className="p-8 lg:p-10 flex flex-col items-center justify-center" style={{ backgroundColor: BRAND.paleGrey }}>
|
||||||
|
|
||||||
|
{/* QR Card with Frame */}
|
||||||
|
<div
|
||||||
|
ref={qrRef}
|
||||||
|
className="bg-white rounded-3xl shadow-xl p-8 flex flex-col items-center"
|
||||||
|
style={{ minWidth: '320px' }}
|
||||||
|
>
|
||||||
|
{/* Frame Label */}
|
||||||
|
{getFrameLabel() && (
|
||||||
|
<div
|
||||||
|
className="mb-5 px-8 py-2.5 rounded-full text-white font-bold text-sm tracking-widest uppercase shadow-md"
|
||||||
|
style={{ backgroundColor: qrColor }}
|
||||||
|
>
|
||||||
|
{getFrameLabel()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* QR Code */}
|
||||||
|
<div className="bg-white">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={url || "https://facebook.com"}
|
||||||
|
size={240}
|
||||||
|
level="M"
|
||||||
|
includeMargin={false}
|
||||||
|
fgColor={qrColor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Preview */}
|
||||||
|
<div className="mt-6 text-center max-w-[260px]">
|
||||||
|
<h3 className="font-bold text-slate-900 text-lg flex items-center justify-center gap-2 truncate">
|
||||||
|
<Facebook className="w-4 h-4 text-slate-400 shrink-0" />
|
||||||
|
<span className="truncate">{url ? url.replace('https://', '') : 'facebook.com/...'}</span>
|
||||||
|
</h3>
|
||||||
|
<div className="text-xs text-slate-500 mt-1">Opens in Facebook App</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Download Buttons */}
|
||||||
|
<div className="flex items-center gap-3 mt-8">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('png')}
|
||||||
|
className="bg-[#1877F2] hover:bg-[#155ebd] text-white shadow-lg"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Download PNG
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('svg')}
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-300 hover:bg-white"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
SVG
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500 mt-4 text-center">
|
||||||
|
Scanning redirects directly to the Facebook profile or page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upsell Banner */}
|
||||||
|
<div className="mt-8 bg-gradient-to-r from-[#1877F2] to-[#155ebd] rounded-2xl p-6 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="text-white text-center sm:text-left">
|
||||||
|
<h3 className="font-bold text-lg">Running a Social Media Campaign?</h3>
|
||||||
|
<p className="text-white/80 text-sm mt-1">
|
||||||
|
Dynamic QR Codes allow you to track clicks, likes, and engagement rates in real-time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/signup">
|
||||||
|
<Button className="bg-white text-[#1877F2] hover:bg-slate-100 shrink-0 shadow-lg">
|
||||||
|
Get Social Analytics
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
365
src/app/(marketing)/tools/facebook-qr-code/page.tsx
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
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';
|
||||||
|
|
||||||
|
// SEO Optimized Metadata
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Free Facebook QR Code Generator | Get Likes & Follows | QR Master',
|
||||||
|
description: 'Create a QR code for your Facebook Page, Profile, or Group. Scanners are redirected to the Facebook app instantly to like and follow. Free & Easy.',
|
||||||
|
keywords: ['facebook qr code', 'fb qr generator', 'facebook page qr', 'follow qr code', 'social media qr code'],
|
||||||
|
alternates: {
|
||||||
|
canonical: 'https://qrmaster.io/tools/facebook-qr-code',
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: 'Free Facebook QR Code Generator | QR Master',
|
||||||
|
description: 'Generate QR codes to grow your Facebook audience. Instant app redirect.',
|
||||||
|
type: 'website',
|
||||||
|
url: 'https://qrmaster.io/tools/facebook-qr-code',
|
||||||
|
images: [{ url: '/og-facebook-generator.png', width: 1200, height: 630 }],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: 'Free Facebook QR Code Generator',
|
||||||
|
description: 'Create QR codes for Facebook. Boost your engagement.',
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// JSON-LD Structured Data
|
||||||
|
const jsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@graph': [
|
||||||
|
{
|
||||||
|
'@type': 'SoftwareApplication',
|
||||||
|
name: 'Facebook QR Code Generator',
|
||||||
|
applicationCategory: 'UtilitiesApplication',
|
||||||
|
operatingSystem: 'Web Browser',
|
||||||
|
offers: {
|
||||||
|
'@type': 'Offer',
|
||||||
|
price: '0',
|
||||||
|
priceCurrency: 'USD',
|
||||||
|
},
|
||||||
|
aggregateRating: {
|
||||||
|
'@type': 'AggregateRating',
|
||||||
|
ratingValue: '4.8',
|
||||||
|
ratingCount: '1120',
|
||||||
|
},
|
||||||
|
description: 'Generate QR codes that direct users to a Facebook page, profile, or post.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowTo',
|
||||||
|
name: 'How to Create a Facebook QR Code',
|
||||||
|
description: 'Create a QR code that opens a Facebook page.',
|
||||||
|
step: [
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 1,
|
||||||
|
name: 'Get Link',
|
||||||
|
text: 'Copy the URL of your Facebook Page, Profile, or Group.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 2,
|
||||||
|
name: 'Paste Link',
|
||||||
|
text: 'Paste the URL into the generator.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 3,
|
||||||
|
name: 'Customize',
|
||||||
|
text: 'Choose your brand color and add a call-to-action frame.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 4,
|
||||||
|
name: 'Download',
|
||||||
|
text: 'Save the QR code and print it on your marketing materials.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 5,
|
||||||
|
name: 'Share',
|
||||||
|
text: 'Distribute it on flyers, business cards, or posters.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalTime: 'PT30S',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: [
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Does it open the Facebook app?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes! On most mobile devices, standard Facebook links are automatically detected and opened in the Facebook app if it is installed.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Can I link to a specific post?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Absolutely. Just paste the direct link to the post (click the timestamp on the post to get the link).',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Does it work for Facebook Events?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes. Simply copy the full URL of your Facebook Event and paste it into the generator.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Is it free?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes, this generator is 100% free to use for personal or business purposes.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Can I track scans?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'This static QR code does not include analytics. To track how many people scan your code, you should use our Dynamic QR Code service.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FacebookQRCodePage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
/>
|
||||||
|
<ToolBreadcrumb toolName="Facebook QR Code Generator" toolSlug="facebook-qr-code" />
|
||||||
|
|
||||||
|
<div className="min-h-screen" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
|
||||||
|
{/* HERO SECTION */}
|
||||||
|
<section className="relative pt-20 pb-20 lg:pt-32 lg:pb-32 px-4 sm:px-6 lg:px-8 overflow-hidden" style={{ backgroundColor: '#1877F2' }}>
|
||||||
|
<div className="absolute inset-0 opacity-10">
|
||||||
|
{/* Facebook Pattern */}
|
||||||
|
<svg className="w-full h-full" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<pattern id="fb_pattern" width="60" height="60" patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M30 30L35 35M25 35L30 30" stroke="white" strokeWidth="2" strokeOpacity="0.2" />
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="url(#fb_pattern)" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto grid lg:grid-cols-2 gap-12 items-center relative z-10">
|
||||||
|
<div className="text-center lg:text-left">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/10 text-white/90 text-sm font-medium mb-6 backdrop-blur-sm border border-white/10 hover:bg-white/20 transition-colors cursor-default">
|
||||||
|
<span className="flex h-2 w-2 relative">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-300 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-300"></span>
|
||||||
|
</span>
|
||||||
|
Free Tool — No Signup Required
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl md:text-5xl lg:text-6xl font-extrabold text-white tracking-tight leading-tight mb-6">
|
||||||
|
Grow Your Audience with <br className="hidden lg:block" />
|
||||||
|
<span className="text-white drop-shadow-md">Facebook QR Codes</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-lg md:text-xl text-blue-50 max-w-2xl mx-auto lg:mx-0 mb-8 leading-relaxed">
|
||||||
|
Make it easy for customers to find and follow you. A single scan opens your Page directly in the Facebook app.
|
||||||
|
<strong className="text-white block sm:inline mt-2 sm:mt-0"> Boost likes instantly.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center lg:justify-start gap-4 text-sm font-medium text-white/80">
|
||||||
|
<div className="flex items-center gap-2 bg-white/10 px-4 py-2.5 rounded-xl border border-white/10 backdrop-blur-sm">
|
||||||
|
<ThumbsUp className="w-4 h-4 text-blue-200" />
|
||||||
|
Get Likes
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/10 px-4 py-2.5 rounded-xl border border-white/10 backdrop-blur-sm">
|
||||||
|
<Zap className="w-4 h-4 text-yellow-300" />
|
||||||
|
Instant Follow
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/10 px-4 py-2.5 rounded-xl border border-white/10 backdrop-blur-sm">
|
||||||
|
<Smartphone className="w-4 h-4 text-green-300" />
|
||||||
|
App Friendly
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visual Abstract */}
|
||||||
|
<div className="hidden lg:flex relative items-center justify-center min-h-[400px]">
|
||||||
|
<div className="absolute w-[500px] h-[500px] bg-blue-500/30 rounded-full blur-[100px] -top-20 -right-20 animate-pulse" />
|
||||||
|
|
||||||
|
<div className="relative w-80 h-96 bg-white/5 backdrop-blur-xl border border-white/20 rounded-3xl shadow-2xl flex flex-col items-center justify-center p-8 transform rotate-6 hover:rotate-3 transition-all duration-700 group">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-white/10 to-transparent rounded-3xl" />
|
||||||
|
|
||||||
|
<div className="w-full bg-white rounded-xl shadow-lg p-4 mb-6 relative overflow-hidden flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-gradient-to-tr from-blue-600 to-blue-400 p-0.5">
|
||||||
|
<div className="w-full h-full bg-white rounded-full flex items-center justify-center">
|
||||||
|
<Facebook className="w-6 h-6 text-[#1877F2]" fill="#1877F2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="h-2.5 w-24 bg-slate-800 rounded-full mb-1.5" />
|
||||||
|
<div className="h-2 w-16 bg-slate-300 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<button className="ml-auto bg-[#1877F2] text-white px-3 py-1 rounded text-xs font-bold">
|
||||||
|
Like
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-48 h-48 bg-white rounded-xl p-2 shadow-inner relative overflow-hidden flex items-center justify-center">
|
||||||
|
<QRCodeSVG value="https://www.qrmaster.net" size={170} fgColor="#1877F2" level="Q" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Badge */}
|
||||||
|
<div className="absolute -bottom-6 -right-6 bg-white py-3 px-5 rounded-xl shadow-xl flex items-center gap-3 transform transition-transform hover:scale-105 duration-300">
|
||||||
|
<div className="bg-blue-100 p-2 rounded-full">
|
||||||
|
<Users className="w-5 h-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-[10px] text-slate-400 font-bold uppercase tracking-wider">Followers</div>
|
||||||
|
<div className="text-sm font-bold text-slate-900">+1 New</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* GENERATOR SECTION */}
|
||||||
|
<section className="py-12 px-4 sm:px-6 lg:px-8 -mt-8">
|
||||||
|
<FacebookGenerator />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* HOW IT WORKS */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8 bg-white">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-12">
|
||||||
|
How Facebook QR Codes Work
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 lg:grid-cols-5 gap-8">
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-[#1877F2]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Facebook className="w-7 h-7 text-[#1877F2]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">1. Copy Link</h3>
|
||||||
|
<p className="text-slate-600 text-sm">
|
||||||
|
Go to your Facebook Page or Profile and copy the URL from the browser address bar.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-[#1877F2]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Smartphone className="w-7 h-7 text-[#1877F2]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">2. Scan</h3>
|
||||||
|
<p className="text-slate-600 text-sm">
|
||||||
|
Your customers scan the code using their phone camera.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#1877F2]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<ThumbsUp className="w-6 h-6 text-[#1877F2]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">3. Engage</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
The Facebook app opens directly.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#1877F2]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Download className="w-6 h-6 text-[#1877F2]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">4. Download</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Save your high-res QR code.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#1877F2]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Share2 className="w-6 h-6 text-[#1877F2]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">5. Share</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Print it and start getting likes.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* FAQ SECTION */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-4">
|
||||||
|
Frequently Asked Questions
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-600 text-center mb-10">
|
||||||
|
Common questions about Facebook QR codes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FaqItem
|
||||||
|
question="Will this work for Facebook Groups?"
|
||||||
|
answer="Yes! You can paste the link to your Facebook Group, and the QR code will direcr users to join."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="What if the user doesn't have the Facebook app?"
|
||||||
|
answer="The link will open in their mobile web browser instead, so they can still see your page and log in."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Can I customize the color?"
|
||||||
|
answer="Yes. While Facebook Blue is recommended for recognition, you can choose any color to match your brand."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Is the QR code permanent?"
|
||||||
|
answer="Yes. As long as your Facebook URL doesn't change, this QR code will work forever."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Does it work for Facebook Events?"
|
||||||
|
answer="Yes. Simply copy the full URL of your Facebook Event and paste it into the generator."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FaqItem({ question, answer }: { question: string; answer: string }) {
|
||||||
|
return (
|
||||||
|
<details className="group bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||||
|
<summary className="flex items-center justify-between p-5 cursor-pointer list-none text-slate-900 font-semibold hover:bg-slate-50 transition-colors">
|
||||||
|
{question}
|
||||||
|
<span className="transition group-open:rotate-180 text-slate-400">
|
||||||
|
<svg fill="none" height="20" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width="20">
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div className="text-slate-600 px-5 pb-5 pt-0 leading-relaxed text-sm">
|
||||||
|
{answer}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import {
|
||||||
|
MapPin,
|
||||||
|
Download,
|
||||||
|
Check,
|
||||||
|
Sparkles,
|
||||||
|
Navigation,
|
||||||
|
Globe
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// Brand Colors
|
||||||
|
const BRAND = {
|
||||||
|
paleGrey: '#ECFDF5', // Emerald-50
|
||||||
|
primary: '#10B981', // Emerald-500
|
||||||
|
primaryDark: '#047857', // Emerald-700
|
||||||
|
};
|
||||||
|
|
||||||
|
// QR Color Options
|
||||||
|
const QR_COLORS = [
|
||||||
|
{ name: 'Emerald', value: '#10B981' },
|
||||||
|
{ name: 'Teal', value: '#0D9488' },
|
||||||
|
{ name: 'Classic Black', value: '#000000' },
|
||||||
|
{ name: 'Navy', value: '#1E3A8A' },
|
||||||
|
{ name: 'Sky', value: '#0EA5E9' },
|
||||||
|
{ name: 'Emerald', value: '#10B981' },
|
||||||
|
{ name: 'Rose', value: '#F43F5E' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Frame Options
|
||||||
|
const FRAME_OPTIONS = [
|
||||||
|
{ id: 'none', label: 'No Frame' },
|
||||||
|
{ id: 'scanme', label: 'Scan Me' },
|
||||||
|
{ id: 'location', label: 'Location' },
|
||||||
|
{ id: 'map', label: 'View Map' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function GeolocationGenerator() {
|
||||||
|
const [latitude, setLatitude] = useState('');
|
||||||
|
const [longitude, setLongitude] = useState('');
|
||||||
|
const [qrColor, setQrColor] = useState(BRAND.primary);
|
||||||
|
const [frameType, setFrameType] = useState('none');
|
||||||
|
|
||||||
|
const qrRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Using Google Maps Universal Link for best compatibility
|
||||||
|
const qrValue = `https://www.google.com/maps/search/?api=1&query=${latitude},${longitude}`;
|
||||||
|
|
||||||
|
const handleDownload = async (format: 'png' | 'svg') => {
|
||||||
|
if (!qrRef.current) return;
|
||||||
|
try {
|
||||||
|
if (format === 'png') {
|
||||||
|
const { toPng } = await import('html-to-image');
|
||||||
|
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3 });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = `location-qr-code.png`;
|
||||||
|
link.href = dataUrl;
|
||||||
|
link.click();
|
||||||
|
} else {
|
||||||
|
const svgData = qrRef.current.querySelector('svg')?.outerHTML;
|
||||||
|
if (svgData) {
|
||||||
|
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `location-qr-code.svg`;
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Download failed', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFrameLabel = () => {
|
||||||
|
const frame = FRAME_OPTIONS.find(f => f.id === frameType);
|
||||||
|
return frame?.id !== 'none' ? frame?.label : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentLocation = () => {
|
||||||
|
if (navigator.geolocation) {
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(position) => {
|
||||||
|
setLatitude(position.coords.latitude.toFixed(6));
|
||||||
|
setLongitude(position.coords.longitude.toFixed(6));
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.error("Error getting location: ", error);
|
||||||
|
alert("Could not access location. Please enter manually.");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
alert("Geolocation is not supported by this browser.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-5xl mx-auto px-4 md:px-6">
|
||||||
|
|
||||||
|
{/* Main Generator Card */}
|
||||||
|
<div className="bg-white rounded-3xl shadow-2xl shadow-slate-900/10 overflow-hidden border border-slate-100">
|
||||||
|
<div className="grid lg:grid-cols-2">
|
||||||
|
|
||||||
|
{/* LEFT: Input Section */}
|
||||||
|
<div className="p-8 lg:p-10 space-y-8 border-r border-slate-100">
|
||||||
|
|
||||||
|
{/* Location Details */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<MapPin className="w-5 h-5 text-[#10B981]" />
|
||||||
|
Coordinates
|
||||||
|
</h2>
|
||||||
|
<Button
|
||||||
|
onClick={getCurrentLocation}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-[#047857] border-[#047857]/20 hover:bg-[#047857]/5"
|
||||||
|
>
|
||||||
|
<Navigation className="w-3 h-3 mr-2" />
|
||||||
|
Get Current Location
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Latitude</label>
|
||||||
|
<Input
|
||||||
|
placeholder="40.712776"
|
||||||
|
value={latitude}
|
||||||
|
onChange={(e) => setLatitude(e.target.value)}
|
||||||
|
className="h-12 text-base rounded-xl border-slate-200 focus:border-[#10B981] focus:ring-[#10B981]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Longitude</label>
|
||||||
|
<Input
|
||||||
|
placeholder="-74.005974"
|
||||||
|
value={longitude}
|
||||||
|
onChange={(e) => setLongitude(e.target.value)}
|
||||||
|
className="h-12 text-base rounded-xl border-slate-200 focus:border-[#10B981] focus:ring-[#10B981]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Tip: You can copy-paste coordinates directly from Google Maps.
|
||||||
|
(Right-click a location on standard Maps, then click the coordinates to copy).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-100"></div>
|
||||||
|
|
||||||
|
{/* Design Options */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Sparkles className="w-5 h-5 text-[#10B981]" />
|
||||||
|
Design Options
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Color Picker */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">QR Code Color</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{QR_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.name}
|
||||||
|
onClick={() => setQrColor(c.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-9 h-9 rounded-full border-2 flex items-center justify-center transition-all hover:scale-110",
|
||||||
|
qrColor === c.value ? "border-slate-900 ring-2 ring-offset-2 ring-slate-200" : "border-white shadow-md"
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: c.value }}
|
||||||
|
aria-label={`Select ${c.name}`}
|
||||||
|
title={c.name}
|
||||||
|
>
|
||||||
|
{qrColor === c.value && <Check className="w-4 h-4 text-white" strokeWidth={3} />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Frame Selector */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">Frame Label</label>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{FRAME_OPTIONS.map((frame) => (
|
||||||
|
<button
|
||||||
|
key={frame.id}
|
||||||
|
onClick={() => setFrameType(frame.id)}
|
||||||
|
className={cn(
|
||||||
|
"py-2.5 px-3 rounded-lg text-sm font-medium transition-all border",
|
||||||
|
frameType === frame.id
|
||||||
|
? "bg-[#10B981] text-white border-[#10B981]"
|
||||||
|
: "bg-slate-50 text-slate-600 border-slate-200 hover:border-slate-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{frame.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT: Preview Section */}
|
||||||
|
<div className="p-8 lg:p-10 flex flex-col items-center justify-center" style={{ backgroundColor: BRAND.paleGrey }}>
|
||||||
|
|
||||||
|
{/* QR Card with Frame */}
|
||||||
|
<div
|
||||||
|
ref={qrRef}
|
||||||
|
className="bg-white rounded-3xl shadow-xl p-8 flex flex-col items-center"
|
||||||
|
style={{ minWidth: '320px' }}
|
||||||
|
>
|
||||||
|
{/* Frame Label */}
|
||||||
|
{getFrameLabel() && (
|
||||||
|
<div
|
||||||
|
className="mb-5 px-8 py-2.5 rounded-full text-white font-bold text-sm tracking-widest uppercase shadow-md"
|
||||||
|
style={{ backgroundColor: qrColor }}
|
||||||
|
>
|
||||||
|
{getFrameLabel()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* QR Code */}
|
||||||
|
<div className="bg-white">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={(latitude && longitude) ? qrValue : "https://maps.google.com"}
|
||||||
|
size={240}
|
||||||
|
level="M"
|
||||||
|
includeMargin={false}
|
||||||
|
fgColor={qrColor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Preview */}
|
||||||
|
<div className="mt-6 text-center max-w-[260px]">
|
||||||
|
<h3 className="font-bold text-slate-900 text-lg flex items-center justify-center gap-2 truncate">
|
||||||
|
<MapPin className="w-4 h-4 text-[#10B981] shrink-0" />
|
||||||
|
<span className="truncate">{latitude || 'Lat'}, {longitude || 'Long'}</span>
|
||||||
|
</h3>
|
||||||
|
<div className="text-xs text-slate-500 mt-1">Google Maps Location</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Download Buttons */}
|
||||||
|
<div className="flex items-center gap-3 mt-8">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('png')}
|
||||||
|
className="bg-[#10B981] hover:bg-[#047857] text-white shadow-lg"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Download PNG
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('svg')}
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-300 hover:bg-white"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
SVG
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500 mt-4 text-center">
|
||||||
|
Scanning opens the location directly in Google Maps.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upsell Banner */}
|
||||||
|
<div className="mt-8 bg-gradient-to-r from-[#10B981] to-[#047857] rounded-2xl p-6 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="text-white text-center sm:text-left">
|
||||||
|
<h3 className="font-bold text-lg">Need a Business Map?</h3>
|
||||||
|
<p className="text-white/80 text-sm mt-1">
|
||||||
|
Create a Dynamic QR Code for your business location. If you move, just update the location without reprinting.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/signup">
|
||||||
|
<Button className="bg-white text-[#047857] hover:bg-slate-100 shrink-0 shadow-lg">
|
||||||
|
Get Dynamic Maps
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
360
src/app/(marketing)/tools/geolocation-qr-code/page.tsx
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
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';
|
||||||
|
|
||||||
|
// SEO Optimized Metadata
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Free Geolocation QR Code Generator | Maps & Directions | QR Master',
|
||||||
|
description: 'Create a QR code for a specific location using Latitude and Longitude. Scanners will open Google Maps directly to your pin. Free & Precise.',
|
||||||
|
keywords: ['location qr code', 'maps qr code', 'google maps qr generator', 'geolocation qr', 'coordinates qr code'],
|
||||||
|
alternates: {
|
||||||
|
canonical: 'https://qrmaster.io/tools/geolocation-qr-code',
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: 'Free Geolocation QR Code Generator | QR Master',
|
||||||
|
description: 'Navigate users to any location with a QR code. Opens directly in Google Maps.',
|
||||||
|
type: 'website',
|
||||||
|
url: 'https://qrmaster.io/tools/geolocation-qr-code',
|
||||||
|
images: [{ url: '/og-geolocation-generator.png', width: 1200, height: 630 }],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: 'Free Geolocation QR Code Generator',
|
||||||
|
description: 'Create QR codes for maps and locations. Instant and free.',
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// JSON-LD Structured Data
|
||||||
|
const jsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@graph': [
|
||||||
|
{
|
||||||
|
'@type': 'SoftwareApplication',
|
||||||
|
name: 'Geolocation QR Code Generator',
|
||||||
|
applicationCategory: 'UtilitiesApplication',
|
||||||
|
operatingSystem: 'Web Browser',
|
||||||
|
offers: {
|
||||||
|
'@type': 'Offer',
|
||||||
|
price: '0',
|
||||||
|
priceCurrency: 'USD',
|
||||||
|
},
|
||||||
|
aggregateRating: {
|
||||||
|
'@type': 'AggregateRating',
|
||||||
|
ratingValue: '4.7',
|
||||||
|
ratingCount: '890',
|
||||||
|
},
|
||||||
|
description: 'Generate QR codes that open specific geographic coordinates in map applications.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowTo',
|
||||||
|
name: 'How to Create a Location QR Code',
|
||||||
|
description: 'Create a QR code that points to a specific map location.',
|
||||||
|
step: [
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 1,
|
||||||
|
name: 'Get Coordinates',
|
||||||
|
text: 'Find the Latitude and Longitude of your location (e.g., from Google Maps).',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 2,
|
||||||
|
name: 'Enter Data',
|
||||||
|
text: 'Paste the coordinates into the generator.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 3,
|
||||||
|
name: 'Customize',
|
||||||
|
text: 'Choose a color and style for your map QR code.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 4,
|
||||||
|
name: 'Download',
|
||||||
|
text: 'Save your QR code as a high-quality image.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 5,
|
||||||
|
name: 'Share',
|
||||||
|
text: 'Place it on invitations, signs, or your website.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalTime: 'PT45S',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: [
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Which map app does it open?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Our generator creates a universal Google Maps link. On most devices, this will open the Google Maps app if installed, or the browser version if not. It is the most compatible method.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'How do I find my Latitude and Longitude?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'On Google Maps desktop: Right-click any spot on the map. The first item in the menu is the coordinates. Click to copy them.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Does it work offline?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'The QR code itself can be scanned offline, but the user will likely need an internet connection to load the map and get directions.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Can I use an address instead?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'For precise results, we use coordinates. However, you can use our URL Generator and paste a link to your Google Maps address search result if you prefer.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Is it free?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes, generating this location QR code is completely free and requires no signup.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function GeolocationQRCodePage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
/>
|
||||||
|
<ToolBreadcrumb toolName="Location QR Code Generator" toolSlug="geolocation-qr-code" />
|
||||||
|
|
||||||
|
<div className="min-h-screen" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
|
||||||
|
{/* HERO SECTION */}
|
||||||
|
<section className="relative pt-20 pb-20 lg:pt-32 lg:pb-32 px-4 sm:px-6 lg:px-8 overflow-hidden" style={{ backgroundColor: '#047857' }}>
|
||||||
|
<div className="absolute inset-0 opacity-10">
|
||||||
|
<svg className="w-full h-full" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||||
|
<path d="M0 100 C 20 0 50 0 100 100 Z" fill="url(#grad1)" />
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" style={{ stopColor: 'white', stopOpacity: 1 }} />
|
||||||
|
<stop offset="100%" style={{ stopColor: 'white', stopOpacity: 0 }} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto grid lg:grid-cols-2 gap-12 items-center relative z-10">
|
||||||
|
<div className="text-center lg:text-left">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/10 text-white/90 text-sm font-medium mb-6 backdrop-blur-sm border border-white/10 hover:bg-white/20 transition-colors cursor-default">
|
||||||
|
<span className="flex h-2 w-2 relative">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-400"></span>
|
||||||
|
</span>
|
||||||
|
Free Tool — No Signup Required
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl md:text-5xl lg:text-6xl font-extrabold text-white tracking-tight leading-tight mb-6">
|
||||||
|
Share Perfect Locations with <br className="hidden lg:block" />
|
||||||
|
<span className="text-transparent bg-clip-text bg-gradient-to-r from-emerald-300 to-teal-300">Map QR Codes</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-lg md:text-xl text-indigo-100 max-w-2xl mx-auto lg:mx-0 mb-8 leading-relaxed">
|
||||||
|
Provide exact directions to your event, store, or secret spot.
|
||||||
|
<strong className="text-white block sm:inline mt-2 sm:mt-0"> Opens directly in Google Maps.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center lg:justify-start gap-4 text-sm font-medium text-white/80">
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/5 backdrop-blur-sm">
|
||||||
|
<Navigation className="w-4 h-4 text-emerald-400" />
|
||||||
|
Exact Directions
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/5 backdrop-blur-sm">
|
||||||
|
<Zap className="w-4 h-4 text-amber-400" />
|
||||||
|
Instant Load
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/5 backdrop-blur-sm">
|
||||||
|
<Shield className="w-4 h-4 text-purple-400" />
|
||||||
|
No Data Saved
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visual Abstract */}
|
||||||
|
<div className="hidden lg:flex relative items-center justify-center min-h-[400px]">
|
||||||
|
<div className="absolute w-[500px] h-[500px] bg-indigo-500/30 rounded-full blur-[100px] -top-20 -right-20 animate-pulse" />
|
||||||
|
|
||||||
|
<div className="relative w-80 h-96 bg-white/5 backdrop-blur-xl border border-white/20 rounded-3xl shadow-2xl flex flex-col items-center justify-center p-8 transform rotate-3 hover:rotate-0 transition-all duration-700 group">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-white/10 to-transparent rounded-3xl" />
|
||||||
|
|
||||||
|
<div className="w-full bg-white rounded-xl shadow-lg h-32 mb-6 relative overflow-hidden grayscale group-hover:grayscale-0 transition-all duration-500">
|
||||||
|
<div className="absolute inset-0 opacity-20 bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] [background-size:16px_16px]"></div>
|
||||||
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||||
|
<MapPin className="w-8 h-8 text-red-500 drop-shadow-lg animate-bounce" />
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-2 left-2 right-2 bg-white/90 p-2 rounded text-[10px] text-slate-500 font-mono text-center">
|
||||||
|
40.7128° N, 74.0060° W
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-44 h-44 bg-white rounded-xl p-2 shadow-inner relative overflow-hidden flex items-center justify-center">
|
||||||
|
<QRCodeSVG value="https://www.qrmaster.net" size={160} fgColor="#0f172a" level="Q" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Badge */}
|
||||||
|
<div className="absolute -bottom-6 -right-6 bg-white py-3 px-5 rounded-xl shadow-xl flex items-center gap-3 transform transition-transform hover:scale-105 duration-300">
|
||||||
|
<div className="bg-emerald-100 p-2 rounded-full">
|
||||||
|
<Map className="w-5 h-5 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-[10px] text-slate-400 font-bold uppercase tracking-wider">Map</div>
|
||||||
|
<div className="text-sm font-bold text-slate-900">Open</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* GENERATOR SECTION */}
|
||||||
|
<section className="py-12 px-4 sm:px-6 lg:px-8 -mt-8">
|
||||||
|
<GeolocationGenerator />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* HOW IT WORKS */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8 bg-white">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-12">
|
||||||
|
How Geolocation QR Codes Work
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 lg:grid-cols-5 gap-8">
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-[#1A1265]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<MapPin className="w-7 h-7 text-[#1A1265]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">1. Pinpoint</h3>
|
||||||
|
<p className="text-slate-600 text-sm">
|
||||||
|
Enter exact GPS coordinates. This ensures users go to the precise spot (e.g., a specific building entrance).
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-[#1A1265]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Smartphone className="w-7 h-7 text-[#1A1265]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">2. Scan</h3>
|
||||||
|
<p className="text-slate-600 text-sm">
|
||||||
|
Users scan the code. It is encoded with a universal map link.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#1A1265]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Download className="w-6 h-6 text-[#1A1265]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">3. Download</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Save your high-quality QR image.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#1A1265]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Smartphone className="w-6 h-6 text-[#1A1265]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">4. Scan</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Users scan the code to load coordinates.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#1A1265]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Share2 className="w-6 h-6 text-[#1A1265]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">5. Go</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
They get instant directions to your spot.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* FAQ SECTION */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-4">
|
||||||
|
Frequently Asked Questions
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-600 text-center mb-10">
|
||||||
|
Common questions about Map QR codes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FaqItem
|
||||||
|
question="Why not just use an address?"
|
||||||
|
answer="Addresses can be ambiguous or cover large areas (like a park or stadium). Coordinates point to an exact geographic spot, ensuring visitors find the specific meeting point or parking entrance."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Does it work on Apple Maps?"
|
||||||
|
answer="Yes. While the underlying link is a Google Maps link, iOS devices usually handle these gracefully, either opening them in the Google Maps app (if installed) or the browser, where Apple Maps can often intercept directions."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Is it free?"
|
||||||
|
answer="Yes, generating this location QR code is completely free and requires no signup."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Can I track who scanned it?"
|
||||||
|
answer="Not with this static tool. If you need scan analytics (e.g., how many people scanned your storefront QR), you should use our Dynamic QR Code service."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Is it free?"
|
||||||
|
answer="Yes, generating this location QR code is completely free and requires no signup."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FaqItem({ question, answer }: { question: string; answer: string }) {
|
||||||
|
return (
|
||||||
|
<details className="group bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||||
|
<summary className="flex items-center justify-between p-5 cursor-pointer list-none text-slate-900 font-semibold hover:bg-slate-50 transition-colors">
|
||||||
|
{question}
|
||||||
|
<span className="transition group-open:rotate-180 text-slate-400">
|
||||||
|
<svg fill="none" height="20" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width="20">
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div className="text-slate-600 px-5 pb-5 pt-0 leading-relaxed text-sm">
|
||||||
|
{answer}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import {
|
||||||
|
Instagram,
|
||||||
|
Download,
|
||||||
|
Check,
|
||||||
|
Sparkles,
|
||||||
|
Camera
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// Brand Colors
|
||||||
|
const BRAND = {
|
||||||
|
paleGrey: '#EBEBDF',
|
||||||
|
richBlue: '#1A1265',
|
||||||
|
richBlueLight: '#2A2275',
|
||||||
|
};
|
||||||
|
|
||||||
|
// QR Color Options - Insta Theme
|
||||||
|
const QR_COLORS = [
|
||||||
|
{ name: 'Insta Pink', value: '#E1306C' },
|
||||||
|
{ name: 'Insta Purple', value: '#833AB4' },
|
||||||
|
{ name: 'Insta Orange', value: '#F77737' },
|
||||||
|
{ name: 'Classic Black', value: '#000000' },
|
||||||
|
{ name: 'Rich Blue', value: '#1A1265' },
|
||||||
|
{ name: 'Teal', value: '#0D9488' },
|
||||||
|
{ name: 'Emerald', value: '#10B981' },
|
||||||
|
{ name: 'Rose', value: '#F43F5E' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Frame Options
|
||||||
|
const FRAME_OPTIONS = [
|
||||||
|
{ id: 'none', label: 'No Frame' },
|
||||||
|
{ id: 'scanme', label: 'Scan Me' },
|
||||||
|
{ id: 'follow', label: 'Follow Us' },
|
||||||
|
{ id: 'insta', label: 'Instagram' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function InstagramGenerator() {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [qrColor, setQrColor] = useState('#E1306C');
|
||||||
|
const [frameType, setFrameType] = useState('none');
|
||||||
|
|
||||||
|
const qrRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Instagram URL construction: https://instagram.com/username
|
||||||
|
const getUrl = () => {
|
||||||
|
const cleanUser = username.replace(/^@/, '').replace(/https?:\/\/(www\.)?instagram\.com\//, '').replace(/\/$/, '');
|
||||||
|
return cleanUser ? `https://instagram.com/${cleanUser}` : 'https://instagram.com';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async (format: 'png' | 'svg') => {
|
||||||
|
if (!qrRef.current) return;
|
||||||
|
try {
|
||||||
|
if (format === 'png') {
|
||||||
|
const { toPng } = await import('html-to-image');
|
||||||
|
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3 });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = `instagram-qr-code.png`;
|
||||||
|
link.href = dataUrl;
|
||||||
|
link.click();
|
||||||
|
} else {
|
||||||
|
const svgData = qrRef.current.querySelector('svg')?.outerHTML;
|
||||||
|
if (svgData) {
|
||||||
|
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `instagram-qr-code.svg`;
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Download failed', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFrameLabel = () => {
|
||||||
|
const frame = FRAME_OPTIONS.find(f => f.id === frameType);
|
||||||
|
return frame?.id !== 'none' ? frame?.label : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-5xl mx-auto px-4 md:px-6">
|
||||||
|
|
||||||
|
{/* Main Generator Card */}
|
||||||
|
<div className="bg-white rounded-3xl shadow-2xl shadow-slate-900/10 overflow-hidden border border-slate-100">
|
||||||
|
<div className="grid lg:grid-cols-2">
|
||||||
|
|
||||||
|
{/* LEFT: Input Section */}
|
||||||
|
<div className="p-8 lg:p-10 space-y-8 border-r border-slate-100">
|
||||||
|
|
||||||
|
{/* Instagram Details */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Instagram className="w-5 h-5 text-[#E1306C]" />
|
||||||
|
Instagram Username
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Username or Link</label>
|
||||||
|
<Input
|
||||||
|
placeholder="@username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
className="h-12 text-base rounded-xl border-slate-200 focus:border-[#E1306C] focus:ring-[#E1306C]"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500 mt-2">Enter your username (without @) or paste full profile link.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-100"></div>
|
||||||
|
|
||||||
|
{/* Design Options */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Sparkles className="w-5 h-5 text-[#E1306C]" />
|
||||||
|
Design Options
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Color Picker */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">QR Code Color</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{QR_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.name}
|
||||||
|
onClick={() => setQrColor(c.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-9 h-9 rounded-full border-2 flex items-center justify-center transition-all hover:scale-110",
|
||||||
|
qrColor === c.value ? "border-slate-900 ring-2 ring-offset-2 ring-slate-200" : "border-white shadow-md"
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: c.value }}
|
||||||
|
aria-label={`Select ${c.name}`}
|
||||||
|
title={c.name}
|
||||||
|
>
|
||||||
|
{qrColor === c.value && <Check className="w-4 h-4 text-white" strokeWidth={3} />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Frame Selector */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">Frame Label</label>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{FRAME_OPTIONS.map((frame) => (
|
||||||
|
<button
|
||||||
|
key={frame.id}
|
||||||
|
onClick={() => setFrameType(frame.id)}
|
||||||
|
className={cn(
|
||||||
|
"py-2.5 px-3 rounded-lg text-sm font-medium transition-all border",
|
||||||
|
frameType === frame.id
|
||||||
|
? "bg-[#E1306C] text-white border-[#E1306C]"
|
||||||
|
: "bg-slate-50 text-slate-600 border-slate-200 hover:border-slate-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{frame.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT: Preview Section */}
|
||||||
|
<div className="p-8 lg:p-10 flex flex-col items-center justify-center" style={{ backgroundColor: BRAND.paleGrey }}>
|
||||||
|
|
||||||
|
{/* QR Card with Frame */}
|
||||||
|
<div
|
||||||
|
ref={qrRef}
|
||||||
|
className="bg-white rounded-3xl shadow-xl p-8 flex flex-col items-center"
|
||||||
|
style={{ minWidth: '320px' }}
|
||||||
|
>
|
||||||
|
{/* Frame Label */}
|
||||||
|
{getFrameLabel() && (
|
||||||
|
<div
|
||||||
|
className="mb-5 px-8 py-2.5 rounded-full text-white font-bold text-sm tracking-widest uppercase shadow-md"
|
||||||
|
style={{ backgroundColor: qrColor }}
|
||||||
|
>
|
||||||
|
{getFrameLabel()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* QR Code */}
|
||||||
|
<div className="bg-white">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={getUrl()}
|
||||||
|
size={240}
|
||||||
|
level="M"
|
||||||
|
includeMargin={false}
|
||||||
|
fgColor={qrColor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Preview */}
|
||||||
|
<div className="mt-6 text-center max-w-[260px]">
|
||||||
|
<h3 className="font-bold text-slate-900 text-lg flex items-center justify-center gap-2 truncate">
|
||||||
|
<Instagram className="w-4 h-4 text-slate-400 shrink-0" />
|
||||||
|
<span className="truncate">{username || '@username'}</span>
|
||||||
|
</h3>
|
||||||
|
<div className="text-xs text-slate-500 mt-1">Opens in Instagram</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Download Buttons */}
|
||||||
|
<div className="flex items-center gap-3 mt-8">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('png')}
|
||||||
|
className="bg-[#E1306C] hover:bg-[#C13584] text-white shadow-lg"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Download PNG
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('svg')}
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-300 hover:bg-white"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
SVG
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500 mt-4 text-center">
|
||||||
|
Scanning redirects directly to your Instagram profile.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upsell Banner */}
|
||||||
|
<div className="mt-8 bg-gradient-to-r from-[#833AB4] via-[#FD1D1D] to-[#FCA145] rounded-2xl p-6 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="text-white text-center sm:text-left">
|
||||||
|
<h3 className="font-bold text-lg">Want a "Link in Bio" QR?</h3>
|
||||||
|
<p className="text-white/80 text-sm mt-1">
|
||||||
|
Create a digital landing page with links to all your socials using Dynamic Codes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/signup">
|
||||||
|
<Button className="bg-white text-[#E1306C] hover:bg-slate-100 shrink-0 shadow-lg">
|
||||||
|
Create Bio Link
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
354
src/app/(marketing)/tools/instagram-qr-code/page.tsx
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
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';
|
||||||
|
|
||||||
|
// SEO Optimized Metadata
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Free Instagram QR Code Generator | Get More Followers | QR Master',
|
||||||
|
description: 'Create a QR code for your Instagram profile or post. Scanners are redirected to the Instagram app instantly to follow you. Free & Customizable.',
|
||||||
|
keywords: ['instagram qr code', 'insta qr generator', 'ig nametag generator', 'instagram follow qr', 'social media qr code'],
|
||||||
|
alternates: {
|
||||||
|
canonical: 'https://qrmaster.io/tools/instagram-qr-code',
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: 'Free Instagram QR Code Generator | QR Master',
|
||||||
|
description: 'Generate QR codes to grow your Instagram following. Instant app redirect.',
|
||||||
|
type: 'website',
|
||||||
|
url: 'https://qrmaster.io/tools/instagram-qr-code',
|
||||||
|
images: [{ url: '/og-instagram-generator.png', width: 1200, height: 630 }],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: 'Free Instagram QR Code Generator',
|
||||||
|
description: 'Create QR codes for Instagram. Boost your followers.',
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// JSON-LD Structured Data
|
||||||
|
const jsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@graph': [
|
||||||
|
{
|
||||||
|
'@type': 'SoftwareApplication',
|
||||||
|
name: 'Instagram QR Code Generator',
|
||||||
|
applicationCategory: 'UtilitiesApplication',
|
||||||
|
operatingSystem: 'Web Browser',
|
||||||
|
offers: {
|
||||||
|
'@type': 'Offer',
|
||||||
|
price: '0',
|
||||||
|
priceCurrency: 'USD',
|
||||||
|
},
|
||||||
|
aggregateRating: {
|
||||||
|
'@type': 'AggregateRating',
|
||||||
|
ratingValue: '4.9',
|
||||||
|
ratingCount: '2150',
|
||||||
|
},
|
||||||
|
description: 'Generate QR codes that direct users to an Instagram profile or post.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowTo',
|
||||||
|
name: 'How to Create an Instagram QR Code',
|
||||||
|
description: 'Create a QR code that opens an Instagram profile.',
|
||||||
|
step: [
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 1,
|
||||||
|
name: 'Enter Username',
|
||||||
|
text: 'Type your Instagram handle (e.g. @yourbrand) or paste your profile link.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 2,
|
||||||
|
name: 'Customize',
|
||||||
|
text: 'Choose a gradient color that matches the Instagram vibe or your own brand.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 3,
|
||||||
|
name: 'Download',
|
||||||
|
text: 'Save the QR code image.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 4,
|
||||||
|
name: 'Test',
|
||||||
|
text: 'Scan the code to ensure it opens the correct profile.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 5,
|
||||||
|
name: 'Share',
|
||||||
|
text: 'Put it on your packaging, business cards, or storefront.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalTime: 'PT30S',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: [
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Is this an Instagram Nametag?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'It works similarly! While Instagram has its own internal "Nametag" or "QR Code" feature, our generator allows you to create a standard QR code that is more customizable and can be tracked with our Dynamic plans.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Does it open the Instagram app?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes. When scanned on a mobile device with Instagram installed, it will deep-link directly to the profile in the app.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Can I link to a specific photo or reel?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes! Instead of your username, just paste the full link to the specific post or reel.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Is it free?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes, generating this QR code is 100% free.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Can I track scans?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Not with this static tool. If you need scan analytics, consider using our Dynamic QR Code solution.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function InstagramQRCodePage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
/>
|
||||||
|
<ToolBreadcrumb toolName="Instagram QR Code Generator" toolSlug="instagram-qr-code" />
|
||||||
|
|
||||||
|
<div className="min-h-screen" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
|
||||||
|
{/* HERO SECTION */}
|
||||||
|
<section className="relative pt-20 pb-20 lg:pt-32 lg:pb-32 px-4 sm:px-6 lg:px-8 overflow-hidden bg-gradient-to-br from-[#833AB4] via-[#FD1D1D] to-[#FCA145]">
|
||||||
|
<div className="absolute inset-0 opacity-10">
|
||||||
|
<svg className="w-full h-full" width="100%" height="100%" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||||
|
<circle cx="0" cy="0" r="40" fill="white" fillOpacity="0.1" />
|
||||||
|
<circle cx="100" cy="100" r="50" fill="white" fillOpacity="0.1" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto grid lg:grid-cols-2 gap-12 items-center relative z-10">
|
||||||
|
<div className="text-center lg:text-left">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/10 text-white/90 text-sm font-medium mb-6 backdrop-blur-sm border border-white/10 hover:bg-white/20 transition-colors cursor-default">
|
||||||
|
<span className="flex h-2 w-2 relative">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-pink-300 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-pink-300"></span>
|
||||||
|
</span>
|
||||||
|
Free Tool — No Signup Required
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl md:text-5xl lg:text-6xl font-extrabold text-white tracking-tight leading-tight mb-6">
|
||||||
|
Boost Your Following with <br className="hidden lg:block" />
|
||||||
|
<span className="text-white drop-shadow-md">Instagram QR Codes</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-lg md:text-xl text-pink-50 max-w-2xl mx-auto lg:mx-0 mb-8 leading-relaxed">
|
||||||
|
Connect physically to digitally. Let customers scan to follow your Instagram profile instantly.
|
||||||
|
<strong className="text-white block sm:inline mt-2 sm:mt-0"> Grow your brand effortlessly.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center lg:justify-start gap-4 text-sm font-medium text-white/80">
|
||||||
|
<div className="flex items-center gap-2 bg-white/10 px-4 py-2.5 rounded-xl border border-white/10 backdrop-blur-sm">
|
||||||
|
<Heart className="w-4 h-4 text-pink-200" />
|
||||||
|
More Likes
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/10 px-4 py-2.5 rounded-xl border border-white/10 backdrop-blur-sm">
|
||||||
|
<Zap className="w-4 h-4 text-yellow-200" />
|
||||||
|
Instant Follow
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/10 px-4 py-2.5 rounded-xl border border-white/10 backdrop-blur-sm">
|
||||||
|
<Smartphone className="w-4 h-4 text-white" />
|
||||||
|
App Deep Link
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visual Abstract */}
|
||||||
|
<div className="hidden lg:flex relative items-center justify-center min-h-[400px]">
|
||||||
|
<div className="absolute w-[500px] h-[500px] bg-white/10 rounded-full blur-[100px] -top-20 -right-20 animate-pulse" />
|
||||||
|
|
||||||
|
<div className="relative w-80 h-96 bg-white/5 backdrop-blur-xl border border-white/20 rounded-3xl shadow-2xl flex flex-col items-center justify-center p-8 transform -rotate-3 hover:rotate-0 transition-all duration-700 group">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-white/10 to-transparent rounded-3xl" />
|
||||||
|
|
||||||
|
<div className="w-full bg-white rounded-xl shadow-lg p-4 mb-6 relative overflow-hidden flex flex-col items-center">
|
||||||
|
<div className="w-16 h-16 rounded-full p-[2px] bg-gradient-to-tr from-[#FCA145] via-[#FD1D1D] to-[#833AB4] mb-2">
|
||||||
|
<div className="w-full h-full rounded-full bg-white p-1">
|
||||||
|
<div className="w-full h-full rounded-full bg-slate-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-bold text-slate-900">@yourbrand</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-48 h-48 bg-white rounded-xl p-2 shadow-inner relative overflow-hidden flex items-center justify-center">
|
||||||
|
<QRCodeSVG value="https://www.qrmaster.net" size={170} fgColor="#E1306C" level="Q" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Badge */}
|
||||||
|
<div className="absolute -bottom-6 -left-6 bg-white py-3 px-5 rounded-xl shadow-xl flex items-center gap-3 transform transition-transform hover:scale-105 duration-300">
|
||||||
|
<div className="bg-gradient-to-tr from-[#FCA145] to-[#E1306C] p-2 rounded-full text-white">
|
||||||
|
<Camera className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-[10px] text-slate-400 font-bold uppercase tracking-wider">Profile</div>
|
||||||
|
<div className="text-sm font-bold text-slate-900">Following</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* GENERATOR SECTION */}
|
||||||
|
<section className="py-12 px-4 sm:px-6 lg:px-8 -mt-8">
|
||||||
|
<InstagramGenerator />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* HOW IT WORKS */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8 bg-white">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-12">
|
||||||
|
How Instagram QR Codes Work
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 lg:grid-cols-5 gap-8">
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-[#E1306C]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Instagram className="w-7 h-7 text-[#E1306C]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">1. Username</h3>
|
||||||
|
<p className="text-slate-600 text-sm">
|
||||||
|
Enter your Instagram handle. No need to login or connect your account.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-[#E1306C]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Smartphone className="w-7 h-7 text-[#E1306C]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">2. Print</h3>
|
||||||
|
<p className="text-slate-600 text-sm">
|
||||||
|
Add the QR code to your packaging, business cards, or table tents.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#E1306C]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Download className="w-6 h-6 text-[#E1306C]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">3. Download</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Save your custom QR code.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#E1306C]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Heart className="w-6 h-6 text-[#E1306C]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">4. Scan</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Fans scan to instantly visit your profile.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#E1306C]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Share2 className="w-6 h-6 text-[#E1306C]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">5. Grow</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Convert offline traffic into followers.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* FAQ SECTION */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-4">
|
||||||
|
Frequently Asked Questions
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-600 text-center mb-10">
|
||||||
|
Common questions about Instagram QR codes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FaqItem
|
||||||
|
question="Does this work for private accounts?"
|
||||||
|
answer="Yes, the link will take users to your profile. If your account is private, they will still have to request to follow you."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Can I link to a Story?"
|
||||||
|
answer="Yes, but Stories expire after 24 hours (unless saved as a Highlight). Linking to a Highlight or your main Profile is usually better for printed materials."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Can I customize the frame?"
|
||||||
|
answer="Yes, we offer several frame options like 'Follow Us' or 'Scan Me' to encourage action."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Does it expire?"
|
||||||
|
answer="No. The QR code will work as long as your Instagram username remains the same."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Can I track scans?"
|
||||||
|
answer="Not with this static tool. If you need scan analytics, consider using our Dynamic QR Code solution."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FaqItem({ question, answer }: { question: string; answer: string }) {
|
||||||
|
return (
|
||||||
|
<details className="group bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||||
|
<summary className="flex items-center justify-between p-5 cursor-pointer list-none text-slate-900 font-semibold hover:bg-slate-50 transition-colors">
|
||||||
|
{question}
|
||||||
|
<span className="transition group-open:rotate-180 text-slate-400">
|
||||||
|
<svg fill="none" height="20" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width="20">
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div className="text-slate-600 px-5 pb-5 pt-0 leading-relaxed text-sm">
|
||||||
|
{answer}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
342
src/app/(marketing)/tools/paypal-qr-code/PayPalGenerator.tsx
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import {
|
||||||
|
CreditCard,
|
||||||
|
Download,
|
||||||
|
Check,
|
||||||
|
Sparkles,
|
||||||
|
DollarSign
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Select } from '@/components/ui/Select';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// Brand Colors - PayPal Blue
|
||||||
|
const BRAND = {
|
||||||
|
paleGrey: '#EFF6FF', // Blue-50
|
||||||
|
primary: '#003087', // PayPal Dark Blue
|
||||||
|
primaryDark: '#001F5C',
|
||||||
|
accent: '#0070BA', // PayPal Light Blue
|
||||||
|
};
|
||||||
|
|
||||||
|
// QR Color Options
|
||||||
|
const QR_COLORS = [
|
||||||
|
{ name: 'PayPal Blue', value: '#003087' },
|
||||||
|
{ name: 'PayPal Light', value: '#0070BA' },
|
||||||
|
{ name: 'Classic Black', value: '#000000' },
|
||||||
|
{ name: 'Indigo', value: '#4F46E5' },
|
||||||
|
{ name: 'Violet', value: '#7C3AED' },
|
||||||
|
{ name: 'Emerald', value: '#10B981' },
|
||||||
|
{ name: 'Rose', value: '#F43F5E' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Frame Options
|
||||||
|
const FRAME_OPTIONS = [
|
||||||
|
{ id: 'none', label: 'No Frame' },
|
||||||
|
{ id: 'scanme', label: 'Scan Me' },
|
||||||
|
{ id: 'pay', label: 'Pay Now' },
|
||||||
|
{ id: 'donate', label: 'Donate' },
|
||||||
|
{ id: 'tip', label: 'Tip Me' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Currency Options
|
||||||
|
const CURRENCIES = [
|
||||||
|
{ value: 'EUR', label: 'EUR (€)' },
|
||||||
|
{ value: 'USD', label: 'USD ($)' },
|
||||||
|
{ value: 'GBP', label: 'GBP (£)' },
|
||||||
|
{ value: 'CHF', label: 'CHF' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Input type options
|
||||||
|
const INPUT_TYPES = [
|
||||||
|
{ id: 'username', label: 'PayPal.me Username' },
|
||||||
|
{ id: 'email', label: 'PayPal Email' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function PayPalGenerator() {
|
||||||
|
const [inputType, setInputType] = useState('email');
|
||||||
|
const [paypalId, setPaypalId] = useState('');
|
||||||
|
const [amount, setAmount] = useState('');
|
||||||
|
const [currency, setCurrency] = useState('EUR');
|
||||||
|
const [qrColor, setQrColor] = useState(BRAND.primary);
|
||||||
|
const [frameType, setFrameType] = useState('none');
|
||||||
|
|
||||||
|
const qrRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Generate PayPal payment link
|
||||||
|
const generatePayPalLink = () => {
|
||||||
|
if (!paypalId.trim()) return 'https://paypal.com';
|
||||||
|
|
||||||
|
if (inputType === 'username') {
|
||||||
|
// PayPal.me link
|
||||||
|
let link = `https://paypal.me/${paypalId.trim()}`;
|
||||||
|
if (amount && parseFloat(amount) > 0) {
|
||||||
|
link += `/${amount}`;
|
||||||
|
}
|
||||||
|
return link;
|
||||||
|
} else {
|
||||||
|
// PayPal email payment link (donation/payment format)
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
cmd: '_donations',
|
||||||
|
business: paypalId.trim(),
|
||||||
|
currency_code: currency,
|
||||||
|
...(amount && parseFloat(amount) > 0 ? { amount } : {}),
|
||||||
|
});
|
||||||
|
return `https://www.paypal.com/cgi-bin/webscr?${params.toString()}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async (format: 'png' | 'svg') => {
|
||||||
|
if (!qrRef.current) return;
|
||||||
|
try {
|
||||||
|
if (format === 'png') {
|
||||||
|
const { toPng } = await import('html-to-image');
|
||||||
|
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3 });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = `paypal-qr-${paypalId || 'code'}.png`;
|
||||||
|
link.href = dataUrl;
|
||||||
|
link.click();
|
||||||
|
} else {
|
||||||
|
const svgData = qrRef.current.querySelector('svg')?.outerHTML;
|
||||||
|
if (svgData) {
|
||||||
|
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `paypal-qr-${paypalId || 'code'}.svg`;
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Download failed', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFrameLabel = () => {
|
||||||
|
const frame = FRAME_OPTIONS.find(f => f.id === frameType);
|
||||||
|
return frame?.id !== 'none' ? frame?.label : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-5xl mx-auto px-4 md:px-6">
|
||||||
|
|
||||||
|
{/* Main Generator Card */}
|
||||||
|
<div className="bg-white rounded-3xl shadow-2xl shadow-slate-900/10 overflow-hidden border border-slate-100">
|
||||||
|
<div className="grid lg:grid-cols-2">
|
||||||
|
|
||||||
|
{/* LEFT: Input Section */}
|
||||||
|
<div className="p-8 lg:p-10 space-y-8 border-r border-slate-100">
|
||||||
|
|
||||||
|
{/* PayPal Details */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<CreditCard className="w-5 h-5 text-[#003087]" />
|
||||||
|
PayPal Details
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Input Type Toggle */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Payment Method</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{INPUT_TYPES.map((type) => (
|
||||||
|
<button
|
||||||
|
key={type.id}
|
||||||
|
onClick={() => setInputType(type.id)}
|
||||||
|
className={cn(
|
||||||
|
"py-2.5 px-3 rounded-lg text-sm font-medium transition-all border",
|
||||||
|
inputType === type.id
|
||||||
|
? "bg-[#003087] text-white border-[#003087]"
|
||||||
|
: "bg-slate-50 text-slate-600 border-slate-200 hover:border-slate-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{type.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||||
|
{inputType === 'username' ? 'PayPal.me Username' : 'PayPal Email Address'}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type={inputType === 'email' ? 'email' : 'text'}
|
||||||
|
placeholder={inputType === 'username' ? 'e.g. johndoe' : 'e.g. mail@example.com'}
|
||||||
|
value={paypalId}
|
||||||
|
onChange={(e) => setPaypalId(e.target.value)}
|
||||||
|
className="h-12 text-base rounded-xl border-slate-200 focus:border-[#003087] focus:ring-[#003087]"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500 mt-2">
|
||||||
|
{inputType === 'username'
|
||||||
|
? <>Find yours at <a href="https://paypal.me" target="_blank" rel="noopener noreferrer" className="text-[#003087] underline">paypal.me</a></>
|
||||||
|
: 'The email address linked to your PayPal account'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Amount (Optional)</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="25.00"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
className="h-12 text-base rounded-xl border-slate-200 focus:border-[#003087] focus:ring-[#003087]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Currency</label>
|
||||||
|
<Select
|
||||||
|
value={currency}
|
||||||
|
onChange={(e) => setCurrency(e.target.value)}
|
||||||
|
className="h-12 rounded-xl border-slate-200"
|
||||||
|
options={CURRENCIES}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-100"></div>
|
||||||
|
|
||||||
|
{/* Design Options */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Sparkles className="w-5 h-5 text-[#003087]" />
|
||||||
|
Design Options
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Color Picker */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">QR Code Color</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{QR_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.name}
|
||||||
|
onClick={() => setQrColor(c.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-9 h-9 rounded-full border-2 flex items-center justify-center transition-all hover:scale-110",
|
||||||
|
qrColor === c.value ? "border-slate-900 ring-2 ring-offset-2 ring-slate-200" : "border-white shadow-md"
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: c.value }}
|
||||||
|
aria-label={`Select ${c.name}`}
|
||||||
|
title={c.name}
|
||||||
|
>
|
||||||
|
{qrColor === c.value && <Check className="w-4 h-4 text-white" strokeWidth={3} />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Frame Selector */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">Frame Label</label>
|
||||||
|
<div className="grid grid-cols-5 gap-2">
|
||||||
|
{FRAME_OPTIONS.map((frame) => (
|
||||||
|
<button
|
||||||
|
key={frame.id}
|
||||||
|
onClick={() => setFrameType(frame.id)}
|
||||||
|
className={cn(
|
||||||
|
"py-2.5 px-2 rounded-lg text-xs font-medium transition-all border",
|
||||||
|
frameType === frame.id
|
||||||
|
? "bg-[#003087] text-white border-[#003087]"
|
||||||
|
: "bg-slate-50 text-slate-600 border-slate-200 hover:border-slate-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{frame.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT: Preview Section */}
|
||||||
|
<div className="p-8 lg:p-10 flex flex-col items-center justify-center" style={{ backgroundColor: BRAND.paleGrey }}>
|
||||||
|
|
||||||
|
{/* QR Card with Frame */}
|
||||||
|
<div
|
||||||
|
ref={qrRef}
|
||||||
|
className="bg-white rounded-3xl shadow-xl p-8 flex flex-col items-center"
|
||||||
|
style={{ minWidth: '320px' }}
|
||||||
|
>
|
||||||
|
{/* Frame Label */}
|
||||||
|
{getFrameLabel() && (
|
||||||
|
<div
|
||||||
|
className="mb-5 px-8 py-2.5 rounded-full text-white font-bold text-sm tracking-widest uppercase shadow-md"
|
||||||
|
style={{ backgroundColor: qrColor }}
|
||||||
|
>
|
||||||
|
{getFrameLabel()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* QR Code */}
|
||||||
|
<div className="bg-white">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={generatePayPalLink()}
|
||||||
|
size={240}
|
||||||
|
level="M"
|
||||||
|
includeMargin={false}
|
||||||
|
fgColor={qrColor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PayPal Info */}
|
||||||
|
<div className="mt-6 text-center max-w-[260px]">
|
||||||
|
<h3 className="font-bold text-slate-900 text-lg flex items-center justify-center gap-2 truncate">
|
||||||
|
<DollarSign className="w-4 h-4 text-[#003087] shrink-0" />
|
||||||
|
<span className="truncate">{paypalId || 'Your PayPal'}</span>
|
||||||
|
</h3>
|
||||||
|
{amount && (
|
||||||
|
<p className="text-sm text-slate-500 mt-1">{amount} {currency}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Download Buttons */}
|
||||||
|
<div className="flex items-center gap-3 mt-8">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('png')}
|
||||||
|
className="bg-[#003087] hover:bg-[#001F5C] text-white shadow-lg"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Download PNG
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('svg')}
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-300 hover:bg-white"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
SVG
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500 mt-4 text-center">
|
||||||
|
Your PayPal link is encoded directly. Static and forever free.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upsell Banner */}
|
||||||
|
<div className="mt-8 bg-gradient-to-r from-[#003087] to-[#0070BA] rounded-2xl p-6 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="text-white text-center sm:text-left">
|
||||||
|
<h3 className="font-bold text-lg">Need payment analytics?</h3>
|
||||||
|
<p className="text-white/80 text-sm mt-1">Track how many people scan your payment QR code with Dynamic QR Codes.</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/signup">
|
||||||
|
<Button className="bg-white text-[#003087] hover:bg-slate-100 shrink-0 shadow-lg">
|
||||||
|
Get Analytics
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
358
src/app/(marketing)/tools/paypal-qr-code/page.tsx
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
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';
|
||||||
|
|
||||||
|
// SEO Optimized Metadata
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Free PayPal QR Code Generator | Accept Payments Instantly | QR Master',
|
||||||
|
description: 'Create a QR code for your PayPal.me link. Let customers pay you instantly by scanning. Support tips, donations, and fixed amounts. 100% free.',
|
||||||
|
keywords: ['paypal qr code', 'paypal.me qr generator', 'payment qr code', 'accept payments qr', 'paypal qr generator', 'tip qr code', 'donation qr code'],
|
||||||
|
alternates: {
|
||||||
|
canonical: 'https://qrmaster.io/tools/paypal-qr-code',
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: 'Free PayPal QR Code Generator | QR Master',
|
||||||
|
description: 'Generate QR codes for PayPal payments. Perfect for tips, donations, and invoices.',
|
||||||
|
type: 'website',
|
||||||
|
url: 'https://qrmaster.io/tools/paypal-qr-code',
|
||||||
|
images: [{ url: '/og-paypal-generator.png', width: 1200, height: 630 }],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: 'Free PayPal QR Code Generator',
|
||||||
|
description: 'Create PayPal payment QR codes. Instant and free.',
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// JSON-LD Structured Data
|
||||||
|
const jsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@graph': [
|
||||||
|
{
|
||||||
|
'@type': 'SoftwareApplication',
|
||||||
|
name: 'PayPal QR Code Generator',
|
||||||
|
applicationCategory: 'FinanceApplication',
|
||||||
|
operatingSystem: 'Web Browser',
|
||||||
|
offers: {
|
||||||
|
'@type': 'Offer',
|
||||||
|
price: '0',
|
||||||
|
priceCurrency: 'USD',
|
||||||
|
},
|
||||||
|
aggregateRating: {
|
||||||
|
'@type': 'AggregateRating',
|
||||||
|
ratingValue: '4.9',
|
||||||
|
ratingCount: '980',
|
||||||
|
},
|
||||||
|
description: 'Generate QR codes that link to your PayPal.me page for instant payments.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowTo',
|
||||||
|
name: 'How to Create a PayPal QR Code',
|
||||||
|
description: 'Create a QR code for receiving PayPal payments.',
|
||||||
|
step: [
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 1,
|
||||||
|
name: 'Enter Username',
|
||||||
|
text: 'Type your PayPal.me username (the part after paypal.me/).',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 2,
|
||||||
|
name: 'Set Amount (Optional)',
|
||||||
|
text: 'Enter a pre-filled amount and currency for fixed payments.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 3,
|
||||||
|
name: 'Customize Design',
|
||||||
|
text: 'Choose PayPal brand colors and add a frame like "Pay Now" or "Tip Me".',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 4,
|
||||||
|
name: 'Download QR Code',
|
||||||
|
text: 'Download your high-quality QR code in PNG or SVG format.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 5,
|
||||||
|
name: 'Share',
|
||||||
|
text: 'Print it on invoices, display at your shop, or share digitally.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalTime: 'PT30S',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: [
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'How does the PayPal QR code work?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'When scanned, it opens the PayPal app or website with your PayPal.me link. If you set an amount, it will be pre-filled for the payer.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Do I need a PayPal Business account?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'No. Any PayPal account with a PayPal.me link can use this generator. Personal accounts work fine for tips and donations.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Is there a fee for using the QR code?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'This generator is 100% free. PayPal may charge their standard transaction fees when you receive payments.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Can I change the amount later?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'No, this is a static QR code. The amount is encoded permanently. For variable amounts, leave the amount field empty.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'What currencies are supported?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'We support EUR, USD, GBP, and CHF. PayPal handles currency conversion automatically.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PayPalQRCodePage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
/>
|
||||||
|
<ToolBreadcrumb toolName="PayPal QR Code Generator" toolSlug="paypal-qr-code" />
|
||||||
|
|
||||||
|
<div className="min-h-screen" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
|
||||||
|
{/* HERO SECTION */}
|
||||||
|
<section className="relative pt-20 pb-20 lg:pt-32 lg:pb-32 px-4 sm:px-6 lg:px-8 overflow-hidden" style={{ backgroundColor: '#003087' }}>
|
||||||
|
<div className="absolute inset-0 opacity-10">
|
||||||
|
<svg className="w-full h-full" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||||
|
<path d="M0 100 C 20 0 50 0 100 100 Z" fill="url(#grad1)" />
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" style={{ stopColor: 'white', stopOpacity: 1 }} />
|
||||||
|
<stop offset="100%" style={{ stopColor: 'white', stopOpacity: 0 }} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto grid lg:grid-cols-2 gap-12 items-center relative z-10">
|
||||||
|
<div className="text-center lg:text-left">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/10 text-white/90 text-sm font-medium mb-6 backdrop-blur-sm border border-white/10 hover:bg-white/20 transition-colors cursor-default">
|
||||||
|
<span className="flex h-2 w-2 relative">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-sky-400 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-sky-400"></span>
|
||||||
|
</span>
|
||||||
|
Free Tool — No Signup Required
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl md:text-5xl lg:text-6xl font-extrabold text-white tracking-tight leading-tight mb-6">
|
||||||
|
Accept Payments with <br className="hidden lg:block" />
|
||||||
|
<span className="text-transparent bg-clip-text bg-gradient-to-r from-sky-300 to-blue-200">PayPal QR Codes</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-lg md:text-xl text-blue-100 max-w-2xl mx-auto lg:mx-0 mb-8 leading-relaxed">
|
||||||
|
Let customers pay you by scanning. Perfect for tips, donations, and invoices.
|
||||||
|
<strong className="text-white block sm:inline mt-2 sm:mt-0"> Instant payments.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center lg:justify-start gap-4 text-sm font-medium text-white/80">
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/5 backdrop-blur-sm">
|
||||||
|
<CreditCard className="w-4 h-4 text-sky-300" />
|
||||||
|
PayPal.me Links
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/5 backdrop-blur-sm">
|
||||||
|
<Zap className="w-4 h-4 text-amber-300" />
|
||||||
|
Pre-fill Amount
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/5 backdrop-blur-sm">
|
||||||
|
<Shield className="w-4 h-4 text-emerald-300" />
|
||||||
|
Secure Payments
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visual Abstract */}
|
||||||
|
<div className="hidden lg:flex relative items-center justify-center min-h-[400px]">
|
||||||
|
<div className="absolute w-[500px] h-[500px] bg-blue-500/30 rounded-full blur-[100px] -top-20 -right-20 animate-pulse" />
|
||||||
|
|
||||||
|
<div className="relative w-80 h-96 bg-white/5 backdrop-blur-xl border border-white/20 rounded-3xl shadow-2xl flex flex-col items-center justify-center p-8 transform rotate-3 hover:rotate-0 transition-all duration-700 group">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-white/10 to-transparent rounded-3xl" />
|
||||||
|
|
||||||
|
{/* Payment Card Mock */}
|
||||||
|
<div className="w-full bg-gradient-to-br from-[#0070BA] to-[#003087] rounded-xl shadow-lg p-4 mb-6 relative overflow-hidden text-white">
|
||||||
|
<div className="flex justify-between items-start mb-3">
|
||||||
|
<Banknote className="w-6 h-6 opacity-80" />
|
||||||
|
<div className="bg-white/20 px-2 py-1 rounded text-xs">EUR</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold tracking-wider">€25.00</div>
|
||||||
|
<div className="text-xs opacity-70 mt-1">Payment Request</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-48 h-48 bg-white rounded-xl p-2 shadow-inner relative overflow-hidden flex items-center justify-center">
|
||||||
|
<QRCodeSVG value="https://www.qrmaster.net" size={170} fgColor="#003087" level="Q" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Badge */}
|
||||||
|
<div className="absolute -bottom-6 -left-6 bg-white py-3 px-5 rounded-xl shadow-xl flex items-center gap-3 transform transition-transform hover:scale-105 duration-300">
|
||||||
|
<div className="bg-blue-100 p-2 rounded-full">
|
||||||
|
<DollarSign className="w-5 h-5 text-[#003087]" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-[10px] text-slate-400 font-bold uppercase tracking-wider">PayPal</div>
|
||||||
|
<div className="text-sm font-bold text-slate-900">Ready</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* GENERATOR SECTION */}
|
||||||
|
<section className="py-12 px-4 sm:px-6 lg:px-8 -mt-8">
|
||||||
|
<PayPalGenerator />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* HOW IT WORKS */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8 bg-white">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-12">
|
||||||
|
How PayPal QR Codes Work
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 lg:grid-cols-5 gap-8">
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#003087]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<CreditCard className="w-6 h-6 text-[#003087]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">1. Username</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">Enter your PayPal.me username.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#003087]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<DollarSign className="w-6 h-6 text-[#003087]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">2. Amount</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">Optional: Set a fixed payment amount.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#003087]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Sparkles className="w-6 h-6 text-[#003087]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">3. Design</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">Pick colors and add a frame.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#003087]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Download className="w-6 h-6 text-[#003087]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">4. Download</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">Save as PNG or SVG file.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#003087]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Share2 className="w-6 h-6 text-[#003087]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">5. Share</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">Print or share to receive payments.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* FAQ SECTION */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-4">
|
||||||
|
Frequently Asked Questions
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-600 text-center mb-10">
|
||||||
|
Common questions about PayPal QR codes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FaqItem
|
||||||
|
question="How does the PayPal QR code work?"
|
||||||
|
answer="When scanned, it opens the PayPal app or website with your PayPal.me link. If you set an amount, it will be pre-filled for the payer."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Do I need a PayPal Business account?"
|
||||||
|
answer="No. Any PayPal account with a PayPal.me link can use this generator. Personal accounts work fine for tips and donations."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Is there a fee for using the QR code?"
|
||||||
|
answer="This generator is 100% free. PayPal may charge their standard transaction fees when you receive payments."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Can I change the amount later?"
|
||||||
|
answer="No, this is a static QR code. The amount is encoded permanently. For variable amounts, leave the amount field empty."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="What if I don't have a PayPal.me link?"
|
||||||
|
answer="You can create one for free in your PayPal account settings. Go to paypal.me to set up your personalized link."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FaqItem({ question, answer }: { question: string; answer: string }) {
|
||||||
|
return (
|
||||||
|
<details className="group bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||||
|
<summary className="flex items-center justify-between p-5 cursor-pointer list-none text-slate-900 font-semibold hover:bg-slate-50 transition-colors">
|
||||||
|
{question}
|
||||||
|
<span className="transition group-open:rotate-180 text-slate-400">
|
||||||
|
<svg fill="none" height="20" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width="20">
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div className="text-slate-600 px-5 pb-5 pt-0 leading-relaxed text-sm">
|
||||||
|
{answer}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sparkles({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
248
src/app/(marketing)/tools/phone-qr-code/PhoneGenerator.tsx
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import {
|
||||||
|
Phone,
|
||||||
|
Download,
|
||||||
|
Check,
|
||||||
|
Sparkles,
|
||||||
|
Smartphone
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// Brand Colors
|
||||||
|
const BRAND = {
|
||||||
|
paleGrey: '#EBEBDF',
|
||||||
|
richBlue: '#1A1265',
|
||||||
|
richBlueLight: '#2A2275',
|
||||||
|
};
|
||||||
|
|
||||||
|
// QR Color Options
|
||||||
|
const QR_COLORS = [
|
||||||
|
{ name: 'Classic Black', value: '#000000' },
|
||||||
|
{ name: 'Deep Blue', value: '#1E40AF' },
|
||||||
|
{ name: 'Violet', value: '#7C3AED' },
|
||||||
|
{ name: 'Teal', value: '#0D9488' },
|
||||||
|
{ name: 'Coral', value: '#F43F5E' },
|
||||||
|
{ name: 'Amber', value: '#D97706' },
|
||||||
|
{ name: 'Emerald', value: '#10B981' },
|
||||||
|
{ name: 'Rose', value: '#F43F5E' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Frame Options
|
||||||
|
const FRAME_OPTIONS = [
|
||||||
|
{ id: 'none', label: 'No Frame' },
|
||||||
|
{ id: 'scanme', label: 'Scan Me' },
|
||||||
|
{ id: 'call', label: 'Call Me' },
|
||||||
|
{ id: 'contact', label: 'Contact' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function PhoneGenerator() {
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [qrColor, setQrColor] = useState(BRAND.richBlue);
|
||||||
|
const [frameType, setFrameType] = useState('none');
|
||||||
|
|
||||||
|
const qrRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const qrValue = `tel:${phone}`;
|
||||||
|
|
||||||
|
const handleDownload = async (format: 'png' | 'svg') => {
|
||||||
|
if (!qrRef.current) return;
|
||||||
|
try {
|
||||||
|
if (format === 'png') {
|
||||||
|
const { toPng } = await import('html-to-image');
|
||||||
|
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3 });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = `phone-qr-code.png`;
|
||||||
|
link.href = dataUrl;
|
||||||
|
link.click();
|
||||||
|
} else {
|
||||||
|
const svgData = qrRef.current.querySelector('svg')?.outerHTML;
|
||||||
|
if (svgData) {
|
||||||
|
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `phone-qr-code.svg`;
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Download failed', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFrameLabel = () => {
|
||||||
|
const frame = FRAME_OPTIONS.find(f => f.id === frameType);
|
||||||
|
return frame?.id !== 'none' ? frame?.label : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-5xl mx-auto px-4 md:px-6">
|
||||||
|
|
||||||
|
{/* Main Generator Card */}
|
||||||
|
<div className="bg-white rounded-3xl shadow-2xl shadow-slate-900/10 overflow-hidden border border-slate-100">
|
||||||
|
<div className="grid lg:grid-cols-2">
|
||||||
|
|
||||||
|
{/* LEFT: Input Section */}
|
||||||
|
<div className="p-8 lg:p-10 space-y-8 border-r border-slate-100">
|
||||||
|
|
||||||
|
{/* Phone Details */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Phone className="w-5 h-5 text-[#1A1265]" />
|
||||||
|
Phone Number
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Number</label>
|
||||||
|
<Input
|
||||||
|
placeholder="+1 (555) 123-4567"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
className="h-12 text-base rounded-xl border-slate-200 focus:border-[#1A1265] focus:ring-[#1A1265]"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500 mt-2">Enter with country code for best results (e.g. +1).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-100"></div>
|
||||||
|
|
||||||
|
{/* Design Options */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Sparkles className="w-5 h-5 text-[#1A1265]" />
|
||||||
|
Design Options
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Color Picker */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">QR Code Color</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{QR_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.name}
|
||||||
|
onClick={() => setQrColor(c.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-9 h-9 rounded-full border-2 flex items-center justify-center transition-all hover:scale-110",
|
||||||
|
qrColor === c.value ? "border-slate-900 ring-2 ring-offset-2 ring-slate-200" : "border-white shadow-md"
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: c.value }}
|
||||||
|
aria-label={`Select ${c.name}`}
|
||||||
|
title={c.name}
|
||||||
|
>
|
||||||
|
{qrColor === c.value && <Check className="w-4 h-4 text-white" strokeWidth={3} />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Frame Selector */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">Frame Label</label>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{FRAME_OPTIONS.map((frame) => (
|
||||||
|
<button
|
||||||
|
key={frame.id}
|
||||||
|
onClick={() => setFrameType(frame.id)}
|
||||||
|
className={cn(
|
||||||
|
"py-2.5 px-3 rounded-lg text-sm font-medium transition-all border",
|
||||||
|
frameType === frame.id
|
||||||
|
? "bg-[#1A1265] text-white border-[#1A1265]"
|
||||||
|
: "bg-slate-50 text-slate-600 border-slate-200 hover:border-slate-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{frame.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT: Preview Section */}
|
||||||
|
<div className="p-8 lg:p-10 flex flex-col items-center justify-center" style={{ backgroundColor: BRAND.paleGrey }}>
|
||||||
|
|
||||||
|
{/* QR Card with Frame */}
|
||||||
|
<div
|
||||||
|
ref={qrRef}
|
||||||
|
className="bg-white rounded-3xl shadow-xl p-8 flex flex-col items-center"
|
||||||
|
style={{ minWidth: '320px' }}
|
||||||
|
>
|
||||||
|
{/* Frame Label */}
|
||||||
|
{getFrameLabel() && (
|
||||||
|
<div
|
||||||
|
className="mb-5 px-8 py-2.5 rounded-full text-white font-bold text-sm tracking-widest uppercase shadow-md"
|
||||||
|
style={{ backgroundColor: qrColor }}
|
||||||
|
>
|
||||||
|
{getFrameLabel()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* QR Code */}
|
||||||
|
<div className="bg-white">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={phone ? qrValue : "tel:+123456789"}
|
||||||
|
size={240}
|
||||||
|
level="M"
|
||||||
|
includeMargin={false}
|
||||||
|
fgColor={qrColor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Preview */}
|
||||||
|
<div className="mt-6 text-center max-w-[260px]">
|
||||||
|
<h3 className="font-bold text-slate-900 text-lg flex items-center justify-center gap-2 truncate">
|
||||||
|
<Phone className="w-4 h-4 text-slate-400 shrink-0" />
|
||||||
|
<span className="truncate">{phone || '+1 555 ...'}</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Download Buttons */}
|
||||||
|
<div className="flex items-center gap-3 mt-8">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('png')}
|
||||||
|
className="bg-[#1A1265] hover:bg-[#2A2275] text-white shadow-lg"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Download PNG
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('svg')}
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-300 hover:bg-white"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
SVG
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500 mt-4 text-center">
|
||||||
|
Scanning initiates a call on any mobile phone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upsell Banner */}
|
||||||
|
<div className="mt-8 bg-gradient-to-r from-[#1A1265] to-[#2A2275] rounded-2xl p-6 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="text-white text-center sm:text-left">
|
||||||
|
<h3 className="font-bold text-lg">Need to change contact info?</h3>
|
||||||
|
<p className="text-white/80 text-sm mt-1">
|
||||||
|
Dynamic QR Codes act as a digital business card that you can update anytime.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/signup">
|
||||||
|
<Button className="bg-white text-[#1A1265] hover:bg-slate-100 shrink-0 shadow-lg">
|
||||||
|
Create vCard Plus
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
362
src/app/(marketing)/tools/phone-qr-code/page.tsx
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
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';
|
||||||
|
|
||||||
|
// SEO Optimized Metadata
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Free Phone QR Code Generator | Call Instantly | QR Master',
|
||||||
|
description: 'Create a QR code that makes a phone call when scanned. Perfect for business cards, flyers, and support lines. 100% Free & No Signup.',
|
||||||
|
keywords: ['phone qr code', 'call qr code', 'phone number qr generator', 'click to call qr', 'business card qr code'],
|
||||||
|
alternates: {
|
||||||
|
canonical: 'https://qrmaster.io/tools/phone-qr-code',
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: 'Free Phone QR Code Generator | QR Master',
|
||||||
|
description: 'Generate QR codes to initiate phone calls instantly. Share your number easily.',
|
||||||
|
type: 'website',
|
||||||
|
url: 'https://qrmaster.io/tools/phone-qr-code',
|
||||||
|
images: [{ url: '/og-phone-generator.png', width: 1200, height: 630 }],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: 'Free Phone QR Code Generator',
|
||||||
|
description: 'Create QR codes for instant calling. Free and reliable.',
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// JSON-LD Structured Data
|
||||||
|
const jsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@graph': [
|
||||||
|
{
|
||||||
|
'@type': 'SoftwareApplication',
|
||||||
|
name: 'Phone QR Code Generator',
|
||||||
|
applicationCategory: 'UtilitiesApplication',
|
||||||
|
operatingSystem: 'Web Browser',
|
||||||
|
offers: {
|
||||||
|
'@type': 'Offer',
|
||||||
|
price: '0',
|
||||||
|
priceCurrency: 'USD',
|
||||||
|
},
|
||||||
|
aggregateRating: {
|
||||||
|
'@type': 'AggregateRating',
|
||||||
|
ratingValue: '4.8',
|
||||||
|
ratingCount: '1500',
|
||||||
|
},
|
||||||
|
description: 'Generate QR codes that trigger a phone call when scanned on a mobile device.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowTo',
|
||||||
|
name: 'How to Create a Phone QR Code',
|
||||||
|
description: 'Create a QR code that dials a number automatically.',
|
||||||
|
step: [
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 1,
|
||||||
|
name: 'Enter Number',
|
||||||
|
text: 'Type your phone number with country code (e.g., +1 555-0199).',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 2,
|
||||||
|
name: 'Customize',
|
||||||
|
text: 'Choose a color and add a label like "Call Me".',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 3,
|
||||||
|
name: 'Download',
|
||||||
|
text: 'Save the QR code and print it on your materials.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 4,
|
||||||
|
name: 'Test',
|
||||||
|
text: 'Scan the code with your phone to ensure the number is correct.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 5,
|
||||||
|
name: 'Share',
|
||||||
|
text: 'Add to business cards, flyers, or supports desks.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalTime: 'PT30S',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: [
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Does it call automatically?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Scanning the QR code opens the phone dialer with the number pre-filled. The user must tap the call button to initiate the call.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Does it work internationally?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes! We recommend entering your number in international format (starting with +) to ensure it works anywhere in the world.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Is my phone number private?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes. We do not store your number. It is encoded directly into the QR code image.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Can I track calls?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'This static QR code cannot track calls. For tracking scans and analytics, consider using our Dynamic QR Code solution.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Is it free?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes, completely free. We do not charge for generating or scanning the code.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PhoneQRCodePage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
/>
|
||||||
|
<ToolBreadcrumb toolName="Phone QR Code Generator" toolSlug="phone-qr-code" />
|
||||||
|
|
||||||
|
<div className="min-h-screen" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
|
||||||
|
{/* HERO SECTION */}
|
||||||
|
<section className="relative pt-20 pb-20 lg:pt-32 lg:pb-32 px-4 sm:px-6 lg:px-8 overflow-hidden" style={{ backgroundColor: '#1A1265' }}>
|
||||||
|
<div className="absolute inset-0 opacity-10">
|
||||||
|
<svg className="w-full h-full" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||||
|
<path d="M0 100 C 20 0 50 0 100 100 Z" fill="url(#grad1)" />
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" style={{ stopColor: 'white', stopOpacity: 1 }} />
|
||||||
|
<stop offset="100%" style={{ stopColor: 'white', stopOpacity: 0 }} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto grid lg:grid-cols-2 gap-12 items-center relative z-10">
|
||||||
|
<div className="text-center lg:text-left">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/10 text-white/90 text-sm font-medium mb-6 backdrop-blur-sm border border-white/10 hover:bg-white/20 transition-colors cursor-default">
|
||||||
|
<span className="flex h-2 w-2 relative">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-400"></span>
|
||||||
|
</span>
|
||||||
|
Free Tool — No Signup Required
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl md:text-5xl lg:text-6xl font-extrabold text-white tracking-tight leading-tight mb-6">
|
||||||
|
Create Instant <br className="hidden lg:block" />
|
||||||
|
<span className="text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-cyan-400">Call-to-Action QR Codes</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-lg md:text-xl text-indigo-100 max-w-2xl mx-auto lg:mx-0 mb-8 leading-relaxed">
|
||||||
|
Make it easy for customers to call you. Scan to dial instantly.
|
||||||
|
<strong className="text-white block sm:inline mt-2 sm:mt-0"> Perfect for print marketing.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center lg:justify-start gap-4 text-sm font-medium text-white/80">
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/5 backdrop-blur-sm">
|
||||||
|
<PhoneCall className="w-4 h-4 text-emerald-400" />
|
||||||
|
One-Tap Call
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/5 backdrop-blur-sm">
|
||||||
|
<Zap className="w-4 h-4 text-amber-400" />
|
||||||
|
Instant Dial
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/5 backdrop-blur-sm">
|
||||||
|
<Shield className="w-4 h-4 text-purple-400" />
|
||||||
|
No Data Saved
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visual Abstract */}
|
||||||
|
<div className="hidden lg:flex relative items-center justify-center min-h-[400px]">
|
||||||
|
<div className="absolute w-[500px] h-[500px] bg-indigo-500/30 rounded-full blur-[100px] -top-20 -right-20 animate-pulse" />
|
||||||
|
|
||||||
|
<div className="relative w-80 h-96 bg-white/5 backdrop-blur-xl border border-white/20 rounded-3xl shadow-2xl flex flex-col items-center justify-center p-8 transform rotate-6 hover:rotate-3 transition-all duration-700 group">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-white/10 to-transparent rounded-3xl" />
|
||||||
|
|
||||||
|
<div className="w-full bg-white rounded-xl shadow-lg p-4 mb-6 relative overflow-hidden">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-green-100 flex items-center justify-center animate-pulse">
|
||||||
|
<Phone className="w-5 h-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="h-2 w-24 bg-slate-200 rounded-full mb-1" />
|
||||||
|
<div className="h-2 w-16 bg-slate-100 rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-48 h-48 bg-white rounded-xl p-2 shadow-inner relative overflow-hidden flex items-center justify-center">
|
||||||
|
<QRCodeSVG value="https://www.qrmaster.net" size={170} fgColor="#0f172a" level="Q" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Badge */}
|
||||||
|
<div className="absolute -bottom-6 -right-6 bg-white py-3 px-5 rounded-xl shadow-xl flex items-center gap-3 transform transition-transform hover:scale-105 duration-300">
|
||||||
|
<div className="bg-emerald-100 p-2 rounded-full">
|
||||||
|
<PhoneCall className="w-5 h-5 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-[10px] text-slate-400 font-bold uppercase tracking-wider">Status</div>
|
||||||
|
<div className="text-sm font-bold text-slate-900">Calling...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* GENERATOR SECTION */}
|
||||||
|
<section className="py-12 px-4 sm:px-6 lg:px-8 -mt-8">
|
||||||
|
<PhoneGenerator />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* HOW IT WORKS */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8 bg-white">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-12">
|
||||||
|
How Phone QR Codes Work
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 lg:grid-cols-5 gap-8">
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-[#1A1265]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Phone className="w-7 h-7 text-[#1A1265]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">1. Enter Number</h3>
|
||||||
|
<p className="text-slate-600 text-sm">
|
||||||
|
Type your phone number. Include the country code for international compatibility.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-[#1A1265]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Smartphone className="w-7 h-7 text-[#1A1265]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">2. Scan</h3>
|
||||||
|
<p className="text-slate-600 text-sm">
|
||||||
|
Customers scan the QR code with their mobile phone's camera.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#1A1265]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<PhoneCall className="w-6 h-6 text-[#1A1265]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">3. Call</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Their phone dialer opens automatically with your number.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#1A1265]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Download className="w-6 h-6 text-[#1A1265]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">4. Download</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Save the QR code image.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#1A1265]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Shield className="w-6 h-6 text-[#1A1265]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">5. Share</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Add it to your marketing materials.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* FAQ SECTION */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-4">
|
||||||
|
Frequently Asked Questions
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-600 text-center mb-10">
|
||||||
|
Common questions about Phone QR codes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FaqItem
|
||||||
|
question="Do I need to include the country code?"
|
||||||
|
answer="We highly recommend it. Adding the country code (e.g., +1 for USA/Canada, +44 for UK) ensures any phone from any region can dial your number correctly."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Does this work on iPhone and Android?"
|
||||||
|
answer="Yes, Phone QR codes are a standard format supported natively by iOS and Android camera apps."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Can I change the number later?"
|
||||||
|
answer="No. Static QR codes can't be edited. If your phone number changes, you'll need a new QR code. Use a Dynamic QR Code if you anticipate changes."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Is there a cost per scan?"
|
||||||
|
answer="No. There are no fees or limits on scanning. It works just like sharing your phone number."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Is it free?"
|
||||||
|
answer="Yes, completely free. We do not charge for generating or scanning the code."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FaqItem({ question, answer }: { question: string; answer: string }) {
|
||||||
|
return (
|
||||||
|
<details className="group bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||||
|
<summary className="flex items-center justify-between p-5 cursor-pointer list-none text-slate-900 font-semibold hover:bg-slate-50 transition-colors">
|
||||||
|
{question}
|
||||||
|
<span className="transition group-open:rotate-180 text-slate-400">
|
||||||
|
<svg fill="none" height="20" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width="20">
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div className="text-slate-600 px-5 pb-5 pt-0 leading-relaxed text-sm">
|
||||||
|
{answer}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
267
src/app/(marketing)/tools/sms-qr-code/SMSGenerator.tsx
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import {
|
||||||
|
MessageSquare,
|
||||||
|
Download,
|
||||||
|
Check,
|
||||||
|
Sparkles,
|
||||||
|
Phone
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// Brand Colors
|
||||||
|
const BRAND = {
|
||||||
|
paleGrey: '#EBEBDF',
|
||||||
|
primary: '#ea580c', // Orange-600
|
||||||
|
primaryDark: '#c2410c', // Orange-700
|
||||||
|
};
|
||||||
|
|
||||||
|
// QR Color Options
|
||||||
|
const QR_COLORS = [
|
||||||
|
{ name: 'Classic Black', value: '#000000' },
|
||||||
|
{ name: 'Deep Blue', value: '#1E40AF' },
|
||||||
|
{ name: 'Violet', value: '#7C3AED' },
|
||||||
|
{ name: 'Teal', value: '#0D9488' },
|
||||||
|
{ name: 'Coral', value: '#F43F5E' },
|
||||||
|
{ name: 'Amber', value: '#D97706' },
|
||||||
|
{ name: 'Emerald', value: '#10B981' },
|
||||||
|
{ name: 'Rose', value: '#F43F5E' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Frame Options
|
||||||
|
const FRAME_OPTIONS = [
|
||||||
|
{ id: 'none', label: 'No Frame' },
|
||||||
|
{ id: 'scanme', label: 'Scan Me' },
|
||||||
|
{ id: 'sms', label: 'SMS Us' },
|
||||||
|
{ id: 'text', label: 'Text Us' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SMSGenerator() {
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [qrColor, setQrColor] = useState(BRAND.primary);
|
||||||
|
const [frameType, setFrameType] = useState('none');
|
||||||
|
|
||||||
|
const qrRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// sms:number?body=message
|
||||||
|
const qrValue = `sms:${phone}?body=${encodeURIComponent(message)}`;
|
||||||
|
|
||||||
|
const handleDownload = async (format: 'png' | 'svg') => {
|
||||||
|
if (!qrRef.current) return;
|
||||||
|
try {
|
||||||
|
if (format === 'png') {
|
||||||
|
const { toPng } = await import('html-to-image');
|
||||||
|
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3 });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = `sms-qr-code.png`;
|
||||||
|
link.href = dataUrl;
|
||||||
|
link.click();
|
||||||
|
} else {
|
||||||
|
const svgData = qrRef.current.querySelector('svg')?.outerHTML;
|
||||||
|
if (svgData) {
|
||||||
|
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `sms-qr-code.svg`;
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Download failed', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFrameLabel = () => {
|
||||||
|
const frame = FRAME_OPTIONS.find(f => f.id === frameType);
|
||||||
|
return frame?.id !== 'none' ? frame?.label : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-5xl mx-auto px-4 md:px-6">
|
||||||
|
|
||||||
|
{/* Main Generator Card */}
|
||||||
|
<div className="bg-white rounded-3xl shadow-2xl shadow-slate-900/10 overflow-hidden border border-slate-100">
|
||||||
|
<div className="grid lg:grid-cols-2">
|
||||||
|
|
||||||
|
{/* LEFT: Input Section */}
|
||||||
|
<div className="p-8 lg:p-10 space-y-8 border-r border-slate-100">
|
||||||
|
|
||||||
|
{/* SMS Details */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<MessageSquare className="w-5 h-5 text-orange-600" />
|
||||||
|
SMS Details
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Phone Number</label>
|
||||||
|
<Input
|
||||||
|
placeholder="+1 555 123 4567"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
className="h-12 text-base rounded-xl border-slate-200 focus:border-orange-600 focus:ring-orange-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Pre-filled Message</label>
|
||||||
|
<textarea
|
||||||
|
className="w-full h-32 p-4 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-orange-600 resize-none text-slate-800 placeholder:text-slate-400"
|
||||||
|
placeholder="I'm interested in..."
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
maxLength={160}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500 mt-2 text-right">{message.length}/160</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-100"></div>
|
||||||
|
|
||||||
|
{/* Design Options */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Sparkles className="w-5 h-5 text-orange-600" />
|
||||||
|
Design Options
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Color Picker */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">QR Code Color</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{QR_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.name}
|
||||||
|
onClick={() => setQrColor(c.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-9 h-9 rounded-full border-2 flex items-center justify-center transition-all hover:scale-110",
|
||||||
|
qrColor === c.value ? "border-slate-900 ring-2 ring-offset-2 ring-slate-200" : "border-white shadow-md"
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: c.value }}
|
||||||
|
aria-label={`Select ${c.name}`}
|
||||||
|
title={c.name}
|
||||||
|
>
|
||||||
|
{qrColor === c.value && <Check className="w-4 h-4 text-white" strokeWidth={3} />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Frame Selector */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">Frame Label</label>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{FRAME_OPTIONS.map((frame) => (
|
||||||
|
<button
|
||||||
|
key={frame.id}
|
||||||
|
onClick={() => setFrameType(frame.id)}
|
||||||
|
className={cn(
|
||||||
|
"py-2.5 px-3 rounded-lg text-sm font-medium transition-all border",
|
||||||
|
frameType === frame.id
|
||||||
|
? "bg-orange-600 text-white border-orange-600"
|
||||||
|
: "bg-slate-50 text-slate-600 border-slate-200 hover:border-slate-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{frame.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT: Preview Section */}
|
||||||
|
<div className="p-8 lg:p-10 flex flex-col items-center justify-center" style={{ backgroundColor: BRAND.paleGrey }}>
|
||||||
|
|
||||||
|
{/* QR Card with Frame */}
|
||||||
|
<div
|
||||||
|
ref={qrRef}
|
||||||
|
className="bg-white rounded-3xl shadow-xl p-8 flex flex-col items-center"
|
||||||
|
style={{ minWidth: '320px' }}
|
||||||
|
>
|
||||||
|
{/* Frame Label */}
|
||||||
|
{getFrameLabel() && (
|
||||||
|
<div
|
||||||
|
className="mb-5 px-8 py-2.5 rounded-full text-white font-bold text-sm tracking-widest uppercase shadow-md"
|
||||||
|
style={{ backgroundColor: qrColor }}
|
||||||
|
>
|
||||||
|
{getFrameLabel()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* QR Code */}
|
||||||
|
<div className="bg-white">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={phone ? qrValue : "sms:+123456789?body=Hello"}
|
||||||
|
size={240}
|
||||||
|
level="M"
|
||||||
|
includeMargin={false}
|
||||||
|
fgColor={qrColor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Preview */}
|
||||||
|
<div className="mt-6 text-center max-w-[260px]">
|
||||||
|
<h3 className="font-bold text-slate-900 text-lg flex items-center justify-center gap-2 truncate">
|
||||||
|
<Phone className="w-4 h-4 text-slate-400 shrink-0" />
|
||||||
|
<span className="truncate">{phone || 'Number'}</span>
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center justify-center gap-2 mt-2 text-slate-500 text-xs">
|
||||||
|
<MessageSquare className="w-3 h-3" />
|
||||||
|
<span className="italic truncate">{message || 'Your message...'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Download Buttons */}
|
||||||
|
<div className="flex items-center gap-3 mt-8">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('png')}
|
||||||
|
className="bg-orange-600 hover:bg-orange-700 text-white shadow-lg"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Download PNG
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('svg')}
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-300 hover:bg-white"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
SVG
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500 mt-4 text-center">
|
||||||
|
Opens the messaging app with text pre-filled.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upsell Banner */}
|
||||||
|
<div className="mt-8 bg-gradient-to-r from-orange-600 to-orange-700 rounded-2xl p-6 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="text-white text-center sm:text-left">
|
||||||
|
<h3 className="font-bold text-lg">Use SMS for marketing?</h3>
|
||||||
|
<p className="text-white/80 text-sm mt-1">
|
||||||
|
Dynamic QR Codes offer better tracking and allow you to change the campaign message later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/signup">
|
||||||
|
<Button className="bg-white text-orange-700 hover:bg-slate-100 shrink-0 shadow-lg">
|
||||||
|
Try Dynamic Codes
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
315
src/app/(marketing)/tools/sms-qr-code/page.tsx
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
import SMSGenerator from './SMSGenerator';
|
||||||
|
import { MessageSquare, Shield, Zap, Smartphone, Send } from 'lucide-react';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import { ToolBreadcrumb } from '@/components/seo/BreadcrumbSchema';
|
||||||
|
|
||||||
|
// SEO Optimized Metadata
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Free SMS QR Code Generator | Pre-filled Texts | QR Master',
|
||||||
|
description: 'Create a QR code to send an SMS text message instantly. Pre-fill the phone number and message body. Free, private, and works on all phones.',
|
||||||
|
keywords: ['sms qr code', 'text message qr code', 'send sms qr', 'sms generator', 'text qr'],
|
||||||
|
alternates: {
|
||||||
|
canonical: 'https://qrmaster.io/tools/sms-qr-code',
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: 'Free SMS QR Code Generator | QR Master',
|
||||||
|
description: 'Generate QR codes for instant SMS messages. Pre-fill text and number.',
|
||||||
|
type: 'website',
|
||||||
|
url: 'https://qrmaster.io/tools/sms-qr-code',
|
||||||
|
images: [{ url: '/og-sms-generator.png', width: 1200, height: 630 }],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: 'Free SMS QR Code Generator',
|
||||||
|
description: 'Create QR codes to send texts. Instant and free.',
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// JSON-LD Structured Data
|
||||||
|
const jsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@graph': [
|
||||||
|
{
|
||||||
|
'@type': 'SoftwareApplication',
|
||||||
|
name: 'SMS QR Code Generator',
|
||||||
|
applicationCategory: 'UtilitiesApplication',
|
||||||
|
operatingSystem: 'Web Browser',
|
||||||
|
offers: {
|
||||||
|
'@type': 'Offer',
|
||||||
|
price: '0',
|
||||||
|
priceCurrency: 'USD',
|
||||||
|
},
|
||||||
|
aggregateRating: {
|
||||||
|
'@type': 'AggregateRating',
|
||||||
|
ratingValue: '4.8',
|
||||||
|
ratingCount: '1350',
|
||||||
|
},
|
||||||
|
description: 'Generate QR codes that open the user\'s SMS app with a pre-filled message.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowTo',
|
||||||
|
name: 'How to Create an SMS QR Code',
|
||||||
|
description: 'Create a QR code that prepares a text message.',
|
||||||
|
step: [
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 1,
|
||||||
|
name: 'Enter Phone Number',
|
||||||
|
text: 'Type the destination phone number.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 2,
|
||||||
|
name: 'Enter Message',
|
||||||
|
text: 'Type the message you want pre-filled (e.g., "Send me info!").',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 3,
|
||||||
|
name: 'Download',
|
||||||
|
text: 'Save the QR code and share it.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalTime: 'PT30S',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: [
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Does the text send automatically?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'No. The QR code opens the messaging app with the text typed out. The user must simply tap "Send". This is a security feature of all smartphones.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Is there a cost?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Generating the code is free. Standard SMS rates apply for the person sending the text message, depending on their carrier plan.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Can I change the message later?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'No. Static QR codes have the message embedded in them. To change the message, you need a new QR code.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'What uses are there for SMS QR codes?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'They are great for SMS marketing opt-ins ("Text JOIN to 12345"), customer support requests, or voting via text.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SMSQRCodePage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
/>
|
||||||
|
<ToolBreadcrumb toolName="SMS QR Code Generator" toolSlug="sms-qr-code" />
|
||||||
|
|
||||||
|
<div className="min-h-screen" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
|
||||||
|
{/* HERO SECTION */}
|
||||||
|
<section className="relative pt-20 pb-20 lg:pt-32 lg:pb-32 px-4 sm:px-6 lg:px-8 overflow-hidden" style={{ backgroundColor: '#ea580c' }}>
|
||||||
|
<div className="absolute inset-0 opacity-10">
|
||||||
|
<svg className="w-full h-full" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||||
|
<path d="M0 100 C 20 0 50 0 100 100 Z" fill="url(#grad1)" />
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" style={{ stopColor: 'white', stopOpacity: 1 }} />
|
||||||
|
<stop offset="100%" style={{ stopColor: 'white', stopOpacity: 0 }} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto grid lg:grid-cols-2 gap-12 items-center relative z-10">
|
||||||
|
<div className="text-center lg:text-left">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/10 text-white/90 text-sm font-medium mb-6 backdrop-blur-sm border border-white/10 hover:bg-white/20 transition-colors cursor-default">
|
||||||
|
<span className="flex h-2 w-2 relative">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-amber-400"></span>
|
||||||
|
</span>
|
||||||
|
Free Tool — No Signup Required
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl md:text-5xl lg:text-6xl font-extrabold text-white tracking-tight leading-tight mb-6">
|
||||||
|
Make Texting Easy with <br className="hidden lg:block" />
|
||||||
|
<span className="text-transparent bg-clip-text bg-gradient-to-r from-amber-200 to-orange-100">SMS QR Codes</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-lg md:text-xl text-orange-50 max-w-2xl mx-auto lg:mx-0 mb-8 leading-relaxed">
|
||||||
|
Let users send you a pre-written text with one scan. Ideal for opt-ins and support.
|
||||||
|
<strong className="text-white block sm:inline mt-2 sm:mt-0"> Universal compatibility.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center lg:justify-start gap-4 text-sm font-medium text-white/80">
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/5 backdrop-blur-sm">
|
||||||
|
<MessageSquare className="w-4 h-4 text-amber-300" />
|
||||||
|
Pre-fill Texts
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/5 backdrop-blur-sm">
|
||||||
|
<Zap className="w-4 h-4 text-amber-300" />
|
||||||
|
Instant Open
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/5 backdrop-blur-sm">
|
||||||
|
<Shield className="w-4 h-4 text-amber-300" />
|
||||||
|
Zero Friction
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visual Abstract */}
|
||||||
|
<div className="hidden lg:flex relative items-center justify-center min-h-[400px]">
|
||||||
|
<div className="absolute w-[500px] h-[500px] bg-orange-500/30 rounded-full blur-[100px] -top-20 -right-20 animate-pulse" />
|
||||||
|
|
||||||
|
<div className="relative w-80 h-96 bg-white/5 backdrop-blur-xl border border-white/20 rounded-3xl shadow-2xl flex flex-col items-center justify-center p-8 transform -rotate-3 hover:rotate-0 transition-all duration-700 group">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-white/10 to-transparent rounded-3xl" />
|
||||||
|
|
||||||
|
<div className="w-full bg-white rounded-xl shadow-lg p-3 mb-6 relative overflow-hidden flex gap-3 items-center">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-orange-100 flex items-center justify-center shrink-0">
|
||||||
|
<MessageSquare className="w-5 h-5 text-orange-600" />
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-100 rounded-2xl rounded-tl-none p-3 text-xs text-slate-600 w-full">
|
||||||
|
Hi, I want to join the club!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-48 h-48 bg-white rounded-xl p-2 shadow-inner relative overflow-hidden flex items-center justify-center">
|
||||||
|
<QRCodeSVG value="https://www.qrmaster.net" size={170} fgColor="#0f172a" level="Q" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Badge */}
|
||||||
|
<div className="absolute -bottom-6 -left-6 bg-white py-3 px-5 rounded-xl shadow-xl flex items-center gap-3 transform transition-transform hover:scale-105 duration-300">
|
||||||
|
<div className="bg-orange-100 p-2 rounded-full">
|
||||||
|
<Send className="w-5 h-5 text-orange-600" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-[10px] text-slate-400 font-bold uppercase tracking-wider">SMS</div>
|
||||||
|
<div className="text-sm font-bold text-slate-900">Sent!</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* GENERATOR SECTION */}
|
||||||
|
<section className="py-12 px-4 sm:px-6 lg:px-8 -mt-8">
|
||||||
|
<SMSGenerator />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* HOW IT WORKS */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8 bg-white">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-12">
|
||||||
|
How SMS QR Codes Work
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 gap-8">
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-orange-50 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<MessageSquare className="w-7 h-7 text-orange-600" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">1. Compose</h3>
|
||||||
|
<p className="text-slate-600 text-sm">
|
||||||
|
Enter the number and the message you want your customers to send.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-orange-50 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Smartphone className="w-7 h-7 text-orange-600" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">2. Scan</h3>
|
||||||
|
<p className="text-slate-600 text-sm">
|
||||||
|
User scans the code. The messages app opens automatically.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-orange-50 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Send className="w-7 h-7 text-orange-600" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">3. Send</h3>
|
||||||
|
<p className="text-slate-600 text-sm">
|
||||||
|
User hits "Send" to trigger the text. Perfect for quick sign-ups or votes.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* FAQ SECTION */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-4">
|
||||||
|
Frequently Asked Questions
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-600 text-center mb-10">
|
||||||
|
Common questions about SMS QR codes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FaqItem
|
||||||
|
question="Does the user need an internet connection?"
|
||||||
|
answer="No. The QR code contains all the info offline. However, the user needs a cellular signal to actually send the SMS message."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Is it free for the user to send?"
|
||||||
|
answer="It depends on their mobile plan. Standard SMS rates apply, though most modern plans include unlimited texting."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Can I use shortcodes?"
|
||||||
|
answer="Yes. You can enter a shortcode (e.g. 55555) in the phone number field instead of a regular number."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Is my phone number visible?"
|
||||||
|
answer="Yes. Since the user is sending a text to you, they will see your number (or shortcode) in their messaging app."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FaqItem({ question, answer }: { question: string; answer: string }) {
|
||||||
|
return (
|
||||||
|
<details className="group bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||||
|
<summary className="flex items-center justify-between p-5 cursor-pointer list-none text-slate-900 font-semibold hover:bg-slate-50 transition-colors">
|
||||||
|
{question}
|
||||||
|
<span className="transition group-open:rotate-180 text-slate-400">
|
||||||
|
<svg fill="none" height="20" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width="20">
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div className="text-slate-600 px-5 pb-5 pt-0 leading-relaxed text-sm">
|
||||||
|
{answer}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
319
src/app/(marketing)/tools/teams-qr-code/TeamsGenerator.tsx
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Download,
|
||||||
|
Check,
|
||||||
|
Sparkles,
|
||||||
|
Video,
|
||||||
|
MessageCircle
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// Brand Colors - Microsoft Teams Purple
|
||||||
|
const BRAND = {
|
||||||
|
paleGrey: '#F3F2F1',
|
||||||
|
primary: '#6264A7',
|
||||||
|
primaryDark: '#464775',
|
||||||
|
};
|
||||||
|
|
||||||
|
// QR Color Options
|
||||||
|
const QR_COLORS = [
|
||||||
|
{ name: 'Teams Purple', value: '#6264A7' },
|
||||||
|
{ name: 'Teams Dark', value: '#464775' },
|
||||||
|
{ name: 'Classic Black', value: '#000000' },
|
||||||
|
{ name: 'Indigo', value: '#4F46E5' },
|
||||||
|
{ name: 'Violet', value: '#7C3AED' },
|
||||||
|
{ name: 'Emerald', value: '#10B981' },
|
||||||
|
{ name: 'Rose', value: '#F43F5E' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Frame Options
|
||||||
|
const FRAME_OPTIONS = [
|
||||||
|
{ id: 'none', label: 'No Frame' },
|
||||||
|
{ id: 'scanme', label: 'Scan Me' },
|
||||||
|
{ id: 'join', label: 'Join Meeting' },
|
||||||
|
{ id: 'teams', label: 'Teams' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Link Type Options
|
||||||
|
const LINK_TYPES = [
|
||||||
|
{ id: 'meeting', label: 'Meeting Link', icon: Video },
|
||||||
|
{ id: 'chat', label: 'Chat with User', icon: MessageCircle },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function TeamsGenerator() {
|
||||||
|
const [linkType, setLinkType] = useState('meeting');
|
||||||
|
const [meetingUrl, setMeetingUrl] = useState('');
|
||||||
|
const [userEmail, setUserEmail] = useState('');
|
||||||
|
const [qrColor, setQrColor] = useState(BRAND.primary);
|
||||||
|
const [frameType, setFrameType] = useState('none');
|
||||||
|
|
||||||
|
const qrRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Generate Teams link
|
||||||
|
const generateTeamsLink = () => {
|
||||||
|
if (linkType === 'meeting') {
|
||||||
|
// If user pastes full Teams meeting URL, use it directly
|
||||||
|
if (meetingUrl.trim().includes('teams.microsoft.com') || meetingUrl.trim().includes('teams.live.com')) {
|
||||||
|
return meetingUrl.trim();
|
||||||
|
}
|
||||||
|
// Otherwise return placeholder
|
||||||
|
return meetingUrl.trim() || 'https://teams.microsoft.com';
|
||||||
|
} else {
|
||||||
|
// Chat link with email
|
||||||
|
if (!userEmail.trim()) return 'https://teams.microsoft.com';
|
||||||
|
return `https://teams.microsoft.com/l/chat/0/0?users=${encodeURIComponent(userEmail.trim())}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async (format: 'png' | 'svg') => {
|
||||||
|
if (!qrRef.current) return;
|
||||||
|
try {
|
||||||
|
if (format === 'png') {
|
||||||
|
const { toPng } = await import('html-to-image');
|
||||||
|
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3 });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = `teams-qr-code.png`;
|
||||||
|
link.href = dataUrl;
|
||||||
|
link.click();
|
||||||
|
} else {
|
||||||
|
const svgData = qrRef.current.querySelector('svg')?.outerHTML;
|
||||||
|
if (svgData) {
|
||||||
|
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `teams-qr-code.svg`;
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Download failed', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFrameLabel = () => {
|
||||||
|
const frame = FRAME_OPTIONS.find(f => f.id === frameType);
|
||||||
|
return frame?.id !== 'none' ? frame?.label : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-5xl mx-auto px-4 md:px-6">
|
||||||
|
|
||||||
|
{/* Main Generator Card */}
|
||||||
|
<div className="bg-white rounded-3xl shadow-2xl shadow-slate-900/10 overflow-hidden border border-slate-100">
|
||||||
|
<div className="grid lg:grid-cols-2">
|
||||||
|
|
||||||
|
{/* LEFT: Input Section */}
|
||||||
|
<div className="p-8 lg:p-10 space-y-8 border-r border-slate-100">
|
||||||
|
|
||||||
|
{/* Teams Details */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Users className="w-5 h-5 text-[#6264A7]" />
|
||||||
|
Microsoft Teams
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Link Type Toggle */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">What do you want to share?</label>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{LINK_TYPES.map((type) => (
|
||||||
|
<button
|
||||||
|
key={type.id}
|
||||||
|
onClick={() => setLinkType(type.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center gap-2 py-3 px-4 rounded-xl font-medium transition-all border",
|
||||||
|
linkType === type.id
|
||||||
|
? "bg-[#6264A7] text-white border-[#6264A7]"
|
||||||
|
: "bg-slate-50 text-slate-600 border-slate-200 hover:border-slate-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<type.icon className="w-4 h-4" />
|
||||||
|
{type.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{linkType === 'meeting' ? (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Teams Meeting URL</label>
|
||||||
|
<Input
|
||||||
|
placeholder="Paste your Teams meeting link here"
|
||||||
|
value={meetingUrl}
|
||||||
|
onChange={(e) => setMeetingUrl(e.target.value)}
|
||||||
|
className="h-12 text-base rounded-xl border-slate-200 focus:border-[#6264A7] focus:ring-[#6264A7]"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500 mt-2">
|
||||||
|
Copy the meeting link from your Teams calendar invite.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">User Email Address</label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder="e.g. colleague@company.com"
|
||||||
|
value={userEmail}
|
||||||
|
onChange={(e) => setUserEmail(e.target.value)}
|
||||||
|
className="h-12 text-base rounded-xl border-slate-200 focus:border-[#6264A7] focus:ring-[#6264A7]"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500 mt-2">
|
||||||
|
The person's work email to start a Teams chat.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-100"></div>
|
||||||
|
|
||||||
|
{/* Design Options */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Sparkles className="w-5 h-5 text-[#6264A7]" />
|
||||||
|
Design Options
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Color Picker */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">QR Code Color</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{QR_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.name}
|
||||||
|
onClick={() => setQrColor(c.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-9 h-9 rounded-full border-2 flex items-center justify-center transition-all hover:scale-110",
|
||||||
|
qrColor === c.value ? "border-slate-900 ring-2 ring-offset-2 ring-slate-200" : "border-white shadow-md"
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: c.value }}
|
||||||
|
aria-label={`Select ${c.name}`}
|
||||||
|
title={c.name}
|
||||||
|
>
|
||||||
|
{qrColor === c.value && <Check className="w-4 h-4 text-white" strokeWidth={3} />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Frame Selector */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">Frame Label</label>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{FRAME_OPTIONS.map((frame) => (
|
||||||
|
<button
|
||||||
|
key={frame.id}
|
||||||
|
onClick={() => setFrameType(frame.id)}
|
||||||
|
className={cn(
|
||||||
|
"py-2.5 px-2 rounded-lg text-xs font-medium transition-all border",
|
||||||
|
frameType === frame.id
|
||||||
|
? "bg-[#6264A7] text-white border-[#6264A7]"
|
||||||
|
: "bg-slate-50 text-slate-600 border-slate-200 hover:border-slate-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{frame.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT: Preview Section */}
|
||||||
|
<div className="p-8 lg:p-10 flex flex-col items-center justify-center" style={{ backgroundColor: BRAND.paleGrey }}>
|
||||||
|
|
||||||
|
{/* QR Card with Frame */}
|
||||||
|
<div
|
||||||
|
ref={qrRef}
|
||||||
|
className="bg-white rounded-3xl shadow-xl p-8 flex flex-col items-center"
|
||||||
|
style={{ minWidth: '320px' }}
|
||||||
|
>
|
||||||
|
{/* Frame Label */}
|
||||||
|
{getFrameLabel() && (
|
||||||
|
<div
|
||||||
|
className="mb-5 px-8 py-2.5 rounded-full text-white font-bold text-sm tracking-widest uppercase shadow-md"
|
||||||
|
style={{ backgroundColor: qrColor }}
|
||||||
|
>
|
||||||
|
{getFrameLabel()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* QR Code */}
|
||||||
|
<div className="bg-white">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={generateTeamsLink()}
|
||||||
|
size={240}
|
||||||
|
level="M"
|
||||||
|
includeMargin={false}
|
||||||
|
fgColor={qrColor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Teams Info */}
|
||||||
|
<div className="mt-6 text-center max-w-[260px]">
|
||||||
|
<h3 className="font-bold text-slate-900 text-lg flex items-center justify-center gap-2">
|
||||||
|
{linkType === 'meeting' ? (
|
||||||
|
<Video className="w-4 h-4 text-[#6264A7] shrink-0" />
|
||||||
|
) : (
|
||||||
|
<MessageCircle className="w-4 h-4 text-[#6264A7] shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="truncate">
|
||||||
|
{linkType === 'meeting' ? 'Teams Meeting' : (userEmail || 'Teams Chat')}
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-slate-500 mt-1">
|
||||||
|
{linkType === 'meeting' ? 'Join Meeting' : 'Start Chat'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Download Buttons */}
|
||||||
|
<div className="flex items-center gap-3 mt-8">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('png')}
|
||||||
|
className="bg-[#6264A7] hover:bg-[#464775] text-white shadow-lg"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Download PNG
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('svg')}
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-300 hover:bg-white"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
SVG
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500 mt-4 text-center">
|
||||||
|
Works with Microsoft Teams desktop and mobile apps.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upsell Banner */}
|
||||||
|
<div className="mt-8 bg-gradient-to-r from-[#6264A7] to-[#464775] rounded-2xl p-6 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="text-white text-center sm:text-left">
|
||||||
|
<h3 className="font-bold text-lg">Need to update meeting links?</h3>
|
||||||
|
<p className="text-white/80 text-sm mt-1">Dynamic QR Codes let you change the destination without reprinting.</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/signup">
|
||||||
|
<Button className="bg-white text-[#6264A7] hover:bg-slate-100 shrink-0 shadow-lg">
|
||||||
|
Create Dynamic QR
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
331
src/app/(marketing)/tools/teams-qr-code/page.tsx
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
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';
|
||||||
|
|
||||||
|
// SEO Optimized Metadata
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Free Microsoft Teams QR Code Generator | Join Meetings Instantly | QR Master',
|
||||||
|
description: 'Create a QR code for your Microsoft Teams meeting. Attendees scan to join instantly. Perfect for conference rooms, hybrid meetings, and event displays.',
|
||||||
|
keywords: ['teams qr code', 'microsoft teams meeting qr', 'join teams qr code', 'meeting room qr', 'teams invitation qr', 'hybrid meeting qr code'],
|
||||||
|
alternates: {
|
||||||
|
canonical: 'https://qrmaster.io/tools/teams-qr-code',
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: 'Free Microsoft Teams QR Code Generator | QR Master',
|
||||||
|
description: 'Generate QR codes for Teams meetings. One scan to join instantly.',
|
||||||
|
type: 'website',
|
||||||
|
url: 'https://qrmaster.io/tools/teams-qr-code',
|
||||||
|
images: [{ url: '/og-teams-generator.png', width: 1200, height: 630 }],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: 'Free Microsoft Teams QR Code Generator',
|
||||||
|
description: 'Create Teams meeting QR codes. Instant and free.',
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// JSON-LD Structured Data
|
||||||
|
const jsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@graph': [
|
||||||
|
{
|
||||||
|
'@type': 'SoftwareApplication',
|
||||||
|
name: 'Microsoft Teams QR Code Generator',
|
||||||
|
applicationCategory: 'BusinessApplication',
|
||||||
|
operatingSystem: 'Web Browser',
|
||||||
|
offers: {
|
||||||
|
'@type': 'Offer',
|
||||||
|
price: '0',
|
||||||
|
priceCurrency: 'USD',
|
||||||
|
},
|
||||||
|
aggregateRating: {
|
||||||
|
'@type': 'AggregateRating',
|
||||||
|
ratingValue: '4.9',
|
||||||
|
ratingCount: '890',
|
||||||
|
},
|
||||||
|
description: 'Generate QR codes that let people join your Microsoft Teams meeting with one scan.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowTo',
|
||||||
|
name: 'How to Create a Microsoft Teams QR Code',
|
||||||
|
description: 'Create a QR code for joining Teams meetings.',
|
||||||
|
step: [
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 1,
|
||||||
|
name: 'Copy Meeting Link',
|
||||||
|
text: 'Copy the Teams meeting URL from your calendar invitation.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 2,
|
||||||
|
name: 'Paste Link',
|
||||||
|
text: 'Paste the meeting link into the generator.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 3,
|
||||||
|
name: 'Customize',
|
||||||
|
text: 'Choose Teams colors and add a frame label.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 4,
|
||||||
|
name: 'Download',
|
||||||
|
text: 'Download your QR code and display it in your meeting room.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalTime: 'PT30S',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: [
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'What happens when someone scans the QR code?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Microsoft Teams opens and the user is prompted to join the meeting. Works on desktop, mobile, and web.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Does it work for recurring meetings?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes! If your recurring meeting uses the same meeting link, the QR code will work for all sessions.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Can guests without Teams accounts join?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes. Guests can join Teams meetings via the web browser without needing a Microsoft account.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Is this for personal or business Teams?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Both! Works with Microsoft Teams for work, school, and personal accounts.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TeamsQRCodePage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
/>
|
||||||
|
<ToolBreadcrumb toolName="Teams QR Code Generator" toolSlug="teams-qr-code" />
|
||||||
|
|
||||||
|
<div className="min-h-screen" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
|
||||||
|
{/* HERO SECTION */}
|
||||||
|
<section className="relative pt-20 pb-20 lg:pt-32 lg:pb-32 px-4 sm:px-6 lg:px-8 overflow-hidden" style={{ backgroundColor: '#6264A7' }}>
|
||||||
|
<div className="absolute inset-0 opacity-10">
|
||||||
|
<svg className="w-full h-full" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||||
|
<path d="M0 100 C 20 0 50 0 100 100 Z" fill="url(#grad1)" />
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" style={{ stopColor: 'white', stopOpacity: 1 }} />
|
||||||
|
<stop offset="100%" style={{ stopColor: 'white', stopOpacity: 0 }} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto grid lg:grid-cols-2 gap-12 items-center relative z-10">
|
||||||
|
<div className="text-center lg:text-left">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/10 text-white/90 text-sm font-medium mb-6 backdrop-blur-sm border border-white/10 hover:bg-white/20 transition-colors cursor-default">
|
||||||
|
<span className="flex h-2 w-2 relative">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-white opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-white"></span>
|
||||||
|
</span>
|
||||||
|
Free Tool — No Signup Required
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl md:text-5xl lg:text-6xl font-extrabold text-white tracking-tight leading-tight mb-6">
|
||||||
|
Join Meetings with <br className="hidden lg:block" />
|
||||||
|
<span className="text-transparent bg-clip-text bg-gradient-to-r from-violet-200 to-white">Teams QR Codes</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-lg md:text-xl text-violet-100 max-w-2xl mx-auto lg:mx-0 mb-8 leading-relaxed">
|
||||||
|
Create QR codes for Microsoft Teams meetings. Attendees scan to join instantly.
|
||||||
|
<strong className="text-white block sm:inline mt-2 sm:mt-0"> Perfect for hybrid workplaces.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center lg:justify-start gap-4 text-sm font-medium text-white/80">
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/5 backdrop-blur-sm">
|
||||||
|
<Video className="w-4 h-4 text-white" />
|
||||||
|
Meeting Links
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/5 backdrop-blur-sm">
|
||||||
|
<MessageCircle className="w-4 h-4 text-white" />
|
||||||
|
Chat Links
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/5 backdrop-blur-sm">
|
||||||
|
<Zap className="w-4 h-4 text-amber-300" />
|
||||||
|
Instant Join
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visual Abstract */}
|
||||||
|
<div className="hidden lg:flex relative items-center justify-center min-h-[400px]">
|
||||||
|
<div className="absolute w-[500px] h-[500px] bg-violet-400/30 rounded-full blur-[100px] -top-20 -right-20 animate-pulse" />
|
||||||
|
|
||||||
|
<div className="relative w-80 h-96 bg-white/5 backdrop-blur-xl border border-white/20 rounded-3xl shadow-2xl flex flex-col items-center justify-center p-8 transform rotate-3 hover:rotate-0 transition-all duration-700 group">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-white/10 to-transparent rounded-3xl" />
|
||||||
|
|
||||||
|
{/* Meeting Card Mock */}
|
||||||
|
<div className="w-full bg-white rounded-xl shadow-lg p-4 mb-6 relative overflow-hidden">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="w-10 h-10 bg-[#6264A7] rounded-lg flex items-center justify-center">
|
||||||
|
<Users className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-bold text-slate-900 text-sm">Team Standup</div>
|
||||||
|
<div className="text-xs text-slate-500">Daily at 9:00 AM</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="bg-green-100 text-green-700 text-xs px-2 py-1 rounded-full">Live Now</div>
|
||||||
|
<div className="bg-slate-100 text-slate-600 text-xs px-2 py-1 rounded-full">8 attending</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-48 h-48 bg-white rounded-xl p-2 shadow-inner relative overflow-hidden flex items-center justify-center">
|
||||||
|
<QRCodeSVG value="https://www.qrmaster.net" size={170} fgColor="#6264A7" level="Q" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Badge */}
|
||||||
|
<div className="absolute -bottom-6 -left-6 bg-white py-3 px-5 rounded-xl shadow-xl flex items-center gap-3 transform transition-transform hover:scale-105 duration-300">
|
||||||
|
<div className="bg-violet-100 p-2 rounded-full">
|
||||||
|
<Video className="w-5 h-5 text-[#6264A7]" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-[10px] text-slate-400 font-bold uppercase tracking-wider">Status</div>
|
||||||
|
<div className="text-sm font-bold text-slate-900">Ready to Join</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* GENERATOR SECTION */}
|
||||||
|
<section className="py-12 px-4 sm:px-6 lg:px-8 -mt-8">
|
||||||
|
<TeamsGenerator />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* HOW IT WORKS */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8 bg-white">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-12">
|
||||||
|
How Teams QR Codes Work
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-4 gap-8">
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#6264A7]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Video className="w-6 h-6 text-[#6264A7]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">1. Get Link</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">Copy your Teams meeting URL.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#6264A7]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Shield className="w-6 h-6 text-[#6264A7]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">2. Paste</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">Paste into the generator.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#6264A7]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Download className="w-6 h-6 text-[#6264A7]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">3. Download</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">Save your QR code.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#6264A7]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Share2 className="w-6 h-6 text-[#6264A7]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">4. Display</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">Put in meeting rooms or invites.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* FAQ SECTION */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-4">
|
||||||
|
Frequently Asked Questions
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-600 text-center mb-10">
|
||||||
|
Common questions about Teams QR codes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FaqItem
|
||||||
|
question="What happens when someone scans the QR code?"
|
||||||
|
answer="Microsoft Teams opens and the user is prompted to join the meeting. Works on desktop, mobile, and web browser."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Does it work for recurring meetings?"
|
||||||
|
answer="Yes! If your recurring meeting uses the same meeting link, the QR code will work for all sessions."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Can guests without Teams accounts join?"
|
||||||
|
answer="Yes. Guests can join Teams meetings via the web browser without needing a Microsoft account."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="What about meeting rooms with digital displays?"
|
||||||
|
answer="Perfect for that! Display the QR code on your room's screen so attendees can scan to join from their devices."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FaqItem({ question, answer }: { question: string; answer: string }) {
|
||||||
|
return (
|
||||||
|
<details className="group bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||||
|
<summary className="flex items-center justify-between p-5 cursor-pointer list-none text-slate-900 font-semibold hover:bg-slate-50 transition-colors">
|
||||||
|
{question}
|
||||||
|
<span className="transition group-open:rotate-180 text-slate-400">
|
||||||
|
<svg fill="none" height="20" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width="20">
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div className="text-slate-600 px-5 pb-5 pt-0 leading-relaxed text-sm">
|
||||||
|
{answer}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
246
src/app/(marketing)/tools/text-qr-code/TextGenerator.tsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import {
|
||||||
|
Type,
|
||||||
|
Download,
|
||||||
|
Check,
|
||||||
|
Sparkles,
|
||||||
|
Copy,
|
||||||
|
FileText
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// Brand Colors
|
||||||
|
const BRAND = {
|
||||||
|
paleGrey: '#EBEBDF',
|
||||||
|
richBlue: '#1A1265',
|
||||||
|
richBlueLight: '#2A2275',
|
||||||
|
};
|
||||||
|
|
||||||
|
// QR Color Options
|
||||||
|
const QR_COLORS = [
|
||||||
|
{ name: 'Classic Black', value: '#000000' },
|
||||||
|
{ name: 'Deep Blue', value: '#1E40AF' },
|
||||||
|
{ name: 'Violet', value: '#7C3AED' },
|
||||||
|
{ name: 'Teal', value: '#0D9488' },
|
||||||
|
{ name: 'Coral', value: '#F43F5E' },
|
||||||
|
{ name: 'Amber', value: '#D97706' },
|
||||||
|
{ name: 'Emerald', value: '#10B981' },
|
||||||
|
{ name: 'Rose', value: '#F43F5E' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Frame Options
|
||||||
|
const FRAME_OPTIONS = [
|
||||||
|
{ id: 'none', label: 'No Frame' },
|
||||||
|
{ id: 'scanme', label: 'Scan Me' },
|
||||||
|
{ id: 'text', label: 'Text' },
|
||||||
|
{ id: 'message', label: 'Message' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function TextGenerator() {
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const [qrColor, setQrColor] = useState(BRAND.richBlue);
|
||||||
|
const [frameType, setFrameType] = useState('none');
|
||||||
|
|
||||||
|
const qrRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const handleDownload = async (format: 'png' | 'svg') => {
|
||||||
|
if (!qrRef.current) return;
|
||||||
|
try {
|
||||||
|
if (format === 'png') {
|
||||||
|
const { toPng } = await import('html-to-image');
|
||||||
|
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3 });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = `text-qr-code.png`;
|
||||||
|
link.href = dataUrl;
|
||||||
|
link.click();
|
||||||
|
} else {
|
||||||
|
const svgData = qrRef.current.querySelector('svg')?.outerHTML;
|
||||||
|
if (svgData) {
|
||||||
|
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `text-qr-code.svg`;
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Download failed', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFrameLabel = () => {
|
||||||
|
const frame = FRAME_OPTIONS.find(f => f.id === frameType);
|
||||||
|
return frame?.id !== 'none' ? frame?.label : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-5xl mx-auto px-4 md:px-6">
|
||||||
|
|
||||||
|
{/* Main Generator Card */}
|
||||||
|
<div className="bg-white rounded-3xl shadow-2xl shadow-slate-900/10 overflow-hidden border border-slate-100">
|
||||||
|
<div className="grid lg:grid-cols-2">
|
||||||
|
|
||||||
|
{/* LEFT: Input Section */}
|
||||||
|
<div className="p-8 lg:p-10 space-y-8 border-r border-slate-100">
|
||||||
|
|
||||||
|
{/* Text Input */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Type className="w-5 h-5 text-[#1A1265]" />
|
||||||
|
Enter Content
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<textarea
|
||||||
|
className="w-full h-40 p-4 pb-8 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-[#1A1265] resize-none text-slate-800 placeholder:text-slate-400"
|
||||||
|
placeholder="Type your text here (up to 300 characters)..."
|
||||||
|
maxLength={300}
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-3 right-3 text-xs text-slate-400 font-medium">
|
||||||
|
{text.length}/300
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-100"></div>
|
||||||
|
|
||||||
|
{/* Design Options */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Sparkles className="w-5 h-5 text-[#1A1265]" />
|
||||||
|
Design Options
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Color Picker */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">QR Code Color</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{QR_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.name}
|
||||||
|
onClick={() => setQrColor(c.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-9 h-9 rounded-full border-2 flex items-center justify-center transition-all hover:scale-110",
|
||||||
|
qrColor === c.value ? "border-slate-900 ring-2 ring-offset-2 ring-slate-200" : "border-white shadow-md"
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: c.value }}
|
||||||
|
aria-label={`Select ${c.name}`}
|
||||||
|
title={c.name}
|
||||||
|
>
|
||||||
|
{qrColor === c.value && <Check className="w-4 h-4 text-white" strokeWidth={3} />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Frame Selector */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">Frame Label</label>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{FRAME_OPTIONS.map((frame) => (
|
||||||
|
<button
|
||||||
|
key={frame.id}
|
||||||
|
onClick={() => setFrameType(frame.id)}
|
||||||
|
className={cn(
|
||||||
|
"py-2.5 px-3 rounded-lg text-sm font-medium transition-all border",
|
||||||
|
frameType === frame.id
|
||||||
|
? "bg-[#1A1265] text-white border-[#1A1265]"
|
||||||
|
: "bg-slate-50 text-slate-600 border-slate-200 hover:border-slate-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{frame.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT: Preview Section */}
|
||||||
|
<div className="p-8 lg:p-10 flex flex-col items-center justify-center" style={{ backgroundColor: BRAND.paleGrey }}>
|
||||||
|
|
||||||
|
{/* QR Card with Frame */}
|
||||||
|
<div
|
||||||
|
ref={qrRef}
|
||||||
|
className="bg-white rounded-3xl shadow-xl p-8 flex flex-col items-center"
|
||||||
|
style={{ minWidth: '320px' }}
|
||||||
|
>
|
||||||
|
{/* Frame Label */}
|
||||||
|
{getFrameLabel() && (
|
||||||
|
<div
|
||||||
|
className="mb-5 px-8 py-2.5 rounded-full text-white font-bold text-sm tracking-widest uppercase shadow-md"
|
||||||
|
style={{ backgroundColor: qrColor }}
|
||||||
|
>
|
||||||
|
{getFrameLabel()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* QR Code */}
|
||||||
|
<div className="bg-white">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={text || "Your Text Here"}
|
||||||
|
size={240}
|
||||||
|
level="M"
|
||||||
|
includeMargin={false}
|
||||||
|
fgColor={qrColor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text Info Preview */}
|
||||||
|
<div className="mt-6 text-center max-w-[260px]">
|
||||||
|
<h3 className="font-bold text-slate-900 text-lg flex items-center justify-center gap-2">
|
||||||
|
<FileText className="w-4 h-4 text-slate-400" />
|
||||||
|
Plain Text
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Download Buttons */}
|
||||||
|
<div className="flex items-center gap-3 mt-8">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('png')}
|
||||||
|
className="bg-[#1A1265] hover:bg-[#2A2275] text-white shadow-lg"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Download PNG
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('svg')}
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-300 hover:bg-white"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
SVG
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500 mt-4 text-center">
|
||||||
|
Your text stays on your device. Nothing is sent to servers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upsell Banner */}
|
||||||
|
<div className="mt-8 bg-gradient-to-r from-[#1A1265] to-[#2A2275] rounded-2xl p-6 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="text-white text-center sm:text-left">
|
||||||
|
<h3 className="font-bold text-lg">Want to track who scans your QR code?</h3>
|
||||||
|
<p className="text-white/80 text-sm mt-1">Dynamic QR codes give you scan analytics and let you edit content anytime.</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/signup">
|
||||||
|
<Button className="bg-white text-[#1A1265] hover:bg-slate-100 shrink-0 shadow-lg">
|
||||||
|
Try Dynamic Codes
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
352
src/app/(marketing)/tools/text-qr-code/page.tsx
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
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';
|
||||||
|
|
||||||
|
// SEO Optimized Metadata
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Free Text QR Code Generator | Instant & No Limits | QR Master',
|
||||||
|
description: 'Create a QR code for any plain text message in seconds. No limit on scans. 100% private & client-side. Download valid SVG/PNG files instantly.',
|
||||||
|
keywords: ['text qr code', 'qr code text generator', 'message to qr code', 'offline qr code', 'text qr generator', 'free qr code'],
|
||||||
|
alternates: {
|
||||||
|
canonical: 'https://qrmaster.io/tools/text-qr-code',
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: 'Free Text QR Code Generator | QR Master',
|
||||||
|
description: 'Turn any text into a QR code instantly. No signup required. 100% text privacy.',
|
||||||
|
type: 'website',
|
||||||
|
url: 'https://qrmaster.io/tools/text-qr-code',
|
||||||
|
images: [{ url: '/og-text-generator.png', width: 1200, height: 630 }],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: 'Free Text QR Code Generator',
|
||||||
|
description: 'Create QR codes for text. Instant, free, and private.',
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// JSON-LD Structured Data
|
||||||
|
const jsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@graph': [
|
||||||
|
{
|
||||||
|
'@type': 'SoftwareApplication',
|
||||||
|
name: 'Text QR Code Generator',
|
||||||
|
applicationCategory: 'UtilitiesApplication',
|
||||||
|
operatingSystem: 'Web Browser',
|
||||||
|
offers: {
|
||||||
|
'@type': 'Offer',
|
||||||
|
price: '0',
|
||||||
|
priceCurrency: 'USD',
|
||||||
|
},
|
||||||
|
aggregateRating: {
|
||||||
|
'@type': 'AggregateRating',
|
||||||
|
ratingValue: '4.8',
|
||||||
|
ratingCount: '1240',
|
||||||
|
},
|
||||||
|
description: 'Generate QR codes for plain text messages. Works offline once generated. No data collection.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowTo',
|
||||||
|
name: 'How to Create a Text QR Code',
|
||||||
|
description: 'Turn any plain text into a scannable QR code.',
|
||||||
|
step: [
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 1,
|
||||||
|
name: 'Enter Content',
|
||||||
|
text: 'Type or paste your text message into the input field.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 2,
|
||||||
|
name: 'Customize Design',
|
||||||
|
text: 'Choose a color and add a frame label like "Scan Me" or "Read".',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 3,
|
||||||
|
name: 'Download QR Code',
|
||||||
|
text: 'Download your high-quality QR code in PNG or SVG format.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 4,
|
||||||
|
name: 'Test',
|
||||||
|
text: 'Scan the code to ensure the text appears correctly.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 5,
|
||||||
|
name: 'Share',
|
||||||
|
text: 'Print it or display it where you want people to read the message.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalTime: 'PT30S',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: [
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Is there a character limit?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes, we recommend keeping it under 300 characters for optimal scanning. While QR codes can hold more, more text makes the code denser and harder to scan.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Do I need internet to scan a Text QR code?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'No. Text QR codes work completely offline. The text content is embedded directly into the QR code pattern.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Is my text private?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes. This generator runs 100% in your browser. We do not store or see the text you type.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'How do I scan a text QR code?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Open your phone camera or a QR scanner app and point it at the code. The text will appear on your screen automatically.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Can I edit the text later?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'No, this is a static QR code. The text is permanent. If you need to change it, you must create a new QR code.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TextQRCodePage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
/>
|
||||||
|
<ToolBreadcrumb toolName="Text QR Code Generator" toolSlug="text-qr-code" />
|
||||||
|
|
||||||
|
<div className="min-h-screen" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
|
||||||
|
{/* HERO SECTION */}
|
||||||
|
<section className="relative pt-20 pb-20 lg:pt-32 lg:pb-32 px-4 sm:px-6 lg:px-8 overflow-hidden" style={{ backgroundColor: '#1A1265' }}>
|
||||||
|
<div className="absolute inset-0 opacity-10">
|
||||||
|
<svg className="w-full h-full" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||||
|
<path d="M0 100 C 20 0 50 0 100 100 Z" fill="url(#grad1)" />
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" style={{ stopColor: 'white', stopOpacity: 1 }} />
|
||||||
|
<stop offset="100%" style={{ stopColor: 'white', stopOpacity: 0 }} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto grid lg:grid-cols-2 gap-12 items-center relative z-10">
|
||||||
|
<div className="text-center lg:text-left">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/10 text-white/90 text-sm font-medium mb-6 backdrop-blur-sm border border-white/10 hover:bg-white/20 transition-colors cursor-default">
|
||||||
|
<span className="flex h-2 w-2 relative">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-400"></span>
|
||||||
|
</span>
|
||||||
|
Free Tool — No Signup Required
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl md:text-5xl lg:text-6xl font-extrabold text-white tracking-tight leading-tight mb-6">
|
||||||
|
Turn Text content into <br className="hidden lg:block" />
|
||||||
|
<span className="text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-cyan-400">Scannable QR Codes</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-lg md:text-xl text-indigo-100 max-w-2xl mx-auto lg:mx-0 mb-8 leading-relaxed">
|
||||||
|
Share notes, codes, keys, or messages instantly. Scan to read without internet.
|
||||||
|
<strong className="text-white block sm:inline mt-2 sm:mt-0"> 100% Private.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center lg:justify-start gap-4 text-sm font-medium text-white/80">
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/5 backdrop-blur-sm">
|
||||||
|
<Shield className="w-4 h-4 text-emerald-400" />
|
||||||
|
No Data Storage
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/5 backdrop-blur-sm">
|
||||||
|
<Zap className="w-4 h-4 text-amber-400" />
|
||||||
|
Instant Create
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/5 backdrop-blur-sm">
|
||||||
|
<Smartphone className="w-4 h-4 text-purple-400" />
|
||||||
|
Offline Readable
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visual Abstract */}
|
||||||
|
<div className="hidden lg:flex relative items-center justify-center min-h-[400px]">
|
||||||
|
<div className="absolute w-[500px] h-[500px] bg-indigo-500/30 rounded-full blur-[100px] -top-20 -right-20 animate-pulse" />
|
||||||
|
|
||||||
|
<div className="relative w-80 h-96 bg-white/5 backdrop-blur-xl border border-white/20 rounded-3xl shadow-2xl flex flex-col items-center justify-center p-8 transform rotate-6 hover:rotate-3 transition-all duration-700 group">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-white/10 to-transparent rounded-3xl" />
|
||||||
|
|
||||||
|
<div className="w-48 h-48 bg-white rounded-xl p-2 shadow-inner mb-6 relative overflow-hidden flex items-center justify-center">
|
||||||
|
<QRCodeSVG value="https://www.qrmaster.net" size={170} fgColor="#0f172a" level="Q" />
|
||||||
|
<div className="absolute top-1/2 left-0 w-full h-1 bg-emerald-500 shadow-[0_0_20px_rgba(16,185,129,1)] animate-pulse" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full bg-white/10 rounded-xl p-4 backdrop-blur-sm border border-white/10">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-indigo-500/20 flex items-center justify-center">
|
||||||
|
<FileText className="w-4 h-4 text-indigo-300" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="h-1.5 w-20 bg-white/30 rounded-full" />
|
||||||
|
<div className="h-1.5 w-12 bg-white/20 rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* GENERATOR SECTION */}
|
||||||
|
<section className="py-12 px-4 sm:px-6 lg:px-8 -mt-8">
|
||||||
|
<TextGenerator />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* HOW IT WORKS */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8 bg-white">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-12">
|
||||||
|
How Text QR Codes Work
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 lg:grid-cols-5 gap-8">
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-[#1A1265]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Type className="w-7 h-7 text-[#1A1265]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">1. Enter Text</h3>
|
||||||
|
<p className="text-slate-600 text-sm">
|
||||||
|
Type your message, code, or note. It is instantly encoded into the QR pattern.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#1A1265]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Zap className="w-6 h-6 text-[#1A1265]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">2. Customize</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Choose a color and add a call-to-action frame.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#1A1265]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Download className="w-6 h-6 text-[#1A1265]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">3. Download</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Get your ready-to-use QR code.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#1A1265]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Smartphone className="w-6 h-6 text-[#1A1265]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">4. Test</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Point camera to read text.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#1A1265]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Share2 className="w-6 h-6 text-[#1A1265]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">5. Share</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Pass information instantly.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* FAQ SECTION */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-4">
|
||||||
|
Frequently Asked Questions
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-600 text-center mb-10">
|
||||||
|
Common questions about Text QR codes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FaqItem
|
||||||
|
question="How do I scan a text QR code?"
|
||||||
|
answer="Open your phone camera or a QR scanner app and point it at the code. The text will appear on your screen automatically."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Is there a character limit?"
|
||||||
|
answer="We recommend keeping it under 300 characters for the best scanning experience. Theoretically, QR codes can hold up to 4,296 characters, but the code becomes very complex and harder to scan with standard phone cameras."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Do I need internet to scan a Text QR code?"
|
||||||
|
answer="No. Text QR codes are 'static' codes, meaning the data is encoded directly into the image pattern. You can scan and read them completely offline, making them perfect for remote locations or secure environments."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Is my text private?"
|
||||||
|
answer="Yes. We prioritize your privacy. The generation process happens entirely in your browser using JavaScript. Your text data is never sent to our servers or stored anywhere."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Can I change the text after printing?"
|
||||||
|
answer="No. Static QR codes are permanent. If you need to change the text later, you must generate a new QR code. For editable content, you would need a Dynamic QR Code (which we also offer)."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FaqItem({ question, answer }: { question: string; answer: string }) {
|
||||||
|
return (
|
||||||
|
<details className="group bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||||
|
<summary className="flex items-center justify-between p-5 cursor-pointer list-none text-slate-900 font-semibold hover:bg-slate-50 transition-colors">
|
||||||
|
{question}
|
||||||
|
<span className="transition group-open:rotate-180 text-slate-400">
|
||||||
|
<svg fill="none" height="20" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width="20">
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div className="text-slate-600 px-5 pb-5 pt-0 leading-relaxed text-sm">
|
||||||
|
{answer}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
253
src/app/(marketing)/tools/tiktok-qr-code/TikTokGenerator.tsx
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import {
|
||||||
|
Music,
|
||||||
|
Download,
|
||||||
|
Check,
|
||||||
|
Sparkles,
|
||||||
|
Video,
|
||||||
|
Share2
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// Brand Colors
|
||||||
|
const BRAND = {
|
||||||
|
paleGrey: '#EBEBDF',
|
||||||
|
richBlue: '#1A1265',
|
||||||
|
richBlueLight: '#2A2275',
|
||||||
|
};
|
||||||
|
|
||||||
|
// QR Color Options - TikTok Theme
|
||||||
|
const QR_COLORS = [
|
||||||
|
{ name: 'TikTok Black', value: '#000000' },
|
||||||
|
{ name: 'TikTok Pink', value: '#FE2C55' },
|
||||||
|
{ name: 'TikTok Cyan', value: '#25F4EE' },
|
||||||
|
{ name: 'Deep Blue', value: '#1A1265' },
|
||||||
|
{ name: 'Purple', value: '#7C3AED' },
|
||||||
|
{ name: 'Emerald', value: '#10B981' },
|
||||||
|
{ name: 'Rose', value: '#F43F5E' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Frame Options
|
||||||
|
const FRAME_OPTIONS = [
|
||||||
|
{ id: 'none', label: 'No Frame' },
|
||||||
|
{ id: 'scanme', label: 'Scan Me' },
|
||||||
|
{ id: 'follow', label: 'Follow' },
|
||||||
|
{ id: 'watch', label: 'Watch' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function TiktokGenerator() {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [qrColor, setQrColor] = useState('#000000');
|
||||||
|
const [frameType, setFrameType] = useState('none');
|
||||||
|
|
||||||
|
const qrRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// TikTok URL: https://www.tiktok.com/@username
|
||||||
|
const getUrl = () => {
|
||||||
|
const cleanUser = username.replace(/^@/, '').replace(/https?:\/\/(www\.)?tiktok\.com\/@?/, '').replace(/\/$/, '');
|
||||||
|
return cleanUser ? `https://www.tiktok.com/@${cleanUser}` : 'https://www.tiktok.com';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async (format: 'png' | 'svg') => {
|
||||||
|
if (!qrRef.current) return;
|
||||||
|
try {
|
||||||
|
if (format === 'png') {
|
||||||
|
const { toPng } = await import('html-to-image');
|
||||||
|
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3 });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = `tiktok-qr-code.png`;
|
||||||
|
link.href = dataUrl;
|
||||||
|
link.click();
|
||||||
|
} else {
|
||||||
|
const svgData = qrRef.current.querySelector('svg')?.outerHTML;
|
||||||
|
if (svgData) {
|
||||||
|
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `tiktok-qr-code.svg`;
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Download failed', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFrameLabel = () => {
|
||||||
|
const frame = FRAME_OPTIONS.find(f => f.id === frameType);
|
||||||
|
return frame?.id !== 'none' ? frame?.label : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-5xl mx-auto px-4 md:px-6">
|
||||||
|
|
||||||
|
{/* Main Generator Card */}
|
||||||
|
<div className="bg-white rounded-3xl shadow-2xl shadow-slate-900/10 overflow-hidden border border-slate-100">
|
||||||
|
<div className="grid lg:grid-cols-2">
|
||||||
|
|
||||||
|
{/* LEFT: Input Section */}
|
||||||
|
<div className="p-8 lg:p-10 space-y-8 border-r border-slate-100">
|
||||||
|
|
||||||
|
{/* TikTok Details */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Music className="w-5 h-5 text-black" />
|
||||||
|
TikTok Username
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Username</label>
|
||||||
|
<Input
|
||||||
|
placeholder="@username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
className="h-12 text-base rounded-xl border-slate-200 focus:border-black focus:ring-black"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500 mt-2">Enter your TikTok handle (e.g. @charlidamelio).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-100"></div>
|
||||||
|
|
||||||
|
{/* Design Options */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Sparkles className="w-5 h-5 text-black" />
|
||||||
|
Design Options
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Color Picker */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">QR Code Color</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{QR_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.name}
|
||||||
|
onClick={() => setQrColor(c.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-9 h-9 rounded-full border-2 flex items-center justify-center transition-all hover:scale-110",
|
||||||
|
qrColor === c.value ? "border-slate-900 ring-2 ring-offset-2 ring-slate-200" : "border-white shadow-md"
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: c.value }}
|
||||||
|
aria-label={`Select ${c.name}`}
|
||||||
|
title={c.name}
|
||||||
|
>
|
||||||
|
{qrColor === c.value && <Check className="w-4 h-4 text-white" strokeWidth={3} />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Frame Selector */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">Frame Label</label>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{FRAME_OPTIONS.map((frame) => (
|
||||||
|
<button
|
||||||
|
key={frame.id}
|
||||||
|
onClick={() => setFrameType(frame.id)}
|
||||||
|
className={cn(
|
||||||
|
"py-2.5 px-3 rounded-lg text-sm font-medium transition-all border",
|
||||||
|
frameType === frame.id
|
||||||
|
? "bg-black text-white border-black"
|
||||||
|
: "bg-slate-50 text-slate-600 border-slate-200 hover:border-slate-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{frame.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT: Preview Section */}
|
||||||
|
<div className="p-8 lg:p-10 flex flex-col items-center justify-center" style={{ backgroundColor: BRAND.paleGrey }}>
|
||||||
|
|
||||||
|
{/* QR Card with Frame */}
|
||||||
|
<div
|
||||||
|
ref={qrRef}
|
||||||
|
className="bg-white rounded-3xl shadow-xl p-8 flex flex-col items-center"
|
||||||
|
style={{ minWidth: '320px' }}
|
||||||
|
>
|
||||||
|
{/* Frame Label */}
|
||||||
|
{getFrameLabel() && (
|
||||||
|
<div
|
||||||
|
className="mb-5 px-8 py-2.5 rounded-full text-white font-bold text-sm tracking-widest uppercase shadow-md"
|
||||||
|
style={{ backgroundColor: qrColor }}
|
||||||
|
>
|
||||||
|
{getFrameLabel()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* QR Code */}
|
||||||
|
<div className="bg-white">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={getUrl()}
|
||||||
|
size={240}
|
||||||
|
level="M"
|
||||||
|
includeMargin={false}
|
||||||
|
fgColor={qrColor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Preview */}
|
||||||
|
<div className="mt-6 text-center max-w-[260px]">
|
||||||
|
<h3 className="font-bold text-slate-900 text-lg flex items-center justify-center gap-2 truncate">
|
||||||
|
<Music className="w-4 h-4 text-slate-400 shrink-0" />
|
||||||
|
<span className="truncate">{username || '@username'}</span>
|
||||||
|
</h3>
|
||||||
|
<div className="text-xs text-slate-500 mt-1">Opens in TikTok</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Download Buttons */}
|
||||||
|
<div className="flex items-center gap-3 mt-8">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('png')}
|
||||||
|
className="bg-black hover:bg-slate-800 text-white shadow-lg"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Download PNG
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('svg')}
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-300 hover:bg-white"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
SVG
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500 mt-4 text-center">
|
||||||
|
Scanning redirects directly to your TikTok profile.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upsell Banner */}
|
||||||
|
<div className="mt-8 bg-gradient-to-r from-[#000000] via-[#25F4EE] to-[#FE2C55] rounded-2xl p-6 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="text-white text-center sm:text-left">
|
||||||
|
<h3 className="font-bold text-lg">Cross-promote on Socials?</h3>
|
||||||
|
<p className="text-white/80 text-sm mt-1">
|
||||||
|
Use one Dynamic Link to share your TikTok, Insta, and YouTube all at once.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/signup">
|
||||||
|
<Button className="bg-white text-black hover:bg-slate-100 shrink-0 shadow-lg">
|
||||||
|
Create All-in-One Link
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
362
src/app/(marketing)/tools/tiktok-qr-code/page.tsx
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
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';
|
||||||
|
|
||||||
|
// SEO Optimized Metadata
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Free TikTok QR Code Generator | Get Followers | QR Master',
|
||||||
|
description: 'Create a QR code for your TikTok profile. Scanners are redirected to the TikTok app instantly to follow you. Customize with colors and frames.',
|
||||||
|
keywords: ['tiktok qr code', 'tik tok qr generator', 'tiktok follow qr', 'social media qr code', 'tiktok profile qr'],
|
||||||
|
alternates: {
|
||||||
|
canonical: 'https://qrmaster.io/tools/tiktok-qr-code',
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: 'Free TikTok QR Code Generator | QR Master',
|
||||||
|
description: 'Generate QR codes to grow your TikTok following. Instant app redirect.',
|
||||||
|
type: 'website',
|
||||||
|
url: 'https://qrmaster.io/tools/tiktok-qr-code',
|
||||||
|
images: [{ url: '/og-tiktok-generator.png', width: 1200, height: 630 }],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: 'Free TikTok QR Code Generator',
|
||||||
|
description: 'Create QR codes for TikTok. Get more followers.',
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// JSON-LD Structured Data
|
||||||
|
const jsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@graph': [
|
||||||
|
{
|
||||||
|
'@type': 'SoftwareApplication',
|
||||||
|
name: 'TikTok QR Code Generator',
|
||||||
|
applicationCategory: 'UtilitiesApplication',
|
||||||
|
operatingSystem: 'Web Browser',
|
||||||
|
offers: {
|
||||||
|
'@type': 'Offer',
|
||||||
|
price: '0',
|
||||||
|
priceCurrency: 'USD',
|
||||||
|
},
|
||||||
|
aggregateRating: {
|
||||||
|
'@type': 'AggregateRating',
|
||||||
|
ratingValue: '4.9',
|
||||||
|
ratingCount: '1560',
|
||||||
|
},
|
||||||
|
description: 'Generate QR codes that direct users to a TikTok profile.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowTo',
|
||||||
|
name: 'How to Create a TikTok QR Code',
|
||||||
|
description: 'Create a QR code that opens a TikTok profile.',
|
||||||
|
step: [
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 1,
|
||||||
|
name: 'Enter Username',
|
||||||
|
text: 'Type your TikTok handle (e.g. @user).',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 2,
|
||||||
|
name: 'Customize',
|
||||||
|
text: 'Select colors like Cyan or Pink to match the TikTok brand.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 3,
|
||||||
|
name: 'Download',
|
||||||
|
text: 'Save the QR code.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 4,
|
||||||
|
name: 'Test',
|
||||||
|
text: 'Scan the code to ensure it links to your profile.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 5,
|
||||||
|
name: 'Share',
|
||||||
|
text: 'Share it on other social media or print it out.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalTime: 'PT30S',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: [
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Does it open the TikTok app?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes! If the app is installed, the QR code will deep-link directly to your profile in the TikTok app.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'What is a TikCode?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'TikCode was TikTok\'s proprietary QR code system. They have moved towards standard QR codes, which is what our tool generates. These are more compatible with standard camera apps.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Is it free?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes, this generator is completely free.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Can I track who scanned my code?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'No, this is a static QR code. For analytics, you need a Dynamic QR Code.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Is it safe?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes. The QR code simply contains a link to your TikTok profile. No personal data is collected.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TiktokQRCodePage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
/>
|
||||||
|
<ToolBreadcrumb toolName="TikTok QR Code Generator" toolSlug="tiktok-qr-code" />
|
||||||
|
|
||||||
|
<div className="min-h-screen" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
|
||||||
|
{/* HERO SECTION */}
|
||||||
|
<section className="relative pt-20 pb-20 lg:pt-32 lg:pb-32 px-4 sm:px-6 lg:px-8 overflow-hidden bg-black">
|
||||||
|
<div className="absolute inset-0 opacity-20">
|
||||||
|
{/* TikTok Pattern */}
|
||||||
|
<svg className="w-full h-full" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<pattern id="tt_pattern" width="60" height="60" patternUnits="userSpaceOnUse">
|
||||||
|
<circle cx="30" cy="30" r="2" fill="cyan" />
|
||||||
|
<circle cx="40" cy="40" r="2" fill="magenta" />
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="url(#tt_pattern)" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto grid lg:grid-cols-2 gap-12 items-center relative z-10">
|
||||||
|
<div className="text-center lg:text-left">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/10 text-white/90 text-sm font-medium mb-6 backdrop-blur-sm border border-white/10 hover:bg-white/20 transition-colors cursor-default">
|
||||||
|
<span className="flex h-2 w-2 relative">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-cyan-400"></span>
|
||||||
|
</span>
|
||||||
|
Free Tool — No Signup Required
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl md:text-5xl lg:text-6xl font-extrabold text-white tracking-tight leading-tight mb-6">
|
||||||
|
Go Viral with <br className="hidden lg:block" />
|
||||||
|
<span className="text-transparent bg-clip-text bg-gradient-to-r from-[#25F4EE] to-[#FE2C55]">TikTok QR Codes</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-lg md:text-xl text-slate-400 max-w-2xl mx-auto lg:mx-0 mb-8 leading-relaxed">
|
||||||
|
Share your TikTok immediately. A quick scan sends fans straight to your profile to follow and watch.
|
||||||
|
<strong className="text-white block sm:inline mt-2 sm:mt-0"> Grow your audience.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center lg:justify-start gap-4 text-sm font-medium text-white/80">
|
||||||
|
<div className="flex items-center gap-2 bg-white/10 px-4 py-2.5 rounded-xl border border-white/10 backdrop-blur-sm">
|
||||||
|
<Music className="w-4 h-4 text-[#25F4EE]" />
|
||||||
|
Get Followers
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/10 px-4 py-2.5 rounded-xl border border-white/10 backdrop-blur-sm">
|
||||||
|
<Video className="w-4 h-4 text-[#FE2C55]" />
|
||||||
|
Share Videos
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/10 px-4 py-2.5 rounded-xl border border-white/10 backdrop-blur-sm">
|
||||||
|
<Smartphone className="w-4 h-4 text-white" />
|
||||||
|
Deep Link
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visual Abstract */}
|
||||||
|
<div className="hidden lg:flex relative items-center justify-center min-h-[400px]">
|
||||||
|
{/* Glow Effects */}
|
||||||
|
<div className="absolute w-[300px] h-[300px] bg-[#25F4EE]/20 rounded-full blur-[80px] -top-10 -right-10 animate-pulse" />
|
||||||
|
<div className="absolute w-[300px] h-[300px] bg-[#FE2C55]/20 rounded-full blur-[80px] bottom-10 left-10 animate-pulse delay-75" />
|
||||||
|
|
||||||
|
<div className="relative w-80 h-96 bg-white/5 backdrop-blur-xl border border-white/20 rounded-3xl shadow-2xl flex flex-col items-center justify-center p-8 transform rotate-3 hover:rotate-0 transition-all duration-700 group">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-white/10 to-transparent rounded-3xl" />
|
||||||
|
|
||||||
|
<div className="w-20 h-20 rounded-full bg-black border-2 border-[#25F4EE] p-1 mb-6 shadow-[#FE2C55]/50 shadow-lg relative">
|
||||||
|
<div className="w-full h-full rounded-full bg-slate-800 flex items-center justify-center overflow-hidden">
|
||||||
|
<Music className="w-10 h-10 text-white animate-bounce" />
|
||||||
|
</div>
|
||||||
|
<div className="absolute -bottom-1 -right-1 bg-[#FE2C55] w-6 h-6 rounded-full border-2 border-black flex items-center justify-center">
|
||||||
|
<Heart className="w-3 h-3 text-white fill-current" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-48 h-48 bg-white rounded-xl p-2 shadow-inner relative overflow-hidden flex items-center justify-center">
|
||||||
|
<QRCodeSVG value="https://www.qrmaster.net" size={170} fgColor="#000000" level="Q" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Badge */}
|
||||||
|
<div className="absolute -bottom-6 -right-6 bg-black border border-white/10 py-3 px-5 rounded-xl shadow-xl flex items-center gap-3 transform transition-transform hover:scale-105 duration-300">
|
||||||
|
<div className="bg-white/10 p-2 rounded-full">
|
||||||
|
<Music className="w-5 h-5 text-[#25F4EE]" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-[10px] text-slate-400 font-bold uppercase tracking-wider">TikTok</div>
|
||||||
|
<div className="text-sm font-bold text-white">Following</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* GENERATOR SECTION */}
|
||||||
|
<section className="py-12 px-4 sm:px-6 lg:px-8 -mt-8">
|
||||||
|
<TiktokGenerator />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* HOW IT WORKS */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8 bg-white">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-12">
|
||||||
|
How TikTok QR Codes Work
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 lg:grid-cols-5 gap-8">
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-black flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Music className="w-7 h-7 text-[#25F4EE]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">1. Enter Handle</h3>
|
||||||
|
<p className="text-slate-600 text-sm">
|
||||||
|
Type in your username. No password required.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-black flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Smartphone className="w-7 h-7 text-[#FE2C55]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">2. Scan</h3>
|
||||||
|
<p className="text-slate-600 text-sm">
|
||||||
|
Fans scan the code to instantly find you in the app.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-black flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Download className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">3. Download</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Save your custom QR code.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-black flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Heart className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">4. Follow</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Fans scan to find you instantly.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-black flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Share2 className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">5. Viral</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Grow your audience everywhere.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* FAQ SECTION */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-4">
|
||||||
|
Frequently Asked Questions
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-600 text-center mb-10">
|
||||||
|
Common questions about TikTok QR codes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FaqItem
|
||||||
|
question="Does this replace the in-app QR code?"
|
||||||
|
answer="You can use either! The advantage of our generator is that you can print high-resolution versions for large posters, customize the color/frame, and it works with any standard QR scanner."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Can I link to a specific video?"
|
||||||
|
answer="Yes, just paste the full video URL (e.g. tiktok.com/@user/video/123...) instead of your username."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Is it free?"
|
||||||
|
answer="Yes, completely free from start to finish."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Can I track who scanned my code?"
|
||||||
|
answer="No, this is a static QR code. For analytics, you need a Dynamic QR Code."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Is it safe?"
|
||||||
|
answer="Yes. The QR code simply contains a link to your TikTok profile. No personal data is collected."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FaqItem({ question, answer }: { question: string; answer: string }) {
|
||||||
|
return (
|
||||||
|
<details className="group bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||||
|
<summary className="flex items-center justify-between p-5 cursor-pointer list-none text-slate-900 font-semibold hover:bg-slate-50 transition-colors">
|
||||||
|
{question}
|
||||||
|
<span className="transition group-open:rotate-180 text-slate-400">
|
||||||
|
<svg fill="none" height="20" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width="20">
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div className="text-slate-600 px-5 pb-5 pt-0 leading-relaxed text-sm">
|
||||||
|
{answer}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
253
src/app/(marketing)/tools/twitter-qr-code/TwitterGenerator.tsx
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import {
|
||||||
|
Twitter,
|
||||||
|
Download,
|
||||||
|
Check,
|
||||||
|
Sparkles,
|
||||||
|
MessageCircle
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// Brand Colors
|
||||||
|
const BRAND = {
|
||||||
|
paleGrey: '#EBEBDF',
|
||||||
|
richBlue: '#1A1265',
|
||||||
|
richBlueLight: '#2A2275',
|
||||||
|
};
|
||||||
|
|
||||||
|
// QR Color Options - X Theme
|
||||||
|
const QR_COLORS = [
|
||||||
|
{ name: 'X Black', value: '#000000' },
|
||||||
|
{ name: 'X Blue', value: '#1DA1F2' },
|
||||||
|
{ name: 'Dark Blue', value: '#1A1265' },
|
||||||
|
{ name: 'Teal', value: '#0D9488' },
|
||||||
|
{ name: 'Coral', value: '#F43F5E' },
|
||||||
|
{ name: 'Grey', value: '#374151' },
|
||||||
|
{ name: 'Emerald', value: '#10B981' },
|
||||||
|
{ name: 'Rose', value: '#F43F5E' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Frame Options
|
||||||
|
const FRAME_OPTIONS = [
|
||||||
|
{ id: 'none', label: 'No Frame' },
|
||||||
|
{ id: 'scanme', label: 'Scan Me' },
|
||||||
|
{ id: 'follow', label: 'Follow' },
|
||||||
|
{ id: 'connect', label: 'Connect' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function TwitterGenerator() {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [qrColor, setQrColor] = useState('#000000');
|
||||||
|
const [frameType, setFrameType] = useState('none');
|
||||||
|
|
||||||
|
const qrRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Twitter URL construction
|
||||||
|
const getUrl = () => {
|
||||||
|
const cleanUser = username.replace(/^@/, '').replace(/https?:\/\/(www\.)?(twitter|x)\.com\//, '').replace(/\/$/, '');
|
||||||
|
return cleanUser ? `https://x.com/${cleanUser}` : 'https://x.com';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async (format: 'png' | 'svg') => {
|
||||||
|
if (!qrRef.current) return;
|
||||||
|
try {
|
||||||
|
if (format === 'png') {
|
||||||
|
const { toPng } = await import('html-to-image');
|
||||||
|
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3 });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = `twitter-qr-code.png`;
|
||||||
|
link.href = dataUrl;
|
||||||
|
link.click();
|
||||||
|
} else {
|
||||||
|
const svgData = qrRef.current.querySelector('svg')?.outerHTML;
|
||||||
|
if (svgData) {
|
||||||
|
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `twitter-qr-code.svg`;
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Download failed', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFrameLabel = () => {
|
||||||
|
const frame = FRAME_OPTIONS.find(f => f.id === frameType);
|
||||||
|
return frame?.id !== 'none' ? frame?.label : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-5xl mx-auto px-4 md:px-6">
|
||||||
|
|
||||||
|
{/* Main Generator Card */}
|
||||||
|
<div className="bg-white rounded-3xl shadow-2xl shadow-slate-900/10 overflow-hidden border border-slate-100">
|
||||||
|
<div className="grid lg:grid-cols-2">
|
||||||
|
|
||||||
|
{/* LEFT: Input Section */}
|
||||||
|
<div className="p-8 lg:p-10 space-y-8 border-r border-slate-100">
|
||||||
|
|
||||||
|
{/* Twitter Details */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Twitter className="w-5 h-5 text-black" />
|
||||||
|
X (Twitter) Username
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Username or Link</label>
|
||||||
|
<Input
|
||||||
|
placeholder="@username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
className="h-12 text-base rounded-xl border-slate-200 focus:border-black focus:ring-black"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500 mt-2">Enter your X (Twitter) handle to create a profile link.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-100"></div>
|
||||||
|
|
||||||
|
{/* Design Options */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Sparkles className="w-5 h-5 text-black" />
|
||||||
|
Design Options
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Color Picker */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">QR Code Color</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{QR_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.name}
|
||||||
|
onClick={() => setQrColor(c.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-9 h-9 rounded-full border-2 flex items-center justify-center transition-all hover:scale-110",
|
||||||
|
qrColor === c.value ? "border-slate-900 ring-2 ring-offset-2 ring-slate-200" : "border-white shadow-md"
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: c.value }}
|
||||||
|
aria-label={`Select ${c.name}`}
|
||||||
|
title={c.name}
|
||||||
|
>
|
||||||
|
{qrColor === c.value && <Check className="w-4 h-4 text-white" strokeWidth={3} />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Frame Selector */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">Frame Label</label>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{FRAME_OPTIONS.map((frame) => (
|
||||||
|
<button
|
||||||
|
key={frame.id}
|
||||||
|
onClick={() => setFrameType(frame.id)}
|
||||||
|
className={cn(
|
||||||
|
"py-2.5 px-3 rounded-lg text-sm font-medium transition-all border",
|
||||||
|
frameType === frame.id
|
||||||
|
? "bg-slate-900 text-white border-slate-900"
|
||||||
|
: "bg-slate-50 text-slate-600 border-slate-200 hover:border-slate-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{frame.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT: Preview Section */}
|
||||||
|
<div className="p-8 lg:p-10 flex flex-col items-center justify-center" style={{ backgroundColor: BRAND.paleGrey }}>
|
||||||
|
|
||||||
|
{/* QR Card with Frame */}
|
||||||
|
<div
|
||||||
|
ref={qrRef}
|
||||||
|
className="bg-white rounded-3xl shadow-xl p-8 flex flex-col items-center"
|
||||||
|
style={{ minWidth: '320px' }}
|
||||||
|
>
|
||||||
|
{/* Frame Label */}
|
||||||
|
{getFrameLabel() && (
|
||||||
|
<div
|
||||||
|
className="mb-5 px-8 py-2.5 rounded-full text-white font-bold text-sm tracking-widest uppercase shadow-md"
|
||||||
|
style={{ backgroundColor: qrColor }}
|
||||||
|
>
|
||||||
|
{getFrameLabel()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* QR Code */}
|
||||||
|
<div className="bg-white">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={getUrl()}
|
||||||
|
size={240}
|
||||||
|
level="M"
|
||||||
|
includeMargin={false}
|
||||||
|
fgColor={qrColor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Preview */}
|
||||||
|
<div className="mt-6 text-center max-w-[260px]">
|
||||||
|
<h3 className="font-bold text-slate-900 text-lg flex items-center justify-center gap-2 truncate">
|
||||||
|
<Twitter className="w-4 h-4 text-slate-400 shrink-0" />
|
||||||
|
<span className="truncate">{username || '@username'}</span>
|
||||||
|
</h3>
|
||||||
|
<div className="text-xs text-slate-500 mt-1">Opens in X (Twitter)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Download Buttons */}
|
||||||
|
<div className="flex items-center gap-3 mt-8">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('png')}
|
||||||
|
className="bg-slate-900 hover:bg-black text-white shadow-lg"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Download PNG
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('svg')}
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-300 hover:bg-white"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
SVG
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500 mt-4 text-center">
|
||||||
|
Scanning redirects directly to the X profile.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upsell Banner */}
|
||||||
|
<div className="mt-8 bg-gradient-to-r from-slate-900 to-slate-700 rounded-2xl p-6 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="text-white text-center sm:text-left">
|
||||||
|
<h3 className="font-bold text-lg">Cross-promote your channels</h3>
|
||||||
|
<p className="text-white/80 text-sm mt-1">
|
||||||
|
Use a single Dynamic QR Code to link to all your social media profiles at once.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/signup">
|
||||||
|
<Button className="bg-white text-slate-900 hover:bg-slate-100 shrink-0 shadow-lg">
|
||||||
|
Create Smart Link
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
363
src/app/(marketing)/tools/twitter-qr-code/page.tsx
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
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';
|
||||||
|
|
||||||
|
// SEO Optimized Metadata
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Free Twitter (X) QR Code Generator | Follow & Connect | QR Master',
|
||||||
|
description: 'Create a QR code for your X (formerly Twitter) profile. Scanners are redirected to the app instantly to follow you. Free & Customizable.',
|
||||||
|
keywords: ['twitter qr code', 'x qr generator', 'twitter follow qr', 'social media qr code', 'x profile qr'],
|
||||||
|
alternates: {
|
||||||
|
canonical: 'https://qrmaster.io/tools/twitter-qr-code',
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: 'Free Twitter (X) QR Code Generator | QR Master',
|
||||||
|
description: 'Generate QR codes to grow your X (Twitter) following. Instant app redirect.',
|
||||||
|
type: 'website',
|
||||||
|
url: 'https://qrmaster.io/tools/twitter-qr-code',
|
||||||
|
images: [{ url: '/og-twitter-generator.png', width: 1200, height: 630 }],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: 'Free Twitter (X) QR Code Generator',
|
||||||
|
description: 'Create QR codes for X. Boost your following.',
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// JSON-LD Structured Data
|
||||||
|
const jsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@graph': [
|
||||||
|
{
|
||||||
|
'@type': 'SoftwareApplication',
|
||||||
|
name: 'Twitter (X) QR Code Generator',
|
||||||
|
applicationCategory: 'UtilitiesApplication',
|
||||||
|
operatingSystem: 'Web Browser',
|
||||||
|
offers: {
|
||||||
|
'@type': 'Offer',
|
||||||
|
price: '0',
|
||||||
|
priceCurrency: 'USD',
|
||||||
|
},
|
||||||
|
aggregateRating: {
|
||||||
|
'@type': 'AggregateRating',
|
||||||
|
ratingValue: '4.8',
|
||||||
|
ratingCount: '980',
|
||||||
|
},
|
||||||
|
description: 'Generate QR codes that direct users to an X (Twitter) profile or tweet.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowTo',
|
||||||
|
name: 'How to Create a Twitter QR Code',
|
||||||
|
description: 'Create a QR code that opens an X profile.',
|
||||||
|
step: [
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 1,
|
||||||
|
name: 'Enter Username',
|
||||||
|
text: 'Enter your X handle (e.g. @elonmusk).',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 2,
|
||||||
|
name: 'Design',
|
||||||
|
text: 'Choose a black frame or custom color.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 3,
|
||||||
|
name: 'Download',
|
||||||
|
text: 'Save the QR code.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 4,
|
||||||
|
name: 'Test',
|
||||||
|
text: 'Scan to verify it goes to the correct profile.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 5,
|
||||||
|
name: 'Share',
|
||||||
|
text: 'Add to your business cards or conference badges.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalTime: 'PT30S',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: [
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Does it work for both Twitter and X?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes, they are the same platform. The QR code links to x.com, which is the current standard, but works for twitter.com links too.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Can I link to a specific tweet?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes! Just paste the full URL of the tweet into the input field instead of your username.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Is it free?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes, generating this QR code is completely free and requires no signup.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Can I track scans?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'This is a static QR code, so tracking is not included. Use our Dynamic QR Code generator for analytics.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'What if I change my handle?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'If you change your handle, the link in the QR code will break. You will need to generate a new QR code.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TwitterQRCodePage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
/>
|
||||||
|
<ToolBreadcrumb toolName="Twitter QR Code Generator" toolSlug="twitter-qr-code" />
|
||||||
|
|
||||||
|
<div className="min-h-screen" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
|
||||||
|
{/* HERO SECTION */}
|
||||||
|
<section className="relative pt-20 pb-20 lg:pt-32 lg:pb-32 px-4 sm:px-6 lg:px-8 overflow-hidden bg-slate-950">
|
||||||
|
<div className="absolute inset-0 opacity-20">
|
||||||
|
{/* X Pattern */}
|
||||||
|
<svg className="w-full h-full" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<pattern id="x_pattern" width="60" height="60" patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M20 20L40 40M40 20L20 40" stroke="white" strokeWidth="1" />
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="url(#x_pattern)" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto grid lg:grid-cols-2 gap-12 items-center relative z-10">
|
||||||
|
<div className="text-center lg:text-left">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/10 text-white/90 text-sm font-medium mb-6 backdrop-blur-sm border border-white/10 hover:bg-white/20 transition-colors cursor-default">
|
||||||
|
<span className="flex h-2 w-2 relative">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-500 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500"></span>
|
||||||
|
</span>
|
||||||
|
Free Tool — No Signup Required
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl md:text-5xl lg:text-6xl font-extrabold text-white tracking-tight leading-tight mb-6">
|
||||||
|
Connect on X with <br className="hidden lg:block" />
|
||||||
|
<span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-white">Twitter QR Codes</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-lg md:text-xl text-slate-400 max-w-2xl mx-auto lg:mx-0 mb-8 leading-relaxed">
|
||||||
|
Share your X profile instantly. A quick scan takes users directly to your timeline to follow and interact.
|
||||||
|
<strong className="text-white block sm:inline mt-2 sm:mt-0"> Grow your community.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center lg:justify-start gap-4 text-sm font-medium text-white/80">
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/10 backdrop-blur-sm">
|
||||||
|
<UserPlus className="w-4 h-4 text-white" />
|
||||||
|
Get Followers
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/10 backdrop-blur-sm">
|
||||||
|
<MessageCircle className="w-4 h-4 text-white" />
|
||||||
|
Start Conversions
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/10 backdrop-blur-sm">
|
||||||
|
<Zap className="w-4 h-4 text-white" />
|
||||||
|
Instant Connect
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visual Abstract */}
|
||||||
|
<div className="hidden lg:flex relative items-center justify-center min-h-[400px]">
|
||||||
|
<div className="absolute w-[500px] h-[500px] bg-blue-500/10 rounded-full blur-[100px] -top-20 -right-20 animate-pulse" />
|
||||||
|
|
||||||
|
<div className="relative w-80 h-96 bg-white/5 backdrop-blur-xl border border-white/20 rounded-3xl shadow-2xl flex flex-col items-center justify-center p-8 transform rotate-2 hover:-rotate-1 transition-all duration-700 group">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-white/5 to-transparent rounded-3xl" />
|
||||||
|
|
||||||
|
<div className="w-full bg-black rounded-xl shadow-lg p-5 mb-6 relative overflow-hidden flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 bg-white rounded-full flex items-center justify-center">
|
||||||
|
<Twitter className="w-6 h-6 text-black" fill="black" />
|
||||||
|
</div>
|
||||||
|
<div className="text-white">
|
||||||
|
<div className="font-bold text-sm">QR Master</div>
|
||||||
|
<div className="text-xs text-slate-400">@qrmaster</div>
|
||||||
|
</div>
|
||||||
|
<button className="ml-auto bg-white text-black px-4 py-1.5 rounded-full text-xs font-bold hover:bg-slate-200">
|
||||||
|
Follow
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-48 h-48 bg-white rounded-xl p-2 shadow-inner relative overflow-hidden flex items-center justify-center">
|
||||||
|
<QRCodeSVG value="https://www.qrmaster.net" size={170} fgColor="#000000" level="Q" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Badge */}
|
||||||
|
<div className="absolute -bottom-6 -right-6 bg-black border border-white/10 py-3 px-5 rounded-xl shadow-xl flex items-center gap-3 transform transition-transform hover:scale-105 duration-300">
|
||||||
|
<div className="bg-white/10 p-2 rounded-full">
|
||||||
|
<Twitter className="w-5 h-5 text-white" fill="white" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-[10px] text-slate-400 font-bold uppercase tracking-wider">Twitter / X</div>
|
||||||
|
<div className="text-sm font-bold text-white">Profile Link</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* GENERATOR SECTION */}
|
||||||
|
<section className="py-12 px-4 sm:px-6 lg:px-8 -mt-8">
|
||||||
|
<TwitterGenerator />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* HOW IT WORKS */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8 bg-white">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-12">
|
||||||
|
How X (Twitter) QR Codes Work
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 lg:grid-cols-5 gap-8">
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-black flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Twitter className="w-7 h-7 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">1. Input Handle</h3>
|
||||||
|
<p className="text-slate-600 text-sm">
|
||||||
|
Simply type your @handle or paste your profile link.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-black flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Smartphone className="w-7 h-7 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">2. Snap</h3>
|
||||||
|
<p className="text-slate-600 text-sm">
|
||||||
|
Put the code on your networking gear. People scan it in seconds.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-black flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Download className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">3. Download</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Save your X QR code.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-black flex items-center justify-center mx-auto mb-4">
|
||||||
|
<UserPlus className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">4. Scan</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
People scan to find you.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-black flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Share2 className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">5. Connect</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
They are instantly on your profile.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* FAQ SECTION */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-4">
|
||||||
|
Frequently Asked Questions
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-600 text-center mb-10">
|
||||||
|
Common questions about Twitter QR codes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FaqItem
|
||||||
|
question="Why create a QR code for X?"
|
||||||
|
answer="It's much faster than telling someone your handle and hoping they spell it right. A scan is instant and error-proof."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Will links to twitter.com still work?"
|
||||||
|
answer="Yes, twitter.com links redirect to x.com, so both work perfectly fine."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Can I change the destination later?"
|
||||||
|
answer="No, this is a static QR code. If you change your handle, you will need a new QR code. Our Dynamic QR codes allow you to edit the link anytime."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Can I track scans?"
|
||||||
|
answer="This is a static QR code, so tracking is not included. Use our Dynamic QR Code generator for analytics."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="What if I change my handle?"
|
||||||
|
answer="If you change your handle, the link in the QR code will break. You will need to generate a new QR code."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FaqItem({ question, answer }: { question: string; answer: string }) {
|
||||||
|
return (
|
||||||
|
<details className="group bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||||
|
<summary className="flex items-center justify-between p-5 cursor-pointer list-none text-slate-900 font-semibold hover:bg-slate-50 transition-colors">
|
||||||
|
{question}
|
||||||
|
<span className="transition group-open:rotate-180 text-slate-400">
|
||||||
|
<svg fill="none" height="20" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width="20">
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div className="text-slate-600 px-5 pb-5 pt-0 leading-relaxed text-sm">
|
||||||
|
{answer}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
246
src/app/(marketing)/tools/url-qr-code/URLGenerator.tsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Link as LinkIcon,
|
||||||
|
Download,
|
||||||
|
Check,
|
||||||
|
Sparkles,
|
||||||
|
Globe
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// Brand Colors
|
||||||
|
const BRAND = {
|
||||||
|
paleGrey: '#EEF2FF', // Indigo-50
|
||||||
|
primary: '#4F46E5', // Indigo-600
|
||||||
|
primaryDark: '#4338CA', // Indigo-700
|
||||||
|
};
|
||||||
|
|
||||||
|
// QR Color Options
|
||||||
|
const QR_COLORS = [
|
||||||
|
{ name: 'Indigo', value: '#4F46E5' },
|
||||||
|
{ name: 'Blue', value: '#2563EB' },
|
||||||
|
{ name: 'Classic Black', value: '#000000' },
|
||||||
|
{ name: 'Violet', value: '#7C3AED' },
|
||||||
|
{ name: 'Pink', value: '#DB2777' },
|
||||||
|
{ name: 'Emerald', value: '#10B981' },
|
||||||
|
{ name: 'Rose', value: '#F43F5E' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Frame Options
|
||||||
|
const FRAME_OPTIONS = [
|
||||||
|
{ id: 'none', label: 'No Frame' },
|
||||||
|
{ id: 'scanme', label: 'Scan Me' },
|
||||||
|
{ id: 'website', label: 'Website' },
|
||||||
|
{ id: 'visit', label: 'Visit' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function URLGenerator() {
|
||||||
|
const [url, setUrl] = useState('');
|
||||||
|
const [qrColor, setQrColor] = useState(BRAND.primary);
|
||||||
|
const [frameType, setFrameType] = useState('none');
|
||||||
|
|
||||||
|
const qrRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const handleDownload = async (format: 'png' | 'svg') => {
|
||||||
|
if (!qrRef.current) return;
|
||||||
|
try {
|
||||||
|
if (format === 'png') {
|
||||||
|
const { toPng } = await import('html-to-image');
|
||||||
|
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3 });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = `url-qr-code.png`;
|
||||||
|
link.href = dataUrl;
|
||||||
|
link.click();
|
||||||
|
} else {
|
||||||
|
const svgData = qrRef.current.querySelector('svg')?.outerHTML;
|
||||||
|
if (svgData) {
|
||||||
|
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `url-qr-code.svg`;
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Download failed', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFrameLabel = () => {
|
||||||
|
const frame = FRAME_OPTIONS.find(f => f.id === frameType);
|
||||||
|
return frame?.id !== 'none' ? frame?.label : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-5xl mx-auto px-4 md:px-6">
|
||||||
|
|
||||||
|
{/* Main Generator Card */}
|
||||||
|
<div className="bg-white rounded-3xl shadow-2xl shadow-slate-900/10 overflow-hidden border border-slate-100">
|
||||||
|
<div className="grid lg:grid-cols-2">
|
||||||
|
|
||||||
|
{/* LEFT: Input Section */}
|
||||||
|
<div className="p-8 lg:p-10 space-y-8 border-r border-slate-100">
|
||||||
|
|
||||||
|
{/* URL Input */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<LinkIcon className="w-5 h-5 text-[#4F46E5]" />
|
||||||
|
Website URL
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Enter URL</label>
|
||||||
|
<Input
|
||||||
|
placeholder="https://www.yourwebsite.com"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
className="h-12 text-base rounded-xl border-slate-200 focus:border-[#4F46E5] focus:ring-[#4F46E5]"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500 mt-2">Include https:// for best results.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-100"></div>
|
||||||
|
|
||||||
|
{/* Design Options */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Sparkles className="w-5 h-5 text-[#4F46E5]" />
|
||||||
|
Design Options
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Color Picker */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">QR Code Color</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{QR_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.name}
|
||||||
|
onClick={() => setQrColor(c.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-9 h-9 rounded-full border-2 flex items-center justify-center transition-all hover:scale-110",
|
||||||
|
qrColor === c.value ? "border-slate-900 ring-2 ring-offset-2 ring-slate-200" : "border-white shadow-md"
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: c.value }}
|
||||||
|
aria-label={`Select ${c.name}`}
|
||||||
|
title={c.name}
|
||||||
|
>
|
||||||
|
{qrColor === c.value && <Check className="w-4 h-4 text-white" strokeWidth={3} />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Frame Selector */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">Frame Label</label>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{FRAME_OPTIONS.map((frame) => (
|
||||||
|
<button
|
||||||
|
key={frame.id}
|
||||||
|
onClick={() => setFrameType(frame.id)}
|
||||||
|
className={cn(
|
||||||
|
"py-2.5 px-3 rounded-lg text-sm font-medium transition-all border",
|
||||||
|
frameType === frame.id
|
||||||
|
? "bg-[#4F46E5] text-white border-[#4F46E5]"
|
||||||
|
: "bg-slate-50 text-slate-600 border-slate-200 hover:border-slate-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{frame.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT: Preview Section */}
|
||||||
|
<div className="p-8 lg:p-10 flex flex-col items-center justify-center" style={{ backgroundColor: BRAND.paleGrey }}>
|
||||||
|
|
||||||
|
{/* QR Card with Frame */}
|
||||||
|
<div
|
||||||
|
ref={qrRef}
|
||||||
|
className="bg-white rounded-3xl shadow-xl p-8 flex flex-col items-center"
|
||||||
|
style={{ minWidth: '320px' }}
|
||||||
|
>
|
||||||
|
{/* Frame Label */}
|
||||||
|
{getFrameLabel() && (
|
||||||
|
<div
|
||||||
|
className="mb-5 px-8 py-2.5 rounded-full text-white font-bold text-sm tracking-widest uppercase shadow-md"
|
||||||
|
style={{ backgroundColor: qrColor }}
|
||||||
|
>
|
||||||
|
{getFrameLabel()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* QR Code */}
|
||||||
|
<div className="bg-white">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={url || "https://qrmaster.io"}
|
||||||
|
size={240}
|
||||||
|
level="M"
|
||||||
|
includeMargin={false}
|
||||||
|
fgColor={qrColor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* URL Preview */}
|
||||||
|
<div className="mt-6 text-center max-w-[260px]">
|
||||||
|
<h3 className="font-bold text-slate-900 text-lg flex items-center justify-center gap-2 truncate">
|
||||||
|
<LinkIcon className="w-4 h-4 text-indigo-600 shrink-0" />
|
||||||
|
<span className="truncate">{url || 'Your Website'}</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Download Buttons */}
|
||||||
|
<div className="flex items-center gap-3 mt-8">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('png')}
|
||||||
|
className="bg-[#4F46E5] hover:bg-[#4338CA] text-white shadow-lg"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Download PNG
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('svg')}
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-300 hover:bg-white"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
SVG
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500 mt-4 text-center">
|
||||||
|
Your link is encoded directly. Static and forever free.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upsell Banner */}
|
||||||
|
<div className="mt-8 bg-gradient-to-r from-[#4F46E5] to-[#4338CA] rounded-2xl p-6 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="text-white text-center sm:text-left">
|
||||||
|
<h3 className="font-bold text-lg">Need to change this link later?</h3>
|
||||||
|
<p className="text-white/80 text-sm mt-1">
|
||||||
|
If your URL changes, this QR code will stop working. Use Dynamic QR Codes to edit links anytime.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/signup">
|
||||||
|
<Button className="bg-white text-[#4F46E5] hover:bg-slate-100 shrink-0 shadow-lg">
|
||||||
|
Create Dynamic QR
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
307
src/app/(marketing)/tools/url-qr-code/page.tsx
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
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 { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import { ToolBreadcrumb } from '@/components/seo/BreadcrumbSchema';
|
||||||
|
|
||||||
|
// SEO Optimized Metadata
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Free URL QR Code Generator | Link to Any Website | QR Master',
|
||||||
|
description: 'Create a QR code for your website, social media, or any link. Static and free forever. No scan limits. Instant download.',
|
||||||
|
keywords: ['url qr code', 'website qr code', 'link qr generator', 'free qr code generator', 'url to qr'],
|
||||||
|
alternates: {
|
||||||
|
canonical: 'https://qrmaster.io/tools/url-qr-code',
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: 'Free URL QR Code Generator | QR Master',
|
||||||
|
description: 'Turn any URL into a QR code. Share websites instantly.',
|
||||||
|
type: 'website',
|
||||||
|
url: 'https://qrmaster.io/tools/url-qr-code',
|
||||||
|
images: [{ url: '/og-url-generator.png', width: 1200, height: 630 }],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: 'Free URL QR Code Generator',
|
||||||
|
description: 'Create QR codes for any link. Instant and free.',
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// JSON-LD Structured Data
|
||||||
|
const jsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@graph': [
|
||||||
|
{
|
||||||
|
'@type': 'SoftwareApplication',
|
||||||
|
name: 'URL QR Code Generator',
|
||||||
|
applicationCategory: 'UtilitiesApplication',
|
||||||
|
operatingSystem: 'Web Browser',
|
||||||
|
offers: {
|
||||||
|
'@type': 'Offer',
|
||||||
|
price: '0',
|
||||||
|
priceCurrency: 'USD',
|
||||||
|
},
|
||||||
|
aggregateRating: {
|
||||||
|
'@type': 'AggregateRating',
|
||||||
|
ratingValue: '4.9',
|
||||||
|
ratingCount: '3100',
|
||||||
|
},
|
||||||
|
description: 'Generate QR codes for URLs and websites. Direct linking, no redirects.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowTo',
|
||||||
|
name: 'How to Create a URL QR Code',
|
||||||
|
description: 'Turn a website link into a scannable QR code.',
|
||||||
|
step: [
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 1,
|
||||||
|
name: 'Enter URL',
|
||||||
|
text: 'Copy and paste your website address (e.g., https://example.com).',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 2,
|
||||||
|
name: 'Customize',
|
||||||
|
text: 'Select a color and add a call-to-action frame like "Scan Me".',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 3,
|
||||||
|
name: 'Download',
|
||||||
|
text: 'Save your QR code as a PNG or SVG image.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalTime: 'PT20S',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: [
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Do these QR codes expire?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'No. These are static QR codes. They directly encode your URL and will work forever as long as your website is online.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Can I track how many people scan it?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'No, static QR codes cannot be tracked. If you need scan usage analytics (location, device, time), you should use our Dynamic QR Code generator.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Can I change the destination URL later?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'No. Once a static QR code is printed, it cannot be changed. If you change your website URL, you will need to print a new code. Use Dynamic QR Codes if you need flexibility.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Is there a scan limit?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'No. There are zero limits on how many times your QR code can be scanned.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function URLQRCodePage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
/>
|
||||||
|
<ToolBreadcrumb toolName="URL QR Code Generator" toolSlug="url-qr-code" />
|
||||||
|
|
||||||
|
<div className="min-h-screen" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
|
||||||
|
{/* HERO SECTION */}
|
||||||
|
<section className="relative pt-20 pb-20 lg:pt-32 lg:pb-32 px-4 sm:px-6 lg:px-8 overflow-hidden" style={{ backgroundColor: '#1A1265' }}>
|
||||||
|
<div className="absolute inset-0 opacity-10">
|
||||||
|
<svg className="w-full h-full" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||||
|
<path d="M0 100 C 20 0 50 0 100 100 Z" fill="url(#grad1)" />
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" style={{ stopColor: 'white', stopOpacity: 1 }} />
|
||||||
|
<stop offset="100%" style={{ stopColor: 'white', stopOpacity: 0 }} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto grid lg:grid-cols-2 gap-12 items-center relative z-10">
|
||||||
|
<div className="text-center lg:text-left">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/10 text-white/90 text-sm font-medium mb-6 backdrop-blur-sm border border-white/10 hover:bg-white/20 transition-colors cursor-default">
|
||||||
|
<span className="flex h-2 w-2 relative">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-400"></span>
|
||||||
|
</span>
|
||||||
|
Free Tool — No Signup Required
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl md:text-5xl lg:text-6xl font-extrabold text-white tracking-tight leading-tight mb-6">
|
||||||
|
Link to Any Website with <br className="hidden lg:block" />
|
||||||
|
<span className="text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-cyan-400">Instant QR Codes</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-lg md:text-xl text-indigo-100 max-w-2xl mx-auto lg:mx-0 mb-8 leading-relaxed">
|
||||||
|
Create a QR code for your website, portfolio, or menu.
|
||||||
|
<strong className="text-white block sm:inline mt-2 sm:mt-0"> Free forever. No expirations.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center lg:justify-start gap-4 text-sm font-medium text-white/80">
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/5 backdrop-blur-sm">
|
||||||
|
<Globe className="w-4 h-4 text-emerald-400" />
|
||||||
|
Universal Links
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/5 backdrop-blur-sm">
|
||||||
|
<Zap className="w-4 h-4 text-amber-400" />
|
||||||
|
Instant Redirect
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/5 backdrop-blur-sm">
|
||||||
|
<Shield className="w-4 h-4 text-purple-400" />
|
||||||
|
Direct Encoding
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visual Abstract */}
|
||||||
|
<div className="hidden lg:flex relative items-center justify-center min-h-[400px]">
|
||||||
|
<div className="absolute w-[500px] h-[500px] bg-indigo-500/30 rounded-full blur-[100px] -top-20 -right-20 animate-pulse" />
|
||||||
|
|
||||||
|
<div className="relative w-80 h-96 bg-white/5 backdrop-blur-xl border border-white/20 rounded-3xl shadow-2xl flex flex-col items-center justify-center p-8 transform -rotate-3 hover:rotate-0 transition-all duration-700 group">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-white/10 to-transparent rounded-3xl" />
|
||||||
|
|
||||||
|
<div className="w-48 h-48 bg-white rounded-xl p-2 shadow-inner mb-6 relative overflow-hidden flex items-center justify-center">
|
||||||
|
<QRCodeSVG value="https://www.qrmaster.net" size={170} fgColor="#0f172a" level="Q" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full bg-white/10 rounded-xl p-4 backdrop-blur-sm border border-white/10">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-indigo-500/20 flex items-center justify-center">
|
||||||
|
<LinkIcon className="w-4 h-4 text-indigo-300" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 w-full">
|
||||||
|
<div className="h-1.5 w-3/4 bg-white/30 rounded-full" />
|
||||||
|
<div className="h-1.5 w-1/2 bg-white/20 rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* GENERATOR SECTION */}
|
||||||
|
<section className="py-12 px-4 sm:px-6 lg:px-8 -mt-8">
|
||||||
|
<URLGenerator />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* HOW IT WORKS */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8 bg-white">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-12">
|
||||||
|
How URL QR Codes Work
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 gap-8">
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-[#1A1265]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<LinkIcon className="w-7 h-7 text-[#1A1265]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">1. Paste Link</h3>
|
||||||
|
<p className="text-slate-600 text-sm">
|
||||||
|
Copy the URL of the webpage you want to link to and paste it into the generator.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-[#1A1265]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Smartphone className="w-7 h-7 text-[#1A1265]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">2. Scan</h3>
|
||||||
|
<p className="text-slate-600 text-sm">
|
||||||
|
Users scan the code and a notification appears to open the link in their browser.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-[#1A1265]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Globe className="w-7 h-7 text-[#1A1265]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">3. Visit</h3>
|
||||||
|
<p className="text-slate-600 text-sm">
|
||||||
|
They are instantly directed to your website, restaurant menu, or social profile.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* FAQ SECTION */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-4">
|
||||||
|
Frequently Asked Questions
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-600 text-center mb-10">
|
||||||
|
Common questions about URL QR codes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FaqItem
|
||||||
|
question="Do these QR codes expire?"
|
||||||
|
answer="No. Static URL QR codes do not expire. They contain the direct link to your website. As long as your website is active, the QR code will work."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Can I track scans?"
|
||||||
|
answer="No, static QR codes cannot be tracked. If you need analytics to see who is scanning your code and from where, you need a Dynamic QR Code."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="What happens if I change my website URL?"
|
||||||
|
answer="If you change your URL, this static QR code will no longer work (unless you set up a redirect on your own server). With a Dynamic QR Code, you can update the destination URL anytime without reprinting the code."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Are there ads on the QR code?"
|
||||||
|
answer="No. We do not insert ads before redirecting users. The scan goes directly to your URL."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FaqItem({ question, answer }: { question: string; answer: string }) {
|
||||||
|
return (
|
||||||
|
<details className="group bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||||
|
<summary className="flex items-center justify-between p-5 cursor-pointer list-none text-slate-900 font-semibold hover:bg-slate-50 transition-colors">
|
||||||
|
{question}
|
||||||
|
<span className="transition group-open:rotate-180 text-slate-400">
|
||||||
|
<svg fill="none" height="20" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width="20">
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div className="text-slate-600 px-5 pb-5 pt-0 leading-relaxed text-sm">
|
||||||
|
{answer}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
349
src/app/(marketing)/tools/vcard-qr-code/VCardGenerator.tsx
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import {
|
||||||
|
User,
|
||||||
|
Download,
|
||||||
|
Check,
|
||||||
|
Sparkles,
|
||||||
|
Briefcase,
|
||||||
|
Phone,
|
||||||
|
Mail,
|
||||||
|
Globe,
|
||||||
|
MapPin,
|
||||||
|
Contact
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// Brand Colors
|
||||||
|
const BRAND = {
|
||||||
|
paleGrey: '#FFF1F2', // Rose-50
|
||||||
|
primary: '#E11D48', // Rose-600
|
||||||
|
primaryDark: '#BE123C', // Rose-700
|
||||||
|
};
|
||||||
|
|
||||||
|
// QR Color Options
|
||||||
|
const QR_COLORS = [
|
||||||
|
{ name: 'Rose', value: '#E11D48' },
|
||||||
|
{ name: 'Pink', value: '#DB2777' },
|
||||||
|
{ name: 'Classic Black', value: '#000000' },
|
||||||
|
{ name: 'Navy', value: '#1E3A8A' },
|
||||||
|
{ name: 'Purple', value: '#7C3AED' },
|
||||||
|
{ name: 'Emerald', value: '#10B981' },
|
||||||
|
{ name: 'Rose', value: '#F43F5E' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Frame Options
|
||||||
|
const FRAME_OPTIONS = [
|
||||||
|
{ id: 'none', label: 'No Frame' },
|
||||||
|
{ id: 'scanme', label: 'Scan Me' },
|
||||||
|
{ id: 'contact', label: 'Save Contact' },
|
||||||
|
{ id: 'vcard', label: 'vCard' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function VCardGenerator() {
|
||||||
|
// Personal Info
|
||||||
|
const [firstName, setFirstName] = useState('');
|
||||||
|
const [lastName, setLastName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [website, setWebsite] = useState('');
|
||||||
|
const [jobTitle, setJobTitle] = useState('');
|
||||||
|
const [company, setCompany] = useState('');
|
||||||
|
|
||||||
|
// Address
|
||||||
|
const [street, setStreet] = useState('');
|
||||||
|
const [city, setCity] = useState('');
|
||||||
|
const [zip, setZip] = useState('');
|
||||||
|
const [country, setCountry] = useState('');
|
||||||
|
|
||||||
|
const [qrColor, setQrColor] = useState(BRAND.primary);
|
||||||
|
const [frameType, setFrameType] = useState('none');
|
||||||
|
|
||||||
|
const qrRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Generate VCard String
|
||||||
|
const generateVCard = () => {
|
||||||
|
const vcard = [
|
||||||
|
'BEGIN:VCARD',
|
||||||
|
'VERSION:3.0',
|
||||||
|
`N:${lastName};${firstName};;;`,
|
||||||
|
`FN:${firstName} ${lastName}`,
|
||||||
|
`ORG:${company}`,
|
||||||
|
`TITLE:${jobTitle}`,
|
||||||
|
`TEL;TYPE=CELL:${phone}`,
|
||||||
|
`EMAIL;TYPE=WORK:${email}`,
|
||||||
|
`URL:${website}`,
|
||||||
|
`ADR;TYPE=WORK:;;${street};${city};;${zip};${country}`,
|
||||||
|
'END:VCARD'
|
||||||
|
].join('\n');
|
||||||
|
return vcard;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async (format: 'png' | 'svg') => {
|
||||||
|
if (!qrRef.current) return;
|
||||||
|
try {
|
||||||
|
if (format === 'png') {
|
||||||
|
const { toPng } = await import('html-to-image');
|
||||||
|
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3 });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = `vcard-${firstName || 'contact'}.png`;
|
||||||
|
link.href = dataUrl;
|
||||||
|
link.click();
|
||||||
|
} else {
|
||||||
|
const svgData = qrRef.current.querySelector('svg')?.outerHTML;
|
||||||
|
if (svgData) {
|
||||||
|
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `vcard-${firstName || 'contact'}.svg`;
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Download failed', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFrameLabel = () => {
|
||||||
|
const frame = FRAME_OPTIONS.find(f => f.id === frameType);
|
||||||
|
return frame?.id !== 'none' ? frame?.label : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-6xl mx-auto px-4 md:px-6">
|
||||||
|
|
||||||
|
{/* Main Generator Card */}
|
||||||
|
<div className="bg-white rounded-3xl shadow-2xl shadow-slate-900/10 overflow-hidden border border-slate-100">
|
||||||
|
<div className="grid lg:grid-cols-12">
|
||||||
|
|
||||||
|
{/* LEFT: Input Section (Wider for VCard) */}
|
||||||
|
<div className="lg:col-span-7 p-8 lg:p-10 space-y-8 border-r border-slate-100">
|
||||||
|
|
||||||
|
{/* Personal Details */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<User className="w-5 h-5 text-[#E11D48]" />
|
||||||
|
Contact Information
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">First Name</label>
|
||||||
|
<Input placeholder="John" value={firstName} onChange={(e) => setFirstName(e.target.value)} className="h-11 rounded-xl border-slate-200 focus:border-[#E11D48] focus:ring-[#E11D48]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Last Name</label>
|
||||||
|
<Input placeholder="Doe" value={lastName} onChange={(e) => setLastName(e.target.value)} className="h-11 rounded-xl border-slate-200 focus:border-[#E11D48] focus:ring-[#E11D48]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Phone</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Phone className="absolute left-3 top-3 w-5 h-5 text-slate-400" />
|
||||||
|
<Input placeholder="+1 555 000 0000" value={phone} onChange={(e) => setPhone(e.target.value)} className="pl-10 h-11 rounded-xl border-slate-200 focus:border-[#E11D48] focus:ring-[#E11D48]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Email</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="absolute left-3 top-3 w-5 h-5 text-slate-400" />
|
||||||
|
<Input placeholder="john@company.com" value={email} onChange={(e) => setEmail(e.target.value)} className="pl-10 h-11 rounded-xl border-slate-200 focus:border-[#E11D48] focus:ring-[#E11D48]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Website</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Globe className="absolute left-3 top-3 w-5 h-5 text-slate-400" />
|
||||||
|
<Input placeholder="https://..." value={website} onChange={(e) => setWebsite(e.target.value)} className="pl-10 h-11 rounded-xl border-slate-200 focus:border-[#E11D48] focus:ring-[#E11D48]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Job Title</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Briefcase className="absolute left-3 top-3 w-5 h-5 text-slate-400" />
|
||||||
|
<Input placeholder="Manager" value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} className="pl-10 h-11 rounded-xl border-slate-200 focus:border-[#E11D48] focus:ring-[#E11D48]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Company</label>
|
||||||
|
<Input placeholder="Acme Corp" value={company} onChange={(e) => setCompany(e.target.value)} className="h-11 rounded-xl border-slate-200 focus:border-[#E11D48] focus:ring-[#E11D48]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Address Details */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<MapPin className="w-5 h-5 text-[#E11D48]" />
|
||||||
|
Address
|
||||||
|
</h2>
|
||||||
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Street</label>
|
||||||
|
<Input placeholder="123 Business Rd" value={street} onChange={(e) => setStreet(e.target.value)} className="h-11 rounded-xl border-slate-200 focus:border-[#E11D48] focus:ring-[#E11D48]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">City</label>
|
||||||
|
<Input placeholder="New York" value={city} onChange={(e) => setCity(e.target.value)} className="h-11 rounded-xl border-slate-200 focus:border-[#E11D48] focus:ring-[#E11D48]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Zip/Postcode</label>
|
||||||
|
<Input placeholder="10001" value={zip} onChange={(e) => setZip(e.target.value)} className="h-11 rounded-xl border-slate-200 focus:border-[#E11D48] focus:ring-[#E11D48]" />
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Country</label>
|
||||||
|
<Input placeholder="USA" value={country} onChange={(e) => setCountry(e.target.value)} className="h-11 rounded-xl border-slate-200 focus:border-[#E11D48] focus:ring-[#E11D48]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-100"></div>
|
||||||
|
|
||||||
|
{/* Design Options */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Sparkles className="w-5 h-5 text-[#E11D48]" />
|
||||||
|
Design Options
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-2 gap-8">
|
||||||
|
{/* Color Picker */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">QR Color</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{QR_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.name}
|
||||||
|
onClick={() => setQrColor(c.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-9 h-9 rounded-full border-2 flex items-center justify-center transition-all hover:scale-110",
|
||||||
|
qrColor === c.value ? "border-slate-900 ring-2 ring-offset-2 ring-slate-200" : "border-white shadow-md"
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: c.value }}
|
||||||
|
title={c.name}
|
||||||
|
>
|
||||||
|
{qrColor === c.value && <Check className="w-4 h-4 text-white" strokeWidth={3} />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Frame Selector */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">Frame Label</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{FRAME_OPTIONS.map((frame) => (
|
||||||
|
<button
|
||||||
|
key={frame.id}
|
||||||
|
onClick={() => setFrameType(frame.id)}
|
||||||
|
className={cn(
|
||||||
|
"py-2 px-3 rounded-lg text-sm font-medium transition-all border text-center",
|
||||||
|
frameType === frame.id
|
||||||
|
? "bg-[#E11D48] text-white border-[#E11D48]"
|
||||||
|
: "bg-slate-50 text-slate-600 border-slate-200 hover:border-slate-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{frame.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT: Preview Section */}
|
||||||
|
<div className="lg:col-span-5 p-8 lg:p-10 flex flex-col items-center justify-center" style={{ backgroundColor: BRAND.paleGrey }}>
|
||||||
|
|
||||||
|
{/* QR Card with Frame */}
|
||||||
|
<div
|
||||||
|
ref={qrRef}
|
||||||
|
className="bg-white rounded-3xl shadow-xl p-8 flex flex-col items-center"
|
||||||
|
style={{ minWidth: '300px' }}
|
||||||
|
>
|
||||||
|
{/* Frame Label */}
|
||||||
|
{getFrameLabel() && (
|
||||||
|
<div
|
||||||
|
className="mb-5 px-8 py-2.5 rounded-full text-white font-bold text-sm tracking-widest uppercase shadow-md"
|
||||||
|
style={{ backgroundColor: qrColor }}
|
||||||
|
>
|
||||||
|
{getFrameLabel()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* QR Code */}
|
||||||
|
<div className="bg-white">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={generateVCard()}
|
||||||
|
size={220}
|
||||||
|
level="M"
|
||||||
|
includeMargin={false}
|
||||||
|
fgColor={qrColor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Preview */}
|
||||||
|
<div className="mt-6 text-center max-w-[260px]">
|
||||||
|
<h3 className="font-bold text-slate-900 text-lg flex items-center justify-center gap-2 truncate">
|
||||||
|
<Contact className="w-4 h-4 text-slate-400 shrink-0" />
|
||||||
|
<span className="truncate">{firstName || 'First'} {lastName || 'Last'}</span>
|
||||||
|
</h3>
|
||||||
|
<div className="text-xs text-slate-500 mt-1 truncate">{company || 'Company Name'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Download Buttons */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-center gap-3 mt-8 w-full max-w-[320px]">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('png')}
|
||||||
|
className="bg-[#E11D48] hover:bg-[#BE123C] text-white shadow-lg w-full"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Download PNG
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('svg')}
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-300 hover:bg-white w-full"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
SVG
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500 mt-4 text-center">
|
||||||
|
Scanning adds this contact to the address book instantly.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upsell Banner */}
|
||||||
|
<div className="mt-8 bg-gradient-to-r from-[#E11D48] to-[#BE123C] rounded-2xl p-6 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="text-white text-center sm:text-left">
|
||||||
|
<h3 className="font-bold text-lg">Want a Digital Business Card?</h3>
|
||||||
|
<p className="text-white/80 text-sm mt-1">
|
||||||
|
Upgrade to Dynamic vCard to include a profile photo, social links, and update your info anytime.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/signup">
|
||||||
|
<Button className="bg-white text-[#E11D48] hover:bg-slate-100 shrink-0 shadow-lg">
|
||||||
|
Create Digital Card
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
320
src/app/(marketing)/tools/vcard-qr-code/page.tsx
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
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';
|
||||||
|
|
||||||
|
// SEO Optimized Metadata
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Free vCard QR Code Generator | Digital Business Card | QR Master',
|
||||||
|
description: 'Create a vCard QR code for your business card. Share contact details (Name, Phone, Email) instantly with one scan. 100% Free & No App Required.',
|
||||||
|
keywords: ['vcard qr code', 'business card qr code', 'contact qr generator', 'digital business card', 'add to contacts qr'],
|
||||||
|
alternates: {
|
||||||
|
canonical: 'https://qrmaster.io/tools/vcard-qr-code',
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: 'Free vCard QR Code Generator | QR Master',
|
||||||
|
description: 'Turn your contact info into a QR code. The modern way to share your business card.',
|
||||||
|
type: 'website',
|
||||||
|
url: 'https://qrmaster.io/tools/vcard-qr-code',
|
||||||
|
images: [{ url: '/og-vcard-generator.png', width: 1200, height: 630 }],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: 'Free vCard QR Code Generator',
|
||||||
|
description: 'Create QR codes for contact sharing. Instant and free.',
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// JSON-LD Structured Data
|
||||||
|
const jsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@graph': [
|
||||||
|
{
|
||||||
|
'@type': 'SoftwareApplication',
|
||||||
|
name: 'vCard QR Code Generator',
|
||||||
|
applicationCategory: 'UtilitiesApplication',
|
||||||
|
operatingSystem: 'Web Browser',
|
||||||
|
offers: {
|
||||||
|
'@type': 'Offer',
|
||||||
|
price: '0',
|
||||||
|
priceCurrency: 'USD',
|
||||||
|
},
|
||||||
|
aggregateRating: {
|
||||||
|
'@type': 'AggregateRating',
|
||||||
|
ratingValue: '4.9',
|
||||||
|
ratingCount: '4200',
|
||||||
|
},
|
||||||
|
description: 'Generate vCard (VCF) QR codes for business cards. Scanners can save contact info instantly.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowTo',
|
||||||
|
name: 'How to Create a vCard QR Code',
|
||||||
|
description: 'Create a QR code that saves your contact details.',
|
||||||
|
step: [
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 1,
|
||||||
|
name: 'Enter Details',
|
||||||
|
text: 'Fill in your Name, Phone, Email, Company, and Address.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 2,
|
||||||
|
name: 'Customize',
|
||||||
|
text: 'Select a color that matches your brand and add a frame.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 3,
|
||||||
|
name: 'Download',
|
||||||
|
text: 'Download the QR code image and place it on your physical business card.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalTime: 'PT1M',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: [
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'How does a vCard QR code work?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'A vCard QR code contains your contact information in a standardized format (VCF). When scanned, the phone recognizes it as a contact card and prompts the user to "Save Contact" to their address book.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Is there a limit to how much info I can add?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Static QR codes hold data directly in the pattern. The more data you add (long addresses, bio), the denser and harder to scan the QR code becomes. We recommend sticking to essential contact info for static codes.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Can I update my info later?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'No. This is a static vCard QR code. Once created, the info cannot be changed. If you move jobs or change numbers, you must print a new code. For editable cards, use our Dynamic vCard Plus.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Does it work on iPhone and Android?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes. Both iOS (Camera app) and Android (Camera or Google Lens) natively support vCard QR codes and correctly import the contact data.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function VCardQRCodePage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
/>
|
||||||
|
<ToolBreadcrumb toolName="vCard QR Code Generator" toolSlug="vcard-qr-code" />
|
||||||
|
|
||||||
|
<div className="min-h-screen" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
|
||||||
|
{/* HERO SECTION */}
|
||||||
|
<section className="relative pt-20 pb-20 lg:pt-32 lg:pb-32 px-4 sm:px-6 lg:px-8 overflow-hidden" style={{ backgroundColor: '#9F1239' }}>
|
||||||
|
<div className="absolute inset-0 opacity-10">
|
||||||
|
<svg className="w-full h-full" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||||
|
<path d="M0 100 C 20 0 50 0 100 100 Z" fill="url(#grad1)" />
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" style={{ stopColor: 'white', stopOpacity: 1 }} />
|
||||||
|
<stop offset="100%" style={{ stopColor: 'white', stopOpacity: 0 }} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto grid lg:grid-cols-2 gap-12 items-center relative z-10">
|
||||||
|
<div className="text-center lg:text-left">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/10 text-white/90 text-sm font-medium mb-6 backdrop-blur-sm border border-white/10 hover:bg-white/20 transition-colors cursor-default">
|
||||||
|
<span className="flex h-2 w-2 relative">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-rose-400 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-rose-400"></span>
|
||||||
|
</span>
|
||||||
|
Free Tool — No Signup Required
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl md:text-5xl lg:text-6xl font-extrabold text-white tracking-tight leading-tight mb-6">
|
||||||
|
The Modern Way to <br className="hidden lg:block" />
|
||||||
|
<span className="text-transparent bg-clip-text bg-gradient-to-r from-rose-300 to-pink-300">Share Your Contact</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-lg md:text-xl text-indigo-100 max-w-2xl mx-auto lg:mx-0 mb-8 leading-relaxed">
|
||||||
|
Create a scannable Digital Business Card. One scan saves your name, phone, email, and address instantly.
|
||||||
|
<strong className="text-white block sm:inline mt-2 sm:mt-0"> Free & Professional.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center lg:justify-start gap-4 text-sm font-medium text-white/80">
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/5 backdrop-blur-sm">
|
||||||
|
<UserPlus className="w-4 h-4 text-rose-300" />
|
||||||
|
Instant Save
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/5 backdrop-blur-sm">
|
||||||
|
<Share2 className="w-4 h-4 text-amber-400" />
|
||||||
|
Easy Share
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/5 backdrop-blur-sm">
|
||||||
|
<Shield className="w-4 h-4 text-purple-400" />
|
||||||
|
No Data Stored
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visual Abstract */}
|
||||||
|
<div className="hidden lg:flex relative items-center justify-center min-h-[400px]">
|
||||||
|
<div className="absolute w-[500px] h-[500px] bg-indigo-500/30 rounded-full blur-[100px] -top-20 -right-20 animate-pulse" />
|
||||||
|
|
||||||
|
<div className="relative w-96 h-60 bg-white/10 backdrop-blur-2xl border border-white/30 rounded-2xl shadow-2xl p-6 transform rotate-6 hover:rotate-3 transition-all duration-700 group">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-white/10 to-transparent" />
|
||||||
|
|
||||||
|
<div className="flex justify-between items-start relative z-10">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-white/20 border-2 border-white/30 flex items-center justify-center">
|
||||||
|
<Contact className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="h-4 w-32 bg-white/90 rounded-sm" />
|
||||||
|
<div className="h-3 w-20 bg-emerald-400/90 rounded-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-24 h-24 bg-white rounded-lg p-1.5 shadow-lg">
|
||||||
|
<QRCodeSVG value="https://www.qrmaster.net" size={84} fgColor="#1A1265" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute bottom-6 left-6 space-y-2 z-10">
|
||||||
|
<div className="h-2 w-48 bg-white/40 rounded-full" />
|
||||||
|
<div className="h-2 w-40 bg-white/30 rounded-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Badge */}
|
||||||
|
<div className="absolute -bottom-4 -left-4 bg-white py-2 px-4 rounded-lg shadow-xl flex items-center gap-2 transform scale-90">
|
||||||
|
<div className="bg-emerald-100 p-1.5 rounded-full">
|
||||||
|
<Check className="w-3 h-3 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold text-slate-900">Saved to Contacts</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* GENERATOR SECTION */}
|
||||||
|
<section className="py-12 px-4 sm:px-6 lg:px-8 -mt-8">
|
||||||
|
<VCardGenerator />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* HOW IT WORKS */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8 bg-white">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-12">
|
||||||
|
How vCard QR Codes Work
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 gap-8">
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-[#1A1265]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Contact className="w-7 h-7 text-[#1A1265]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">1. Enter Details</h3>
|
||||||
|
<p className="text-slate-600 text-sm">
|
||||||
|
Fill in your professional contact information. Only add what you want to share.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-[#1A1265]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Smartphone className="w-7 h-7 text-[#1A1265]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">2. Scan</h3>
|
||||||
|
<p className="text-slate-600 text-sm">
|
||||||
|
A potential client or partner scans your card with their phone camera.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-[#1A1265]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<UserPlus className="w-7 h-7 text-[#1A1265]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">3. Save</h3>
|
||||||
|
<p className="text-slate-600 text-sm">
|
||||||
|
They tap "Create New Contact" to save your details instantly. No typing errors.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* FAQ SECTION */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-4">
|
||||||
|
Frequently Asked Questions
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-600 text-center mb-10">
|
||||||
|
Common questions about vCard QR codes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FaqItem
|
||||||
|
question="Can I add a profile picture?"
|
||||||
|
answer="Not on a static vCard QR code. Static codes store data in the pixels, so adding an image would make the code too complex to scan. For profile pictures, social links, and rich media, use our Dynamic vCard Plus solution."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="How long does the QR code last?"
|
||||||
|
answer="Forever. Static vCard QR codes do not expire because the data is embedded directly in the image."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="What information is required?"
|
||||||
|
answer="Nothing is strictly required, but we recommend at least a First Name and either a Phone Number or Email so the contact is useful."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Is my data safe?"
|
||||||
|
answer="Yes. This tool operates 100% in your browser. We do not store, see, or optimize your contact data. It goes directly from your input to the QR code."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FaqItem({ question, answer }: { question: string; answer: string }) {
|
||||||
|
return (
|
||||||
|
<details className="group bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||||
|
<summary className="flex items-center justify-between p-5 cursor-pointer list-none text-slate-900 font-semibold hover:bg-slate-50 transition-colors">
|
||||||
|
{question}
|
||||||
|
<span className="transition group-open:rotate-180 text-slate-400">
|
||||||
|
<svg fill="none" height="20" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width="20">
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div className="text-slate-600 px-5 pb-5 pt-0 leading-relaxed text-sm">
|
||||||
|
{answer}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
268
src/app/(marketing)/tools/whatsapp-qr-code/WhatsAppGenerator.tsx
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import {
|
||||||
|
Phone,
|
||||||
|
Download,
|
||||||
|
Check,
|
||||||
|
Sparkles,
|
||||||
|
MessageCircle,
|
||||||
|
Send
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Textarea } from '@/components/ui/Textarea';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Brand Colors
|
||||||
|
const BRAND = {
|
||||||
|
paleGrey: '#EBEBDF',
|
||||||
|
richBlue: '#1A1265',
|
||||||
|
richBlueLight: '#2A2275',
|
||||||
|
};
|
||||||
|
|
||||||
|
// QR Color Options - WhatsApp Theme
|
||||||
|
const QR_COLORS = [
|
||||||
|
{ name: 'WhatsApp Green', value: '#25D366' },
|
||||||
|
{ name: 'Teal', value: '#128C7E' },
|
||||||
|
{ name: 'Classic Black', value: '#000000' },
|
||||||
|
{ name: 'Rich Blue', value: '#1A1265' },
|
||||||
|
{ name: 'Purple', value: '#7C3AED' },
|
||||||
|
{ name: 'Emerald', value: '#10B981' },
|
||||||
|
{ name: 'Rose', value: '#F43F5E' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Frame Options
|
||||||
|
const FRAME_OPTIONS = [
|
||||||
|
{ id: 'none', label: 'No Frame' },
|
||||||
|
{ id: 'scanme', label: 'Scan Me' },
|
||||||
|
{ id: 'chat', label: 'Chat With Us' },
|
||||||
|
{ id: 'support', label: 'Support' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function WhatsappGenerator() {
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [qrColor, setQrColor] = useState('#25D366');
|
||||||
|
const [frameType, setFrameType] = useState('none');
|
||||||
|
|
||||||
|
const qrRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// WhatsApp URL: https://wa.me/number?text=message
|
||||||
|
const getUrl = () => {
|
||||||
|
const cleanPhone = phone.replace(/\D/g, ''); // Remove non-digits
|
||||||
|
const encodedMessage = encodeURIComponent(message);
|
||||||
|
return `https://wa.me/${cleanPhone}?text=${encodedMessage}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async (format: 'png' | 'svg') => {
|
||||||
|
if (!qrRef.current) return;
|
||||||
|
try {
|
||||||
|
if (format === 'png') {
|
||||||
|
const { toPng } = await import('html-to-image');
|
||||||
|
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3 });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = `whatsapp-qr-code.png`;
|
||||||
|
link.href = dataUrl;
|
||||||
|
link.click();
|
||||||
|
} else {
|
||||||
|
const svgData = qrRef.current.querySelector('svg')?.outerHTML;
|
||||||
|
if (svgData) {
|
||||||
|
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `whatsapp-qr-code.svg`;
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Download failed', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFrameLabel = () => {
|
||||||
|
const frame = FRAME_OPTIONS.find(f => f.id === frameType);
|
||||||
|
return frame?.id !== 'none' ? frame?.label : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-5xl mx-auto px-4 md:px-6">
|
||||||
|
|
||||||
|
{/* Main Generator Card */}
|
||||||
|
<div className="bg-white rounded-3xl shadow-2xl shadow-slate-900/10 overflow-hidden border border-slate-100">
|
||||||
|
<div className="grid lg:grid-cols-2">
|
||||||
|
|
||||||
|
{/* LEFT: Input Section */}
|
||||||
|
<div className="p-8 lg:p-10 space-y-8 border-r border-slate-100">
|
||||||
|
|
||||||
|
{/* WhatsApp Details */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<MessageCircle className="w-5 h-5 text-[#25D366]" />
|
||||||
|
WhatsApp Details
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Phone Number</label>
|
||||||
|
<Input
|
||||||
|
placeholder="15551234567"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
className="h-12 text-base rounded-xl border-slate-200 focus:border-[#25D366] focus:ring-[#25D366]"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500 mt-2">Include country code (e.g. 1 for US, 44 for UK). No '+' symbol.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Pre-filled Message (Optional)</label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Hi, I'm interested in your services..."
|
||||||
|
value={message}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setMessage(e.target.value)}
|
||||||
|
className="h-24 p-4 text-base rounded-xl border-slate-200 focus:border-[#25D366] focus:ring-[#25D366] resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-100"></div>
|
||||||
|
|
||||||
|
{/* Design Options */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Sparkles className="w-5 h-5 text-[#25D366]" />
|
||||||
|
Design Options
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Color Picker */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">QR Code Color</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{QR_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.name}
|
||||||
|
onClick={() => setQrColor(c.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-9 h-9 rounded-full border-2 flex items-center justify-center transition-all hover:scale-110",
|
||||||
|
qrColor === c.value ? "border-slate-900 ring-2 ring-offset-2 ring-slate-200" : "border-white shadow-md"
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: c.value }}
|
||||||
|
aria-label={`Select ${c.name}`}
|
||||||
|
title={c.name}
|
||||||
|
>
|
||||||
|
{qrColor === c.value && <Check className="w-4 h-4 text-white" strokeWidth={3} />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Frame Selector */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">Frame Label</label>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{FRAME_OPTIONS.map((frame) => (
|
||||||
|
<button
|
||||||
|
key={frame.id}
|
||||||
|
onClick={() => setFrameType(frame.id)}
|
||||||
|
className={cn(
|
||||||
|
"py-2.5 px-3 rounded-lg text-sm font-medium transition-all border",
|
||||||
|
frameType === frame.id
|
||||||
|
? "bg-[#25D366] text-white border-[#25D366]"
|
||||||
|
: "bg-slate-50 text-slate-600 border-slate-200 hover:border-slate-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{frame.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT: Preview Section */}
|
||||||
|
<div className="p-8 lg:p-10 flex flex-col items-center justify-center" style={{ backgroundColor: BRAND.paleGrey }}>
|
||||||
|
|
||||||
|
{/* QR Card with Frame */}
|
||||||
|
<div
|
||||||
|
ref={qrRef}
|
||||||
|
className="bg-white rounded-3xl shadow-xl p-8 flex flex-col items-center"
|
||||||
|
style={{ minWidth: '320px' }}
|
||||||
|
>
|
||||||
|
{/* Frame Label */}
|
||||||
|
{getFrameLabel() && (
|
||||||
|
<div
|
||||||
|
className="mb-5 px-8 py-2.5 rounded-full text-white font-bold text-sm tracking-widest uppercase shadow-md"
|
||||||
|
style={{ backgroundColor: qrColor }}
|
||||||
|
>
|
||||||
|
{getFrameLabel()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* QR Code */}
|
||||||
|
<div className="bg-white">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={getUrl()}
|
||||||
|
size={240}
|
||||||
|
level="M"
|
||||||
|
includeMargin={false}
|
||||||
|
fgColor={qrColor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Preview */}
|
||||||
|
<div className="mt-6 text-center max-w-[260px]">
|
||||||
|
<h3 className="font-bold text-slate-900 text-lg flex items-center justify-center gap-2 truncate">
|
||||||
|
<Phone className="w-4 h-4 text-slate-400 shrink-0" />
|
||||||
|
<span className="truncate">{phone ? `+${phone}` : 'Number'}</span>
|
||||||
|
</h3>
|
||||||
|
<div className="text-xs text-slate-500 mt-1">Starts WhatsApp Chat</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Download Buttons */}
|
||||||
|
<div className="flex items-center gap-3 mt-8">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('png')}
|
||||||
|
className="bg-[#25D366] hover:bg-[#128C7E] text-white shadow-lg"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Download PNG
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('svg')}
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-300 hover:bg-white"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
SVG
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500 mt-4 text-center">
|
||||||
|
Scanning starts a chat with this number instantly.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upsell Banner */}
|
||||||
|
<div className="mt-8 bg-gradient-to-r from-[#128C7E] to-[#25D366] rounded-2xl p-6 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="text-white text-center sm:text-left">
|
||||||
|
<h3 className="font-bold text-lg">Using WhatsApp for Business?</h3>
|
||||||
|
<p className="text-white/80 text-sm mt-1">
|
||||||
|
Track how many customers contact you via QR code analytics with our Pro plan.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/signup">
|
||||||
|
<Button className="bg-white text-[#128C7E] hover:bg-slate-100 shrink-0 shadow-lg">
|
||||||
|
Get Business Analytics
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
363
src/app/(marketing)/tools/whatsapp-qr-code/page.tsx
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
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';
|
||||||
|
|
||||||
|
// SEO Optimized Metadata
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Free WhatsApp QR Code Generator | Start Chats Instantly | QR Master',
|
||||||
|
description: 'Create a QR code that opens a WhatsApp chat with you. Add a pre-filled message. Perfect for customer support and sales.',
|
||||||
|
keywords: ['whatsapp qr code', 'wa.me generator', 'whatsapp chat qr', 'whatsapp link generator', 'contact qr code'],
|
||||||
|
alternates: {
|
||||||
|
canonical: 'https://qrmaster.io/tools/whatsapp-qr-code',
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: 'Free WhatsApp QR Code Generator | QR Master',
|
||||||
|
description: 'Generate QR codes to start WhatsApp chats. Add a pre-filled message.',
|
||||||
|
type: 'website',
|
||||||
|
url: 'https://qrmaster.io/tools/whatsapp-qr-code',
|
||||||
|
images: [{ url: '/og-whatsapp-generator.png', width: 1200, height: 630 }],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: 'Free WhatsApp QR Code Generator',
|
||||||
|
description: 'Create QR codes for WhatsApp. Chat instantly.',
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// JSON-LD Structured Data
|
||||||
|
const jsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@graph': [
|
||||||
|
{
|
||||||
|
'@type': 'SoftwareApplication',
|
||||||
|
name: 'WhatsApp QR Code Generator',
|
||||||
|
applicationCategory: 'UtilitiesApplication',
|
||||||
|
operatingSystem: 'Web Browser',
|
||||||
|
offers: {
|
||||||
|
'@type': 'Offer',
|
||||||
|
price: '0',
|
||||||
|
priceCurrency: 'USD',
|
||||||
|
},
|
||||||
|
aggregateRating: {
|
||||||
|
'@type': 'AggregateRating',
|
||||||
|
ratingValue: '4.8',
|
||||||
|
ratingCount: '2300',
|
||||||
|
},
|
||||||
|
description: 'Generate QR codes that start a WhatsApp conversation with a specific number.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowTo',
|
||||||
|
name: 'How to Create a WhatsApp QR Code',
|
||||||
|
description: 'Create a QR code that opens a WhatsApp chat.',
|
||||||
|
step: [
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 1,
|
||||||
|
name: 'Enter Number',
|
||||||
|
text: 'Type your WhatsApp phone number with country code (e.g. 1555...).',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 2,
|
||||||
|
name: 'Customize',
|
||||||
|
text: 'Add a pre-written message and choose your brand color.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 3,
|
||||||
|
name: 'Download',
|
||||||
|
text: 'Save the QR code.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 4,
|
||||||
|
name: 'Test',
|
||||||
|
text: 'Scan the code to ensure it opens the correct chat.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 5,
|
||||||
|
name: 'Share',
|
||||||
|
text: 'Add it to your website, business cards, or support materials.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalTime: 'PT45S',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: [
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Do users need to save my number first?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'No! That is the best part. Scanning the code opens the chat immediately without them needing to save you as a contact first.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Can I use this for WhatsApp Business?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes, it works perfectly for both personal and business accounts.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'What is the Pre-filled Message?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'It is text that automatically appears in the user\'s typing field when they scan the code (e.g., "Hello, I want to order pizza"). They just have to hit send.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Is it free?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes, this generator is completely free.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Can I track scans?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'This is a static QR code, so tracking is not included. Use our Dynamic QR Code generator for analytics.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WhatsappQRCodePage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
/>
|
||||||
|
<ToolBreadcrumb toolName="WhatsApp QR Code Generator" toolSlug="whatsapp-qr-code" />
|
||||||
|
|
||||||
|
<div className="min-h-screen" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
|
||||||
|
{/* HERO SECTION */}
|
||||||
|
<section className="relative pt-20 pb-20 lg:pt-32 lg:pb-32 px-4 sm:px-6 lg:px-8 overflow-hidden bg-[#128C7E]">
|
||||||
|
<div className="absolute inset-0 opacity-10">
|
||||||
|
{/* WhatsApp Pattern */}
|
||||||
|
<svg className="w-full h-full" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<pattern id="wa_pattern" width="60" height="60" patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M30 30L35 35M25 35L30 30" stroke="white" strokeWidth="2" strokeOpacity="0.2" />
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="url(#wa_pattern)" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto grid lg:grid-cols-2 gap-12 items-center relative z-10">
|
||||||
|
<div className="text-center lg:text-left">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/10 text-white/90 text-sm font-medium mb-6 backdrop-blur-sm border border-white/10 hover:bg-white/20 transition-colors cursor-default">
|
||||||
|
<span className="flex h-2 w-2 relative">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-300 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-300"></span>
|
||||||
|
</span>
|
||||||
|
Free Tool — No Signup Required
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl md:text-5xl lg:text-6xl font-extrabold text-white tracking-tight leading-tight mb-6">
|
||||||
|
Start Chats Instantly with <br className="hidden lg:block" />
|
||||||
|
<span className="text-white drop-shadow-md">WhatsApp QR Codes</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-lg md:text-xl text-green-50 max-w-2xl mx-auto lg:mx-0 mb-8 leading-relaxed">
|
||||||
|
Let customers message you without saving your number.
|
||||||
|
<strong className="text-white block sm:inline mt-2 sm:mt-0"> Perfect for support, sales, and bookings.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center lg:justify-start gap-4 text-sm font-medium text-white/80">
|
||||||
|
<div className="flex items-center gap-2 bg-white/10 px-4 py-2.5 rounded-xl border border-white/10 backdrop-blur-sm">
|
||||||
|
<Send className="w-4 h-4 text-green-200" />
|
||||||
|
Instant Chat
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/10 px-4 py-2.5 rounded-xl border border-white/10 backdrop-blur-sm">
|
||||||
|
<MessageCircle className="w-4 h-4 text-white" />
|
||||||
|
Pre-filled Msg
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/10 px-4 py-2.5 rounded-xl border border-white/10 backdrop-blur-sm">
|
||||||
|
<Phone className="w-4 h-4 text-white" />
|
||||||
|
No Save Contact
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visual Abstract */}
|
||||||
|
<div className="hidden lg:flex relative items-center justify-center min-h-[400px]">
|
||||||
|
<div className="absolute w-[500px] h-[500px] bg-green-500/30 rounded-full blur-[100px] -top-20 -right-20 animate-pulse" />
|
||||||
|
|
||||||
|
<div className="relative w-80 h-96 bg-white/5 backdrop-blur-xl border border-white/20 rounded-3xl shadow-2xl flex flex-col items-center justify-center p-8 transform -rotate-3 hover:rotate-0 transition-all duration-700 group">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-white/10 to-transparent rounded-3xl" />
|
||||||
|
|
||||||
|
<div className="w-full bg-[#ECE5DD] rounded-xl shadow-lg h-40 mb-6 relative overflow-hidden flex flex-col justify-end p-4">
|
||||||
|
{/* Chat Bubble Right */}
|
||||||
|
<div className="bg-[#DCF8C6] p-2 rounded-lg self-end mb-2 max-w-[80%] text-[10px] text-slate-800 shadow-sm">
|
||||||
|
Hi! I'd like to book an appointment.
|
||||||
|
<div className="text-[8px] text-slate-500 text-right mt-0.5">10:42 AM <span className="text-blue-500">✓✓</span></div>
|
||||||
|
</div>
|
||||||
|
{/* Chat Bubble Left */}
|
||||||
|
<div className="bg-white p-2 rounded-lg self-start max-w-[80%] text-[10px] text-slate-800 shadow-sm">
|
||||||
|
Sure! What time works for you?
|
||||||
|
<div className="text-[8px] text-slate-500 text-right mt-0.5">10:43 AM</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-44 h-44 bg-white rounded-xl p-2 shadow-inner relative overflow-hidden flex items-center justify-center">
|
||||||
|
<QRCodeSVG value="https://www.qrmaster.net" size={160} fgColor="#128C7E" level="Q" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Badge */}
|
||||||
|
<div className="absolute -bottom-6 -left-6 bg-white py-3 px-5 rounded-xl shadow-xl flex items-center gap-3 transform transition-transform hover:scale-105 duration-300">
|
||||||
|
<div className="bg-green-100 p-2 rounded-full">
|
||||||
|
<MessageCircle className="w-5 h-5 text-[#25D366]" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-[10px] text-slate-400 font-bold uppercase tracking-wider">WhatsApp</div>
|
||||||
|
<div className="text-sm font-bold text-slate-900">Start Chat</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* GENERATOR SECTION */}
|
||||||
|
<section className="py-12 px-4 sm:px-6 lg:px-8 -mt-8">
|
||||||
|
<WhatsappGenerator />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* HOW IT WORKS */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8 bg-white">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-12">
|
||||||
|
How WhatsApp QR Codes Work
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 lg:grid-cols-5 gap-8">
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-[#25D366]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Phone className="w-7 h-7 text-[#25D366]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">1. Your Number</h3>
|
||||||
|
<p className="text-slate-600 text-sm">
|
||||||
|
Enter your WhatsApp phone number. Ensure it includes the country code.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#25D366]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<MessageCircle className="w-6 h-6 text-[#25D366]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">2. Customize</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Add a message and choose your color.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#25D366]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Download className="w-6 h-6 text-[#25D366]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">3. Download</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Save your QR code.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#25D366]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Check className="w-6 h-6 text-[#25D366]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">4. Test</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Scan to ensure it works.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#25D366]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Send className="w-6 h-6 text-[#25D366]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">5. Chat</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Start chatting instantly.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* FAQ SECTION */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-4">
|
||||||
|
Frequently Asked Questions
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-600 text-center mb-10">
|
||||||
|
Common questions about WhatsApp QR codes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FaqItem
|
||||||
|
question="Does it work internationally?"
|
||||||
|
answer="Yes. Since you include the country code, it works for anyone, anywhere in the world."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Can I use this on my website?"
|
||||||
|
answer="Yes, you can display the QR code on your contact page so desktop users can easily scan it to chat on their phone."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Is my phone number visible?"
|
||||||
|
answer="Yes, the phone number is embedded in the link. It is the same visibility as putting your phone number on a business card."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Is it free?"
|
||||||
|
answer="Yes, this generator is completely free."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Can I track scans?"
|
||||||
|
answer="This is a static QR code, so tracking is not included. Use our Dynamic QR Code generator for analytics."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FaqItem({ question, answer }: { question: string; answer: string }) {
|
||||||
|
return (
|
||||||
|
<details className="group bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||||
|
<summary className="flex items-center justify-between p-5 cursor-pointer list-none text-slate-900 font-semibold hover:bg-slate-50 transition-colors">
|
||||||
|
{question}
|
||||||
|
<span className="transition group-open:rotate-180 text-slate-400">
|
||||||
|
<svg fill="none" height="20" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width="20">
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div className="text-slate-600 px-5 pb-5 pt-0 leading-relaxed text-sm">
|
||||||
|
{answer}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
307
src/app/(marketing)/tools/wifi-qr-code/WiFiGenerator.tsx
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Wifi,
|
||||||
|
Download,
|
||||||
|
Printer,
|
||||||
|
Check,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Sparkles
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Select } from '@/components/ui/Select';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// Brand Colors
|
||||||
|
const BRAND = {
|
||||||
|
paleGrey: '#F0F9FF', // Sky-50
|
||||||
|
primary: '#0EA5E9', // Sky-500
|
||||||
|
primaryDark: '#0284C7', // Sky-600
|
||||||
|
};
|
||||||
|
|
||||||
|
// QR Color Options
|
||||||
|
const QR_COLORS = [
|
||||||
|
{ name: 'Sky Blue', value: '#0EA5E9' },
|
||||||
|
{ name: 'Classic Black', value: '#000000' },
|
||||||
|
{ name: 'Deep Blue', value: '#1E40AF' },
|
||||||
|
{ name: 'Cyan', value: '#06B6D4' },
|
||||||
|
{ name: 'Violet', value: '#7C3AED' },
|
||||||
|
{ name: 'Emerald', value: '#10B981' },
|
||||||
|
{ name: 'Rose', value: '#F43F5E' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Frame Options
|
||||||
|
const FRAME_OPTIONS = [
|
||||||
|
{ id: 'none', label: 'No Frame' },
|
||||||
|
{ id: 'scanme', label: 'Scan Me' },
|
||||||
|
{ id: 'wifi', label: 'WiFi' },
|
||||||
|
{ id: 'connect', label: 'Connect' },
|
||||||
|
{ id: 'free', label: 'Free WiFi' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function WiFiGenerator() {
|
||||||
|
const [ssid, setSsid] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [encryption, setEncryption] = useState('WPA');
|
||||||
|
const [hidden, setHidden] = useState(false);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
|
// Customization
|
||||||
|
const [qrColor, setQrColor] = useState(BRAND.primary);
|
||||||
|
const [frameType, setFrameType] = useState('none');
|
||||||
|
|
||||||
|
const qrRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const wifiString = `WIFI:T:${encryption};S:${ssid};P:${password};H:${hidden};;`;
|
||||||
|
|
||||||
|
const handleDownload = async (format: 'png' | 'svg') => {
|
||||||
|
if (!qrRef.current) return;
|
||||||
|
try {
|
||||||
|
if (format === 'png') {
|
||||||
|
const { toPng } = await import('html-to-image');
|
||||||
|
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3 });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = `wifi-qr-${ssid || 'code'}.png`;
|
||||||
|
link.href = dataUrl;
|
||||||
|
link.click();
|
||||||
|
} else {
|
||||||
|
const svgData = qrRef.current.querySelector('svg')?.outerHTML;
|
||||||
|
if (svgData) {
|
||||||
|
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `wifi-qr-${ssid || 'code'}.svg`;
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Download failed', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFrameLabel = () => {
|
||||||
|
const frame = FRAME_OPTIONS.find(f => f.id === frameType);
|
||||||
|
return frame?.id !== 'none' ? frame?.label : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-5xl mx-auto px-4 md:px-6">
|
||||||
|
|
||||||
|
{/* Main Generator Card */}
|
||||||
|
<div className="bg-white rounded-3xl shadow-2xl shadow-slate-900/10 overflow-hidden border border-slate-100">
|
||||||
|
<div className="grid lg:grid-cols-2">
|
||||||
|
|
||||||
|
{/* LEFT: Input Section */}
|
||||||
|
<div className="p-8 lg:p-10 space-y-8 border-r border-slate-100">
|
||||||
|
|
||||||
|
{/* Network Details */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Wifi className="w-5 h-5 text-[#0EA5E9]" />
|
||||||
|
Network Details
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Network Name (SSID)</label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. MyHomeWiFi"
|
||||||
|
value={ssid}
|
||||||
|
onChange={(e) => setSsid(e.target.value)}
|
||||||
|
className="h-12 text-base rounded-xl border-slate-200 focus:border-[#0EA5E9] focus:ring-[#0EA5E9]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Password</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
placeholder="Your WiFi Password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="h-12 text-base rounded-xl border-slate-200 focus:border-[#0EA5E9] focus:ring-[#0EA5E9] pr-12"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 text-slate-400 hover:text-slate-600 transition-colors"
|
||||||
|
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Security</label>
|
||||||
|
<Select
|
||||||
|
value={encryption}
|
||||||
|
onChange={(e) => setEncryption(e.target.value)}
|
||||||
|
className="h-12 rounded-xl border-slate-200"
|
||||||
|
options={[
|
||||||
|
{ value: 'WPA', label: 'WPA / WPA2' },
|
||||||
|
{ value: 'WEP', label: 'WEP' },
|
||||||
|
{ value: 'nopass', label: 'No Password' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end pb-1">
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer group">
|
||||||
|
<div className={cn(
|
||||||
|
"w-5 h-5 rounded border-2 flex items-center justify-center transition-all",
|
||||||
|
hidden ? "bg-[#1A1265] border-[#1A1265]" : "border-slate-300 group-hover:border-slate-400"
|
||||||
|
)}>
|
||||||
|
{hidden && <Check className="w-3.5 h-3.5 text-white" strokeWidth={3} />}
|
||||||
|
</div>
|
||||||
|
<input type="checkbox" checked={hidden} onChange={(e) => setHidden(e.target.checked)} className="sr-only" />
|
||||||
|
<span className="text-sm font-medium text-slate-700">Hidden Network</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="border-t border-slate-100"></div>
|
||||||
|
|
||||||
|
{/* Design Options */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Sparkles className="w-5 h-5 text-[#0EA5E9]" />
|
||||||
|
Design Options
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Color Picker */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">QR Code Color</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{QR_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.name}
|
||||||
|
onClick={() => setQrColor(c.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-9 h-9 rounded-full border-2 flex items-center justify-center transition-all hover:scale-110",
|
||||||
|
qrColor === c.value ? "border-slate-900 ring-2 ring-offset-2 ring-slate-200" : "border-white shadow-md"
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: c.value }}
|
||||||
|
aria-label={`Select ${c.name}`}
|
||||||
|
title={c.name}
|
||||||
|
>
|
||||||
|
{qrColor === c.value && <Check className="w-4 h-4 text-white" strokeWidth={3} />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Frame Selector */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">Frame Label</label>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{FRAME_OPTIONS.map((frame) => (
|
||||||
|
<button
|
||||||
|
key={frame.id}
|
||||||
|
onClick={() => setFrameType(frame.id)}
|
||||||
|
className={cn(
|
||||||
|
"py-2.5 px-3 rounded-lg text-sm font-medium transition-all border",
|
||||||
|
frameType === frame.id
|
||||||
|
? "bg-[#0EA5E9] text-white border-[#0EA5E9]"
|
||||||
|
: "bg-slate-50 text-slate-600 border-slate-200 hover:border-slate-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{frame.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT: Preview Section */}
|
||||||
|
<div className="p-8 lg:p-10 flex flex-col items-center justify-center" style={{ backgroundColor: BRAND.paleGrey }}>
|
||||||
|
|
||||||
|
{/* QR Card with Frame */}
|
||||||
|
<div
|
||||||
|
ref={qrRef}
|
||||||
|
className="bg-white rounded-3xl shadow-xl p-8 flex flex-col items-center"
|
||||||
|
style={{ minWidth: '320px' }}
|
||||||
|
>
|
||||||
|
{/* Frame Label - CTA Button Style */}
|
||||||
|
{getFrameLabel() && (
|
||||||
|
<div
|
||||||
|
className="mb-5 px-8 py-2.5 rounded-full text-white font-bold text-sm tracking-widest uppercase shadow-md"
|
||||||
|
style={{ backgroundColor: qrColor }}
|
||||||
|
>
|
||||||
|
{getFrameLabel()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* QR Code */}
|
||||||
|
<div className="bg-white">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={wifiString}
|
||||||
|
size={240}
|
||||||
|
level="M"
|
||||||
|
includeMargin={false}
|
||||||
|
fgColor={qrColor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Network Info */}
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<h3 className="font-bold text-slate-900 text-xl truncate max-w-[260px] mx-auto">
|
||||||
|
{ssid || 'Network Name'}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Download Buttons */}
|
||||||
|
<div className="flex items-center gap-3 mt-8">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('png')}
|
||||||
|
className="bg-[#0EA5E9] hover:bg-[#0284C7] text-white shadow-lg"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Download PNG
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('svg')}
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-300 hover:bg-white"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
SVG
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500 mt-4 text-center">
|
||||||
|
Your credentials stay on your device. Nothing is sent to servers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upsell Banner */}
|
||||||
|
<div className="mt-8 bg-gradient-to-r from-[#0EA5E9] to-[#0284C7] rounded-2xl p-6 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="text-white text-center sm:text-left">
|
||||||
|
<h3 className="font-bold text-lg">Hospitality or Business WiFi?</h3>
|
||||||
|
<p className="text-white/80 text-sm mt-1">Get scan analytics and collect customer reviews with our Pro plan.</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/signup">
|
||||||
|
<Button className="bg-white text-[#0EA5E9] hover:bg-slate-100 shrink-0 shadow-lg">
|
||||||
|
Get Business Tools
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
369
src/app/(marketing)/tools/wifi-qr-code/page.tsx
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
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';
|
||||||
|
|
||||||
|
// SEO Optimized Metadata
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Free WiFi QR Code Generator | Instant & Secure | QR Master',
|
||||||
|
description: 'Create a WiFi QR code in seconds. Guests scan to connect instantly—no typing passwords. 100% private: your credentials never leave your browser. Free forever.',
|
||||||
|
keywords: ['wifi qr code', 'qr code generator', 'wifi qr code generator', 'share wifi', 'wifi password qr', 'guest wifi'],
|
||||||
|
alternates: {
|
||||||
|
canonical: 'https://qrmaster.io/tools/wifi-qr-code',
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: 'Free WiFi QR Code Generator | QR Master',
|
||||||
|
description: 'Share your WiFi without sharing your password. Guests scan the QR code to connect instantly.',
|
||||||
|
type: 'website',
|
||||||
|
url: 'https://qrmaster.io/tools/wifi-qr-code',
|
||||||
|
images: [{ url: '/og-wifi-generator.png', width: 1200, height: 630 }],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: 'Free WiFi QR Code Generator',
|
||||||
|
description: 'Share WiFi instantly with a QR code. No typing passwords.',
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// JSON-LD Structured Data
|
||||||
|
const jsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@graph': [
|
||||||
|
// SoftwareApplication Schema
|
||||||
|
{
|
||||||
|
'@type': 'SoftwareApplication',
|
||||||
|
name: 'WiFi QR Code Generator',
|
||||||
|
applicationCategory: 'UtilitiesApplication',
|
||||||
|
operatingSystem: 'Web Browser',
|
||||||
|
offers: {
|
||||||
|
'@type': 'Offer',
|
||||||
|
price: '0',
|
||||||
|
priceCurrency: 'USD',
|
||||||
|
},
|
||||||
|
aggregateRating: {
|
||||||
|
'@type': 'AggregateRating',
|
||||||
|
ratingValue: '4.9',
|
||||||
|
ratingCount: '2847',
|
||||||
|
},
|
||||||
|
description: 'Generate QR codes for WiFi networks. Guests scan to connect without typing passwords.',
|
||||||
|
},
|
||||||
|
// HowTo Schema for Featured Snippets
|
||||||
|
{
|
||||||
|
'@type': 'HowTo',
|
||||||
|
name: 'How to Create a WiFi QR Code',
|
||||||
|
description: 'Create a QR code that connects devices to your WiFi network automatically.',
|
||||||
|
step: [
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 1,
|
||||||
|
name: 'Enter Network Name',
|
||||||
|
text: 'Type your WiFi network name (SSID) in the Network Name field.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 2,
|
||||||
|
name: 'Enter Password',
|
||||||
|
text: 'Enter your WiFi password. This is processed locally and never sent to any server.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 3,
|
||||||
|
name: 'Select Security Type',
|
||||||
|
text: 'Choose WPA/WPA2 (most common), WEP, or No Password for open networks.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 4,
|
||||||
|
name: 'Download QR Code',
|
||||||
|
text: 'Click Download PNG or SVG to save your QR code. Print it or share digitally.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 5,
|
||||||
|
name: 'Connect',
|
||||||
|
text: 'Print the code. Guests can scan it to join your network instantly.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalTime: 'PT1M',
|
||||||
|
},
|
||||||
|
// FAQPage Schema
|
||||||
|
{
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: [
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Is it safe to enter my WiFi password?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes, completely safe. This tool processes everything in your browser (client-side). Your password never leaves your device and is not sent to any server.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Do WiFi QR codes work on iPhone and Android?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes. Both iOS (11+) and Android devices can scan WiFi QR codes using their built-in camera app. No additional apps required.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'What happens if I change my WiFi password?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'You will need to generate a new QR code with the updated password. Consider using dynamic QR codes if you change passwords frequently.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Can I customize the QR code design?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes. You can change the QR code color and add frame labels like "Scan Me" or "WiFi" to make it more recognizable.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Does it work for hidden networks?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes, just check the "Hidden Network" box if your SSID is hidden. The QR code contains the standard WiFi string configuration.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WiFiQRCodePage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* JSON-LD Script */}
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
/>
|
||||||
|
<ToolBreadcrumb toolName="WiFi QR Code Generator" toolSlug="wifi-qr-code" />
|
||||||
|
|
||||||
|
<div className="min-h-screen" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
|
||||||
|
{/* HERO SECTION */}
|
||||||
|
<section className="relative pt-20 pb-20 lg:pt-32 lg:pb-32 px-4 sm:px-6 lg:px-8 overflow-hidden" style={{ backgroundColor: '#1A1265' }}>
|
||||||
|
{/* Background Pattern */}
|
||||||
|
<div className="absolute inset-0 opacity-10">
|
||||||
|
<svg className="w-full h-full" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||||
|
<path d="M0 100 C 20 0 50 0 100 100 Z" fill="url(#grad1)" />
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" style={{ stopColor: 'white', stopOpacity: 1 }} />
|
||||||
|
<stop offset="100%" style={{ stopColor: 'white', stopOpacity: 0 }} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto grid lg:grid-cols-2 gap-12 items-center relative z-10">
|
||||||
|
|
||||||
|
{/* Left: Text Content */}
|
||||||
|
<div className="text-center lg:text-left">
|
||||||
|
{/* Badge */}
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/10 text-white/90 text-sm font-medium mb-6 backdrop-blur-sm border border-white/10 hover:bg-white/20 transition-colors cursor-default">
|
||||||
|
<span className="flex h-2 w-2 relative">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-400"></span>
|
||||||
|
</span>
|
||||||
|
Free Tool — No Signup Required
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl md:text-5xl lg:text-6xl font-extrabold text-white tracking-tight leading-tight mb-6">
|
||||||
|
The Safest Way to <br className="hidden lg:block" />
|
||||||
|
<span className="text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-cyan-400">Share Your WiFi</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-lg md:text-xl text-indigo-100 max-w-2xl mx-auto lg:mx-0 mb-8 leading-relaxed">
|
||||||
|
Generate a secure QR code in seconds. No more spelling out complicated passwords.
|
||||||
|
<strong className="text-white block sm:inline mt-2 sm:mt-0"> 100% Client-Side & Private.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center lg:justify-start gap-4 text-sm font-medium text-white/80">
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/5 backdrop-blur-sm">
|
||||||
|
<Lock className="w-4 h-4 text-emerald-400" />
|
||||||
|
No Server Uploads
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/5 backdrop-blur-sm">
|
||||||
|
<Zap className="w-4 h-4 text-amber-400" />
|
||||||
|
Instant Connect
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/5 backdrop-blur-sm">
|
||||||
|
<Smartphone className="w-4 h-4 text-purple-400" />
|
||||||
|
iOS & Android
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Visual Abstract Composition */}
|
||||||
|
<div className="hidden lg:flex relative items-center justify-center min-h-[400px]">
|
||||||
|
{/* Decorative Glow */}
|
||||||
|
<div className="absolute w-[500px] h-[500px] bg-indigo-500/30 rounded-full blur-[100px] -top-20 -right-20 animate-pulse" />
|
||||||
|
|
||||||
|
{/* Floating Glass Card */}
|
||||||
|
<div className="relative w-80 h-96 bg-white/5 backdrop-blur-xl border border-white/20 rounded-3xl shadow-2xl flex flex-col items-center justify-center p-8 transform rotate-6 hover:rotate-3 transition-all duration-700 group">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-white/10 to-transparent rounded-3xl" />
|
||||||
|
|
||||||
|
{/* Mock QR */}
|
||||||
|
<div className="w-48 h-48 bg-white rounded-xl p-2 shadow-inner mb-6 relative overflow-hidden flex items-center justify-center">
|
||||||
|
<QRCodeSVG value="https://www.qrmaster.net" size={170} fgColor="#0f172a" level="Q" />
|
||||||
|
{/* Scan Line */}
|
||||||
|
<div className="absolute top-1/2 left-0 w-full h-1 bg-emerald-500 shadow-[0_0_20px_rgba(16,185,129,1)] animate-pulse" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full space-y-3">
|
||||||
|
<div className="h-2 w-32 bg-white/20 rounded-full mx-auto" />
|
||||||
|
<div className="h-2 w-20 bg-white/10 rounded-full mx-auto" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Badge */}
|
||||||
|
<div className="absolute -bottom-6 -left-6 bg-white py-3 px-5 rounded-xl shadow-xl flex items-center gap-3 transform transition-transform hover:scale-105 duration-300">
|
||||||
|
<div className="bg-emerald-100 p-2 rounded-full">
|
||||||
|
<Wifi className="w-5 h-5 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-[10px] text-slate-400 font-bold uppercase tracking-wider">Status</div>
|
||||||
|
<div className="text-sm font-bold text-slate-900">Connected</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* GENERATOR SECTION */}
|
||||||
|
<section className="py-12 px-4 sm:px-6 lg:px-8 -mt-8">
|
||||||
|
<WiFiGenerator />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* HOW IT WORKS - AEO/GEO Content */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8 bg-white">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-12">
|
||||||
|
How WiFi QR Codes Work
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 lg:grid-cols-5 gap-8">
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#1A1265]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Wifi className="w-6 h-6 text-[#1A1265]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">1. Network</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Enter your WiFi SSID and password.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#1A1265]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Shield className="w-6 h-6 text-[#1A1265]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">2. Security</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Select WPA/WPA2 encryption.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#1A1265]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Zap className="w-6 h-6 text-[#1A1265]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">3. Style</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Customize colors and add a frame.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#1A1265]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Download className="w-6 h-6 text-[#1A1265]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">4. Download</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Get your high-quality QR image.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#1A1265]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Smartphone className="w-6 h-6 text-[#1A1265]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">5. Connect</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Print it out. Guests scan to join!
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 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">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-4">
|
||||||
|
Frequently Asked Questions
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-600 text-center mb-10">
|
||||||
|
Everything you need to know about WiFi QR codes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FaqItem
|
||||||
|
question="Is it safe to enter my WiFi password here?"
|
||||||
|
answer="Yes, completely safe. This tool uses client-side processing, meaning your WiFi password never leaves your device. It's processed locally in your browser to generate the QR code—no data is sent to any server."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Do WiFi QR codes work on iPhone and Android?"
|
||||||
|
answer="Yes. iOS 11 and later, as well as all modern Android devices, can scan WiFi QR codes using the built-in camera app. Simply point the camera at the QR code and tap the notification to connect."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="What happens if I change my WiFi password?"
|
||||||
|
answer="If you change your WiFi password, the old QR code will stop working. You'll need to generate a new QR code with the updated credentials. For frequently changing passwords, consider using dynamic QR codes."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Can I customize the QR code design?"
|
||||||
|
answer="Yes. You can change the foreground color of the QR code and add frame labels such as 'Scan Me', 'WiFi', or 'Connect' to make your QR code more recognizable and user-friendly."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Does it work for hidden networks?"
|
||||||
|
answer="Yes, just check the 'Hidden Network' box if your SSID is hidden. The QR code contains the standard WiFi string configuration."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// FAQ Item Component
|
||||||
|
function FaqItem({ question, answer }: { question: string; answer: string }) {
|
||||||
|
return (
|
||||||
|
<details className="group bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||||
|
<summary className="flex items-center justify-between p-5 cursor-pointer list-none text-slate-900 font-semibold hover:bg-slate-50 transition-colors">
|
||||||
|
{question}
|
||||||
|
<span className="transition group-open:rotate-180 text-slate-400">
|
||||||
|
<svg fill="none" height="20" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width="20">
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div className="text-slate-600 px-5 pb-5 pt-0 leading-relaxed text-sm">
|
||||||
|
{answer}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
246
src/app/(marketing)/tools/youtube-qr-code/YouTubeGenerator.tsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import {
|
||||||
|
Youtube,
|
||||||
|
Download,
|
||||||
|
Check,
|
||||||
|
Sparkles,
|
||||||
|
Play
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// Brand Colors
|
||||||
|
const BRAND = {
|
||||||
|
paleGrey: '#EBEBDF',
|
||||||
|
richBlue: '#1A1265',
|
||||||
|
richBlueLight: '#2A2275',
|
||||||
|
};
|
||||||
|
|
||||||
|
// QR Color Options - YT Theme
|
||||||
|
const QR_COLORS = [
|
||||||
|
{ name: 'YouTube Red', value: '#FF0000' },
|
||||||
|
{ name: 'Classic Black', value: '#000000' },
|
||||||
|
{ name: 'Dark Blue', value: '#1A1265' },
|
||||||
|
{ name: 'Teal', value: '#0D9488' },
|
||||||
|
{ name: 'Grey', value: '#374151' },
|
||||||
|
{ name: 'Emerald', value: '#10B981' },
|
||||||
|
{ name: 'Rose', value: '#F43F5E' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Frame Options
|
||||||
|
const FRAME_OPTIONS = [
|
||||||
|
{ id: 'none', label: 'No Frame' },
|
||||||
|
{ id: 'scanme', label: 'Scan Me' },
|
||||||
|
{ id: 'watch', label: 'Watch' },
|
||||||
|
{ id: 'subscribe', label: 'Subscribe' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function YoutubeGenerator() {
|
||||||
|
const [url, setUrl] = useState('');
|
||||||
|
const [qrColor, setQrColor] = useState('#FF0000');
|
||||||
|
const [frameType, setFrameType] = useState('none');
|
||||||
|
|
||||||
|
const qrRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const handleDownload = async (format: 'png' | 'svg') => {
|
||||||
|
if (!qrRef.current) return;
|
||||||
|
try {
|
||||||
|
if (format === 'png') {
|
||||||
|
const { toPng } = await import('html-to-image');
|
||||||
|
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3 });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = `youtube-qr-code.png`;
|
||||||
|
link.href = dataUrl;
|
||||||
|
link.click();
|
||||||
|
} else {
|
||||||
|
const svgData = qrRef.current.querySelector('svg')?.outerHTML;
|
||||||
|
if (svgData) {
|
||||||
|
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `youtube-qr-code.svg`;
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Download failed', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFrameLabel = () => {
|
||||||
|
const frame = FRAME_OPTIONS.find(f => f.id === frameType);
|
||||||
|
return frame?.id !== 'none' ? frame?.label : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-5xl mx-auto px-4 md:px-6">
|
||||||
|
|
||||||
|
{/* Main Generator Card */}
|
||||||
|
<div className="bg-white rounded-3xl shadow-2xl shadow-slate-900/10 overflow-hidden border border-slate-100">
|
||||||
|
<div className="grid lg:grid-cols-2">
|
||||||
|
|
||||||
|
{/* LEFT: Input Section */}
|
||||||
|
<div className="p-8 lg:p-10 space-y-8 border-r border-slate-100">
|
||||||
|
|
||||||
|
{/* YouTube Details */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Youtube className="w-5 h-5 text-[#FF0000]" />
|
||||||
|
YouTube Video or Channel
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Video/Channel URL</label>
|
||||||
|
<Input
|
||||||
|
placeholder="https://youtube.com/watch?v=..."
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
className="h-12 text-base rounded-xl border-slate-200 focus:border-[#FF0000] focus:ring-[#FF0000]"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500 mt-2">Paste a link to any video, channel, or playlist.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-100"></div>
|
||||||
|
|
||||||
|
{/* Design Options */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Sparkles className="w-5 h-5 text-[#FF0000]" />
|
||||||
|
Design Options
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Color Picker */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">QR Code Color</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{QR_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.name}
|
||||||
|
onClick={() => setQrColor(c.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-9 h-9 rounded-full border-2 flex items-center justify-center transition-all hover:scale-110",
|
||||||
|
qrColor === c.value ? "border-slate-900 ring-2 ring-offset-2 ring-slate-200" : "border-white shadow-md"
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: c.value }}
|
||||||
|
aria-label={`Select ${c.name}`}
|
||||||
|
title={c.name}
|
||||||
|
>
|
||||||
|
{qrColor === c.value && <Check className="w-4 h-4 text-white" strokeWidth={3} />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Frame Selector */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">Frame Label</label>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{FRAME_OPTIONS.map((frame) => (
|
||||||
|
<button
|
||||||
|
key={frame.id}
|
||||||
|
onClick={() => setFrameType(frame.id)}
|
||||||
|
className={cn(
|
||||||
|
"py-2.5 px-3 rounded-lg text-sm font-medium transition-all border",
|
||||||
|
frameType === frame.id
|
||||||
|
? "bg-[#FF0000] text-white border-[#FF0000]"
|
||||||
|
: "bg-slate-50 text-slate-600 border-slate-200 hover:border-slate-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{frame.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT: Preview Section */}
|
||||||
|
<div className="p-8 lg:p-10 flex flex-col items-center justify-center" style={{ backgroundColor: BRAND.paleGrey }}>
|
||||||
|
|
||||||
|
{/* QR Card with Frame */}
|
||||||
|
<div
|
||||||
|
ref={qrRef}
|
||||||
|
className="bg-white rounded-3xl shadow-xl p-8 flex flex-col items-center"
|
||||||
|
style={{ minWidth: '320px' }}
|
||||||
|
>
|
||||||
|
{/* Frame Label */}
|
||||||
|
{getFrameLabel() && (
|
||||||
|
<div
|
||||||
|
className="mb-5 px-8 py-2.5 rounded-full text-white font-bold text-sm tracking-widest uppercase shadow-md"
|
||||||
|
style={{ backgroundColor: qrColor }}
|
||||||
|
>
|
||||||
|
{getFrameLabel()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* QR Code */}
|
||||||
|
<div className="bg-white">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={url || "https://youtube.com"}
|
||||||
|
size={240}
|
||||||
|
level="M"
|
||||||
|
includeMargin={false}
|
||||||
|
fgColor={qrColor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Preview */}
|
||||||
|
<div className="mt-6 text-center max-w-[260px]">
|
||||||
|
<h3 className="font-bold text-slate-900 text-lg flex items-center justify-center gap-2 truncate">
|
||||||
|
<Youtube className="w-4 h-4 text-slate-400 shrink-0" />
|
||||||
|
<span className="truncate">{url ? 'YouTube Content' : 'youtube.com'}</span>
|
||||||
|
</h3>
|
||||||
|
<div className="text-xs text-slate-500 mt-1">Opens in YouTube App</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Download Buttons */}
|
||||||
|
<div className="flex items-center gap-3 mt-8">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('png')}
|
||||||
|
className="bg-[#FF0000] hover:bg-[#cc0000] text-white shadow-lg"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Download PNG
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('svg')}
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-300 hover:bg-white"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
SVG
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500 mt-4 text-center">
|
||||||
|
Scanning redirects directly to the video or channel.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upsell Banner */}
|
||||||
|
<div className="mt-8 bg-gradient-to-r from-[#FF0000] to-[#cc0000] rounded-2xl p-6 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="text-white text-center sm:text-left">
|
||||||
|
<h3 className="font-bold text-lg">Promoting a Video Channel?</h3>
|
||||||
|
<p className="text-white/80 text-sm mt-1">
|
||||||
|
Dynamic QR Codes give you stats on scans, locations, and time of day.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/signup">
|
||||||
|
<Button className="bg-white text-[#FF0000] hover:bg-slate-100 shrink-0 shadow-lg">
|
||||||
|
Get Video Stats
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
358
src/app/(marketing)/tools/youtube-qr-code/page.tsx
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
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';
|
||||||
|
|
||||||
|
// SEO Optimized Metadata
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Free YouTube QR Code Generator | Get Views & Subscribers | QR Master',
|
||||||
|
description: 'Create a QR code for your YouTube video or channel. Scanners are redirected to the YouTube app instantly to watch. Free & Fast.',
|
||||||
|
keywords: ['youtube qr code', 'video qr code', 'youtube channel qr', 'youtube subscribe qr', 'social media qr code'],
|
||||||
|
alternates: {
|
||||||
|
canonical: 'https://qrmaster.io/tools/youtube-qr-code',
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: 'Free YouTube QR Code Generator | QR Master',
|
||||||
|
description: 'Generate QR codes to grow your YouTube channel. Instant video play.',
|
||||||
|
type: 'website',
|
||||||
|
url: 'https://qrmaster.io/tools/youtube-qr-code',
|
||||||
|
images: [{ url: '/og-youtube-generator.png', width: 1200, height: 630 }],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: 'Free YouTube QR Code Generator',
|
||||||
|
description: 'Create QR codes for YouTube videos. Get more views.',
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// JSON-LD Structured Data
|
||||||
|
const jsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@graph': [
|
||||||
|
{
|
||||||
|
'@type': 'SoftwareApplication',
|
||||||
|
name: 'YouTube QR Code Generator',
|
||||||
|
applicationCategory: 'UtilitiesApplication',
|
||||||
|
operatingSystem: 'Web Browser',
|
||||||
|
offers: {
|
||||||
|
'@type': 'Offer',
|
||||||
|
price: '0',
|
||||||
|
priceCurrency: 'USD',
|
||||||
|
},
|
||||||
|
aggregateRating: {
|
||||||
|
'@type': 'AggregateRating',
|
||||||
|
ratingValue: '4.8',
|
||||||
|
ratingCount: '1340',
|
||||||
|
},
|
||||||
|
description: 'Generate QR codes that direct users to a YouTube video or channel.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowTo',
|
||||||
|
name: 'How to Create a YouTube QR Code',
|
||||||
|
description: 'Create a QR code that opens a YouTube video.',
|
||||||
|
step: [
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 1,
|
||||||
|
name: 'Copy URL',
|
||||||
|
text: 'Copy the link of your YouTube video or channel.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 2,
|
||||||
|
name: 'Paste',
|
||||||
|
text: 'Paste the link into the generator input.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 3,
|
||||||
|
name: 'Customize',
|
||||||
|
text: 'Add a "Watch Now" frame or change the color to YouTube Red.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 4,
|
||||||
|
name: 'Download',
|
||||||
|
text: 'Save your QR code image for printing.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 5,
|
||||||
|
name: 'Share',
|
||||||
|
text: 'Place it on posters, merch, or video end screens.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalTime: 'PT30S',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: [
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Does it open the YouTube app?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes! If the user has the YouTube app installed, the QR code will automatically launch the app and play the video.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Can I link to a specific timestamp?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes. If you include the timestamp in your YouTube link (e.g., ?t=60s), the video will start playing from that exact moment.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Can I use this for a playlist?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Absolutely. Just paste the playlist URL, and users will be taken to the full list of videos.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Is it free?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes, this tool is 100% free forever.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Does it work for YouTube Shorts?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes, just paste the "Share" link from any YouTube Short.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function YoutubeQRCodePage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
/>
|
||||||
|
<ToolBreadcrumb toolName="YouTube QR Code Generator" toolSlug="youtube-qr-code" />
|
||||||
|
|
||||||
|
<div className="min-h-screen" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
|
||||||
|
{/* HERO SECTION */}
|
||||||
|
<section className="relative pt-20 pb-20 lg:pt-32 lg:pb-32 px-4 sm:px-6 lg:px-8 overflow-hidden bg-[#FF0000]">
|
||||||
|
<div className="absolute inset-0 opacity-10">
|
||||||
|
{/* Play Button Pattern */}
|
||||||
|
<svg className="w-full h-full" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<pattern id="yt_pattern" width="60" height="60" patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M20 20 L40 30 L20 40 Z" fill="white" />
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="url(#yt_pattern)" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto grid lg:grid-cols-2 gap-12 items-center relative z-10">
|
||||||
|
<div className="text-center lg:text-left">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/10 text-white/90 text-sm font-medium mb-6 backdrop-blur-sm border border-white/10 hover:bg-white/20 transition-colors cursor-default">
|
||||||
|
<span className="flex h-2 w-2 relative">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-300 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-300"></span>
|
||||||
|
</span>
|
||||||
|
Free Tool — No Signup Required
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl md:text-5xl lg:text-6xl font-extrabold text-white tracking-tight leading-tight mb-6">
|
||||||
|
Get More Views with <br className="hidden lg:block" />
|
||||||
|
<span className="text-white drop-shadow-md">YouTube QR Codes</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-lg md:text-xl text-red-50 max-w-2xl mx-auto lg:mx-0 mb-8 leading-relaxed">
|
||||||
|
From print to play in one scan. Direct your audience to your latest video, channel, or playlist instantly.
|
||||||
|
<strong className="text-white block sm:inline mt-2 sm:mt-0"> Boost subscriber growth.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center lg:justify-start gap-4 text-sm font-medium text-white/80">
|
||||||
|
<div className="flex items-center gap-2 bg-white/10 px-4 py-2.5 rounded-xl border border-white/10 backdrop-blur-sm">
|
||||||
|
<Play className="w-4 h-4 text-white" />
|
||||||
|
Instant Play
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/10 px-4 py-2.5 rounded-xl border border-white/10 backdrop-blur-sm">
|
||||||
|
<Radio className="w-4 h-4 text-white" />
|
||||||
|
Grow Channel
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/10 px-4 py-2.5 rounded-xl border border-white/10 backdrop-blur-sm">
|
||||||
|
<Smartphone className="w-4 h-4 text-white" />
|
||||||
|
App Friendly
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visual Abstract */}
|
||||||
|
<div className="hidden lg:flex relative items-center justify-center min-h-[400px]">
|
||||||
|
<div className="absolute w-[500px] h-[500px] bg-red-600/30 rounded-full blur-[100px] -top-20 -right-20 animate-pulse" />
|
||||||
|
|
||||||
|
<div className="relative w-80 h-96 bg-white/5 backdrop-blur-xl border border-white/20 rounded-3xl shadow-2xl flex flex-col items-center justify-center p-8 transform rotate-3 hover:rotate-0 transition-all duration-700 group">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-white/10 to-transparent rounded-3xl" />
|
||||||
|
|
||||||
|
<div className="w-full bg-black rounded-xl shadow-lg h-40 mb-6 relative overflow-hidden group-hover:scale-105 transition-transform flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1611162617474-5b21e879e113?q=80&w=1000&auto=format&fit=crop')] bg-cover bg-center opacity-70"></div>
|
||||||
|
<div className="w-12 h-12 bg-red-600 rounded-full flex items-center justify-center relative z-10 shadow-xl">
|
||||||
|
<Play className="w-6 h-6 text-white ml-1" fill="white" />
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-2 right-2 bg-black/80 px-2 rounded text-xs text-white font-bold">10:24</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-44 h-44 bg-white rounded-xl p-2 shadow-inner relative overflow-hidden flex items-center justify-center">
|
||||||
|
<QRCodeSVG value="https://www.qrmaster.net" size={160} fgColor="#FF0000" level="Q" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Badge */}
|
||||||
|
<div className="absolute -bottom-6 -left-6 bg-white py-3 px-5 rounded-xl shadow-xl flex items-center gap-3 transform transition-transform hover:scale-105 duration-300">
|
||||||
|
<div className="bg-red-100 p-2 rounded-full">
|
||||||
|
<Youtube className="w-5 h-5 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-[10px] text-slate-400 font-bold uppercase tracking-wider">Subscribers</div>
|
||||||
|
<div className="text-sm font-bold text-slate-900">+10 New</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* GENERATOR SECTION */}
|
||||||
|
<section className="py-12 px-4 sm:px-6 lg:px-8 -mt-8">
|
||||||
|
<YoutubeGenerator />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* HOW IT WORKS */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8 bg-white">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-12">
|
||||||
|
How YouTube QR Codes Work
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 lg:grid-cols-5 gap-8">
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-[#FF0000]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Youtube className="w-7 h-7 text-[#FF0000]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">1. Paste Link</h3>
|
||||||
|
<p className="text-slate-600 text-sm">
|
||||||
|
Copy the URL of your video, channel, or playlist.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-[#FF0000]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Smartphone className="w-7 h-7 text-[#FF0000]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">2. Print Code</h3>
|
||||||
|
<p className="text-slate-600 text-sm">
|
||||||
|
Place the QR code on flyers, posters, or merchandise.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#FF0000]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Download className="w-6 h-6 text-[#FF0000]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">3. Download</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Save your high-quality QR code.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#FF0000]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Play className="w-6 h-6 text-[#FF0000]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">4. Scan</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Customers scan the code.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#FF0000]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Share2 className="w-6 h-6 text-[#FF0000]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">5. Watch</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
The video plays instantly.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* FAQ SECTION */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-4">
|
||||||
|
Frequently Asked Questions
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-600 text-center mb-10">
|
||||||
|
Common questions about YouTube QR codes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FaqItem
|
||||||
|
question="Can I link to my Live Stream?"
|
||||||
|
answer="Yes! Paste your channel's live link (e.g., youtube.com/c/YourChannel/live) and it will always go to your current live stream."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Does the video auto-play?"
|
||||||
|
answer="Most smartphones will open the YouTube app and auto-play the video, but it depends on the user's specific settings."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Can I change the video later?"
|
||||||
|
answer="Only if you use our Dynamic QR Code service. This static code will always point to the original link you entered."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Is it safe?"
|
||||||
|
answer="Yes. The QR code simply contains your video link. No personal data is stored or tracked by this free tool."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Does it work for YouTube Shorts?"
|
||||||
|
answer="Yes, just paste the 'Share' link from any YouTube Short."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FaqItem({ question, answer }: { question: string; answer: string }) {
|
||||||
|
return (
|
||||||
|
<details className="group bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||||
|
<summary className="flex items-center justify-between p-5 cursor-pointer list-none text-slate-900 font-semibold hover:bg-slate-50 transition-colors">
|
||||||
|
{question}
|
||||||
|
<span className="transition group-open:rotate-180 text-slate-400">
|
||||||
|
<svg fill="none" height="20" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width="20">
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div className="text-slate-600 px-5 pb-5 pt-0 leading-relaxed text-sm">
|
||||||
|
{answer}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
303
src/app/(marketing)/tools/zoom-qr-code/ZoomGenerator.tsx
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import {
|
||||||
|
Video,
|
||||||
|
Download,
|
||||||
|
Check,
|
||||||
|
Sparkles,
|
||||||
|
Users
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// Brand Colors - Zoom Blue
|
||||||
|
const BRAND = {
|
||||||
|
paleGrey: '#EFF6FF',
|
||||||
|
primary: '#2D8CFF',
|
||||||
|
primaryDark: '#0B5CDB',
|
||||||
|
};
|
||||||
|
|
||||||
|
// QR Color Options
|
||||||
|
const QR_COLORS = [
|
||||||
|
{ name: 'Zoom Blue', value: '#2D8CFF' },
|
||||||
|
{ name: 'Dark Blue', value: '#0B5CDB' },
|
||||||
|
{ name: 'Classic Black', value: '#000000' },
|
||||||
|
{ name: 'Indigo', value: '#4F46E5' },
|
||||||
|
{ name: 'Violet', value: '#7C3AED' },
|
||||||
|
{ name: 'Emerald', value: '#10B981' },
|
||||||
|
{ name: 'Rose', value: '#F43F5E' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Frame Options
|
||||||
|
const FRAME_OPTIONS = [
|
||||||
|
{ id: 'none', label: 'No Frame' },
|
||||||
|
{ id: 'scanme', label: 'Scan Me' },
|
||||||
|
{ id: 'join', label: 'Join Meeting' },
|
||||||
|
{ id: 'zoom', label: 'Zoom' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ZoomGenerator() {
|
||||||
|
const [meetingId, setMeetingId] = useState('');
|
||||||
|
const [passcode, setPasscode] = useState('');
|
||||||
|
const [useDirectLink, setUseDirectLink] = useState(false); // Default to web URL for compatibility
|
||||||
|
const [qrColor, setQrColor] = useState(BRAND.primary);
|
||||||
|
const [frameType, setFrameType] = useState('none');
|
||||||
|
|
||||||
|
const qrRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Format meeting ID for display (xxx xxxx xxxx)
|
||||||
|
const formatMeetingId = (id: string) => {
|
||||||
|
const cleaned = id.replace(/\D/g, '');
|
||||||
|
if (cleaned.length <= 3) return cleaned;
|
||||||
|
if (cleaned.length <= 7) return `${cleaned.slice(0, 3)} ${cleaned.slice(3)}`;
|
||||||
|
return `${cleaned.slice(0, 3)} ${cleaned.slice(3, 7)} ${cleaned.slice(7, 11)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate Zoom link
|
||||||
|
const generateZoomLink = () => {
|
||||||
|
const cleanId = meetingId.replace(/\D/g, '');
|
||||||
|
if (!cleanId) return 'https://zoom.us/j/1234567890';
|
||||||
|
|
||||||
|
if (useDirectLink) {
|
||||||
|
// zoommtg protocol for direct app open
|
||||||
|
let link = `zoommtg://zoom.us/join?confno=${cleanId}`;
|
||||||
|
if (passcode) {
|
||||||
|
link += `&pwd=${passcode}`;
|
||||||
|
}
|
||||||
|
return link;
|
||||||
|
} else {
|
||||||
|
// Regular web link
|
||||||
|
let link = `https://zoom.us/j/${cleanId}`;
|
||||||
|
if (passcode) {
|
||||||
|
link += `?pwd=${passcode}`;
|
||||||
|
}
|
||||||
|
return link;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async (format: 'png' | 'svg') => {
|
||||||
|
if (!qrRef.current) return;
|
||||||
|
try {
|
||||||
|
if (format === 'png') {
|
||||||
|
const { toPng } = await import('html-to-image');
|
||||||
|
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3 });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = `zoom-qr-${meetingId.replace(/\D/g, '') || 'meeting'}.png`;
|
||||||
|
link.href = dataUrl;
|
||||||
|
link.click();
|
||||||
|
} else {
|
||||||
|
const svgData = qrRef.current.querySelector('svg')?.outerHTML;
|
||||||
|
if (svgData) {
|
||||||
|
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `zoom-qr-${meetingId.replace(/\D/g, '') || 'meeting'}.svg`;
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Download failed', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFrameLabel = () => {
|
||||||
|
const frame = FRAME_OPTIONS.find(f => f.id === frameType);
|
||||||
|
return frame?.id !== 'none' ? frame?.label : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-5xl mx-auto px-4 md:px-6">
|
||||||
|
|
||||||
|
{/* Main Generator Card */}
|
||||||
|
<div className="bg-white rounded-3xl shadow-2xl shadow-slate-900/10 overflow-hidden border border-slate-100">
|
||||||
|
<div className="grid lg:grid-cols-2">
|
||||||
|
|
||||||
|
{/* LEFT: Input Section */}
|
||||||
|
<div className="p-8 lg:p-10 space-y-8 border-r border-slate-100">
|
||||||
|
|
||||||
|
{/* Meeting Details */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Video className="w-5 h-5 text-[#2D8CFF]" />
|
||||||
|
Meeting Details
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Meeting ID</label>
|
||||||
|
<Input
|
||||||
|
placeholder="123 4567 8901"
|
||||||
|
value={formatMeetingId(meetingId)}
|
||||||
|
onChange={(e) => setMeetingId(e.target.value.replace(/\D/g, ''))}
|
||||||
|
className="h-12 text-base rounded-xl border-slate-200 focus:border-[#2D8CFF] focus:ring-[#2D8CFF]"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500 mt-2">The 10-11 digit meeting ID from your Zoom invite.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">Passcode (Optional)</label>
|
||||||
|
<Input
|
||||||
|
placeholder="abc123"
|
||||||
|
value={passcode}
|
||||||
|
onChange={(e) => setPasscode(e.target.value)}
|
||||||
|
className="h-12 text-base rounded-xl border-slate-200 focus:border-[#2D8CFF] focus:ring-[#2D8CFF]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer group">
|
||||||
|
<div className={cn(
|
||||||
|
"w-5 h-5 rounded border-2 flex items-center justify-center transition-all",
|
||||||
|
useDirectLink ? "bg-[#2D8CFF] border-[#2D8CFF]" : "border-slate-300 group-hover:border-slate-400"
|
||||||
|
)}>
|
||||||
|
{useDirectLink && <Check className="w-3.5 h-3.5 text-white" strokeWidth={3} />}
|
||||||
|
</div>
|
||||||
|
<input type="checkbox" checked={useDirectLink} onChange={(e) => setUseDirectLink(e.target.checked)} className="sr-only" />
|
||||||
|
<span className="text-sm font-medium text-slate-700">Open Zoom app directly</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-100"></div>
|
||||||
|
|
||||||
|
{/* Design Options */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Sparkles className="w-5 h-5 text-[#2D8CFF]" />
|
||||||
|
Design Options
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Color Picker */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">QR Code Color</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{QR_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.name}
|
||||||
|
onClick={() => setQrColor(c.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-9 h-9 rounded-full border-2 flex items-center justify-center transition-all hover:scale-110",
|
||||||
|
qrColor === c.value ? "border-slate-900 ring-2 ring-offset-2 ring-slate-200" : "border-white shadow-md"
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: c.value }}
|
||||||
|
aria-label={`Select ${c.name}`}
|
||||||
|
title={c.name}
|
||||||
|
>
|
||||||
|
{qrColor === c.value && <Check className="w-4 h-4 text-white" strokeWidth={3} />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Frame Selector */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">Frame Label</label>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{FRAME_OPTIONS.map((frame) => (
|
||||||
|
<button
|
||||||
|
key={frame.id}
|
||||||
|
onClick={() => setFrameType(frame.id)}
|
||||||
|
className={cn(
|
||||||
|
"py-2.5 px-2 rounded-lg text-xs font-medium transition-all border",
|
||||||
|
frameType === frame.id
|
||||||
|
? "bg-[#2D8CFF] text-white border-[#2D8CFF]"
|
||||||
|
: "bg-slate-50 text-slate-600 border-slate-200 hover:border-slate-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{frame.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT: Preview Section */}
|
||||||
|
<div className="p-8 lg:p-10 flex flex-col items-center justify-center" style={{ backgroundColor: BRAND.paleGrey }}>
|
||||||
|
|
||||||
|
{/* QR Card with Frame */}
|
||||||
|
<div
|
||||||
|
ref={qrRef}
|
||||||
|
className="bg-white rounded-3xl shadow-xl p-8 flex flex-col items-center"
|
||||||
|
style={{ minWidth: '320px' }}
|
||||||
|
>
|
||||||
|
{/* Frame Label */}
|
||||||
|
{getFrameLabel() && (
|
||||||
|
<div
|
||||||
|
className="mb-5 px-8 py-2.5 rounded-full text-white font-bold text-sm tracking-widest uppercase shadow-md"
|
||||||
|
style={{ backgroundColor: qrColor }}
|
||||||
|
>
|
||||||
|
{getFrameLabel()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* QR Code */}
|
||||||
|
<div className="bg-white">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={generateZoomLink()}
|
||||||
|
size={240}
|
||||||
|
level="M"
|
||||||
|
includeMargin={false}
|
||||||
|
fgColor={qrColor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meeting Info */}
|
||||||
|
<div className="mt-6 text-center max-w-[260px]">
|
||||||
|
<h3 className="font-bold text-slate-900 text-lg flex items-center justify-center gap-2">
|
||||||
|
<Video className="w-4 h-4 text-[#2D8CFF] shrink-0" />
|
||||||
|
<span className="truncate">{formatMeetingId(meetingId) || 'Meeting ID'}</span>
|
||||||
|
</h3>
|
||||||
|
{passcode && (
|
||||||
|
<p className="text-sm text-slate-500 mt-1">Passcode: {passcode}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Download Buttons */}
|
||||||
|
<div className="flex items-center gap-3 mt-8">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('png')}
|
||||||
|
className="bg-[#2D8CFF] hover:bg-[#0B5CDB] text-white shadow-lg"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Download PNG
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('svg')}
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-300 hover:bg-white"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
SVG
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500 mt-4 text-center">
|
||||||
|
Your meeting ID is encoded directly. Static and forever free.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upsell Banner */}
|
||||||
|
<div className="mt-8 bg-gradient-to-r from-[#2D8CFF] to-[#0B5CDB] rounded-2xl p-6 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="text-white text-center sm:text-left">
|
||||||
|
<h3 className="font-bold text-lg">Need to update meeting details?</h3>
|
||||||
|
<p className="text-white/80 text-sm mt-1">Dynamic QR Codes let you change the meeting link without reprinting.</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/signup">
|
||||||
|
<Button className="bg-white text-[#2D8CFF] hover:bg-slate-100 shrink-0 shadow-lg">
|
||||||
|
Create Dynamic QR
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
331
src/app/(marketing)/tools/zoom-qr-code/page.tsx
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
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';
|
||||||
|
|
||||||
|
// SEO Optimized Metadata
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Free Zoom QR Code Generator | Join Meetings Instantly | QR Master',
|
||||||
|
description: 'Create a QR code for your Zoom meeting. Attendees scan to join instantly. Includes meeting ID and passcode. Perfect for conference rooms and flyers.',
|
||||||
|
keywords: ['zoom qr code', 'zoom meeting qr', 'join zoom qr code', 'meeting room qr', 'zoom invitation qr', 'conference qr code'],
|
||||||
|
alternates: {
|
||||||
|
canonical: 'https://qrmaster.io/tools/zoom-qr-code',
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: 'Free Zoom QR Code Generator | QR Master',
|
||||||
|
description: 'Generate QR codes for Zoom meetings. One scan to join instantly.',
|
||||||
|
type: 'website',
|
||||||
|
url: 'https://qrmaster.io/tools/zoom-qr-code',
|
||||||
|
images: [{ url: '/og-zoom-generator.png', width: 1200, height: 630 }],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: 'Free Zoom QR Code Generator',
|
||||||
|
description: 'Create Zoom meeting QR codes. Instant and free.',
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// JSON-LD Structured Data
|
||||||
|
const jsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@graph': [
|
||||||
|
{
|
||||||
|
'@type': 'SoftwareApplication',
|
||||||
|
name: 'Zoom QR Code Generator',
|
||||||
|
applicationCategory: 'BusinessApplication',
|
||||||
|
operatingSystem: 'Web Browser',
|
||||||
|
offers: {
|
||||||
|
'@type': 'Offer',
|
||||||
|
price: '0',
|
||||||
|
priceCurrency: 'USD',
|
||||||
|
},
|
||||||
|
aggregateRating: {
|
||||||
|
'@type': 'AggregateRating',
|
||||||
|
ratingValue: '4.8',
|
||||||
|
ratingCount: '720',
|
||||||
|
},
|
||||||
|
description: 'Generate QR codes that let people join your Zoom meeting with one scan.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowTo',
|
||||||
|
name: 'How to Create a Zoom QR Code',
|
||||||
|
description: 'Create a QR code for joining Zoom meetings.',
|
||||||
|
step: [
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 1,
|
||||||
|
name: 'Enter Meeting ID',
|
||||||
|
text: 'Copy the 10-11 digit meeting ID from your Zoom invitation.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 2,
|
||||||
|
name: 'Add Passcode',
|
||||||
|
text: 'If your meeting has a passcode, enter it to include in the QR.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 3,
|
||||||
|
name: 'Choose Link Type',
|
||||||
|
text: 'Select whether to open Zoom app directly or use a web link.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 4,
|
||||||
|
name: 'Download',
|
||||||
|
text: 'Download your QR code and display it in your meeting room or invitation.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalTime: 'PT30S',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: [
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'What happens when someone scans the QR code?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'The Zoom app opens directly with your meeting ID and passcode pre-filled. They just tap "Join" to enter the meeting.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Does it work for recurring meetings?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes! If your recurring meeting uses a fixed Personal Meeting ID (PMI), the QR code will work for all sessions.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'What if the meeting ID changes?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Static QR codes cannot be updated. You\'ll need to generate a new code. For changeable meetings, consider our Dynamic QR Codes.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Does it work on all devices?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes. The QR code works on iOS, Android, and can also open Zoom on desktop computers if the Zoom app is installed.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ZoomQRCodePage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
/>
|
||||||
|
<ToolBreadcrumb toolName="Zoom QR Code Generator" toolSlug="zoom-qr-code" />
|
||||||
|
|
||||||
|
<div className="min-h-screen" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
|
||||||
|
{/* HERO SECTION */}
|
||||||
|
<section className="relative pt-20 pb-20 lg:pt-32 lg:pb-32 px-4 sm:px-6 lg:px-8 overflow-hidden" style={{ backgroundColor: '#2D8CFF' }}>
|
||||||
|
<div className="absolute inset-0 opacity-10">
|
||||||
|
<svg className="w-full h-full" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||||
|
<path d="M0 100 C 20 0 50 0 100 100 Z" fill="url(#grad1)" />
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" style={{ stopColor: 'white', stopOpacity: 1 }} />
|
||||||
|
<stop offset="100%" style={{ stopColor: 'white', stopOpacity: 0 }} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto grid lg:grid-cols-2 gap-12 items-center relative z-10">
|
||||||
|
<div className="text-center lg:text-left">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/10 text-white/90 text-sm font-medium mb-6 backdrop-blur-sm border border-white/10 hover:bg-white/20 transition-colors cursor-default">
|
||||||
|
<span className="flex h-2 w-2 relative">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-white opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-white"></span>
|
||||||
|
</span>
|
||||||
|
Free Tool — No Signup Required
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl md:text-5xl lg:text-6xl font-extrabold text-white tracking-tight leading-tight mb-6">
|
||||||
|
Join Meetings with <br className="hidden lg:block" />
|
||||||
|
<span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-200 to-cyan-100">Zoom QR Codes</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-lg md:text-xl text-blue-100 max-w-2xl mx-auto lg:mx-0 mb-8 leading-relaxed">
|
||||||
|
Create QR codes for your Zoom meetings. Attendees scan to join instantly.
|
||||||
|
<strong className="text-white block sm:inline mt-2 sm:mt-0"> Perfect for conference rooms.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center lg:justify-start gap-4 text-sm font-medium text-white/80">
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/5 backdrop-blur-sm">
|
||||||
|
<Video className="w-4 h-4 text-white" />
|
||||||
|
Direct Join
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/5 backdrop-blur-sm">
|
||||||
|
<Zap className="w-4 h-4 text-amber-300" />
|
||||||
|
Instant Open
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/5 backdrop-blur-sm">
|
||||||
|
<Users className="w-4 h-4 text-emerald-300" />
|
||||||
|
Any Device
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visual Abstract */}
|
||||||
|
<div className="hidden lg:flex relative items-center justify-center min-h-[400px]">
|
||||||
|
<div className="absolute w-[500px] h-[500px] bg-blue-400/30 rounded-full blur-[100px] -top-20 -right-20 animate-pulse" />
|
||||||
|
|
||||||
|
<div className="relative w-80 h-96 bg-white/5 backdrop-blur-xl border border-white/20 rounded-3xl shadow-2xl flex flex-col items-center justify-center p-8 transform -rotate-3 hover:rotate-0 transition-all duration-700 group">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-white/10 to-transparent rounded-3xl" />
|
||||||
|
|
||||||
|
{/* Meeting Card Mock */}
|
||||||
|
<div className="w-full bg-white rounded-xl shadow-lg p-4 mb-6 relative overflow-hidden">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="w-10 h-10 bg-[#2D8CFF] rounded-full flex items-center justify-center">
|
||||||
|
<Video className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-bold text-slate-900 text-sm">Team Standup</div>
|
||||||
|
<div className="text-xs text-slate-500">ID: 123 456 7890</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="bg-green-100 text-green-700 text-xs px-2 py-1 rounded-full">Live</div>
|
||||||
|
<div className="bg-slate-100 text-slate-600 text-xs px-2 py-1 rounded-full">12 attending</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-48 h-48 bg-white rounded-xl p-2 shadow-inner relative overflow-hidden flex items-center justify-center">
|
||||||
|
<QRCodeSVG value="https://www.qrmaster.net" size={170} fgColor="#2D8CFF" level="Q" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Badge */}
|
||||||
|
<div className="absolute -bottom-6 -right-6 bg-white py-3 px-5 rounded-xl shadow-xl flex items-center gap-3 transform transition-transform hover:scale-105 duration-300">
|
||||||
|
<div className="bg-blue-100 p-2 rounded-full">
|
||||||
|
<Users className="w-5 h-5 text-[#2D8CFF]" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-[10px] text-slate-400 font-bold uppercase tracking-wider">Status</div>
|
||||||
|
<div className="text-sm font-bold text-slate-900">Ready to Join</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* GENERATOR SECTION */}
|
||||||
|
<section className="py-12 px-4 sm:px-6 lg:px-8 -mt-8">
|
||||||
|
<ZoomGenerator />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* HOW IT WORKS */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8 bg-white">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-12">
|
||||||
|
How Zoom QR Codes Work
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-4 gap-8">
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#2D8CFF]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Video className="w-6 h-6 text-[#2D8CFF]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">1. Meeting ID</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">Enter your Zoom meeting ID.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#2D8CFF]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Shield className="w-6 h-6 text-[#2D8CFF]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">2. Passcode</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">Add passcode if required.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#2D8CFF]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Download className="w-6 h-6 text-[#2D8CFF]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">3. Download</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">Save your QR code.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[#2D8CFF]/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Share2 className="w-6 h-6 text-[#2D8CFF]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">4. Display</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">Put in meeting rooms or invites.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* FAQ SECTION */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-4">
|
||||||
|
Frequently Asked Questions
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-600 text-center mb-10">
|
||||||
|
Common questions about Zoom QR codes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FaqItem
|
||||||
|
question="What happens when someone scans the QR code?"
|
||||||
|
answer="The Zoom app opens directly with your meeting ID and passcode pre-filled. They just tap 'Join' to enter the meeting."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Does it work for recurring meetings?"
|
||||||
|
answer="Yes! If your recurring meeting uses a fixed Personal Meeting ID (PMI), the QR code will work for all sessions."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="What if the meeting ID changes?"
|
||||||
|
answer="Static QR codes cannot be updated. You'll need to generate a new code. For changeable meetings, consider our Dynamic QR Codes."
|
||||||
|
/>
|
||||||
|
<FaqItem
|
||||||
|
question="Does it work without the Zoom app installed?"
|
||||||
|
answer="If 'Open Zoom app directly' is unchecked, the QR links to join.zoom.us which works in any browser."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FaqItem({ question, answer }: { question: string; answer: string }) {
|
||||||
|
return (
|
||||||
|
<details className="group bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||||
|
<summary className="flex items-center justify-between p-5 cursor-pointer list-none text-slate-900 font-semibold hover:bg-slate-50 transition-colors">
|
||||||
|
{question}
|
||||||
|
<span className="transition group-open:rotate-180 text-slate-400">
|
||||||
|
<svg fill="none" height="20" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width="20">
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div className="text-slate-600 px-5 pb-5 pt-0 leading-relaxed text-sm">
|
||||||
|
{answer}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/app/api/feedback/route.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { slug, rating, comment } = body;
|
||||||
|
|
||||||
|
if (!slug || !rating) {
|
||||||
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the QR code
|
||||||
|
const qrCode = await db.qRCode.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!qrCode) {
|
||||||
|
return NextResponse.json({ error: 'QR Code not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log feedback as a scan with additional data
|
||||||
|
// In a full implementation, you'd have a Feedback model
|
||||||
|
// For now, we'll store it in QRScan with special markers
|
||||||
|
await db.qRScan.create({
|
||||||
|
data: {
|
||||||
|
qrId: qrCode.id,
|
||||||
|
ipHash: 'feedback',
|
||||||
|
userAgent: `rating:${rating}|comment:${comment?.substring(0, 200) || ''}`,
|
||||||
|
device: 'feedback',
|
||||||
|
isUnique: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error submitting feedback:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
122
src/app/api/qrs/[id]/feedback/route.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
let userId: string | undefined;
|
||||||
|
|
||||||
|
// Try NextAuth session first
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (session?.user?.id) {
|
||||||
|
userId = session.user.id;
|
||||||
|
} else {
|
||||||
|
// Fallback: Check raw userId cookie (like /api/user does)
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
userId = cookieStore.get('userId')?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const page = parseInt(searchParams.get('page') || '1');
|
||||||
|
const limit = parseInt(searchParams.get('limit') || '20');
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
// Verify QR ownership and type
|
||||||
|
const qrCode = await db.qRCode.findUnique({
|
||||||
|
where: { id, userId: userId },
|
||||||
|
select: { id: true, contentType: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!qrCode) {
|
||||||
|
return NextResponse.json({ error: 'QR code not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if consistent with schema (Prisma enum mismatch fix)
|
||||||
|
// @ts-ignore - Temporary ignore until client regeneration catches up fully in all envs
|
||||||
|
if (qrCode.contentType !== 'FEEDBACK') {
|
||||||
|
return NextResponse.json({ error: 'Not a feedback QR code' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch feedback entries (stored as QRScans with ipHash='feedback')
|
||||||
|
const [feedbackEntries, totalCount] = await Promise.all([
|
||||||
|
db.qRScan.findMany({
|
||||||
|
where: { qrId: id, ipHash: 'feedback' },
|
||||||
|
orderBy: { ts: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
select: { id: true, userAgent: true, ts: true },
|
||||||
|
}),
|
||||||
|
db.qRScan.count({
|
||||||
|
where: { qrId: id, ipHash: 'feedback' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Parse feedback data from userAgent field
|
||||||
|
const feedbacks = feedbackEntries.map((entry) => {
|
||||||
|
const parsed = parseFeedback(entry.userAgent || '');
|
||||||
|
return {
|
||||||
|
id: entry.id,
|
||||||
|
rating: parsed.rating,
|
||||||
|
comment: parsed.comment,
|
||||||
|
date: entry.ts,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate stats
|
||||||
|
const allRatings = await db.qRScan.findMany({
|
||||||
|
where: { qrId: id, ipHash: 'feedback' },
|
||||||
|
select: { userAgent: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const ratings = allRatings.map((e) => parseFeedback(e.userAgent || '').rating).filter((r) => r > 0);
|
||||||
|
const avgRating = ratings.length > 0 ? ratings.reduce((a, b) => a + b, 0) / ratings.length : 0;
|
||||||
|
|
||||||
|
// Rating distribution
|
||||||
|
const distribution = {
|
||||||
|
5: ratings.filter((r) => r === 5).length,
|
||||||
|
4: ratings.filter((r) => r === 4).length,
|
||||||
|
3: ratings.filter((r) => r === 3).length,
|
||||||
|
2: ratings.filter((r) => r === 2).length,
|
||||||
|
1: ratings.filter((r) => r === 1).length,
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
feedbacks,
|
||||||
|
stats: {
|
||||||
|
total: totalCount,
|
||||||
|
avgRating: Math.round(avgRating * 10) / 10,
|
||||||
|
distribution,
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(totalCount / limit),
|
||||||
|
hasMore: skip + limit < totalCount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching feedback:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFeedback(userAgent: string): { rating: number; comment: string } {
|
||||||
|
// Format: "rating:4|comment:Great service!"
|
||||||
|
const ratingMatch = userAgent.match(/rating:(\d)/);
|
||||||
|
const commentMatch = userAgent.match(/comment:(.+)/);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rating: ratingMatch ? parseInt(ratingMatch[1]) : 0,
|
||||||
|
comment: commentMatch ? commentMatch[1] : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
37
src/app/api/qrs/public/[slug]/route.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { slug } = await params;
|
||||||
|
|
||||||
|
const qrCode = await db.qRCode.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
content: true,
|
||||||
|
contentType: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!qrCode) {
|
||||||
|
return NextResponse.json({ error: 'QR Code not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (qrCode.status === 'PAUSED') {
|
||||||
|
return NextResponse.json({ error: 'QR Code is paused' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
contentType: qrCode.contentType,
|
||||||
|
content: qrCode.content,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching public QR:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,227 +1,234 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { generateSlug } from '@/lib/hash';
|
import { generateSlug } from '@/lib/hash';
|
||||||
import { createQRSchema, validateRequest } from '@/lib/validationSchemas';
|
import { createQRSchema, validateRequest } from '@/lib/validationSchemas';
|
||||||
import { csrfProtection } from '@/lib/csrf';
|
import { csrfProtection } from '@/lib/csrf';
|
||||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||||
|
|
||||||
// GET /api/qrs - List user's QR codes
|
// GET /api/qrs - List user's QR codes
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const userId = cookies().get('userId')?.value;
|
const userId = cookies().get('userId')?.value;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const qrCodes = await db.qRCode.findMany({
|
const qrCodes = await db.qRCode.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
include: {
|
include: {
|
||||||
_count: {
|
_count: {
|
||||||
select: { scans: true },
|
select: { scans: true },
|
||||||
},
|
},
|
||||||
scans: {
|
scans: {
|
||||||
where: { isUnique: true },
|
where: { isUnique: true },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Transform the data
|
// Transform the data
|
||||||
const transformed = qrCodes.map(qr => ({
|
const transformed = qrCodes.map(qr => ({
|
||||||
...qr,
|
...qr,
|
||||||
scans: qr._count.scans,
|
scans: qr._count.scans,
|
||||||
uniqueScans: qr.scans.length, // Count of scans where isUnique=true
|
uniqueScans: qr.scans.length, // Count of scans where isUnique=true
|
||||||
_count: undefined,
|
_count: undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return NextResponse.json(transformed);
|
return NextResponse.json(transformed);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching QR codes:', error);
|
console.error('Error fetching QR codes:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Internal server error' },
|
{ error: 'Internal server error' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plan limits
|
// Plan limits
|
||||||
const PLAN_LIMITS = {
|
const PLAN_LIMITS = {
|
||||||
FREE: 3,
|
FREE: 3,
|
||||||
PRO: 50,
|
PRO: 50,
|
||||||
BUSINESS: 500,
|
BUSINESS: 500,
|
||||||
};
|
};
|
||||||
|
|
||||||
// POST /api/qrs - Create a new QR code
|
// POST /api/qrs - Create a new QR code
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// CSRF Protection
|
// CSRF Protection
|
||||||
const csrfCheck = csrfProtection(request);
|
const csrfCheck = csrfProtection(request);
|
||||||
if (!csrfCheck.valid) {
|
if (!csrfCheck.valid) {
|
||||||
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
|
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = cookies().get('userId')?.value;
|
const userId = cookies().get('userId')?.value;
|
||||||
console.log('POST /api/qrs - userId from cookie:', userId);
|
|
||||||
|
// Rate Limiting (user-based)
|
||||||
// Rate Limiting (user-based)
|
const clientId = userId || getClientIdentifier(request);
|
||||||
const clientId = userId || getClientIdentifier(request);
|
const rateLimitResult = rateLimit(clientId, RateLimits.QR_CREATE);
|
||||||
const rateLimitResult = rateLimit(clientId, RateLimits.QR_CREATE);
|
|
||||||
|
if (!rateLimitResult.success) {
|
||||||
if (!rateLimitResult.success) {
|
return NextResponse.json(
|
||||||
return NextResponse.json(
|
{
|
||||||
{
|
error: 'Too many requests. Please try again later.',
|
||||||
error: 'Too many requests. Please try again later.',
|
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
|
||||||
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
|
},
|
||||||
},
|
{
|
||||||
{
|
status: 429,
|
||||||
status: 429,
|
headers: {
|
||||||
headers: {
|
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
||||||
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
||||||
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
||||||
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
}
|
||||||
}
|
}
|
||||||
}
|
);
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
if (!userId) {
|
||||||
if (!userId) {
|
return NextResponse.json({ error: 'Unauthorized - no userId cookie' }, { status: 401 });
|
||||||
return NextResponse.json({ error: 'Unauthorized - no userId cookie' }, { status: 401 });
|
}
|
||||||
}
|
|
||||||
|
const user = await db.user.findUnique({
|
||||||
// Check if user exists and get their plan
|
where: { id: userId },
|
||||||
const user = await db.user.findUnique({
|
select: { plan: true },
|
||||||
where: { id: userId },
|
});
|
||||||
select: { plan: true },
|
|
||||||
});
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: `User not found: ${userId}` }, { status: 404 });
|
||||||
console.log('User exists:', !!user);
|
}
|
||||||
|
|
||||||
if (!user) {
|
const body = await request.json();
|
||||||
return NextResponse.json({ error: `User not found: ${userId}` }, { status: 404 });
|
|
||||||
}
|
// Validate request body with Zod (only for non-static QRs or simplified validation)
|
||||||
|
// Note: Static QRs have complex nested content structure, so we do basic validation
|
||||||
const body = await request.json();
|
if (!body.isStatic) {
|
||||||
console.log('Request body:', body);
|
const validation = await validateRequest(createQRSchema, body);
|
||||||
|
if (!validation.success) {
|
||||||
// Validate request body with Zod (only for non-static QRs or simplified validation)
|
return NextResponse.json(validation.error, { status: 400 });
|
||||||
// Note: Static QRs have complex nested content structure, so we do basic validation
|
}
|
||||||
if (!body.isStatic) {
|
}
|
||||||
const validation = await validateRequest(createQRSchema, body);
|
|
||||||
if (!validation.success) {
|
// Check if this is a static QR request
|
||||||
return NextResponse.json(validation.error, { status: 400 });
|
const isStatic = body.isStatic === true;
|
||||||
}
|
|
||||||
}
|
// Only check limits for DYNAMIC QR codes (static QR codes are unlimited)
|
||||||
|
if (!isStatic) {
|
||||||
// Check if this is a static QR request
|
// Count existing dynamic QR codes
|
||||||
const isStatic = body.isStatic === true;
|
const dynamicQRCount = await db.qRCode.count({
|
||||||
|
where: {
|
||||||
// Only check limits for DYNAMIC QR codes (static QR codes are unlimited)
|
userId,
|
||||||
if (!isStatic) {
|
type: 'DYNAMIC',
|
||||||
// Count existing dynamic QR codes
|
},
|
||||||
const dynamicQRCount = await db.qRCode.count({
|
});
|
||||||
where: {
|
|
||||||
userId,
|
const userPlan = user.plan || 'FREE';
|
||||||
type: 'DYNAMIC',
|
const limit = PLAN_LIMITS[userPlan as keyof typeof PLAN_LIMITS] || PLAN_LIMITS.FREE;
|
||||||
},
|
|
||||||
});
|
if (dynamicQRCount >= limit) {
|
||||||
|
return NextResponse.json(
|
||||||
const userPlan = user.plan || 'FREE';
|
{
|
||||||
const limit = PLAN_LIMITS[userPlan as keyof typeof PLAN_LIMITS] || PLAN_LIMITS.FREE;
|
error: 'Limit reached',
|
||||||
|
message: `You have reached the limit of ${limit} dynamic QR codes for your ${userPlan} plan. Please upgrade to create more.`,
|
||||||
if (dynamicQRCount >= limit) {
|
currentCount: dynamicQRCount,
|
||||||
return NextResponse.json(
|
limit,
|
||||||
{
|
plan: userPlan,
|
||||||
error: 'Limit reached',
|
},
|
||||||
message: `You have reached the limit of ${limit} dynamic QR codes for your ${userPlan} plan. Please upgrade to create more.`,
|
{ status: 403 }
|
||||||
currentCount: dynamicQRCount,
|
);
|
||||||
limit,
|
}
|
||||||
plan: userPlan,
|
}
|
||||||
},
|
|
||||||
{ status: 403 }
|
let enrichedContent = body.content;
|
||||||
);
|
|
||||||
}
|
// For STATIC QR codes, calculate what the QR should contain
|
||||||
}
|
if (isStatic) {
|
||||||
|
let qrContent = '';
|
||||||
let enrichedContent = body.content;
|
switch (body.contentType) {
|
||||||
|
case 'URL':
|
||||||
// For STATIC QR codes, calculate what the QR should contain
|
qrContent = body.content.url;
|
||||||
if (isStatic) {
|
break;
|
||||||
let qrContent = '';
|
case 'PHONE':
|
||||||
switch (body.contentType) {
|
qrContent = `tel:${body.content.phone}`;
|
||||||
case 'URL':
|
break;
|
||||||
qrContent = body.content.url;
|
case 'SMS':
|
||||||
break;
|
qrContent = `sms:${body.content.phone}${body.content.message ? `?body=${encodeURIComponent(body.content.message)}` : ''}`;
|
||||||
case 'PHONE':
|
break;
|
||||||
qrContent = `tel:${body.content.phone}`;
|
case 'VCARD':
|
||||||
break;
|
qrContent = `BEGIN:VCARD
|
||||||
case 'SMS':
|
VERSION:3.0
|
||||||
qrContent = `sms:${body.content.phone}${body.content.message ? `?body=${encodeURIComponent(body.content.message)}` : ''}`;
|
FN:${body.content.firstName || ''} ${body.content.lastName || ''}
|
||||||
break;
|
N:${body.content.lastName || ''};${body.content.firstName || ''};;;
|
||||||
case 'VCARD':
|
${body.content.organization ? `ORG:${body.content.organization}` : ''}
|
||||||
qrContent = `BEGIN:VCARD
|
${body.content.title ? `TITLE:${body.content.title}` : ''}
|
||||||
VERSION:3.0
|
${body.content.email ? `EMAIL:${body.content.email}` : ''}
|
||||||
FN:${body.content.firstName || ''} ${body.content.lastName || ''}
|
${body.content.phone ? `TEL:${body.content.phone}` : ''}
|
||||||
N:${body.content.lastName || ''};${body.content.firstName || ''};;;
|
END:VCARD`;
|
||||||
${body.content.organization ? `ORG:${body.content.organization}` : ''}
|
break;
|
||||||
${body.content.title ? `TITLE:${body.content.title}` : ''}
|
case 'GEO':
|
||||||
${body.content.email ? `EMAIL:${body.content.email}` : ''}
|
const lat = body.content.latitude || 0;
|
||||||
${body.content.phone ? `TEL:${body.content.phone}` : ''}
|
const lon = body.content.longitude || 0;
|
||||||
END:VCARD`;
|
const label = body.content.label ? `?q=${encodeURIComponent(body.content.label)}` : '';
|
||||||
break;
|
qrContent = `geo:${lat},${lon}${label}`;
|
||||||
case 'GEO':
|
break;
|
||||||
const lat = body.content.latitude || 0;
|
case 'TEXT':
|
||||||
const lon = body.content.longitude || 0;
|
qrContent = body.content.text;
|
||||||
const label = body.content.label ? `?q=${encodeURIComponent(body.content.label)}` : '';
|
break;
|
||||||
qrContent = `geo:${lat},${lon}${label}`;
|
case 'WHATSAPP':
|
||||||
break;
|
qrContent = `https://wa.me/${body.content.phone}${body.content.message ? `?text=${encodeURIComponent(body.content.message)}` : ''}`;
|
||||||
case 'TEXT':
|
break;
|
||||||
qrContent = body.content.text;
|
case 'PDF':
|
||||||
break;
|
qrContent = body.content.fileUrl || 'https://example.com/file.pdf';
|
||||||
case 'WHATSAPP':
|
break;
|
||||||
qrContent = `https://wa.me/${body.content.phone}${body.content.message ? `?text=${encodeURIComponent(body.content.message)}` : ''}`;
|
case 'APP':
|
||||||
break;
|
qrContent = body.content.fallbackUrl || body.content.iosUrl || body.content.androidUrl || 'https://example.com';
|
||||||
default:
|
break;
|
||||||
qrContent = body.content.url || 'https://example.com';
|
case 'COUPON':
|
||||||
}
|
qrContent = `Coupon: ${body.content.code || 'CODE'} - ${body.content.discount || 'Discount'}`;
|
||||||
|
break;
|
||||||
// Add qrContent to the content object
|
case 'FEEDBACK':
|
||||||
enrichedContent = {
|
qrContent = body.content.feedbackUrl || 'https://example.com/feedback';
|
||||||
...body.content,
|
break;
|
||||||
qrContent // This is what the QR code should actually contain
|
default:
|
||||||
};
|
qrContent = body.content.url || 'https://example.com';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate slug for the QR code
|
// Add qrContent to the content object
|
||||||
const slug = generateSlug(body.title);
|
enrichedContent = {
|
||||||
|
...body.content,
|
||||||
// Create QR code
|
qrContent // This is what the QR code should actually contain
|
||||||
const qrCode = await db.qRCode.create({
|
};
|
||||||
data: {
|
}
|
||||||
userId,
|
|
||||||
title: body.title,
|
// Generate slug for the QR code
|
||||||
type: isStatic ? 'STATIC' : 'DYNAMIC',
|
const slug = generateSlug(body.title);
|
||||||
contentType: body.contentType,
|
|
||||||
content: enrichedContent,
|
// Create QR code
|
||||||
tags: body.tags || [],
|
const qrCode = await db.qRCode.create({
|
||||||
style: body.style || {
|
data: {
|
||||||
foregroundColor: '#000000',
|
userId,
|
||||||
backgroundColor: '#FFFFFF',
|
title: body.title,
|
||||||
cornerStyle: 'square',
|
type: isStatic ? 'STATIC' : 'DYNAMIC',
|
||||||
size: 200,
|
contentType: body.contentType,
|
||||||
},
|
content: enrichedContent,
|
||||||
slug,
|
tags: body.tags || [],
|
||||||
status: 'ACTIVE',
|
style: body.style || {
|
||||||
},
|
foregroundColor: '#000000',
|
||||||
});
|
backgroundColor: '#FFFFFF',
|
||||||
|
cornerStyle: 'square',
|
||||||
return NextResponse.json(qrCode);
|
size: 200,
|
||||||
} catch (error) {
|
},
|
||||||
console.error('Error creating QR code:', error);
|
slug,
|
||||||
return NextResponse.json(
|
status: 'ACTIVE',
|
||||||
{ error: 'Internal server error', details: String(error) },
|
},
|
||||||
{ status: 500 }
|
});
|
||||||
);
|
|
||||||
}
|
return NextResponse.json(qrCode);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating QR code:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error', details: String(error) },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
82
src/app/api/upload/route.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
import { uploadFileToR2 } from '@/lib/r2';
|
||||||
|
import { env } from '@/lib/env';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// 1. Authentication Check
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
let userId = session?.user?.id;
|
||||||
|
|
||||||
|
// Fallback: Check for simple-login cookie if no NextAuth session
|
||||||
|
if (!userId) {
|
||||||
|
const cookieUserId = request.cookies.get('userId')?.value;
|
||||||
|
if (cookieUserId) {
|
||||||
|
// Verify user exists
|
||||||
|
const user = await db.user.findUnique({
|
||||||
|
where: { id: cookieUserId },
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
if (user) {
|
||||||
|
userId = user.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return new NextResponse('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Parse Form Data
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get('file') as File | null;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'No file provided' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Validation
|
||||||
|
// Check file size (default 10MB)
|
||||||
|
const MAX_SIZE = parseInt(env.MAX_UPLOAD_SIZE || '10485760');
|
||||||
|
if (file.size > MAX_SIZE) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `File too large. Maximum size: ${MAX_SIZE / 1024 / 1024}MB` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file type (allow images and PDFs)
|
||||||
|
const allowedTypes = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'];
|
||||||
|
if (!allowedTypes.includes(file.type)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid file type. Only PDF and Images are allowed.' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Upload to R2
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const publicUrl = await uploadFileToR2(buffer, file.name, file.type);
|
||||||
|
|
||||||
|
// 5. Success
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
url: publicUrl,
|
||||||
|
filename: file.name,
|
||||||
|
type: file.type
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error during upload' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
167
src/app/coupon/[slug]/page.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { Copy, Check, ExternalLink, Gift } from 'lucide-react';
|
||||||
|
|
||||||
|
interface CouponData {
|
||||||
|
code: string;
|
||||||
|
discount: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
expiryDate?: string;
|
||||||
|
redeemUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CouponPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const slug = params.slug as string;
|
||||||
|
const [coupon, setCoupon] = useState<CouponData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchCoupon() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/qrs/public/${slug}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.contentType === 'COUPON') {
|
||||||
|
setCoupon(data.content as CouponData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching coupon:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchCoupon();
|
||||||
|
}, [slug]);
|
||||||
|
|
||||||
|
const copyCode = async () => {
|
||||||
|
if (coupon?.code) {
|
||||||
|
await navigator.clipboard.writeText(coupon.code);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Loading
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-rose-50 to-pink-100">
|
||||||
|
<div className="w-10 h-10 border-3 border-pink-200 border-t-pink-600 rounded-full animate-spin"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not found
|
||||||
|
if (!coupon) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-rose-50 to-pink-100 px-6">
|
||||||
|
<div className="text-center bg-white rounded-2xl p-8 shadow-lg">
|
||||||
|
<p className="text-gray-500 text-lg">This coupon is not available.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isExpired = coupon.expiryDate && new Date(coupon.expiryDate) < new Date();
|
||||||
|
|
||||||
|
return (<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#FAF8F5] via-[#C6C0B3] to-[#4C5F4E] px-6 py-12">
|
||||||
|
<div className="max-w-sm w-full">
|
||||||
|
{/* Card */}
|
||||||
|
<div className="bg-white rounded-3xl shadow-xl overflow-hidden">
|
||||||
|
{/* Colorful Header */}
|
||||||
|
<div className="bg-gradient-to-br from-[#DB5375] to-[#B3FFB3] text-gray-900 p-8 text-center relative overflow-hidden">
|
||||||
|
{/* Decorative circles */}
|
||||||
|
<div className="absolute top-0 right-0 w-32 h-32 bg-white/10 rounded-full -translate-y-1/2 translate-x-1/2"></div>
|
||||||
|
<div className="absolute bottom-0 left-0 w-24 h-24 bg-white/10 rounded-full translate-y-1/2 -translate-x-1/2"></div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<div className="w-14 h-14 bg-[#DB5375]/10 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Gift className="w-7 h-7 text-[#DB5375]" />
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-700 text-sm mb-1">{coupon.title || 'Special Offer'}</p>
|
||||||
|
<p className="text-4xl font-bold tracking-tight text-gray-900">{coupon.discount}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dotted line with circles */}
|
||||||
|
<div className="relative py-4">
|
||||||
|
<div className="relative py-4">
|
||||||
|
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-5 h-10 bg-gradient-to-br from-[#FAF8F5] via-[#C6C0B3] to-[#4C5F4E] rounded-r-full"></div>
|
||||||
|
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-5 h-10 bg-gradient-to-br from-[#FAF8F5] via-[#C6C0B3] to-[#4C5F4E] rounded-l-full"></div>
|
||||||
|
<div className="border-t-2 border-dashed border-gray-200 mx-8"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="px-8 pb-8">
|
||||||
|
{coupon.description && (
|
||||||
|
<p className="text-gray-500 text-sm text-center mb-6">{coupon.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Code Box */}
|
||||||
|
<div className="bg-gray-50 rounded-2xl p-5 mb-4 border border-emerald-100">
|
||||||
|
<p className="text-xs text-emerald-600 text-center mb-2 font-medium uppercase tracking-wider">Your Code</p>
|
||||||
|
<div className="flex items-center justify-center gap-3">
|
||||||
|
<code className="text-2xl font-mono font-bold text-gray-900 tracking-wider">
|
||||||
|
{coupon.code}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={copyCode}
|
||||||
|
className={`p-2.5 rounded-xl transition-all ${copied
|
||||||
|
? 'bg-emerald-100 text-emerald-600'
|
||||||
|
: 'bg-white text-gray-500 hover:text-rose-500 shadow-sm hover:shadow'
|
||||||
|
}`}
|
||||||
|
aria-label="Copy code"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-5 h-5" /> : <Copy className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{copied && (
|
||||||
|
<p className="text-emerald-600 text-xs text-center mt-2 font-medium">Copied!</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expiry */}
|
||||||
|
{coupon.expiryDate && (
|
||||||
|
<p className={`text-sm text-center mb-6 font-medium ${isExpired ? 'text-red-500' : 'text-gray-400'}`}>
|
||||||
|
{isExpired
|
||||||
|
? '⚠️ This coupon has expired'
|
||||||
|
: `Valid until ${new Date(coupon.expiryDate).toLocaleDateString('en-US', {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
})}`
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Redeem Button */}
|
||||||
|
{coupon.redeemUrl && !isExpired && (
|
||||||
|
<a
|
||||||
|
href={coupon.redeemUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="block w-full py-4 rounded-xl font-semibold text-center bg-gradient-to-r from-[#076653] to-[#0C342C] text-white hover:from-[#087861] hover:to-[#0E4036] transition-all shadow-lg shadow-emerald-200"
|
||||||
|
>
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
Redeem Now
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<p className="text-center text-sm text-white/60 mt-6">
|
||||||
|
Powered by QR Master
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,119 +1,119 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
|
||||||
export default function Error({
|
export default function Error({
|
||||||
error,
|
error,
|
||||||
reset,
|
reset,
|
||||||
}: {
|
}: {
|
||||||
error: Error & { digest?: string };
|
error: Error & { digest?: string };
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}) {
|
}) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Log the error to an error reporting service
|
// Log the error to an error reporting service
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white flex items-center justify-center px-4">
|
<div className="min-h-screen bg-white flex items-center justify-center px-4">
|
||||||
<div className="max-w-2xl w-full text-center">
|
<div className="max-w-2xl w-full text-center">
|
||||||
{/* Error Icon */}
|
{/* Error Icon */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="inline-flex items-center justify-center w-24 h-24 bg-red-100 rounded-full mb-6">
|
<div className="inline-flex items-center justify-center w-24 h-24 bg-red-100 rounded-full mb-6">
|
||||||
<svg
|
<svg
|
||||||
className="w-12 h-12 text-red-600"
|
className="w-12 h-12 text-red-600"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error Text */}
|
{/* Error Text */}
|
||||||
<h1 className="text-6xl md:text-8xl font-bold text-gray-900 mb-4">500</h1>
|
<h1 className="text-6xl md:text-8xl font-bold text-gray-900 mb-4">500</h1>
|
||||||
<h2 className="text-2xl md:text-3xl font-semibold text-gray-700 mb-4">
|
<h2 className="text-2xl md:text-3xl font-semibold text-gray-700 mb-4">
|
||||||
Something Went Wrong
|
Something Went Wrong
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-lg text-gray-600 mb-8 max-w-md mx-auto">
|
<p className="text-lg text-gray-600 mb-8 max-w-md mx-auto">
|
||||||
We're sorry, but something unexpected happened. Our team has been notified and is working on a fix.
|
We're sorry, but something unexpected happened. Our team has been notified and is working on a fix.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Error Details (only in development) */}
|
{/* Error Details (only in development) */}
|
||||||
{process.env.NODE_ENV === 'development' && error.message && (
|
{process.env.NODE_ENV === 'development' && error.message && (
|
||||||
<div className="mb-8 p-4 bg-red-50 border border-red-200 rounded-lg text-left">
|
<div className="mb-8 p-4 bg-red-50 border border-red-200 rounded-lg text-left">
|
||||||
<p className="text-sm font-mono text-red-800 break-all">
|
<p className="text-sm font-mono text-red-800 break-all">
|
||||||
<strong>Error:</strong> {error.message}
|
<strong>Error:</strong> {error.message}
|
||||||
</p>
|
</p>
|
||||||
{error.digest && (
|
{error.digest && (
|
||||||
<p className="text-sm font-mono text-red-600 mt-2">
|
<p className="text-sm font-mono text-red-600 mt-2">
|
||||||
<strong>Digest:</strong> {error.digest}
|
<strong>Digest:</strong> {error.digest}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||||
<Button size="lg" onClick={reset}>
|
<Button size="lg" onClick={reset}>
|
||||||
<svg
|
<svg
|
||||||
className="w-5 h-5 mr-2"
|
className="w-5 h-5 mr-2"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Try Again
|
Try Again
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<Button variant="outline" size="lg">
|
<Button variant="outline" size="lg">
|
||||||
<svg
|
<svg
|
||||||
className="w-5 h-5 mr-2"
|
className="w-5 h-5 mr-2"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={2}
|
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"
|
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>
|
</svg>
|
||||||
Go Home
|
Go Home
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Help Text */}
|
{/* Help Text */}
|
||||||
<div className="mt-12 pt-8 border-t border-gray-200">
|
<div className="mt-12 pt-8 border-t border-gray-200">
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
If this problem persists, please{' '}
|
If this problem persists, please{' '}
|
||||||
<Link href="/#faq" className="text-primary-600 hover:text-primary-700 font-medium">
|
<Link href="/#faq" className="text-primary-600 hover:text-primary-700 font-medium">
|
||||||
check our FAQ
|
check our FAQ
|
||||||
</Link>
|
</Link>
|
||||||
{' '}or contact support.
|
{' '}or contact support.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
195
src/app/feedback/[slug]/page.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { Star, Send, Check } from 'lucide-react';
|
||||||
|
|
||||||
|
interface FeedbackData {
|
||||||
|
businessName: string;
|
||||||
|
googleReviewUrl?: string;
|
||||||
|
thankYouMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FeedbackPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const slug = params.slug as string;
|
||||||
|
const [feedback, setFeedback] = useState<FeedbackData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [rating, setRating] = useState(0);
|
||||||
|
const [hoverRating, setHoverRating] = useState(0);
|
||||||
|
const [comment, setComment] = useState('');
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchFeedback() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/qrs/public/${slug}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.contentType === 'FEEDBACK') {
|
||||||
|
setFeedback(data.content as FeedbackData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching feedback data:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchFeedback();
|
||||||
|
}, [slug]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (rating === 0) return;
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await fetch('/api/feedback', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ slug, rating, comment }),
|
||||||
|
});
|
||||||
|
|
||||||
|
setSubmitted(true);
|
||||||
|
|
||||||
|
if (rating >= 4 && feedback?.googleReviewUrl) {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = feedback.googleReviewUrl!;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error submitting feedback:', error);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Loading
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#FAF8F5] via-[#C6C0B3] to-[#4C5F4E]">
|
||||||
|
<div className="w-10 h-10 border-3 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not found
|
||||||
|
if (!feedback) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#FAF8F5] via-[#C6C0B3] to-[#4C5F4E] px-6">
|
||||||
|
<div className="text-center bg-white rounded-2xl p-8 shadow-lg">
|
||||||
|
<p className="text-gray-500 text-lg">This feedback form is not available.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success
|
||||||
|
if (submitted) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#FAF8F5] via-[#C6C0B3] to-[#4C5F4E] px-6">
|
||||||
|
<div className="max-w-sm w-full bg-white rounded-3xl shadow-xl p-10 text-center">
|
||||||
|
<div className="w-20 h-20 bg-gradient-to-br from-[#4C5F4E] to-[#FAF8F5] rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg">
|
||||||
|
<Check className="w-10 h-10 text-white" strokeWidth={2.5} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||||
|
Thank you!
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-gray-500">
|
||||||
|
{feedback.thankYouMessage || 'Your feedback has been submitted.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{rating >= 4 && feedback.googleReviewUrl && (
|
||||||
|
<p className="text-sm text-teal-600 mt-6 font-medium">
|
||||||
|
Redirecting to Google Reviews...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rating Form
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#FAF8F5] via-[#C6C0B3] to-[#4C5F4E] px-6 py-12">
|
||||||
|
<div className="max-w-md w-full">
|
||||||
|
{/* Card */}
|
||||||
|
<div className="bg-white rounded-3xl shadow-xl overflow-hidden">
|
||||||
|
{/* Colored Header */}
|
||||||
|
<div className="bg-gradient-to-r from-[#4C5F4E] via-[#C6C0B3] to-[#FAF8F5] p-8 text-center">
|
||||||
|
<div className="w-14 h-14 bg-white/20 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Star className="w-7 h-7 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold mb-1 text-gray-900">How was your experience?</h1>
|
||||||
|
<p className="text-gray-700">{feedback.businessName}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-8">
|
||||||
|
{/* Stars */}
|
||||||
|
<div className="flex justify-center gap-2 mb-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<button
|
||||||
|
key={star}
|
||||||
|
onClick={() => setRating(star)}
|
||||||
|
onMouseEnter={() => setHoverRating(star)}
|
||||||
|
onMouseLeave={() => setHoverRating(0)}
|
||||||
|
className="p-1 transition-transform hover:scale-110 focus:outline-none"
|
||||||
|
aria-label={`Rate ${star} stars`}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
className={`w-11 h-11 transition-all ${star <= (hoverRating || rating)
|
||||||
|
? 'text-amber-400 fill-amber-400 drop-shadow-sm'
|
||||||
|
: 'text-gray-200'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rating text */}
|
||||||
|
<p className="text-center text-sm font-medium h-6 mb-6" style={{ color: rating > 0 ? '#6366f1' : 'transparent' }}>
|
||||||
|
{rating === 1 && 'Poor'}
|
||||||
|
{rating === 2 && 'Fair'}
|
||||||
|
{rating === 3 && 'Good'}
|
||||||
|
{rating === 4 && 'Great!'}
|
||||||
|
{rating === 5 && 'Excellent!'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Comment */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<textarea
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
placeholder="Share your thoughts (optional)"
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={rating === 0 || submitting}
|
||||||
|
className={`w-full py-4 rounded-xl font-semibold flex items-center justify-center gap-2 transition-all ${rating === 0
|
||||||
|
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||||
|
: 'bg-gradient-to-r from-[#4C5F4E] to-[#0C342C] text-white hover:from-[#5a705c] hover:to-[#0E4036] shadow-lg shadow-emerald-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Send className="w-4 h-4" />
|
||||||
|
{submitting ? 'Sending...' : 'Submit Feedback'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<p className="text-center text-sm text-white/60 mt-6">
|
||||||
|
Powered by QR Master
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,72 +1,72 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import '@/styles/globals.css';
|
import '@/styles/globals.css';
|
||||||
import { ToastContainer } from '@/components/ui/Toast';
|
import { ToastContainer } from '@/components/ui/Toast';
|
||||||
import AuthProvider from '@/components/SessionProvider';
|
import AuthProvider from '@/components/SessionProvider';
|
||||||
import { PostHogProvider } from '@/components/PostHogProvider';
|
import { PostHogProvider } from '@/components/PostHogProvider';
|
||||||
import CookieBanner from '@/components/CookieBanner';
|
import CookieBanner from '@/components/CookieBanner';
|
||||||
|
|
||||||
const isIndexable = process.env.NEXT_PUBLIC_INDEXABLE === 'true';
|
const isIndexable = process.env.NEXT_PUBLIC_INDEXABLE === 'true';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
metadataBase: new URL('https://www.qrmaster.net'),
|
metadataBase: new URL('https://www.qrmaster.net'),
|
||||||
title: {
|
title: {
|
||||||
default: 'QR Master – Smart QR Generator & Analytics',
|
default: 'QR Master – Smart QR Generator & Analytics',
|
||||||
template: '%s | QR Master',
|
template: '%s | QR Master',
|
||||||
},
|
},
|
||||||
description: 'Create dynamic QR codes, track scans, and scale campaigns with secure analytics.',
|
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',
|
keywords: 'QR code, QR generator, dynamic QR, QR tracking, QR analytics, branded QR, bulk QR generator',
|
||||||
robots: isIndexable
|
robots: isIndexable
|
||||||
? { index: true, follow: true }
|
? { index: true, follow: true }
|
||||||
: { index: false, follow: false },
|
: { index: false, follow: false },
|
||||||
icons: {
|
icons: {
|
||||||
icon: [
|
icon: [
|
||||||
{ url: '/favicon.svg', type: 'image/svg+xml' },
|
{ url: '/favicon.svg', type: 'image/svg+xml' },
|
||||||
{ url: '/logo.svg', type: 'image/svg+xml' },
|
{ url: '/logo.svg', type: 'image/svg+xml' },
|
||||||
],
|
],
|
||||||
apple: '/logo.svg',
|
apple: '/logo.svg',
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
site: '@qrmaster',
|
site: '@qrmaster',
|
||||||
images: ['https://www.qrmaster.net/static/og-image.png'],
|
images: ['https://www.qrmaster.net/static/og-image.png'],
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
type: 'website',
|
type: 'website',
|
||||||
siteName: 'QR Master',
|
siteName: 'QR Master',
|
||||||
title: 'QR Master – Smart QR Generator & Analytics',
|
title: 'QR Master – Smart QR Generator & Analytics',
|
||||||
description: 'Create dynamic QR codes, track scans, and scale campaigns with secure analytics.',
|
description: 'Create dynamic QR codes, track scans, and scale campaigns with secure analytics.',
|
||||||
url: 'https://www.qrmaster.net',
|
url: 'https://www.qrmaster.net',
|
||||||
images: [
|
images: [
|
||||||
{
|
{
|
||||||
url: 'https://www.qrmaster.net/static/og-image.png',
|
url: 'https://www.qrmaster.net/static/og-image.png',
|
||||||
width: 1200,
|
width: 1200,
|
||||||
height: 630,
|
height: 630,
|
||||||
alt: 'QR Master - Dynamic QR Code Generator and Analytics Platform',
|
alt: 'QR Master - Dynamic QR Code Generator and Analytics Platform',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
locale: 'en_US',
|
locale: 'en_US',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className="font-sans">
|
<body className="font-sans">
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<PostHogProvider>
|
<PostHogProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
{children}
|
{children}
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
<CookieBanner />
|
<CookieBanner />
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
</PostHogProvider>
|
</PostHogProvider>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||