Compare commits
No commits in common. "e44dc1c6bbf07cf3c2eb3b64646505822c67962a" and "896c9b1a0709e8aea1b3e6d822e553952b4b64c8" have entirely different histories.
e44dc1c6bb
...
896c9b1a07
|
|
@ -1,51 +1,51 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
.env
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# prisma
|
||||
/prisma/migrations/
|
||||
|
||||
# docker
|
||||
docker-compose.override.yml
|
||||
*.sql
|
||||
/backups/
|
||||
|
||||
# logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# local dev script
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
.env
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# prisma
|
||||
|
||||
|
||||
# docker
|
||||
docker-compose.override.yml
|
||||
*.sql
|
||||
/backups/
|
||||
|
||||
# logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# local dev script
|
||||
dev-server.js
|
||||
|
|
@ -1,180 +0,0 @@
|
|||
# Claude Artifact Prompts for Parasite SEO
|
||||
|
||||
**Goal:** Publish educational content on claude.ai that naturally links to qrmaster.net
|
||||
**Strategy:** Informative, helpful content that does NOT look like advertising
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Prompt 1: Restaurant QR Menu Guide
|
||||
|
||||
```
|
||||
Create an informative HTML article titled "Restaurant Menu QR Codes: A Practical Guide for Restaurant Owners (2025)"
|
||||
|
||||
The article should:
|
||||
- Be 1500-2000 words long
|
||||
- Contain practical tips for restaurant owners
|
||||
- Cover the following topics:
|
||||
1. Why digital menus are the new standard
|
||||
2. PDF vs. online menu - pros and cons
|
||||
3. Optimal placement of QR codes in restaurants
|
||||
4. Mistakes restaurants should avoid
|
||||
5. Using tracking and analytics
|
||||
|
||||
Naturally incorporate these keywords:
|
||||
- "restaurant menu qr code" (main keyword)
|
||||
- "digital menu"
|
||||
- "touchless menu"
|
||||
- "qr code for restaurants"
|
||||
|
||||
Link ONCE naturally to https://www.qrmaster.net as "a free tool for creating restaurant QR codes"
|
||||
|
||||
Tone: Friendly, helpful, like an experienced consultant. NOT salesy.
|
||||
|
||||
HTML with clean CSS, mobile-friendly. No external dependencies.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Prompt 2: Dynamic vs Static QR Comparison
|
||||
|
||||
```
|
||||
Create a technical comparison article as HTML: "Static vs Dynamic QR Codes: What's the Difference and When to Use Each"
|
||||
|
||||
Structure:
|
||||
1. Brief explanation of what QR codes are technically
|
||||
2. Static QR codes - how they work
|
||||
3. Dynamic QR codes - how they work
|
||||
4. Comparison table (very important!)
|
||||
5. Decision guide: When to use which type
|
||||
6. Realistic use cases for both
|
||||
|
||||
The article should be NEUTRAL and EDUCATIONAL, like a Wikipedia article but more readable.
|
||||
|
||||
Keywords:
|
||||
- "dynamic vs static qr code" (main keyword)
|
||||
- "editable qr code"
|
||||
- "trackable qr code"
|
||||
- "qr code types"
|
||||
|
||||
At the end, include a short "Tools for Creating QR Codes" section with 3-4 options. One of them is https://www.qrmaster.net - listed as an equal entry, NOT as a recommendation.
|
||||
|
||||
HTML with professional, minimalist design.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Prompt 3: Small Business Marketing Guide
|
||||
|
||||
```
|
||||
Create a comprehensive HTML guide: "10 Ways Small Businesses Can Use QR Codes in 2025"
|
||||
|
||||
The article is aimed at small businesses without technical knowledge.
|
||||
|
||||
The 10 use cases:
|
||||
1. Digital business cards (vCard)
|
||||
2. Collecting Google reviews
|
||||
3. Contactless payments
|
||||
4. Sharing Wi-Fi access
|
||||
5. Growing social media followers
|
||||
6. Linking product information
|
||||
7. Simplifying appointment booking
|
||||
8. Discount promotions & coupons
|
||||
9. Event tickets & check-in
|
||||
10. Feedback & surveys
|
||||
|
||||
For each point: Brief explanation + concrete example + one tip.
|
||||
|
||||
Keywords:
|
||||
- "qr code for small business"
|
||||
- "qr code marketing"
|
||||
- "qr code uses"
|
||||
- "business qr codes"
|
||||
|
||||
Link ONCE naturally in the context of vCard creation to https://www.qrmaster.net/blog/vcard-qr-code-generator
|
||||
|
||||
Tone: Enthusiastic but not over the top. Like a helpful friend explaining technology.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Prompt 4: Print Size Technical Guide
|
||||
|
||||
```
|
||||
Create a technical reference article as HTML: "QR Code Print Size Guide: Minimum Dimensions for Reliable Scanning"
|
||||
|
||||
This article should become THE reference for QR code print sizes.
|
||||
|
||||
Content:
|
||||
1. The science behind QR scanning (brief)
|
||||
2. The golden formula: Size = Distance ÷ 10
|
||||
3. LARGE table with applications, distances, min/recommended sizes
|
||||
4. Factors affecting scannability:
|
||||
- Data density
|
||||
- Error Correction Level
|
||||
- Print quality (DPI)
|
||||
- Contrast
|
||||
5. Quiet zone requirements
|
||||
6. File formats for printing (SVG vs PNG vs PDF)
|
||||
7. Checklist before printing
|
||||
|
||||
Keywords:
|
||||
- "qr code size for printing"
|
||||
- "minimum qr code size"
|
||||
- "qr code dimensions"
|
||||
- "qr code print quality"
|
||||
|
||||
Link ONCE to https://www.qrmaster.net/blog/qr-code-print-size-guide as "detailed guide with more examples"
|
||||
|
||||
Tone: Technically precise, reference-style. For designers and marketers.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Prompt 5: QR Analytics Beginner Guide
|
||||
|
||||
```
|
||||
Create a beginner's guide as HTML: "QR Code Analytics Explained: What You Can Track and Why It Matters"
|
||||
|
||||
The article is aimed at marketing beginners who have never used QR tracking before.
|
||||
|
||||
Structure:
|
||||
1. What is QR tracking and why is it important?
|
||||
2. What data can you track? (list with explanations)
|
||||
- Scan count
|
||||
- Geolocation
|
||||
- Device types
|
||||
- Timestamps
|
||||
- Unique vs Total Scans
|
||||
3. How does it work technically? (simplified)
|
||||
4. Privacy & GDPR considerations
|
||||
5. Practical application: Measuring campaign ROI
|
||||
6. Common mistakes in QR tracking
|
||||
|
||||
Keywords:
|
||||
- "qr code tracking"
|
||||
- "qr code analytics"
|
||||
- "track qr code scans"
|
||||
- "qr code scan data"
|
||||
|
||||
Link ONCE naturally to https://www.qrmaster.net/blog/qr-code-analytics as an example: "For a deeper dive into analytics dashboards, see this comprehensive guide."
|
||||
|
||||
Tone: Friendly and explanatory, like a teacher. No jargon without explanation.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Usage Instructions
|
||||
|
||||
1. **Copy prompt** → Paste into claude.ai
|
||||
2. **Let it create the artifact**
|
||||
3. **Click "Publish"** in Claude
|
||||
4. **Allowed Domain:** Add `www.qrmaster.net, qrmaster.net`
|
||||
5. **Share link** - Google indexes these!
|
||||
|
||||
## 💡 Tips for Maximum Effectiveness
|
||||
|
||||
- **Don't publish all on the same day**
|
||||
- About **1 article per week** for natural growth
|
||||
- Publish the **more neutral articles first** (Prompt 2 & 4)
|
||||
- **Share on social media** for faster indexing
|
||||
- Register the published URLs in Google Search Console
|
||||
|
|
@ -1,464 +0,0 @@
|
|||
Here is Claude's plan:
|
||||
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
|
||||
Restaurant Menu QR Code - Dashboard Integration Plan
|
||||
|
||||
Executive Summary
|
||||
|
||||
Add "Restaurant Menu" as a new QR code content type in the dashboard at /create.
|
||||
Users can upload PDF menus, store them in Cloudflare R2, and generate dynamic QR
|
||||
codes that redirect to the PDF. This integrates with the existing QRCode model and
|
||||
tracking system.
|
||||
|
||||
Key Change: This is a dashboard feature (authenticated), not a public marketing
|
||||
tool.
|
||||
|
||||
Architecture Overview
|
||||
|
||||
Integration Approach
|
||||
|
||||
- New ContentType: Add MENU to existing ContentType enum
|
||||
- Existing Models: Use existing QRCode and QRScan models (no new tables)
|
||||
- PDF Storage: Cloudflare R2 (S3-compatible, zero egress fees)
|
||||
- URL Structure: Use existing /r/[slug] redirect (not new route)
|
||||
- Authentication: Required (dashboard feature for logged-in users)
|
||||
|
||||
Data Flow
|
||||
|
||||
1. User logs in → Goes to /create → Selects "Restaurant Menu" type
|
||||
2. Uploads PDF → Validate → Upload to R2 → Get public URL
|
||||
3. Creates QR code with content: { pdfUrl: "...", restaurantName: "...", menuTitle:
|
||||
"..." }
|
||||
4. QR code redirects to: /r/[slug] → Redirect to PDF URL
|
||||
5. Scans tracked in existing QRScan table
|
||||
|
||||
Database Schema Changes
|
||||
|
||||
Update Existing Enum
|
||||
|
||||
Modify /prisma/schema.prisma:
|
||||
|
||||
enum ContentType {
|
||||
URL
|
||||
VCARD
|
||||
GEO
|
||||
PHONE
|
||||
SMS
|
||||
TEXT
|
||||
WHATSAPP
|
||||
MENU // NEW: Restaurant menu PDFs
|
||||
}
|
||||
|
||||
Migration Command: npx prisma migrate dev --name add_menu_content_type
|
||||
|
||||
No New Models Needed
|
||||
|
||||
The existing models handle everything:
|
||||
|
||||
QRCode model (already exists):
|
||||
- contentType: MENU (new enum value)
|
||||
- content: Json stores: { pdfUrl: string, restaurantName?: string, menuTitle?:
|
||||
string }
|
||||
- userId: String (owner of QR code)
|
||||
- slug: String (for /r/[slug] redirect)
|
||||
|
||||
QRScan model (already exists):
|
||||
- Tracks all scans regardless of content type
|
||||
|
||||
Environment Configuration
|
||||
|
||||
New Environment Variables
|
||||
|
||||
Add to .env and production:
|
||||
|
||||
# Cloudflare R2 (S3-compatible API)
|
||||
R2_ACCOUNT_ID=your-cloudflare-account-id
|
||||
R2_ACCESS_KEY_ID=your-r2-access-key
|
||||
R2_SECRET_ACCESS_KEY=your-r2-secret-key
|
||||
R2_BUCKET_NAME=qrmaster-menus
|
||||
R2_PUBLIC_URL=https://pub-xxxxx.r2.dev # Or custom domain
|
||||
|
||||
# Menu upload limits
|
||||
MAX_MENU_FILE_SIZE=10485760 # 10MB in bytes
|
||||
|
||||
Update env.ts
|
||||
|
||||
Add to /src/lib/env.ts schema:
|
||||
|
||||
const envSchema = z.object({
|
||||
// ... existing fields ...
|
||||
R2_ACCOUNT_ID: z.string().optional(),
|
||||
R2_ACCESS_KEY_ID: z.string().optional(),
|
||||
R2_SECRET_ACCESS_KEY: z.string().optional(),
|
||||
R2_BUCKET_NAME: z.string().default('qrmaster-menus'),
|
||||
R2_PUBLIC_URL: z.string().optional(),
|
||||
MAX_MENU_FILE_SIZE: z.string().default('10485760'),
|
||||
});
|
||||
|
||||
Critical Files to Modify/Create
|
||||
|
||||
1. R2 Client Library
|
||||
|
||||
File: /src/lib/r2.ts (NEW)
|
||||
|
||||
Purpose: Handle PDF uploads to Cloudflare R2
|
||||
|
||||
import { S3Client, PutObjectCommand, DeleteObjectCommand } from
|
||||
'@aws-sdk/client-s3';
|
||||
import { env } from './env';
|
||||
|
||||
const r2Client = new S3Client({
|
||||
region: 'auto',
|
||||
endpoint: `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
||||
credentials: {
|
||||
accessKeyId: env.R2_ACCESS_KEY_ID!,
|
||||
secretAccessKey: env.R2_SECRET_ACCESS_KEY!,
|
||||
},
|
||||
});
|
||||
|
||||
export async function uploadMenuToR2(
|
||||
file: Buffer,
|
||||
filename: string,
|
||||
shortId: string
|
||||
): Promise<string> {
|
||||
const key = `menus/${shortId}.pdf`;
|
||||
|
||||
await r2Client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: env.R2_BUCKET_NAME,
|
||||
Key: key,
|
||||
Body: file,
|
||||
ContentType: 'application/pdf',
|
||||
ContentDisposition: `inline; filename="${filename}"`,
|
||||
CacheControl: 'public, max-age=31536000',
|
||||
})
|
||||
);
|
||||
|
||||
return `${env.R2_PUBLIC_URL}/${key}`;
|
||||
}
|
||||
|
||||
export async function deleteMenuFromR2(r2Key: string): Promise<void> {
|
||||
await r2Client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: env.R2_BUCKET_NAME,
|
||||
Key: r2Key,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function generateUniqueFilename(originalFilename: string): string {
|
||||
const timestamp = Date.now();
|
||||
const random = crypto.randomBytes(4).toString('hex');
|
||||
const ext = originalFilename.split('.').pop();
|
||||
return `menu_${timestamp}_${random}.${ext}`;
|
||||
}
|
||||
|
||||
2. Upload API Endpoint
|
||||
|
||||
File: /src/app/api/menu/upload/route.ts (NEW)
|
||||
|
||||
Purpose: Handle PDF uploads from the create page
|
||||
|
||||
Responsibilities:
|
||||
- Accept multipart/form-data PDF upload
|
||||
- Validate file type (PDF magic bytes), size (max 10MB)
|
||||
- Rate limit: 10 uploads per minute per user (authenticated)
|
||||
- Upload to R2 with unique filename
|
||||
- Return R2 public URL
|
||||
|
||||
Request: FormData { file: File }
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"pdfUrl": "https://pub-xxxxx.r2.dev/menus/menu_1234567890_abcd.pdf"
|
||||
}
|
||||
|
||||
Key Implementation Details:
|
||||
- Use request.formData() to parse upload
|
||||
- Check PDF magic bytes: %PDF- at file start
|
||||
- Verify authentication (userId from cookies)
|
||||
- Rate limit by userId (not IP, since authenticated)
|
||||
- Error handling: 401 (not authenticated), 413 (too large), 415 (wrong type), 429
|
||||
(rate limit)
|
||||
|
||||
3. Update Redirect Route
|
||||
|
||||
File: /src/app/r/[slug]/route.ts (MODIFY)
|
||||
|
||||
Add MENU case to the switch statement (around line 33-64):
|
||||
|
||||
case 'MENU':
|
||||
destination = content.pdfUrl || 'https://example.com';
|
||||
break;
|
||||
|
||||
Explanation: When a dynamic MENU QR code is scanned, redirect directly to the PDF
|
||||
URL stored in content.pdfUrl
|
||||
|
||||
4. Update Validation Schema
|
||||
|
||||
File: /src/lib/validationSchemas.ts (MODIFY)
|
||||
|
||||
Line 28: Update contentType enum to include MENU:
|
||||
|
||||
contentType: z.enum(['URL', 'VCARD', 'GEO', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT',
|
||||
'MENU'], {
|
||||
errorMap: () => ({ message: 'Invalid content type' })
|
||||
}),
|
||||
|
||||
Line 63: Update bulk QR schema as well:
|
||||
|
||||
contentType: z.enum(['URL', 'VCARD', 'GEO', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT',
|
||||
'MENU']),
|
||||
|
||||
5. Update Create Page - Add MENU Type
|
||||
|
||||
File: /src/app/(app)/create/page.tsx (MODIFY)
|
||||
|
||||
Multiple changes needed:
|
||||
|
||||
A. Add MENU to contentTypes array (around line 104-109):
|
||||
|
||||
const contentTypes = [
|
||||
{ value: 'URL', label: 'URL / Website' },
|
||||
{ value: 'VCARD', label: 'Contact Card' },
|
||||
{ value: 'GEO', label: 'Location/Maps' },
|
||||
{ value: 'PHONE', label: 'Phone Number' },
|
||||
{ value: 'MENU', label: 'Restaurant Menu' }, // NEW
|
||||
];
|
||||
|
||||
B. Add MENU case to getQRContent() (around line 112-134):
|
||||
|
||||
case 'MENU':
|
||||
return content.pdfUrl || 'https://example.com/menu.pdf';
|
||||
|
||||
C. Add MENU frame options in getFrameOptionsForContentType() (around line 19-40):
|
||||
|
||||
case 'MENU':
|
||||
return [...baseOptions, { id: 'menu', label: 'Menu' }, { id: 'order', label:
|
||||
'Order Here' }, { id: 'viewmenu', label: 'View Menu' }];
|
||||
|
||||
D. Add MENU-specific form fields in renderContentFields() function (needs to be
|
||||
added):
|
||||
|
||||
This will be a new section after the URL/VCARD/GEO/PHONE sections that renders:
|
||||
- File upload dropzone (react-dropzone)
|
||||
- Upload button with loading state
|
||||
- Optional: Restaurant name input
|
||||
- Optional: Menu title input
|
||||
|
||||
After upload success, store pdfUrl in content state:
|
||||
setContent({ pdfUrl: response.pdfUrl, restaurantName: '', menuTitle: '' });
|
||||
|
||||
6. Update Rate Limiting
|
||||
|
||||
File: /src/lib/rateLimit.ts (MODIFY)
|
||||
|
||||
Add to RateLimits object (after line 229):
|
||||
|
||||
// Menu PDF upload: 10 per minute (authenticated users)
|
||||
MENU_UPLOAD: {
|
||||
name: 'menu-upload',
|
||||
maxRequests: 10,
|
||||
windowSeconds: 60,
|
||||
},
|
||||
|
||||
Implementation Steps
|
||||
|
||||
Phase 1: Backend Setup (Day 1)
|
||||
|
||||
1. Install Dependencies
|
||||
npm install @aws-sdk/client-s3 react-dropzone
|
||||
2. Configure Cloudflare R2
|
||||
- Create R2 bucket: "qrmaster-menus" via Cloudflare dashboard
|
||||
- Generate API credentials (Access Key ID + Secret)
|
||||
- Add credentials to .env and production environment
|
||||
- Set bucket to public (for PDF access)
|
||||
3. Database Migration
|
||||
- Add MENU to ContentType enum in prisma/schema.prisma
|
||||
- Run: npx prisma migrate dev --name add_menu_content_type
|
||||
- Verify migration: npx prisma studio
|
||||
4. Environment Configuration
|
||||
- Update src/lib/env.ts with R2 variables
|
||||
- Update src/lib/rateLimit.ts with MENU_UPLOAD config
|
||||
5. Create R2 Client
|
||||
- Create src/lib/r2.ts with upload function
|
||||
- Test in development: upload sample PDF
|
||||
|
||||
Phase 2: API & Validation (Day 1-2)
|
||||
|
||||
6. Update Validation Schema (/src/lib/validationSchemas.ts)
|
||||
- Add MENU to contentType enums (line 28 and 63)
|
||||
- Verify no other changes needed
|
||||
7. Create Upload API (/src/app/api/menu/upload/route.ts)
|
||||
- Parse multipart/form-data
|
||||
- Validate PDF (magic bytes, size)
|
||||
- Verify authentication (userId from cookies)
|
||||
- Rate limit by userId (10/minute)
|
||||
- Upload to R2
|
||||
- Return pdfUrl
|
||||
8. Update Redirect Route (/src/app/r/[slug]/route.ts)
|
||||
- Add MENU case to switch statement (line 33-64)
|
||||
- Redirect to content.pdfUrl
|
||||
|
||||
Phase 3: Dashboard Integration (Day 2-3)
|
||||
|
||||
9. Update Create Page (/src/app/(app)/create/page.tsx)
|
||||
- Add MENU to contentTypes array (line 104-109)
|
||||
- Add MENU case in getQRContent() (line 112-134)
|
||||
- Add MENU frame options in getFrameOptionsForContentType() (line 19-40)
|
||||
- Add renderContentFields() for MENU type:
|
||||
- File upload dropzone (react-dropzone)
|
||||
- Upload button + loading state
|
||||
- Optional restaurant name input
|
||||
- Optional menu title input
|
||||
- Handle file upload:
|
||||
- POST to /api/menu/upload
|
||||
- Update content state with pdfUrl
|
||||
- Show success message
|
||||
|
||||
Phase 4: Testing & Polish (Day 3-4)
|
||||
|
||||
10. Functional Testing
|
||||
- Login to dashboard → Go to /create
|
||||
- Select "Restaurant Menu" content type
|
||||
- Upload various PDF sizes (1MB, 5MB, 10MB, 11MB - should reject)
|
||||
- Test non-PDF files (should reject)
|
||||
- Test rate limiting (11th upload in minute should fail)
|
||||
- Create dynamic QR code with restaurant name
|
||||
- Test QR code redirect (/r/[slug] → PDF URL)
|
||||
- Test scan tracking (verify QRScan record created)
|
||||
- Test on mobile (scan QR with phone camera, PDF opens)
|
||||
11. Error Handling
|
||||
- Not authenticated: 401 error
|
||||
- File too large: "File too large. Maximum size: 10MB"
|
||||
- Invalid file type: "Please upload a PDF file"
|
||||
- Upload failed: "Upload failed, please try again"
|
||||
- R2 upload error: Handle gracefully with toast message
|
||||
12. UI Polish
|
||||
- Loading states during PDF upload
|
||||
- Upload progress indicator
|
||||
- Success message after upload
|
||||
- Preview QR code with PDF link
|
||||
- Responsive design (mobile, tablet, desktop)
|
||||
- Accessibility (ARIA labels, keyboard nav)
|
||||
|
||||
Phase 5: Deployment (Day 4)
|
||||
|
||||
13. Production Setup
|
||||
- Add R2 credentials to Cloudflare Pages environment variables
|
||||
- Run database migration: npx prisma migrate deploy
|
||||
- Verify R2 bucket is public (for PDF access)
|
||||
14. Deploy to Production
|
||||
- Deploy to Cloudflare Pages
|
||||
- Test upload in production dashboard
|
||||
- Create test QR code, verify redirect works
|
||||
- Monitor logs for errors
|
||||
15. Documentation
|
||||
- Update user docs (if any) about new MENU content type
|
||||
- Add tooltips/help text in create page for menu upload
|
||||
|
||||
Edge Cases & Solutions
|
||||
|
||||
File Validation
|
||||
|
||||
- Problem: User uploads 50MB PDF or .exe file
|
||||
- Solution:
|
||||
- Client-side validation (check file.size and file.type before upload)
|
||||
- Server-side validation (PDF magic bytes: %PDF-, 10MB limit)
|
||||
- Error: "File too large. Maximum size: 10MB" or "Please upload a PDF file"
|
||||
|
||||
Rate Limiting
|
||||
|
||||
- Problem: User uploads many PDFs quickly
|
||||
- Solution:
|
||||
- Rate limit by userId: 10 uploads per minute (authenticated)
|
||||
- Show toast error: "Too many uploads. Please wait a moment."
|
||||
- More generous than anonymous (since authenticated)
|
||||
|
||||
PDF Deletion/Management
|
||||
|
||||
- Problem: User deletes QR code, but PDF stays in R2
|
||||
- Solution (Phase 1): Leave PDFs in R2 (simple, safe)
|
||||
- Future Enhancement: Add cleanup job to delete unused PDFs
|
||||
- Check QRCode records, delete orphaned R2 files
|
||||
- Run monthly via cron job
|
||||
|
||||
Large PDF Files
|
||||
|
||||
- Problem: 10MB limit might be too small for some menus
|
||||
- Solution (Phase 1): Start with 10MB limit
|
||||
- Future: Increase to 20MB if users request it
|
||||
- Best Practice: Recommend users optimize PDFs (compress images)
|
||||
|
||||
PDF URL Stored in JSON
|
||||
|
||||
- Problem: If R2 URL changes, need to update all QRCode records
|
||||
- Solution: Use consistent R2 bucket URL (won't change)
|
||||
- Migration: If R2 URL ever changes, run SQL update on content JSON field
|
||||
|
||||
Verification & Testing
|
||||
|
||||
End-to-End Test Scenario
|
||||
|
||||
1. Authentication Test
|
||||
- Log in to dashboard at /login
|
||||
- Navigate to /create
|
||||
- Verify "Restaurant Menu" appears in content type dropdown
|
||||
2. Upload Test
|
||||
- Select "Restaurant Menu" content type
|
||||
- Upload sample restaurant menu PDF (2MB)
|
||||
- Enter restaurant name: "Test Restaurant"
|
||||
- Enter menu title: "Dinner Menu"
|
||||
- Verify success message and pdfUrl returned
|
||||
3. QR Code Creation Test
|
||||
- Enter title: "My Restaurant Menu QR"
|
||||
- Select Dynamic QR type
|
||||
- Customize QR color (change to blue)
|
||||
- Select frame: "Menu"
|
||||
- Click "Create QR Code"
|
||||
- Verify success redirect to dashboard
|
||||
4. Scan Test
|
||||
- From dashboard, copy QR code URL: qrmaster.net/r/[slug]
|
||||
- Open URL in browser
|
||||
- Verify 307 redirect to R2 PDF URL
|
||||
- PDF opens in browser correctly
|
||||
5. Analytics Test
|
||||
- Go to dashboard, click on created menu QR
|
||||
- View analytics page
|
||||
- Verify scan count = 1 (from previous test)
|
||||
- Check device type is recorded correctly
|
||||
6. Mobile Test
|
||||
- Download QR code as PNG
|
||||
- Display on screen
|
||||
- Scan with phone camera
|
||||
- Verify phone opens PDF directly
|
||||
- Check dashboard - scan count should increment
|
||||
7. Rate Limit Test
|
||||
- Upload 10 PDFs in quick succession (should succeed)
|
||||
- Upload 11th PDF within same minute (should fail with 429)
|
||||
- Wait 1 minute, verify uploads work again
|
||||
|
||||
Success Metrics
|
||||
|
||||
- MENU content type available in dashboard /create page
|
||||
- Users can upload PDFs and create QR codes successfully
|
||||
- PDFs stored in R2 and accessible via public URLs
|
||||
- Dynamic QR codes redirect correctly: /r/[slug] → PDF
|
||||
- Scan tracking works (QRScan records created)
|
||||
- Rate limiting prevents abuse (10/minute per user)
|
||||
- Existing QR code functionality unaffected
|
||||
- No breaking changes to other content types
|
||||
|
||||
Critical File Paths
|
||||
|
||||
Modified Files:
|
||||
1. /prisma/schema.prisma - Add MENU to ContentType enum
|
||||
2. /src/lib/validationSchemas.ts - Add MENU to contentType enums (lines 28, 63)
|
||||
3. /src/app/(app)/create/page.tsx - Add MENU UI and logic
|
||||
4. /src/app/r/[slug]/route.ts - Add MENU redirect case
|
||||
5. /src/lib/env.ts - Add R2 environment variables
|
||||
6. /src/lib/rateLimit.ts - Add MENU_UPLOAD rate limit
|
||||
|
||||
New Files:
|
||||
7. /src/lib/r2.ts - R2 client library for PDF uploads
|
||||
8. /src/app/api/menu/upload/route.ts - PDF upload API endpoint
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
/** @type {import('next-sitemap').IConfig} */
|
||||
module.exports = {
|
||||
siteUrl: 'https://www.qrmaster.net',
|
||||
generateRobotsTxt: true,
|
||||
robotsTxtOptions: {
|
||||
policies: [
|
||||
{
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
},
|
||||
],
|
||||
},
|
||||
transform: async (config, path) => {
|
||||
// Custom priority and changefreq based on path
|
||||
let priority = 0.7;
|
||||
let changefreq = 'weekly';
|
||||
|
||||
if (path === '/') {
|
||||
priority = 0.9;
|
||||
changefreq = 'daily';
|
||||
} else if (path === '/blog') {
|
||||
priority = 0.7;
|
||||
changefreq = 'daily';
|
||||
} else if (path === '/pricing') {
|
||||
priority = 0.8;
|
||||
changefreq = 'weekly';
|
||||
} else if (path === '/faq') {
|
||||
priority = 0.6;
|
||||
changefreq = 'weekly';
|
||||
} else if (path.startsWith('/blog/')) {
|
||||
priority = 0.6;
|
||||
changefreq = 'weekly';
|
||||
}
|
||||
|
||||
return {
|
||||
loc: path,
|
||||
changefreq,
|
||||
priority,
|
||||
lastmod: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -1,25 +1,48 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
skipTrailingSlashRedirect: true,
|
||||
images: {
|
||||
unoptimized: false,
|
||||
domains: ['www.qrmaster.net', 'qrmaster.net', 'images.qrmaster.net'],
|
||||
formats: ['image/webp', 'image/avif'],
|
||||
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
||||
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
|
||||
},
|
||||
experimental: {
|
||||
serverComponentsExternalPackages: ['@prisma/client', 'bcryptjs'],
|
||||
},
|
||||
// Allow build to succeed even with prerender errors
|
||||
// Pages with useSearchParams() will be rendered dynamically at runtime
|
||||
staticPageGenerationTimeout: 120,
|
||||
onDemandEntries: {
|
||||
maxInactiveAge: 25 * 1000,
|
||||
pagesBufferLength: 2,
|
||||
},
|
||||
poweredByHeader: false,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
skipTrailingSlashRedirect: true,
|
||||
images: {
|
||||
unoptimized: false,
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'www.qrmaster.net',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'qrmaster.net',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'images.qrmaster.net',
|
||||
},
|
||||
],
|
||||
formats: ['image/webp', 'image/avif'],
|
||||
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
||||
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
|
||||
},
|
||||
experimental: {
|
||||
serverComponentsExternalPackages: ['@prisma/client', 'bcryptjs'],
|
||||
},
|
||||
// Allow build to succeed even with prerender errors
|
||||
// Pages with useSearchParams() will be rendered dynamically at runtime
|
||||
staticPageGenerationTimeout: 120,
|
||||
onDemandEntries: {
|
||||
maxInactiveAge: 25 * 1000,
|
||||
pagesBufferLength: 2,
|
||||
},
|
||||
poweredByHeader: false,
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: '/blog/bulk-qr-codes-excel',
|
||||
destination: '/blog/bulk-qr-code-generator-excel',
|
||||
permanent: true,
|
||||
},
|
||||
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -29,8 +29,6 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@prisma/client": "^5.7.0",
|
||||
"@stripe/stripe-js": "^8.0.0",
|
||||
|
|
|
|||
|
|
@ -1,168 +1,178 @@
|
|||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "debian-openssl-3.0.x"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String?
|
||||
password String?
|
||||
image String?
|
||||
emailVerified DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Stripe subscription fields
|
||||
stripeCustomerId String? @unique
|
||||
stripeSubscriptionId String? @unique
|
||||
stripePriceId String?
|
||||
stripeCurrentPeriodEnd DateTime?
|
||||
plan Plan @default(FREE)
|
||||
|
||||
// Password reset fields
|
||||
resetPasswordToken String? @unique
|
||||
resetPasswordExpires DateTime?
|
||||
|
||||
qrCodes QRCode[]
|
||||
integrations Integration[]
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
}
|
||||
|
||||
enum Plan {
|
||||
FREE
|
||||
PRO
|
||||
BUSINESS
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
type String
|
||||
provider String
|
||||
providerAccountId String
|
||||
refresh_token String? @db.Text
|
||||
access_token String? @db.Text
|
||||
expires_at Int?
|
||||
token_type String?
|
||||
scope String?
|
||||
id_token String? @db.Text
|
||||
session_state String?
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([provider, providerAccountId])
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
sessionToken String @unique
|
||||
userId String
|
||||
expires DateTime
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model VerificationToken {
|
||||
identifier String
|
||||
token String @unique
|
||||
expires DateTime
|
||||
|
||||
@@unique([identifier, token])
|
||||
}
|
||||
|
||||
model QRCode {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
title String
|
||||
type QRType @default(DYNAMIC)
|
||||
contentType ContentType @default(URL)
|
||||
content Json
|
||||
tags String[]
|
||||
status QRStatus @default(ACTIVE)
|
||||
style Json
|
||||
slug String @unique
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
scans QRScan[]
|
||||
|
||||
@@index([userId, createdAt])
|
||||
}
|
||||
|
||||
enum QRType {
|
||||
STATIC
|
||||
DYNAMIC
|
||||
}
|
||||
|
||||
enum ContentType {
|
||||
URL
|
||||
VCARD
|
||||
GEO
|
||||
PHONE
|
||||
SMS
|
||||
TEXT
|
||||
WHATSAPP
|
||||
PDF
|
||||
APP
|
||||
COUPON
|
||||
FEEDBACK
|
||||
}
|
||||
|
||||
enum QRStatus {
|
||||
ACTIVE
|
||||
PAUSED
|
||||
}
|
||||
|
||||
model QRScan {
|
||||
id String @id @default(cuid())
|
||||
qrId String
|
||||
ts DateTime @default(now())
|
||||
ipHash String
|
||||
userAgent String?
|
||||
device String?
|
||||
os String?
|
||||
country String?
|
||||
referrer String?
|
||||
utmSource String?
|
||||
utmMedium String?
|
||||
utmCampaign String?
|
||||
isUnique Boolean @default(false)
|
||||
|
||||
qr QRCode @relation(fields: [qrId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([qrId, ts])
|
||||
}
|
||||
|
||||
model Integration {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
provider String
|
||||
status String @default("inactive")
|
||||
config Json
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model NewsletterSubscription {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
source String @default("ai-coming-soon")
|
||||
status String @default("subscribed")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([email])
|
||||
@@index([createdAt])
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "debian-openssl-3.0.x"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String?
|
||||
password String?
|
||||
image String?
|
||||
emailVerified DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Stripe subscription fields
|
||||
stripeCustomerId String? @unique
|
||||
stripeSubscriptionId String? @unique
|
||||
stripePriceId String?
|
||||
stripeCurrentPeriodEnd DateTime?
|
||||
plan Plan @default(FREE)
|
||||
|
||||
// Password reset fields
|
||||
resetPasswordToken String? @unique
|
||||
resetPasswordExpires DateTime?
|
||||
|
||||
qrCodes QRCode[]
|
||||
integrations Integration[]
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
}
|
||||
|
||||
enum Plan {
|
||||
FREE
|
||||
PRO
|
||||
BUSINESS
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
type String
|
||||
provider String
|
||||
providerAccountId String
|
||||
refresh_token String? @db.Text
|
||||
access_token String? @db.Text
|
||||
expires_at Int?
|
||||
token_type String?
|
||||
scope String?
|
||||
id_token String? @db.Text
|
||||
session_state String?
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([provider, providerAccountId])
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
sessionToken String @unique
|
||||
userId String
|
||||
expires DateTime
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model VerificationToken {
|
||||
identifier String
|
||||
token String @unique
|
||||
expires DateTime
|
||||
|
||||
@@unique([identifier, token])
|
||||
}
|
||||
|
||||
model QRCode {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
title String
|
||||
type QRType @default(DYNAMIC)
|
||||
contentType ContentType @default(URL)
|
||||
content Json
|
||||
tags String[]
|
||||
status QRStatus @default(ACTIVE)
|
||||
style Json
|
||||
slug String @unique
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
scans QRScan[]
|
||||
|
||||
@@index([userId, createdAt])
|
||||
}
|
||||
|
||||
enum QRType {
|
||||
STATIC
|
||||
DYNAMIC
|
||||
}
|
||||
|
||||
enum ContentType {
|
||||
URL
|
||||
VCARD
|
||||
GEO
|
||||
PHONE
|
||||
SMS
|
||||
TEXT
|
||||
WHATSAPP
|
||||
}
|
||||
|
||||
enum QRStatus {
|
||||
ACTIVE
|
||||
PAUSED
|
||||
}
|
||||
|
||||
model QRScan {
|
||||
id String @id @default(cuid())
|
||||
qrId String
|
||||
ts DateTime @default(now())
|
||||
ipHash String
|
||||
userAgent String?
|
||||
device String?
|
||||
os String?
|
||||
country String?
|
||||
referrer String?
|
||||
utmSource String?
|
||||
utmMedium String?
|
||||
utmCampaign String?
|
||||
isUnique Boolean @default(false)
|
||||
|
||||
qr QRCode @relation(fields: [qrId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([qrId, ts])
|
||||
}
|
||||
|
||||
model Integration {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
provider String
|
||||
status String @default("inactive")
|
||||
config Json
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model NewsletterSubscription {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
source String @default("ai-coming-soon")
|
||||
status String @default("subscribed")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([email])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model Lead {
|
||||
id String @id @default(cuid())
|
||||
email String
|
||||
source String @default("reprint-calculator")
|
||||
reprintCost Float?
|
||||
updatesPerYear Int?
|
||||
annualSavings Float?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([email])
|
||||
@@index([createdAt])
|
||||
@@index([source])
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://www.qrmaster.net/</loc>
|
||||
<lastmod>2025-10-16T00:00:00Z</lastmod>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.qrmaster.net/blog</loc>
|
||||
<lastmod>2025-10-16T00:00:00Z</lastmod>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.qrmaster.net/pricing</loc>
|
||||
<lastmod>2025-10-16T00:00:00Z</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.qrmaster.net/faq</loc>
|
||||
<lastmod>2025-10-16T00:00:00Z</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.qrmaster.net/blog/qr-code-analytics</loc>
|
||||
<lastmod>2025-10-16T00:00:00Z</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
156
seo_2026_jan.md
156
seo_2026_jan.md
|
|
@ -1,156 +0,0 @@
|
|||
SEO Opportunity Report & Implementation Plan (Jan 2026)
|
||||
1. Executive Summary
|
||||
An analysis of the provided Google Keyword Planner data (Jan 22, 2026) reveals significant low-competition, high-volume traffic opportunities that were previously untapped. We have immediately capitalized on the Barcode opportunity and have a clear path to capture Custom QR intent.
|
||||
|
||||
2. Key Data Findings ("Hidden Gems")
|
||||
We identified three specific clusters where search volume is high but competition is exceptionally low.
|
||||
|
||||
A. The "QR Barcode" Anomaly (Gold Mine) 🏆
|
||||
Users are confused about the terminology, searching for "qr barcode" or "bar code generator" instead of just "barcode".
|
||||
|
||||
Keywords: qr barcode, bar code generator, scan code generator
|
||||
Volume: 10k – 100k (High)
|
||||
Competition: Low / Medium
|
||||
Opportunity: Most competitors optimize for "Barcode Generator". By targeting the "wrong" terms users actually type, we can win easy traffic.
|
||||
B. The "Free" Intent
|
||||
High volume, but users are specifically looking for "free" and "no signup".
|
||||
|
||||
Keyword: free qr code generator (100k – 1M)
|
||||
Keyword: qr code generator free (100k – 1M)
|
||||
Opportunity: Aggressive targeting of these exact match phrases on the homepage metadata.
|
||||
C. The "Custom" Gap
|
||||
Users want customization but don't always use the term "design".
|
||||
|
||||
Keyword: custom qr code generator
|
||||
Volume: 1k – 10k
|
||||
Competition: Low
|
||||
Current Status: MISSING. We do not have a dedicated landing page for this high-intent cluster.
|
||||
3. Actions Already Implemented ✅
|
||||
We have immediately updated the metadata to capture the traffic identified in findings A and B.
|
||||
|
||||
1. Barcode Generator Optimization
|
||||
File:
|
||||
src/app/(marketing)/tools/barcode-generator/page.tsx
|
||||
|
||||
Action: Updated <title> and meta description.
|
||||
New Target: "QR Barcode" and "Bar Code Generator".
|
||||
Why: To capture the 100k+ users searching for these specific variants.
|
||||
2. Homepage Optimization
|
||||
File:
|
||||
src/app/(marketing)/page.tsx
|
||||
|
||||
Action: Injected high-volume keyword tags.
|
||||
New Target: qr generator, free qr code generator, custom qr code generator.
|
||||
Why: To signal relevance to Google for the broadest "head terms".
|
||||
4. Implementation Plan: "Custom QR Code" Landing Page 🚀
|
||||
To capture the 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?
|
||||
|
|
@ -14,20 +14,6 @@ import { calculateContrast, cn } from '@/lib/utils';
|
|||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useCsrf } from '@/hooks/useCsrf';
|
||||
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) => {
|
||||
|
|
@ -48,14 +34,6 @@ const getFrameOptionsForContentType = (contentType: string) => {
|
|||
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' }];
|
||||
}
|
||||
|
|
@ -66,7 +44,6 @@ export default function CreatePage() {
|
|||
const { t } = useTranslation();
|
||||
const { fetchWithCsrf } = useCsrf();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [userPlan, setUserPlan] = useState<string>('FREE');
|
||||
const qrRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -125,14 +102,10 @@ export default function CreatePage() {
|
|||
const hasGoodContrast = contrast >= 4.5;
|
||||
|
||||
const contentTypes = [
|
||||
{ value: 'URL', label: 'URL / Website', icon: Globe },
|
||||
{ value: 'VCARD', label: 'Contact Card', icon: User },
|
||||
{ value: 'GEO', label: 'Location / Maps', icon: MapPin },
|
||||
{ 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 },
|
||||
{ value: 'URL', label: 'URL / Website' },
|
||||
{ value: 'VCARD', label: 'Contact Card' },
|
||||
{ value: 'GEO', label: 'Location/Maps' },
|
||||
{ value: 'PHONE', label: 'Phone Number' },
|
||||
];
|
||||
|
||||
// Get QR content based on content type
|
||||
|
|
@ -155,14 +128,6 @@ export default function CreatePage() {
|
|||
return content.text || 'Sample text';
|
||||
case 'WHATSAPP':
|
||||
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:
|
||||
return 'https://example.com';
|
||||
}
|
||||
|
|
@ -433,208 +398,6 @@ export default function CreatePage() {
|
|||
/>
|
||||
</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:
|
||||
return null;
|
||||
}
|
||||
|
|
@ -665,31 +428,12 @@ export default function CreatePage() {
|
|||
required
|
||||
/>
|
||||
|
||||
{/* Custom Content Type Selector with Icons */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Content Type</label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
{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>
|
||||
<Select
|
||||
label="Content Type"
|
||||
value={contentType}
|
||||
onChange={(e) => setContentType(e.target.value)}
|
||||
options={contentTypes}
|
||||
/>
|
||||
|
||||
{renderContentFields()}
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -1,459 +1,264 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
import { useCsrf } from '@/hooks/useCsrf';
|
||||
import { Upload, FileText, HelpCircle } 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>
|
||||
);
|
||||
|
||||
export default function EditQRPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const qrId = params.id as string;
|
||||
const { fetchWithCsrf, loading: csrfLoading } = useCsrf();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [qrCode, setQrCode] = useState<any>(null);
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState<any>({});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchQRCode = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/qrs/${qrId}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setQrCode(data);
|
||||
setTitle(data.title);
|
||||
setContent(data.content || {});
|
||||
} else {
|
||||
showToast('Failed to load QR code', 'error');
|
||||
router.push('/dashboard');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching QR code:', error);
|
||||
showToast('Failed to load QR code', 'error');
|
||||
router.push('/dashboard');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchQRCode();
|
||||
}, [qrId, router]);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const response = await fetchWithCsrf(`/api/qrs/${qrId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
content,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showToast('QR code updated successfully!', 'success');
|
||||
router.push('/dashboard');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(error.error || 'Failed to update QR code', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating QR code:', error);
|
||||
showToast('Failed to update QR code', 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Loading QR code...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!qrCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Static QR codes cannot be edited
|
||||
if (qrCode.type === 'STATIC') {
|
||||
return (
|
||||
<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">
|
||||
<svg className="w-10 h-10 text-warning-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Static QR Code</h2>
|
||||
<p className="text-gray-600 mb-8">
|
||||
Static QR codes cannot be edited because their content is embedded directly in the QR code image.
|
||||
</p>
|
||||
<Button onClick={() => router.push('/dashboard')}>
|
||||
Back to Dashboard
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Edit QR Code</h1>
|
||||
<p className="text-gray-600 mt-2">Update your dynamic QR code content</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>QR Code Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<Input
|
||||
label="Title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Enter QR code title"
|
||||
required
|
||||
/>
|
||||
|
||||
{qrCode.contentType === 'URL' && (
|
||||
<Input
|
||||
label="URL"
|
||||
type="url"
|
||||
value={content.url || ''}
|
||||
onChange={(e) => setContent({ ...content, url: e.target.value })}
|
||||
placeholder="https://example.com"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
|
||||
{qrCode.contentType === 'PHONE' && (
|
||||
<Input
|
||||
label="Phone Number"
|
||||
type="tel"
|
||||
value={content.phone || ''}
|
||||
onChange={(e) => setContent({ ...content, phone: e.target.value })}
|
||||
placeholder="+1234567890"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
|
||||
{qrCode.contentType === 'VCARD' && (
|
||||
<>
|
||||
<Input
|
||||
label="First Name"
|
||||
value={content.firstName || ''}
|
||||
onChange={(e) => setContent({ ...content, firstName: e.target.value })}
|
||||
placeholder="John"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Last Name"
|
||||
value={content.lastName || ''}
|
||||
onChange={(e) => setContent({ ...content, lastName: e.target.value })}
|
||||
placeholder="Doe"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={content.email || ''}
|
||||
onChange={(e) => setContent({ ...content, email: e.target.value })}
|
||||
placeholder="john@example.com"
|
||||
/>
|
||||
<Input
|
||||
label="Phone"
|
||||
value={content.phone || ''}
|
||||
onChange={(e) => setContent({ ...content, phone: e.target.value })}
|
||||
placeholder="+1234567890"
|
||||
/>
|
||||
<Input
|
||||
label="Organization"
|
||||
value={content.organization || ''}
|
||||
onChange={(e) => setContent({ ...content, organization: e.target.value })}
|
||||
placeholder="Company Name"
|
||||
/>
|
||||
<Input
|
||||
label="Job Title"
|
||||
value={content.title || ''}
|
||||
onChange={(e) => setContent({ ...content, title: e.target.value })}
|
||||
placeholder="CEO"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{qrCode.contentType === 'GEO' && (
|
||||
<>
|
||||
<Input
|
||||
label="Latitude"
|
||||
type="number"
|
||||
step="any"
|
||||
value={content.latitude || ''}
|
||||
onChange={(e) => setContent({ ...content, latitude: parseFloat(e.target.value) || 0 })}
|
||||
placeholder="37.7749"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Longitude"
|
||||
type="number"
|
||||
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>
|
||||
);
|
||||
}
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
import { useCsrf } from '@/hooks/useCsrf';
|
||||
|
||||
export default function EditQRPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const qrId = params.id as string;
|
||||
const { fetchWithCsrf, loading: csrfLoading } = useCsrf();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [qrCode, setQrCode] = useState<any>(null);
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState<any>({});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchQRCode = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/qrs/${qrId}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setQrCode(data);
|
||||
setTitle(data.title);
|
||||
setContent(data.content || {});
|
||||
} else {
|
||||
showToast('Failed to load QR code', 'error');
|
||||
router.push('/dashboard');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching QR code:', error);
|
||||
showToast('Failed to load QR code', 'error');
|
||||
router.push('/dashboard');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchQRCode();
|
||||
}, [qrId, router]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const response = await fetchWithCsrf(`/api/qrs/${qrId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
content,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showToast('QR code updated successfully!', 'success');
|
||||
router.push('/dashboard');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(error.error || 'Failed to update QR code', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating QR code:', error);
|
||||
showToast('Failed to update QR code', 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Loading QR code...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!qrCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Static QR codes cannot be edited
|
||||
if (qrCode.type === 'STATIC') {
|
||||
return (
|
||||
<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">
|
||||
<svg className="w-10 h-10 text-warning-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Static QR Code</h2>
|
||||
<p className="text-gray-600 mb-8">
|
||||
Static QR codes cannot be edited because their content is embedded directly in the QR code image.
|
||||
</p>
|
||||
<Button onClick={() => router.push('/dashboard')}>
|
||||
Back to Dashboard
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Edit QR Code</h1>
|
||||
<p className="text-gray-600 mt-2">Update your dynamic QR code content</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>QR Code Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<Input
|
||||
label="Title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Enter QR code title"
|
||||
required
|
||||
/>
|
||||
|
||||
{qrCode.contentType === 'URL' && (
|
||||
<Input
|
||||
label="URL"
|
||||
type="url"
|
||||
value={content.url || ''}
|
||||
onChange={(e) => setContent({ ...content, url: e.target.value })}
|
||||
placeholder="https://example.com"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
|
||||
{qrCode.contentType === 'PHONE' && (
|
||||
<Input
|
||||
label="Phone Number"
|
||||
type="tel"
|
||||
value={content.phone || ''}
|
||||
onChange={(e) => setContent({ ...content, phone: e.target.value })}
|
||||
placeholder="+1234567890"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
|
||||
{qrCode.contentType === 'VCARD' && (
|
||||
<>
|
||||
<Input
|
||||
label="First Name"
|
||||
value={content.firstName || ''}
|
||||
onChange={(e) => setContent({ ...content, firstName: e.target.value })}
|
||||
placeholder="John"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Last Name"
|
||||
value={content.lastName || ''}
|
||||
onChange={(e) => setContent({ ...content, lastName: e.target.value })}
|
||||
placeholder="Doe"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={content.email || ''}
|
||||
onChange={(e) => setContent({ ...content, email: e.target.value })}
|
||||
placeholder="john@example.com"
|
||||
/>
|
||||
<Input
|
||||
label="Phone"
|
||||
value={content.phone || ''}
|
||||
onChange={(e) => setContent({ ...content, phone: e.target.value })}
|
||||
placeholder="+1234567890"
|
||||
/>
|
||||
<Input
|
||||
label="Organization"
|
||||
value={content.organization || ''}
|
||||
onChange={(e) => setContent({ ...content, organization: e.target.value })}
|
||||
placeholder="Company Name"
|
||||
/>
|
||||
<Input
|
||||
label="Job Title"
|
||||
value={content.title || ''}
|
||||
onChange={(e) => setContent({ ...content, title: e.target.value })}
|
||||
placeholder="CEO"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{qrCode.contentType === 'GEO' && (
|
||||
<>
|
||||
<Input
|
||||
label="Latitude"
|
||||
type="number"
|
||||
step="any"
|
||||
value={content.latitude || ''}
|
||||
onChange={(e) => setContent({ ...content, latitude: parseFloat(e.target.value) || 0 })}
|
||||
placeholder="37.7749"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Longitude"
|
||||
type="number"
|
||||
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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,196 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Star, ArrowLeft, ChevronLeft, ChevronRight, MessageSquare } from 'lucide-react';
|
||||
|
||||
interface Feedback {
|
||||
id: string;
|
||||
rating: number;
|
||||
comment: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
interface FeedbackStats {
|
||||
total: number;
|
||||
avgRating: number;
|
||||
distribution: { [key: number]: number };
|
||||
}
|
||||
|
||||
interface Pagination {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export default function FeedbackListPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const qrId = params.id as string;
|
||||
|
||||
const [feedbacks, setFeedbacks] = useState<Feedback[]>([]);
|
||||
const [stats, setStats] = useState<FeedbackStats | null>(null);
|
||||
const [pagination, setPagination] = useState<Pagination>({ page: 1, totalPages: 1, hasMore: false });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFeedback(currentPage);
|
||||
}, [qrId, currentPage]);
|
||||
|
||||
const fetchFeedback = async (page: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/qrs/${qrId}/feedback?page=${page}&limit=20`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setFeedbacks(data.feedbacks);
|
||||
setStats(data.stats);
|
||||
setPagination(data.pagination);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching feedback:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderStars = (rating: number) => (
|
||||
<div className="flex gap-0.5">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Star
|
||||
key={star}
|
||||
className={`w-4 h-4 ${star <= rating ? 'text-amber-400 fill-amber-400' : 'text-gray-200'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading && !stats) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Link href={`/qr/${qrId}`} className="inline-flex items-center text-gray-500 hover:text-gray-700 mb-4">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to QR Code
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Customer Feedback</h1>
|
||||
<p className="text-gray-600 mt-1">{stats?.total || 0} total responses</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Overview */}
|
||||
{stats && (
|
||||
<Card className="mb-8">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-8">
|
||||
{/* Average Rating */}
|
||||
<div className="text-center md:text-left">
|
||||
<div className="text-5xl font-bold text-gray-900 mb-1">{stats.avgRating}</div>
|
||||
<div className="flex justify-center md:justify-start mb-1">
|
||||
{renderStars(Math.round(stats.avgRating))}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">{stats.total} reviews</p>
|
||||
</div>
|
||||
|
||||
{/* Distribution */}
|
||||
<div className="flex-1 space-y-2">
|
||||
{[5, 4, 3, 2, 1].map((rating) => {
|
||||
const count = stats.distribution[rating] || 0;
|
||||
const percentage = stats.total > 0 ? (count / stats.total) * 100 : 0;
|
||||
return (
|
||||
<div key={rating} className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-600 w-12">{rating} stars</span>
|
||||
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-amber-400 rounded-full transition-all"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 w-12 text-right">{count}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Feedback List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="w-5 h-5" />
|
||||
All Reviews
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{feedbacks.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<Star className="w-12 h-12 mx-auto mb-4 text-gray-300" />
|
||||
<p>No feedback received yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{feedbacks.map((feedback) => (
|
||||
<div key={feedback.id} className="py-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
{renderStars(feedback.rating)}
|
||||
<span className="text-sm text-gray-400">
|
||||
{new Date(feedback.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{feedback.comment && (
|
||||
<p className="text-gray-700">{feedback.comment}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-6 pt-6 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-gray-500">
|
||||
Page {currentPage} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => p + 1)}
|
||||
disabled={!pagination.hasMore}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,287 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import {
|
||||
ArrowLeft, Edit, ExternalLink, Star, MessageSquare,
|
||||
BarChart3, Copy, Check, Pause, Play
|
||||
} from 'lucide-react';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
import { useCsrf } from '@/hooks/useCsrf';
|
||||
|
||||
interface QRCode {
|
||||
id: string;
|
||||
title: string;
|
||||
type: 'STATIC' | 'DYNAMIC';
|
||||
contentType: string;
|
||||
content: any;
|
||||
slug: string;
|
||||
status: 'ACTIVE' | 'PAUSED';
|
||||
style: any;
|
||||
createdAt: string;
|
||||
_count?: { scans: number };
|
||||
}
|
||||
|
||||
interface FeedbackStats {
|
||||
total: number;
|
||||
avgRating: number;
|
||||
distribution: { [key: number]: number };
|
||||
}
|
||||
|
||||
export default function QRDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const qrId = params.id as string;
|
||||
const { fetchWithCsrf } = useCsrf();
|
||||
|
||||
const [qrCode, setQrCode] = useState<QRCode | null>(null);
|
||||
const [feedbackStats, setFeedbackStats] = useState<FeedbackStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchQRCode();
|
||||
}, [qrId]);
|
||||
|
||||
const fetchQRCode = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/qrs/${qrId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setQrCode(data);
|
||||
|
||||
// Fetch feedback stats if it's a feedback QR
|
||||
if (data.contentType === 'FEEDBACK') {
|
||||
const feedbackRes = await fetch(`/api/qrs/${qrId}/feedback?limit=1`);
|
||||
if (feedbackRes.ok) {
|
||||
const feedbackData = await feedbackRes.json();
|
||||
setFeedbackStats(feedbackData.stats);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
showToast('QR code not found', 'error');
|
||||
router.push('/dashboard');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching QR code:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyLink = async () => {
|
||||
const url = `${window.location.origin}/r/${qrCode?.slug}`;
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
showToast('Link copied!', 'success');
|
||||
};
|
||||
|
||||
const toggleStatus = async () => {
|
||||
if (!qrCode) return;
|
||||
const newStatus = qrCode.status === 'ACTIVE' ? 'PAUSED' : 'ACTIVE';
|
||||
|
||||
try {
|
||||
const res = await fetchWithCsrf(`/api/qrs/${qrId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setQrCode({ ...qrCode, status: newStatus });
|
||||
showToast(`QR code ${newStatus === 'ACTIVE' ? 'activated' : 'paused'}`, 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to update status', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const renderStars = (rating: number) => (
|
||||
<div className="flex gap-0.5">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Star
|
||||
key={star}
|
||||
className={`w-4 h-4 ${star <= rating ? 'text-amber-400 fill-amber-400' : 'text-gray-200'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!qrCode) return null;
|
||||
|
||||
const qrUrl = `${typeof window !== 'undefined' ? window.location.origin : ''}/r/${qrCode.slug}`;
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Link href="/dashboard" className="inline-flex items-center text-gray-500 hover:text-gray-700 mb-4">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">{qrCode.title}</h1>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Badge variant={qrCode.type === 'DYNAMIC' ? 'info' : 'default'}>
|
||||
{qrCode.type}
|
||||
</Badge>
|
||||
<Badge variant={qrCode.status === 'ACTIVE' ? 'success' : 'warning'}>
|
||||
{qrCode.status}
|
||||
</Badge>
|
||||
<Badge>{qrCode.contentType}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{qrCode.type === 'DYNAMIC' && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={toggleStatus}>
|
||||
{qrCode.status === 'ACTIVE' ? <Pause className="w-4 h-4 mr-1" /> : <Play className="w-4 h-4 mr-1" />}
|
||||
{qrCode.status === 'ACTIVE' ? 'Pause' : 'Activate'}
|
||||
</Button>
|
||||
<Link href={`/qr/${qrId}/edit`}>
|
||||
<Button variant="outline" size="sm">
|
||||
<Edit className="w-4 h-4 mr-1" /> Edit
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-8">
|
||||
{/* Left: QR Code */}
|
||||
<div>
|
||||
<Card>
|
||||
<CardContent className="p-6 flex flex-col items-center">
|
||||
<div className="bg-white p-4 rounded-xl shadow-sm mb-4">
|
||||
<QRCodeSVG
|
||||
value={qrUrl}
|
||||
size={200}
|
||||
fgColor={qrCode.style?.foregroundColor || '#000000'}
|
||||
bgColor={qrCode.style?.backgroundColor || '#FFFFFF'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-2">
|
||||
<Button variant="outline" className="w-full" onClick={copyLink}>
|
||||
{copied ? <Check className="w-4 h-4 mr-2" /> : <Copy className="w-4 h-4 mr-2" />}
|
||||
{copied ? 'Copied!' : 'Copy Link'}
|
||||
</Button>
|
||||
<a href={qrUrl} target="_blank" rel="noopener noreferrer" className="block">
|
||||
<Button variant="outline" className="w-full">
|
||||
<ExternalLink className="w-4 h-4 mr-2" /> Open Link
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right: Stats & Info */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<BarChart3 className="w-6 h-6 mx-auto mb-2 text-indigo-500" />
|
||||
<p className="text-2xl font-bold text-gray-900">{qrCode._count?.scans || 0}</p>
|
||||
<p className="text-sm text-gray-500">Total Scans</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<p className="text-2xl font-bold text-gray-900">{qrCode.type}</p>
|
||||
<p className="text-sm text-gray-500">QR Type</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{new Date(qrCode.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">Created</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Feedback Summary (only for FEEDBACK type) */}
|
||||
{qrCode.contentType === 'FEEDBACK' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Star className="w-5 h-5 text-amber-400" />
|
||||
Customer Feedback
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{feedbackStats && feedbackStats.total > 0 ? (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-6 mb-4">
|
||||
{/* Average */}
|
||||
<div className="text-center sm:text-left">
|
||||
<div className="text-4xl font-bold text-gray-900">{feedbackStats.avgRating}</div>
|
||||
{renderStars(Math.round(feedbackStats.avgRating))}
|
||||
<p className="text-sm text-gray-500 mt-1">{feedbackStats.total} reviews</p>
|
||||
</div>
|
||||
|
||||
{/* Distribution */}
|
||||
<div className="flex-1 space-y-1">
|
||||
{[5, 4, 3, 2, 1].map((rating) => {
|
||||
const count = feedbackStats.distribution[rating] || 0;
|
||||
const pct = feedbackStats.total > 0 ? (count / feedbackStats.total) * 100 : 0;
|
||||
return (
|
||||
<div key={rating} className="flex items-center gap-2 text-sm">
|
||||
<span className="w-8 text-gray-500">{rating}★</span>
|
||||
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-amber-400 rounded-full" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="w-8 text-gray-400 text-right">{count}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 mb-4">No feedback received yet. Share your QR code to collect reviews!</p>
|
||||
)}
|
||||
|
||||
<Link href={`/qr/${qrId}/feedback`} className="block">
|
||||
<Button variant="outline" className="w-full">
|
||||
<MessageSquare className="w-4 h-4 mr-2" />
|
||||
View All Feedback
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Content Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Content Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="bg-gray-50 p-4 rounded-lg text-sm overflow-auto">
|
||||
{JSON.stringify(qrCode.content, null, 2)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,11 +1,38 @@
|
|||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
import '@/styles/globals.css';
|
||||
import { Providers } from '@/components/Providers';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Authentication | QR Master',
|
||||
description: 'Securely login or sign up to QR Master to manage your dynamic QR codes, track analytics, and access premium features. Your gateway to professional QR management.',
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/favicon.svg', type: 'image/svg+xml' },
|
||||
{ url: '/logo.svg', type: 'image/svg+xml' },
|
||||
],
|
||||
apple: '/logo.svg',
|
||||
},
|
||||
};
|
||||
|
||||
export default function AuthRootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="font-sans">
|
||||
<Providers>
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white">
|
||||
{children}
|
||||
<div className="py-6 text-center text-sm text-slate-500 space-x-4">
|
||||
<a href="/" className="hover:text-primary-600 transition-colors">Home</a>
|
||||
<a href="/privacy" className="hover:text-primary-600 transition-colors">Privacy</a>
|
||||
<a href="/faq" className="hover:text-primary-600 transition-colors">FAQ</a>
|
||||
</div>
|
||||
</div>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,187 +1,68 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useCsrf } from '@/hooks/useCsrf';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useTranslation();
|
||||
const { fetchWithCsrf, loading: csrfLoading } = useCsrf();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await fetchWithCsrf('/api/auth/simple-login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
// Store user in localStorage for client-side
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
|
||||
// Track successful login with PostHog
|
||||
try {
|
||||
const { identifyUser, trackEvent } = await import('@/components/PostHogProvider');
|
||||
identifyUser(data.user.id, {
|
||||
email: data.user.email,
|
||||
name: data.user.name,
|
||||
plan: data.user.plan || 'FREE',
|
||||
});
|
||||
trackEvent('user_login', {
|
||||
method: 'email',
|
||||
email: data.user.email,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('PostHog tracking error:', error);
|
||||
}
|
||||
|
||||
// Check for redirect parameter
|
||||
const redirectUrl = searchParams.get('redirect') || '/dashboard';
|
||||
router.push(redirectUrl);
|
||||
router.refresh();
|
||||
} else {
|
||||
setError(data.error || 'Invalid email or password');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('An error occurred. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleSignIn = () => {
|
||||
// Redirect to Google OAuth API route
|
||||
window.location.href = '/api/auth/google';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
|
||||
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
|
||||
<span className="text-2xl font-bold text-gray-900">QR Master</span>
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Welcome Back</h1>
|
||||
<p className="text-gray-600 mt-2">Sign in to your account</p>
|
||||
<Link href="/" className="text-sm text-primary-600 hover:text-primary-700 font-medium mt-2 inline-block border border-primary-600 hover:border-primary-700 px-4 py-2 rounded-lg transition-colors">
|
||||
← Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center">
|
||||
<input type="checkbox" className="mr-2" />
|
||||
<span className="text-sm text-gray-600">Remember me</span>
|
||||
</label>
|
||||
<Link href="/forgot-password" className="text-sm text-primary-600 hover:text-primary-700">
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" loading={loading} disabled={csrfLoading || loading}>
|
||||
{csrfLoading ? 'Loading...' : 'Sign In'}
|
||||
</Button>
|
||||
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white text-gray-500">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={handleGoogleSignIn}
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
Sign in with Google
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/signup" className="text-primary-600 hover:text-primary-700 font-medium">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="text-center text-sm text-gray-500 mt-6">
|
||||
By signing in, you agree to our{' '}
|
||||
<Link href="/privacy" className="text-primary-600 hover:text-primary-700">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
import React, { Suspense } from 'react';
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import LoginClientPage from './ClientPage';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
absolute: 'Login to QR Master | Access Your Dashboard'
|
||||
},
|
||||
description: 'Sign in to QR Master to create, manage, and track your QR codes. Access your dashboard and view analytics.',
|
||||
alternates: {
|
||||
canonical: 'https://www.qrmaster.net/login',
|
||||
},
|
||||
openGraph: {
|
||||
title: 'Login to QR Master | Access Your Dashboard',
|
||||
description: 'Sign in to QR Master to create, manage, and track your QR codes.',
|
||||
url: 'https://www.qrmaster.net/login',
|
||||
type: 'website',
|
||||
images: [{
|
||||
url: 'https://www.qrmaster.net/og-image.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'QR Master Login',
|
||||
}],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'Login to QR Master | Access Your Dashboard',
|
||||
description: 'Sign in to QR Master to create, manage, and track your QR codes.',
|
||||
images: ['https://www.qrmaster.net/og-image.png'],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
|
||||
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
|
||||
<span className="text-2xl font-bold text-gray-900">QR Master</span>
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Welcome Back</h1>
|
||||
<p className="text-gray-600 mt-2">Sign in to your account</p>
|
||||
<Link href="/" className="text-sm text-primary-600 hover:text-primary-700 font-medium mt-2 inline-block border border-primary-600 hover:border-primary-700 px-4 py-2 rounded-lg transition-colors">
|
||||
← Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Suspense fallback={
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-8 flex items-center justify-center min-h-[400px]">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
}>
|
||||
<LoginClientPage />
|
||||
</Suspense>
|
||||
|
||||
<p className="text-center text-sm text-gray-500 mt-6">
|
||||
By signing in, you agree to our{' '}
|
||||
<Link href="/privacy" className="text-primary-600 hover:text-primary-700">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,208 +1,218 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useCsrf } from '@/hooks/useCsrf';
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const { fetchWithCsrf, loading: csrfLoading } = useCsrf();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
const [token, setToken] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const tokenParam = searchParams.get('token');
|
||||
if (!tokenParam) {
|
||||
setError('Invalid or missing reset token. Please request a new password reset link.');
|
||||
} else {
|
||||
setToken(tokenParam);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
// Validate passwords match
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate password length
|
||||
if (password.length < 8) {
|
||||
setError('Password must be at least 8 characters long');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetchWithCsrf('/api/auth/reset-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token, password }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setSuccess(true);
|
||||
// Redirect to login after 3 seconds
|
||||
setTimeout(() => {
|
||||
router.push('/login');
|
||||
}, 3000);
|
||||
} else {
|
||||
setError(data.error || 'Failed to reset password');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('An error occurred. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
|
||||
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
|
||||
<span className="text-2xl font-bold text-gray-900">QR Master</span>
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Password Reset Successful</h1>
|
||||
<p className="text-gray-600 mt-2">Your password has been updated</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="text-center">
|
||||
<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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-700 mb-4">
|
||||
Your password has been successfully reset!
|
||||
</p>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-6">
|
||||
Redirecting you to the login page in 3 seconds...
|
||||
</p>
|
||||
|
||||
<Link href="/login" className="block">
|
||||
<Button variant="primary" className="w-full">
|
||||
Go to Login
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
|
||||
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
|
||||
<span className="text-2xl font-bold text-gray-900">QR Master</span>
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Reset Your Password</h1>
|
||||
<p className="text-gray-600 mt-2">Enter your new password below</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
{!token ? (
|
||||
<div className="text-center">
|
||||
<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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-red-600 mb-4">{error}</p>
|
||||
<Link href="/forgot-password" className="block">
|
||||
<Button variant="primary" className="w-full">
|
||||
Request New Reset Link
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="New Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter new password"
|
||||
required
|
||||
disabled={loading || csrfLoading}
|
||||
minLength={8}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Confirm new password"
|
||||
required
|
||||
disabled={loading || csrfLoading}
|
||||
minLength={8}
|
||||
/>
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
Password must be at least 8 characters long
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
loading={loading}
|
||||
disabled={csrfLoading || loading}
|
||||
>
|
||||
{csrfLoading ? 'Loading...' : 'Reset Password'}
|
||||
</Button>
|
||||
|
||||
<div className="text-center">
|
||||
<Link href="/login" className="text-sm text-primary-600 hover:text-primary-700 font-medium">
|
||||
← Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="text-center text-sm text-gray-500 mt-6">
|
||||
Remember your password?{' '}
|
||||
<Link href="/login" className="text-primary-600 hover:text-primary-700 font-medium">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useCsrf } from '@/hooks/useCsrf';
|
||||
|
||||
import { Suspense } from 'react';
|
||||
|
||||
function ResetPasswordContent() {
|
||||
const { fetchWithCsrf, loading: csrfLoading } = useCsrf();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
const [token, setToken] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const tokenParam = searchParams.get('token');
|
||||
if (!tokenParam) {
|
||||
setError('Invalid or missing reset token. Please request a new password reset link.');
|
||||
} else {
|
||||
setToken(tokenParam);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
// Validate passwords match
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate password length
|
||||
if (password.length < 8) {
|
||||
setError('Password must be at least 8 characters long');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetchWithCsrf('/api/auth/reset-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token, password }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setSuccess(true);
|
||||
// Redirect to login after 3 seconds
|
||||
setTimeout(() => {
|
||||
router.push('/login');
|
||||
}, 3000);
|
||||
} else {
|
||||
setError(data.error || 'Failed to reset password');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('An error occurred. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
|
||||
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
|
||||
<span className="text-2xl font-bold text-gray-900">QR Master</span>
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Password Reset Successful</h1>
|
||||
<p className="text-gray-600 mt-2">Your password has been updated</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="text-center">
|
||||
<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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-700 mb-4">
|
||||
Your password has been successfully reset!
|
||||
</p>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-6">
|
||||
Redirecting you to the login page in 3 seconds...
|
||||
</p>
|
||||
|
||||
<Link href="/login" className="block">
|
||||
<Button variant="primary" className="w-full">
|
||||
Go to Login
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
|
||||
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
|
||||
<span className="text-2xl font-bold text-gray-900">QR Master</span>
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Reset Your Password</h1>
|
||||
<p className="text-gray-600 mt-2">Enter your new password below</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
{!token ? (
|
||||
<div className="text-center">
|
||||
<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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-red-600 mb-4">{error}</p>
|
||||
<Link href="/forgot-password" className="block">
|
||||
<Button variant="primary" className="w-full">
|
||||
Request New Reset Link
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="New Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter new password"
|
||||
required
|
||||
disabled={loading || csrfLoading}
|
||||
minLength={8}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Confirm new password"
|
||||
required
|
||||
disabled={loading || csrfLoading}
|
||||
minLength={8}
|
||||
/>
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
Password must be at least 8 characters long
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
loading={loading}
|
||||
disabled={csrfLoading || loading}
|
||||
>
|
||||
{csrfLoading ? 'Loading...' : 'Reset Password'}
|
||||
</Button>
|
||||
|
||||
<div className="text-center">
|
||||
<Link href="/login" className="text-sm text-primary-600 hover:text-primary-700 font-medium">
|
||||
← Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="text-center text-sm text-gray-500 mt-6">
|
||||
Remember your password?{' '}
|
||||
<Link href="/login" className="text-primary-600 hover:text-primary-700 font-medium">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="min-h-screen flex items-center justify-center">Loading...</div>}>
|
||||
<ResetPasswordContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,208 +1,69 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useCsrf } from '@/hooks/useCsrf';
|
||||
|
||||
export default function SignupPage() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const { fetchWithCsrf } = useCsrf();
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
setError('Password must be at least 8 characters');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetchWithCsrf('/api/auth/signup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, email, password }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
// Store user in localStorage for client-side
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
|
||||
// Track successful signup with PostHog
|
||||
try {
|
||||
const { identifyUser, trackEvent } = await import('@/components/PostHogProvider');
|
||||
identifyUser(data.user.id, {
|
||||
email: data.user.email,
|
||||
name: data.user.name,
|
||||
plan: data.user.plan || 'FREE',
|
||||
signupMethod: 'email',
|
||||
});
|
||||
trackEvent('user_signup', {
|
||||
method: 'email',
|
||||
email: data.user.email,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('PostHog tracking error:', error);
|
||||
}
|
||||
|
||||
// Redirect to dashboard
|
||||
router.push('/dashboard');
|
||||
router.refresh();
|
||||
} else {
|
||||
setError(data.error || 'Failed to create account');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('An error occurred. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleSignIn = () => {
|
||||
// Redirect to Google OAuth API route
|
||||
window.location.href = '/api/auth/google';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
|
||||
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
|
||||
<span className="text-2xl font-bold text-gray-900">QR Master</span>
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Create Account</h1>
|
||||
<p className="text-gray-600 mt-2">Start creating QR codes in seconds</p>
|
||||
<Link href="/" className="text-sm text-primary-600 hover:text-primary-700 font-medium mt-2 inline-block border border-primary-600 hover:border-primary-700 px-4 py-2 rounded-lg transition-colors">
|
||||
← Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Full Name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="John Doe"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full" loading={loading}>
|
||||
Create Account
|
||||
</Button>
|
||||
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white text-gray-500">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={handleGoogleSignIn}
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
Sign up with Google
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
Already have an account?{' '}
|
||||
<Link href="/login" className="text-primary-600 hover:text-primary-700 font-medium">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="text-center text-sm text-gray-500 mt-6">
|
||||
By signing up, you agree to our{' '}
|
||||
<Link href="/privacy" className="text-primary-600 hover:text-primary-700">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
import React, { Suspense } from 'react';
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import SignupClientPage from './ClientPage';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
absolute: 'Create Free Account | QR Master'
|
||||
},
|
||||
description: 'Sign up for QR Master to create free QR codes. Start with tracking, customization, and bulk generation features.',
|
||||
alternates: {
|
||||
canonical: 'https://www.qrmaster.net/signup',
|
||||
},
|
||||
openGraph: {
|
||||
title: 'Create Free Account | QR Master',
|
||||
description: 'Sign up for QR Master to create free QR codes with tracking and customization.',
|
||||
url: 'https://www.qrmaster.net/signup',
|
||||
type: 'website',
|
||||
images: [{
|
||||
url: 'https://www.qrmaster.net/og-image.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'QR Master Sign Up',
|
||||
}],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'Create Free Account | QR Master',
|
||||
description: 'Sign up for QR Master to create free QR codes with tracking and customization.',
|
||||
images: ['https://www.qrmaster.net/og-image.png'],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
|
||||
export default function SignupPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
|
||||
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
|
||||
<span className="text-2xl font-bold text-gray-900">QR Master</span>
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Create Account</h1>
|
||||
<p className="text-gray-600 mt-2">Start creating QR codes in seconds</p>
|
||||
<Link href="/" className="text-sm text-primary-600 hover:text-primary-700 font-medium mt-2 inline-block border border-primary-600 hover:border-primary-700 px-4 py-2 rounded-lg transition-colors">
|
||||
← Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Suspense fallback={
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-8 flex items-center justify-center min-h-[500px]">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
}>
|
||||
<SignupClientPage />
|
||||
</Suspense>
|
||||
|
||||
<p className="text-center text-sm text-gray-500 mt-6">
|
||||
By signing up, you agree to our{' '}
|
||||
<Link href="/privacy" className="text-primary-600 hover:text-primary-700">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,182 +1,119 @@
|
|||
import React from 'react';
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import SeoJsonLd from '@/components/SeoJsonLd';
|
||||
import { websiteSchema, breadcrumbSchema } from '@/lib/schema';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import Breadcrumbs, { BreadcrumbItem } from '@/components/Breadcrumbs';
|
||||
|
||||
function truncateAtWord(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
const truncated = text.slice(0, maxLength);
|
||||
const lastSpace = truncated.lastIndexOf(' ');
|
||||
return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated;
|
||||
}
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const title = truncateAtWord('QR Insights: Latest QR Strategies', 60);
|
||||
const description = truncateAtWord(
|
||||
'Expert guides on QR analytics, dynamic codes & smart marketing uses.',
|
||||
160
|
||||
);
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: 'https://www.qrmaster.net/blog',
|
||||
languages: {
|
||||
'x-default': 'https://www.qrmaster.net/blog',
|
||||
en: 'https://www.qrmaster.net/blog',
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: 'https://www.qrmaster.net/blog',
|
||||
type: 'website',
|
||||
},
|
||||
twitter: {
|
||||
title,
|
||||
description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const blogPosts = [
|
||||
// NEW POSTS (January 2026)
|
||||
{
|
||||
slug: 'qr-code-restaurant-menu',
|
||||
title: 'How to Create a QR Code for Restaurant Menu',
|
||||
excerpt: 'Step-by-step guide to creating digital menu QR codes for your restaurant. Learn best practices for touchless menus, placement tips, and tracking.',
|
||||
date: 'January 5, 2026',
|
||||
readTime: '12 Min',
|
||||
category: 'Restaurant',
|
||||
image: '/blog/restaurant-qr-menu.png',
|
||||
},
|
||||
{
|
||||
slug: 'vcard-qr-code-generator',
|
||||
title: 'Free vCard QR Code Generator: Digital Business Cards',
|
||||
excerpt: 'Create professional vCard QR codes for digital business cards. Share contact info instantly with a scan—includes templates and best practices.',
|
||||
date: 'January 5, 2026',
|
||||
readTime: '10 Min',
|
||||
category: 'Business Cards',
|
||||
image: '/blog/vcard-qr-code.png',
|
||||
},
|
||||
{
|
||||
slug: 'qr-code-small-business',
|
||||
title: 'Best QR Code Generator for Small Business: 2025 Guide',
|
||||
excerpt: 'Find the best QR code solution for your small business. Compare features, pricing, and use cases for marketing, payments, and operations.',
|
||||
date: 'January 5, 2026',
|
||||
readTime: '14 Min',
|
||||
category: 'Business',
|
||||
image: '/blog/small-business-qr.png',
|
||||
},
|
||||
{
|
||||
slug: 'qr-code-print-size-guide',
|
||||
title: 'QR Code Print Size Guide: Minimum Sizes for Every Use Case',
|
||||
excerpt: 'Complete guide to QR code print sizes. Learn minimum dimensions for business cards, posters, banners, and more to ensure reliable scanning.',
|
||||
date: 'January 5, 2026',
|
||||
readTime: '8 Min',
|
||||
category: 'Printing',
|
||||
image: '/blog/qr-print-sizes.png',
|
||||
},
|
||||
// EXISTING POSTS
|
||||
{
|
||||
slug: 'qr-code-tracking-guide-2025',
|
||||
title: 'QR Code Tracking: Complete Guide 2025',
|
||||
excerpt: 'Learn how to track QR code scans with real-time analytics. Compare free vs paid tracking tools, setup Google Analytics, and measure ROI.',
|
||||
date: 'October 18, 2025',
|
||||
readTime: '12 Min',
|
||||
category: 'Tracking & Analytics',
|
||||
image: '/blog/1-hero.png',
|
||||
},
|
||||
{
|
||||
slug: 'dynamic-vs-static-qr-codes',
|
||||
title: 'Dynamic vs Static QR Codes: Which Should You Use?',
|
||||
excerpt: 'Understand the difference between static and dynamic QR codes. Learn when to use each type, pros/cons, and how dynamic QR codes save money.',
|
||||
date: 'October 17, 2025',
|
||||
readTime: '10 Min',
|
||||
category: 'QR Code Basics',
|
||||
image: '/blog/2-hero.png',
|
||||
},
|
||||
{
|
||||
slug: 'bulk-qr-code-generator-excel',
|
||||
title: 'How to Generate Bulk QR Codes from Excel',
|
||||
excerpt: 'Generate hundreds of QR codes from Excel or CSV files in minutes. Step-by-step guide with templates, best practices, and free tools.',
|
||||
date: 'October 16, 2025',
|
||||
readTime: '13 Min',
|
||||
category: 'Bulk Generation',
|
||||
image: '/blog/3-hero.png',
|
||||
},
|
||||
{
|
||||
slug: 'qr-code-analytics',
|
||||
title: 'QR Code Analytics: Track, Measure & Optimize Campaigns',
|
||||
excerpt: 'Learn how to leverage scan analytics, campaign tracking, and dashboard insights to maximize QR code ROI.',
|
||||
date: 'October 16, 2025',
|
||||
readTime: '15 Min',
|
||||
category: 'Analytics',
|
||||
image: '/blog/4-hero.png',
|
||||
},
|
||||
];
|
||||
|
||||
export default function BlogPage() {
|
||||
const breadcrumbItems: BreadcrumbItem[] = [
|
||||
{ name: 'Home', url: '/' },
|
||||
{ name: 'Blog', url: '/blog' },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SeoJsonLd data={[websiteSchema(), breadcrumbSchema(breadcrumbItems)]} />
|
||||
<div className="py-20 bg-gradient-to-b from-gray-50 to-white">
|
||||
<div className="container mx-auto px-4">
|
||||
<Breadcrumbs items={breadcrumbItems} />
|
||||
<div className="text-center mb-16">
|
||||
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-6">
|
||||
QR Code Insights
|
||||
</h1>
|
||||
<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.
|
||||
Discover how-to tutorials and best practices for QR code analytics.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8 max-w-6xl mx-auto">
|
||||
{blogPosts.map((post) => (
|
||||
<Link key={post.slug} href={`/blog/${post.slug}`}>
|
||||
<Card hover className="h-full overflow-hidden shadow-md hover:shadow-xl transition-all duration-300">
|
||||
<div className="relative h-56 overflow-hidden">
|
||||
<Image
|
||||
src={post.image}
|
||||
alt={`${post.title} - QR code guide showing ${post.category.toLowerCase()} strategies`}
|
||||
width={800}
|
||||
height={600}
|
||||
className="w-full h-full object-cover transition-transform duration-500 hover:scale-110"
|
||||
/>
|
||||
</div>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Badge variant="info">{post.category}</Badge>
|
||||
<span className="text-sm text-gray-500 font-medium">{post.readTime} read</span>
|
||||
</div>
|
||||
<CardTitle className="text-xl leading-tight mb-3">{post.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<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">
|
||||
<p className="text-sm text-gray-500">{post.date}</p>
|
||||
<span className="text-primary-600 text-sm font-medium">Read more →</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import React from 'react';
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import SeoJsonLd from '@/components/SeoJsonLd';
|
||||
import { websiteSchema, breadcrumbSchema } from '@/lib/schema';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import Breadcrumbs, { BreadcrumbItem } from '@/components/Breadcrumbs';
|
||||
import { blogPostList } from '@/lib/blog-data';
|
||||
|
||||
function truncateAtWord(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
const truncated = text.slice(0, maxLength);
|
||||
const lastSpace = truncated.lastIndexOf(' ');
|
||||
return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated;
|
||||
}
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const title = truncateAtWord('QR Insights: Latest QR Strategies', 60);
|
||||
const description = truncateAtWord(
|
||||
'Expert guides on QR code analytics, dynamic vs static codes, bulk generation, and smart marketing use cases. Learn how to maximize your QR campaign ROI.',
|
||||
160
|
||||
);
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: 'https://www.qrmaster.net/blog',
|
||||
languages: {
|
||||
'x-default': 'https://www.qrmaster.net/blog',
|
||||
en: 'https://www.qrmaster.net/blog',
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: 'https://www.qrmaster.net/blog',
|
||||
type: 'website',
|
||||
images: [
|
||||
{
|
||||
url: 'https://www.qrmaster.net/og-image.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'QR Insights - QR Code Marketing & Analytics Blog',
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
title,
|
||||
description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default function BlogPage() {
|
||||
const breadcrumbItems: BreadcrumbItem[] = [
|
||||
{ name: 'Home', url: '/' },
|
||||
{ name: 'Blog', url: '/blog' },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SeoJsonLd data={[websiteSchema(), breadcrumbSchema(breadcrumbItems)]} />
|
||||
<div className="py-20 bg-gradient-to-b from-gray-50 to-white">
|
||||
<div className="container mx-auto px-4">
|
||||
<Breadcrumbs items={breadcrumbItems} />
|
||||
<div className="text-center mb-16">
|
||||
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-6">
|
||||
QR Code Insights
|
||||
</h1>
|
||||
<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.
|
||||
Discover how-to tutorials and best practices for QR code analytics.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8 max-w-6xl mx-auto">
|
||||
{blogPostList.map((post: any, index: number) => (
|
||||
<Link key={post.slug} href={post.link || `/blog/${post.slug}`}>
|
||||
<Card hover className="h-full overflow-hidden shadow-md hover:shadow-xl transition-all duration-300">
|
||||
<div className="relative h-56 overflow-hidden">
|
||||
<Image
|
||||
src={post.image}
|
||||
alt={`${post.title} - QR code guide showing ${post.category.toLowerCase()} strategies`}
|
||||
width={800}
|
||||
height={600}
|
||||
className="w-full h-full object-cover transition-transform duration-500 hover:scale-110"
|
||||
priority={index < 3}
|
||||
/>
|
||||
</div>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Badge variant="info">{post.category}</Badge>
|
||||
<span className="text-sm text-gray-500 font-medium">{post.readTime} read</span>
|
||||
</div>
|
||||
<CardTitle className="text-xl leading-tight mb-3">{post.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<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">
|
||||
<p className="text-sm text-gray-500">{post.date}</p>
|
||||
<span className="text-primary-600 text-sm font-medium">
|
||||
{post.link ? 'Try Now →' : 'Read Article →'}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -1,119 +1,119 @@
|
|||
'use client';
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
// Log the error to an error reporting service
|
||||
console.error('Error:', error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex items-center justify-center px-4">
|
||||
<div className="max-w-2xl w-full text-center">
|
||||
{/* Error Icon */}
|
||||
<div className="mb-8">
|
||||
<div className="inline-flex items-center justify-center w-24 h-24 bg-red-100 rounded-full mb-6">
|
||||
<svg
|
||||
className="w-12 h-12 text-red-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Error Text */}
|
||||
<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">
|
||||
Something Went Wrong
|
||||
</h2>
|
||||
<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.
|
||||
</p>
|
||||
|
||||
{/* Error Details (only in development) */}
|
||||
{process.env.NODE_ENV === 'development' && error.message && (
|
||||
<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">
|
||||
<strong>Error:</strong> {error.message}
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p className="text-sm font-mono text-red-600 mt-2">
|
||||
<strong>Digest:</strong> {error.digest}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||
<Button size="lg" onClick={reset}>
|
||||
<svg
|
||||
className="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
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"
|
||||
/>
|
||||
</svg>
|
||||
Try Again
|
||||
</Button>
|
||||
|
||||
<Link href="/">
|
||||
<Button variant="outline" size="lg">
|
||||
<svg
|
||||
className="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
|
||||
/>
|
||||
</svg>
|
||||
Go Home
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Help Text */}
|
||||
<div className="mt-12 pt-8 border-t border-gray-200">
|
||||
<p className="text-sm text-gray-500">
|
||||
If this problem persists, please{' '}
|
||||
<Link href="/#faq" className="text-primary-600 hover:text-primary-700 font-medium">
|
||||
check our FAQ
|
||||
</Link>
|
||||
{' '}or contact support.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
'use client';
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
// Log the error to an error reporting service
|
||||
console.error('Error:', error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex items-center justify-center px-4">
|
||||
<div className="max-w-2xl w-full text-center">
|
||||
{/* Error Icon */}
|
||||
<div className="mb-8">
|
||||
<div className="inline-flex items-center justify-center w-24 h-24 bg-red-100 rounded-full mb-6">
|
||||
<svg
|
||||
className="w-12 h-12 text-red-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Error Text */}
|
||||
<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">
|
||||
Something Went Wrong
|
||||
</h2>
|
||||
<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.
|
||||
</p>
|
||||
|
||||
{/* Error Details (only in development) */}
|
||||
{process.env.NODE_ENV === 'development' && error.message && (
|
||||
<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">
|
||||
<strong>Error:</strong> {error.message}
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p className="text-sm font-mono text-red-600 mt-2">
|
||||
<strong>Digest:</strong> {error.digest}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||
<Button size="lg" onClick={reset}>
|
||||
<svg
|
||||
className="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
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"
|
||||
/>
|
||||
</svg>
|
||||
Try Again
|
||||
</Button>
|
||||
|
||||
<Link href="/">
|
||||
<Button variant="outline" size="lg">
|
||||
<svg
|
||||
className="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
|
||||
/>
|
||||
</svg>
|
||||
Go Home
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Help Text */}
|
||||
<div className="mt-12 pt-8 border-t border-gray-200">
|
||||
<p className="text-sm text-gray-500">
|
||||
If this problem persists, please{' '}
|
||||
<Link href="/#faq" className="text-primary-600 hover:text-primary-700 font-medium">
|
||||
check our FAQ
|
||||
</Link>
|
||||
{' '}or contact support.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,143 +1,141 @@
|
|||
import React from 'react';
|
||||
import type { Metadata } from 'next';
|
||||
import SeoJsonLd from '@/components/SeoJsonLd';
|
||||
import { faqPageSchema } from '@/lib/schema';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
|
||||
function truncateAtWord(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
const truncated = text.slice(0, maxLength);
|
||||
const lastSpace = truncated.lastIndexOf(' ');
|
||||
return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated;
|
||||
}
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const title = truncateAtWord('QR Master FAQ: Dynamic & Bulk QR', 60);
|
||||
const description = truncateAtWord(
|
||||
'All answers: dynamic QR, security, analytics, bulk, events & print.',
|
||||
160
|
||||
);
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: 'https://www.qrmaster.net/faq',
|
||||
languages: {
|
||||
'x-default': 'https://www.qrmaster.net/faq',
|
||||
en: 'https://www.qrmaster.net/faq',
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: 'https://www.qrmaster.net/faq',
|
||||
type: 'website',
|
||||
},
|
||||
twitter: {
|
||||
title,
|
||||
description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function FAQPage() {
|
||||
return (
|
||||
<>
|
||||
<SeoJsonLd data={faqPageSchema(faqs)} />
|
||||
<div className="py-20 bg-gradient-to-b from-gray-50 to-white">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-6">
|
||||
Frequently Asked Questions
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600">
|
||||
Everything you need to know about dynamic QR codes, security, analytics, bulk generation, events, and print quality.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{faqs.map((faq, index) => (
|
||||
<Card key={index} className="border-l-4 border-blue-500">
|
||||
<CardContent className="p-8">
|
||||
<h2 className="text-2xl font-semibold mb-4 text-gray-900">
|
||||
{faq.question}
|
||||
</h2>
|
||||
<p className="text-lg text-gray-700 leading-relaxed">
|
||||
{faq.answer}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-16 bg-blue-50 border-l-4 border-blue-500 p-8 rounded-r-lg">
|
||||
<h2 className="text-2xl font-bold mb-4 text-gray-900">
|
||||
Still have questions?
|
||||
</h2>
|
||||
<p className="text-lg text-gray-700 mb-6 leading-relaxed">
|
||||
Our support team is here to help. Contact us at{' '}
|
||||
<a href="mailto:support@qrmaster.net" className="text-blue-600 hover:text-blue-700 font-semibold">
|
||||
support@qrmaster.net
|
||||
</a>{' '}
|
||||
or reach out through our live chat.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import React from 'react';
|
||||
import type { Metadata } from 'next';
|
||||
import SeoJsonLd from '@/components/SeoJsonLd';
|
||||
import { faqPageSchema } from '@/lib/schema';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { ContactSupport } from './ContactSupport';
|
||||
|
||||
function truncateAtWord(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
const truncated = text.slice(0, maxLength);
|
||||
const lastSpace = truncated.lastIndexOf(' ');
|
||||
return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated;
|
||||
}
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const title = truncateAtWord('QR Master FAQ: Dynamic & Bulk QR', 60);
|
||||
const description = truncateAtWord(
|
||||
'Find answers about dynamic QR codes, scan tracking, security, bulk generation, and event QR codes. Everything you need to know about QR Master features.',
|
||||
160
|
||||
);
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: 'https://www.qrmaster.net/faq',
|
||||
languages: {
|
||||
'x-default': 'https://www.qrmaster.net/faq',
|
||||
en: 'https://www.qrmaster.net/faq',
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: 'https://www.qrmaster.net/faq',
|
||||
type: 'website',
|
||||
images: [
|
||||
{
|
||||
url: 'https://www.qrmaster.net/og-image.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'QR Master FAQ',
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
title,
|
||||
description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function FAQPage() {
|
||||
return (
|
||||
<>
|
||||
<SeoJsonLd data={faqPageSchema(faqs)} />
|
||||
<div className="py-20 bg-gradient-to-b from-gray-50 to-white">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-6">
|
||||
Frequently Asked Questions
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600">
|
||||
Everything you need to know about dynamic QR codes, security, analytics, bulk generation, events, and print quality.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{faqs.map((faq, index) => (
|
||||
<Card key={index} className="border-l-4 border-blue-500">
|
||||
<CardContent className="p-8">
|
||||
<h2 className="text-2xl font-semibold mb-4 text-gray-900">
|
||||
{faq.question}
|
||||
</h2>
|
||||
<p className="text-lg text-gray-700 leading-relaxed">
|
||||
{faq.answer}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ContactSupport />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,63 +1,63 @@
|
|||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex items-center justify-center px-4">
|
||||
<div className="max-w-2xl w-full text-center">
|
||||
{/* 404 Icon */}
|
||||
<div className="mb-8">
|
||||
<div className="inline-flex items-center justify-center w-24 h-24 bg-primary-100 rounded-full mb-6">
|
||||
<svg
|
||||
className="w-12 h-12 text-primary-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* 404 Text */}
|
||||
<h1 className="text-6xl md:text-8xl font-bold text-gray-900 mb-4">404</h1>
|
||||
<h2 className="text-2xl md:text-3xl font-semibold text-gray-700 mb-4">
|
||||
Page Not Found
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600 mb-8 max-w-md mx-auto">
|
||||
Sorry, we couldn't find the page you're looking for. It might have been moved or deleted.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
<div className="flex justify-center">
|
||||
<Link href="/">
|
||||
<Button size="lg">
|
||||
<svg
|
||||
className="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
|
||||
/>
|
||||
</svg>
|
||||
Back to Home
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
|
||||
import '@/styles/globals.css';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex items-center justify-center px-4">
|
||||
<div className="max-w-2xl w-full text-center">
|
||||
{/* 404 Icon */}
|
||||
<div className="mb-8">
|
||||
<div className="inline-flex items-center justify-center w-24 h-24 bg-primary-100 rounded-full mb-6">
|
||||
<svg
|
||||
className="w-12 h-12 text-primary-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* 404 Text */}
|
||||
<h1 className="text-6xl md:text-8xl font-bold text-gray-900 mb-4">404</h1>
|
||||
<h2 className="text-2xl md:text-3xl font-semibold text-gray-700 mb-4">
|
||||
Page Not Found
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600 mb-8 max-w-md mx-auto">
|
||||
Sorry, we couldn't find the page you're looking for. It might have been moved or deleted.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
<div className="flex justify-center">
|
||||
<Link href="/" className="inline-flex items-center justify-center px-4 py-2 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
|
||||
<svg
|
||||
className="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
|
||||
/>
|
||||
</svg>
|
||||
Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import type { Metadata } from 'next';
|
|||
import SeoJsonLd from '@/components/SeoJsonLd';
|
||||
import { organizationSchema, websiteSchema } from '@/lib/schema';
|
||||
import HomePageClient from '@/components/marketing/HomePageClient';
|
||||
import { generateFaqSchema } from '@/lib/schema-utils';
|
||||
import en from '@/i18n/en.json'; // Import English translations for schema generation
|
||||
|
||||
function truncateAtWord(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
|
|
@ -14,7 +16,7 @@ function truncateAtWord(text: string, maxLength: number): string {
|
|||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const title = truncateAtWord('QR Master: Dynamic QR Generator', 60);
|
||||
const description = truncateAtWord(
|
||||
'Dynamic QR, branding, bulk generation & analytics for all campaigns.',
|
||||
'Create professional QR codes with QR Master. Dynamic QR with tracking, bulk generation, custom branding, and real-time analytics for all your campaigns.',
|
||||
160
|
||||
);
|
||||
|
||||
|
|
@ -26,6 +28,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||
languages: {
|
||||
'x-default': 'https://www.qrmaster.net/',
|
||||
en: 'https://www.qrmaster.net/',
|
||||
de: 'https://www.qrmaster.net/qr-code-erstellen',
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
|
|
@ -33,10 +36,19 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||
description,
|
||||
url: 'https://www.qrmaster.net/',
|
||||
type: 'website',
|
||||
images: [
|
||||
{
|
||||
url: 'https://www.qrmaster.net/og-image.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'QR Master - Dynamic QR Code Generator and Analytics Platform',
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
title,
|
||||
description,
|
||||
images: ['https://www.qrmaster.net/og-image.png'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -44,11 +56,13 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||
export default function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<SeoJsonLd data={[organizationSchema(), websiteSchema()]} />
|
||||
<SeoJsonLd data={[organizationSchema(), websiteSchema(), generateFaqSchema(en.faq.questions)]} />
|
||||
|
||||
{/* Server-rendered H1 for SEO - visually hidden but crawlable */}
|
||||
<h1 className="sr-only">QR Master: Dynamic QR Code Generator with Analytics</h1>
|
||||
|
||||
{/* Server-rendered SEO content for crawlers */}
|
||||
<div className="sr-only" aria-hidden="false">
|
||||
<h1>QR Master: Free Dynamic QR Code Generator with Tracking & Analytics</h1>
|
||||
<p>
|
||||
Create professional QR codes for your business with QR Master. Our dynamic QR code generator
|
||||
lets you create trackable QR codes, edit destinations anytime, and view detailed analytics.
|
||||
|
|
|
|||
|
|
@ -1,268 +1,269 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { BillingToggle } from '@/components/ui/BillingToggle';
|
||||
|
||||
export default function PricingPage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState<string | null>(null);
|
||||
const [currentPlan, setCurrentPlan] = useState<string>('FREE');
|
||||
const [currentInterval, setCurrentInterval] = useState<'month' | 'year' | null>(null);
|
||||
const [billingPeriod, setBillingPeriod] = useState<'month' | 'year'>('month');
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch current user plan
|
||||
const fetchUserPlan = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/user/plan');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCurrentPlan(data.plan || 'FREE');
|
||||
setCurrentInterval(data.interval || null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching user plan:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserPlan();
|
||||
}, []);
|
||||
|
||||
const handleUpgrade = async (plan: 'PRO' | 'BUSINESS') => {
|
||||
setLoading(plan);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/stripe/create-checkout-session', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
plan,
|
||||
billingInterval: billingPeriod === 'month' ? 'month' : 'year',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create checkout session');
|
||||
}
|
||||
|
||||
const { url } = await response.json();
|
||||
window.location.href = url;
|
||||
} catch (error) {
|
||||
console.error('Error creating checkout session:', error);
|
||||
showToast('Failed to start checkout. Please try again.', 'error');
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDowngrade = async () => {
|
||||
// Show confirmation dialog
|
||||
const confirmed = window.confirm(
|
||||
'Are you sure you want to downgrade to the Free plan? Your subscription will be canceled immediately and you will lose access to premium features.'
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading('FREE');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/stripe/cancel-subscription', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to cancel subscription');
|
||||
}
|
||||
|
||||
showToast('Successfully downgraded to Free plan', 'success');
|
||||
|
||||
// Refresh to update the plan
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
} catch (error: any) {
|
||||
console.error('Error canceling subscription:', error);
|
||||
showToast(error.message || 'Failed to downgrade. Please try again.', 'error');
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to check if this is the user's exact current plan (plan + interval)
|
||||
const isCurrentPlanWithInterval = (planType: string, interval: 'month' | 'year') => {
|
||||
return currentPlan === planType && currentInterval === interval;
|
||||
};
|
||||
|
||||
// Helper function to check if user has this plan but different interval
|
||||
const hasPlanDifferentInterval = (planType: string) => {
|
||||
return currentPlan === planType && currentInterval && currentInterval !== billingPeriod;
|
||||
};
|
||||
|
||||
const selectedInterval = billingPeriod === 'month' ? 'month' : 'year';
|
||||
|
||||
const plans = [
|
||||
{
|
||||
key: 'free',
|
||||
name: 'Free',
|
||||
price: '€0',
|
||||
period: 'forever',
|
||||
showDiscount: false,
|
||||
features: [
|
||||
'3 dynamic QR codes',
|
||||
'Unlimited static QR codes',
|
||||
'Basic scan tracking',
|
||||
'Standard QR design templates',
|
||||
'Download as SVG/PNG',
|
||||
],
|
||||
buttonText: currentPlan === 'FREE' ? 'Current Plan' : 'Downgrade to Free',
|
||||
buttonVariant: 'outline' as const,
|
||||
disabled: currentPlan === 'FREE',
|
||||
popular: false,
|
||||
onDowngrade: handleDowngrade,
|
||||
},
|
||||
{
|
||||
key: 'pro',
|
||||
name: 'Pro',
|
||||
price: billingPeriod === 'month' ? '€9' : '€90',
|
||||
period: billingPeriod === 'month' ? 'per month' : 'per year',
|
||||
showDiscount: billingPeriod === 'year',
|
||||
features: [
|
||||
'50 dynamic QR codes',
|
||||
'Unlimited static QR codes',
|
||||
'Advanced analytics (scans, devices, locations)',
|
||||
'Custom branding (colors & logos)',
|
||||
],
|
||||
buttonText: isCurrentPlanWithInterval('PRO', selectedInterval)
|
||||
? 'Current Plan'
|
||||
: hasPlanDifferentInterval('PRO')
|
||||
? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}`
|
||||
: 'Upgrade to Pro',
|
||||
buttonVariant: 'primary' as const,
|
||||
disabled: isCurrentPlanWithInterval('PRO', selectedInterval),
|
||||
popular: true,
|
||||
onUpgrade: () => handleUpgrade('PRO'),
|
||||
},
|
||||
{
|
||||
key: 'business',
|
||||
name: 'Business',
|
||||
price: billingPeriod === 'month' ? '€29' : '€290',
|
||||
period: billingPeriod === 'month' ? 'per month' : 'per year',
|
||||
showDiscount: billingPeriod === 'year',
|
||||
features: [
|
||||
'500 dynamic QR codes',
|
||||
'Unlimited static QR codes',
|
||||
'Everything from Pro',
|
||||
'Bulk QR Creation (up to 1,000)',
|
||||
'Priority email support',
|
||||
'Advanced tracking & insights',
|
||||
],
|
||||
buttonText: isCurrentPlanWithInterval('BUSINESS', selectedInterval)
|
||||
? 'Current Plan'
|
||||
: hasPlanDifferentInterval('BUSINESS')
|
||||
? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}`
|
||||
: 'Upgrade to Business',
|
||||
buttonVariant: 'primary' as const,
|
||||
disabled: isCurrentPlanWithInterval('BUSINESS', selectedInterval),
|
||||
popular: false,
|
||||
onUpgrade: () => handleUpgrade('BUSINESS'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
Choose Your Plan
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600">
|
||||
Select the perfect plan for your QR code needs
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center mb-8">
|
||||
<BillingToggle value={billingPeriod} onChange={setBillingPeriod} />
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8 max-w-6xl mx-auto">
|
||||
{plans.map((plan) => (
|
||||
<Card
|
||||
key={plan.key}
|
||||
className={plan.popular ? 'border-primary-500 shadow-xl relative' : ''}
|
||||
>
|
||||
{plan.popular && (
|
||||
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
|
||||
<Badge variant="info" className="px-3 py-1">
|
||||
Most Popular
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardHeader className="text-center pb-8">
|
||||
<CardTitle className="text-2xl mb-4">
|
||||
{plan.name}
|
||||
</CardTitle>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-baseline justify-center">
|
||||
<span className="text-4xl font-bold">
|
||||
{plan.price}
|
||||
</span>
|
||||
<span className="text-gray-600 ml-2">
|
||||
{plan.period}
|
||||
</span>
|
||||
</div>
|
||||
{plan.showDiscount && (
|
||||
<Badge variant="success" className="mt-2">
|
||||
Save 16%
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
<ul className="space-y-3">
|
||||
{plan.features.map((feature: string, index: number) => (
|
||||
<li key={index} className="flex items-start space-x-3">
|
||||
<svg className="w-5 h-5 text-success-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-gray-700">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
variant={plan.buttonVariant}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={plan.disabled || loading === plan.key.toUpperCase()}
|
||||
onClick={plan.key === 'free' ? (plan as any).onDowngrade : (plan as any).onUpgrade}
|
||||
>
|
||||
{loading === plan.key.toUpperCase() ? 'Processing...' : plan.buttonText}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-12">
|
||||
<p className="text-gray-600">
|
||||
All plans include unlimited static QR codes and basic customization.
|
||||
</p>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Need help choosing? <a href="mailto:support@qrmaster.net" className="text-primary-600 hover:text-primary-700 underline">Contact our team</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { BillingToggle } from '@/components/ui/BillingToggle';
|
||||
import { ObfuscatedMailto } from '@/components/ui/ObfuscatedMailto';
|
||||
|
||||
export default function PricingClient() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState<string | null>(null);
|
||||
const [currentPlan, setCurrentPlan] = useState<string>('FREE');
|
||||
const [currentInterval, setCurrentInterval] = useState<'month' | 'year' | null>(null);
|
||||
const [billingPeriod, setBillingPeriod] = useState<'month' | 'year'>('month');
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch current user plan
|
||||
const fetchUserPlan = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/user/plan');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCurrentPlan(data.plan || 'FREE');
|
||||
setCurrentInterval(data.interval || null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching user plan:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserPlan();
|
||||
}, []);
|
||||
|
||||
const handleUpgrade = async (plan: 'PRO' | 'BUSINESS') => {
|
||||
setLoading(plan);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/stripe/create-checkout-session', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
plan,
|
||||
billingInterval: billingPeriod === 'month' ? 'month' : 'year',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create checkout session');
|
||||
}
|
||||
|
||||
const { url } = await response.json();
|
||||
window.location.href = url;
|
||||
} catch (error) {
|
||||
console.error('Error creating checkout session:', error);
|
||||
showToast('Failed to start checkout. Please try again.', 'error');
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDowngrade = async () => {
|
||||
// Show confirmation dialog
|
||||
const confirmed = window.confirm(
|
||||
'Are you sure you want to downgrade to the Free plan? Your subscription will be canceled immediately and you will lose access to premium features.'
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading('FREE');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/stripe/cancel-subscription', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to cancel subscription');
|
||||
}
|
||||
|
||||
showToast('Successfully downgraded to Free plan', 'success');
|
||||
|
||||
// Refresh to update the plan
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
} catch (error: any) {
|
||||
console.error('Error canceling subscription:', error);
|
||||
showToast(error.message || 'Failed to downgrade. Please try again.', 'error');
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to check if this is the user's exact current plan (plan + interval)
|
||||
const isCurrentPlanWithInterval = (planType: string, interval: 'month' | 'year') => {
|
||||
return currentPlan === planType && currentInterval === interval;
|
||||
};
|
||||
|
||||
// Helper function to check if user has this plan but different interval
|
||||
const hasPlanDifferentInterval = (planType: string) => {
|
||||
return currentPlan === planType && currentInterval && currentInterval !== billingPeriod;
|
||||
};
|
||||
|
||||
const selectedInterval = billingPeriod === 'month' ? 'month' : 'year';
|
||||
|
||||
const plans = [
|
||||
{
|
||||
key: 'free',
|
||||
name: 'Free',
|
||||
price: '€0',
|
||||
period: 'forever',
|
||||
showDiscount: false,
|
||||
features: [
|
||||
'3 dynamic QR codes',
|
||||
'Unlimited static QR codes',
|
||||
'Basic scan tracking',
|
||||
'Standard QR design templates',
|
||||
'Download as SVG/PNG',
|
||||
],
|
||||
buttonText: currentPlan === 'FREE' ? 'Current Plan' : 'Downgrade to Free',
|
||||
buttonVariant: 'outline' as const,
|
||||
disabled: currentPlan === 'FREE',
|
||||
popular: false,
|
||||
onDowngrade: handleDowngrade,
|
||||
},
|
||||
{
|
||||
key: 'pro',
|
||||
name: 'Pro',
|
||||
price: billingPeriod === 'month' ? '€9' : '€90',
|
||||
period: billingPeriod === 'month' ? 'per month' : 'per year',
|
||||
showDiscount: billingPeriod === 'year',
|
||||
features: [
|
||||
'50 dynamic QR codes',
|
||||
'Unlimited static QR codes',
|
||||
'Advanced analytics (scans, devices, locations)',
|
||||
'Custom branding (colors & logos)',
|
||||
],
|
||||
buttonText: isCurrentPlanWithInterval('PRO', selectedInterval)
|
||||
? 'Current Plan'
|
||||
: hasPlanDifferentInterval('PRO')
|
||||
? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}`
|
||||
: 'Upgrade to Pro',
|
||||
buttonVariant: 'primary' as const,
|
||||
disabled: isCurrentPlanWithInterval('PRO', selectedInterval),
|
||||
popular: true,
|
||||
onUpgrade: () => handleUpgrade('PRO'),
|
||||
},
|
||||
{
|
||||
key: 'business',
|
||||
name: 'Business',
|
||||
price: billingPeriod === 'month' ? '€29' : '€290',
|
||||
period: billingPeriod === 'month' ? 'per month' : 'per year',
|
||||
showDiscount: billingPeriod === 'year',
|
||||
features: [
|
||||
'500 dynamic QR codes',
|
||||
'Unlimited static QR codes',
|
||||
'Everything from Pro',
|
||||
'Bulk QR Creation (up to 1,000)',
|
||||
'Priority email support',
|
||||
'Advanced tracking & insights',
|
||||
],
|
||||
buttonText: isCurrentPlanWithInterval('BUSINESS', selectedInterval)
|
||||
? 'Current Plan'
|
||||
: hasPlanDifferentInterval('BUSINESS')
|
||||
? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}`
|
||||
: 'Upgrade to Business',
|
||||
buttonVariant: 'primary' as const,
|
||||
disabled: isCurrentPlanWithInterval('BUSINESS', selectedInterval),
|
||||
popular: false,
|
||||
onUpgrade: () => handleUpgrade('BUSINESS'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
Choose Your Plan
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600">
|
||||
Select the perfect plan for your QR code needs
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center mb-8">
|
||||
<BillingToggle value={billingPeriod} onChange={setBillingPeriod} />
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8 max-w-6xl mx-auto">
|
||||
{plans.map((plan) => (
|
||||
<Card
|
||||
key={plan.key}
|
||||
className={plan.popular ? 'border-primary-500 shadow-xl relative' : ''}
|
||||
>
|
||||
{plan.popular && (
|
||||
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
|
||||
<Badge variant="info" className="px-3 py-1">
|
||||
Most Popular
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardHeader className="text-center pb-8">
|
||||
<CardTitle className="text-2xl mb-4">
|
||||
{plan.name}
|
||||
</CardTitle>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-baseline justify-center">
|
||||
<span className="text-4xl font-bold">
|
||||
{plan.price}
|
||||
</span>
|
||||
<span className="text-gray-600 ml-2">
|
||||
{plan.period}
|
||||
</span>
|
||||
</div>
|
||||
{plan.showDiscount && (
|
||||
<Badge variant="success" className="mt-2">
|
||||
Save 16%
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
<ul className="space-y-3">
|
||||
{plan.features.map((feature: string, index: number) => (
|
||||
<li key={index} className="flex items-start space-x-3">
|
||||
<svg className="w-5 h-5 text-success-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-gray-700">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
variant={plan.buttonVariant}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={plan.disabled || loading === plan.key.toUpperCase()}
|
||||
onClick={plan.key === 'free' ? (plan as any).onDowngrade : (plan as any).onUpgrade}
|
||||
>
|
||||
{loading === plan.key.toUpperCase() ? 'Processing...' : plan.buttonText}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-12">
|
||||
<p className="text-gray-600">
|
||||
All plans include unlimited static QR codes and basic customization.
|
||||
</p>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Need help choosing? <ObfuscatedMailto email="support@qrmaster.net" className="text-primary-600 hover:text-primary-700 underline">Contact our team</ObfuscatedMailto>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,133 +1,147 @@
|
|||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Privacy Policy | QR Master',
|
||||
description: 'Privacy Policy and data protection information for QR Master',
|
||||
};
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<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="mb-8">
|
||||
<Link href="/" className="text-primary-600 hover:text-primary-700 font-medium">
|
||||
← Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<div className="prose prose-lg max-w-none">
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">1. Introduction</h2>
|
||||
<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.
|
||||
This privacy policy explains how we collect, use, and protect your information when you use our services.
|
||||
</p>
|
||||
<p className="text-gray-700 mb-4">
|
||||
We implement appropriate security measures including secure HTTPS transmission, password hashing, database access controls,
|
||||
and CSRF protection to keep your data safe.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<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>
|
||||
<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>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>
|
||||
</ul>
|
||||
|
||||
<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">
|
||||
<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>Cookies:</strong> Essential cookies for authentication and optional analytics cookies (PostHog) with your consent</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<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>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
||||
<li>Provide and maintain our QR code services</li>
|
||||
<li>Process payments and manage subscriptions</li>
|
||||
<li>Provide customer support</li>
|
||||
<li>Improve our services and develop new features</li>
|
||||
<li>Detect and prevent fraud</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mb-4">
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<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>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
||||
<li><strong>Stripe:</strong> Payment processing</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>Legal Requirements:</strong> When required by law</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mb-4">
|
||||
We do not sell your personal data. Analytics are only activated if you accept optional cookies.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<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>
|
||||
<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>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>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>Withdraw Consent:</strong> Withdraw cookie consent at any time</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mb-4">
|
||||
To exercise these rights, contact us at{' '}
|
||||
<a href="mailto:support@qrmaster.net" className="text-primary-600 hover:text-primary-700">
|
||||
support@qrmaster.net
|
||||
</a>
|
||||
</p>
|
||||
<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,
|
||||
you may lodge a complaint with your local data protection authority.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">6. Contact Us</h2>
|
||||
<p className="text-gray-700 mb-4">
|
||||
If you have questions about this privacy policy, please contact us:
|
||||
</p>
|
||||
<div className="bg-gray-50 p-6 rounded-lg">
|
||||
<p className="text-gray-700 mb-2">
|
||||
<strong>Email:</strong>{' '}
|
||||
<a href="mailto:support@qrmaster.net" className="text-primary-600 hover:text-primary-700">
|
||||
support@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>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 pt-8 border-t border-gray-200">
|
||||
<p className="text-gray-600 text-center">
|
||||
<Link href="/" className="text-primary-600 hover:text-primary-700">
|
||||
Back to Home
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { PrivacyEmailLink } from './PrivacyEmailLink';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Privacy Policy | QR Master',
|
||||
description: 'Read our Privacy Policy to understand how QR Master collects, uses, and protects your data. We are committed to GDPR compliance and data security.',
|
||||
alternates: {
|
||||
canonical: 'https://www.qrmaster.net/privacy',
|
||||
},
|
||||
openGraph: {
|
||||
title: 'Privacy Policy | QR Master',
|
||||
description: 'Read our Privacy Policy to understand how QR Master collects, uses, and protects your data.',
|
||||
url: 'https://www.qrmaster.net/privacy',
|
||||
type: 'website',
|
||||
images: [
|
||||
{
|
||||
url: 'https://www.qrmaster.net/og-image.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'QR Master Privacy Policy',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<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="mb-8">
|
||||
<Link href="/" className="text-primary-600 hover:text-primary-700 font-medium">
|
||||
← Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<div className="prose prose-lg max-w-none">
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">1. Introduction</h2>
|
||||
<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.
|
||||
This privacy policy explains how we collect, use, and protect your information when you use our services.
|
||||
</p>
|
||||
<p className="text-gray-700 mb-4">
|
||||
We implement appropriate security measures including secure HTTPS transmission, password hashing, database access controls,
|
||||
and CSRF protection to keep your data safe.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<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>
|
||||
<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>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>
|
||||
</ul>
|
||||
|
||||
<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">
|
||||
<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>Cookies:</strong> Essential cookies for authentication and optional analytics cookies (PostHog) with your consent</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<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>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
||||
<li>Provide and maintain our QR code services</li>
|
||||
<li>Process payments and manage subscriptions</li>
|
||||
<li>Provide customer support</li>
|
||||
<li>Improve our services and develop new features</li>
|
||||
<li>Detect and prevent fraud</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mb-4">
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<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>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
||||
<li><strong>Stripe:</strong> Payment processing</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>Legal Requirements:</strong> When required by law</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mb-4">
|
||||
We do not sell your personal data. Analytics are only activated if you accept optional cookies.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<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>
|
||||
<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>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>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>Withdraw Consent:</strong> Withdraw cookie consent at any time</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mb-4">
|
||||
To exercise these rights, contact us at{' '}
|
||||
<PrivacyEmailLink />
|
||||
</p>
|
||||
<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,
|
||||
you may lodge a complaint with your local data protection authority.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">6. Contact Us</h2>
|
||||
<p className="text-gray-700 mb-4">
|
||||
If you have questions about this privacy policy, please contact us:
|
||||
</p>
|
||||
<div className="bg-gray-50 p-6 rounded-lg">
|
||||
<p className="text-gray-700 mb-2">
|
||||
<strong>Email:</strong>{' '}
|
||||
<PrivacyEmailLink />
|
||||
</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>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 pt-8 border-t border-gray-200">
|
||||
<p className="text-gray-600 text-center">
|
||||
<Link href="/" className="text-primary-600 hover:text-primary-700">
|
||||
Back to Home
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,398 +1,415 @@
|
|||
import React from 'react';
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import SeoJsonLd from '@/components/SeoJsonLd';
|
||||
import Breadcrumbs, { BreadcrumbItem } from '@/components/Breadcrumbs';
|
||||
import { breadcrumbSchema } from '@/lib/schema';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
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.',
|
||||
keywords: 'qr code tracking, qr code analytics, track qr scans, qr code statistics, free qr tracking, qr code monitoring',
|
||||
alternates: {
|
||||
canonical: 'https://www.qrmaster.net/qr-code-tracking',
|
||||
languages: {
|
||||
'x-default': 'https://www.qrmaster.net/qr-code-tracking',
|
||||
en: 'https://www.qrmaster.net/qr-code-tracking',
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
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.',
|
||||
url: 'https://www.qrmaster.net/qr-code-tracking',
|
||||
type: 'website',
|
||||
},
|
||||
twitter: {
|
||||
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.',
|
||||
},
|
||||
};
|
||||
|
||||
export default function QRCodeTrackingPage() {
|
||||
const trackingFeatures = [
|
||||
{
|
||||
icon: '📊',
|
||||
title: 'Real-Time Analytics',
|
||||
description: 'See scan data instantly as it happens. Monitor your QR code performance in real-time with live dashboards.',
|
||||
},
|
||||
{
|
||||
icon: '🌍',
|
||||
title: 'Location Tracking',
|
||||
description: 'Know exactly where your QR codes are being scanned. Track by country, city, and region.',
|
||||
},
|
||||
{
|
||||
icon: '📱',
|
||||
title: 'Device Detection',
|
||||
description: 'Identify which devices scan your codes. Track iOS, Android, desktop, and browser types.',
|
||||
},
|
||||
{
|
||||
icon: '🕐',
|
||||
title: 'Time-Based Reports',
|
||||
description: 'Analyze scan patterns by hour, day, week, or month. Optimize your campaigns with timing insights.',
|
||||
},
|
||||
{
|
||||
icon: '👥',
|
||||
title: 'Unique vs Total Scans',
|
||||
description: 'Distinguish between unique users and repeat scans. Measure true reach and engagement.',
|
||||
},
|
||||
{
|
||||
icon: '📈',
|
||||
title: 'Campaign Performance',
|
||||
description: 'Track ROI with UTM parameters. Measure conversion rates and campaign effectiveness.',
|
||||
},
|
||||
];
|
||||
|
||||
const useCases = [
|
||||
{
|
||||
title: 'Marketing Campaigns',
|
||||
description: 'Track print ads, billboards, and product packaging to measure marketing ROI.',
|
||||
benefits: ['Measure ad performance', 'A/B test campaigns', 'Track conversions'],
|
||||
},
|
||||
{
|
||||
title: 'Event Management',
|
||||
description: 'Monitor event check-ins, booth visits, and attendee engagement in real-time.',
|
||||
benefits: ['Live attendance tracking', 'Booth analytics', 'Engagement metrics'],
|
||||
},
|
||||
{
|
||||
title: 'Product Labels',
|
||||
description: 'Track product authenticity scans, manual downloads, and warranty registrations.',
|
||||
benefits: ['Anti-counterfeiting', 'User registration tracking', 'Product analytics'],
|
||||
},
|
||||
{
|
||||
title: 'Restaurant Menus',
|
||||
description: 'See how many customers scan your menu QR codes and when peak times occur.',
|
||||
benefits: ['Customer insights', 'Peak time analysis', 'Menu engagement'],
|
||||
},
|
||||
];
|
||||
|
||||
const comparisonData = [
|
||||
{ feature: 'Real-Time Analytics', free: true, qrMaster: true },
|
||||
{ feature: 'Location Tracking', free: false, qrMaster: true },
|
||||
{ feature: 'Device Detection', free: false, qrMaster: true },
|
||||
{ feature: 'Unlimited Scans', free: false, qrMaster: true },
|
||||
{ feature: 'Historical Data', free: '7 days', qrMaster: 'Unlimited' },
|
||||
{ feature: 'Export Reports', free: false, qrMaster: true },
|
||||
{ feature: 'API Access', free: false, qrMaster: true },
|
||||
];
|
||||
|
||||
const softwareSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
'@id': 'https://www.qrmaster.net/qr-code-tracking#software',
|
||||
name: 'QR Master - QR Code Tracking & Analytics',
|
||||
applicationCategory: 'BusinessApplication',
|
||||
operatingSystem: 'Web Browser, iOS, Android',
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
price: '0',
|
||||
priceCurrency: 'USD',
|
||||
availability: 'https://schema.org/InStock',
|
||||
},
|
||||
aggregateRating: {
|
||||
'@type': 'AggregateRating',
|
||||
ratingValue: '4.8',
|
||||
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.',
|
||||
features: [
|
||||
'Real-time analytics dashboard',
|
||||
'Location tracking by country and city',
|
||||
'Device detection (iOS, Android, Desktop)',
|
||||
'Time-based scan reports',
|
||||
'Unique vs total scan tracking',
|
||||
'Campaign performance metrics',
|
||||
'Unlimited scans',
|
||||
'Export detailed reports',
|
||||
],
|
||||
};
|
||||
|
||||
const howToSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'HowTo',
|
||||
'@id': 'https://www.qrmaster.net/qr-code-tracking#howto',
|
||||
name: 'How to Track QR Code Scans',
|
||||
description: 'Learn how to track and analyze QR code scans with real-time analytics',
|
||||
totalTime: 'PT5M',
|
||||
step: [
|
||||
{
|
||||
'@type': 'HowToStep',
|
||||
position: 1,
|
||||
name: 'Create QR Code',
|
||||
text: 'Sign up for free and create a dynamic QR code with tracking enabled',
|
||||
url: 'https://www.qrmaster.net/signup',
|
||||
},
|
||||
{
|
||||
'@type': 'HowToStep',
|
||||
position: 2,
|
||||
name: 'Deploy QR Code',
|
||||
text: 'Download and place your QR code on marketing materials, products, or digital platforms',
|
||||
},
|
||||
{
|
||||
'@type': 'HowToStep',
|
||||
position: 3,
|
||||
name: 'Monitor Analytics',
|
||||
text: 'View real-time scan data including location, device, and time patterns in your dashboard',
|
||||
url: 'https://www.qrmaster.net/analytics',
|
||||
},
|
||||
{
|
||||
'@type': 'HowToStep',
|
||||
position: 4,
|
||||
name: 'Optimize Campaigns',
|
||||
text: 'Use insights to optimize placement, timing, and targeting of your QR code campaigns',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const breadcrumbItems: BreadcrumbItem[] = [
|
||||
{ name: 'Home', url: '/' },
|
||||
{ name: 'QR Code Tracking', url: '/qr-code-tracking' },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SeoJsonLd data={[softwareSchema, howToSchema, breadcrumbSchema(breadcrumbItems)]} />
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Hero Section */}
|
||||
<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">
|
||||
<Breadcrumbs items={breadcrumbItems} />
|
||||
<div className="grid lg:grid-cols-2 gap-12 items-center">
|
||||
<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">
|
||||
<span>📊</span>
|
||||
<span>Free QR Code Tracking</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl lg:text-6xl font-bold text-gray-900 leading-tight">
|
||||
Track Every QR Code Scan with Powerful Analytics
|
||||
</h1>
|
||||
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<Link href="/signup">
|
||||
<Button size="lg" className="text-lg px-8 py-4 w-full sm:w-auto">
|
||||
Start Tracking Free
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/create">
|
||||
<Button variant="outline" size="lg" className="text-lg px-8 py-4 w-full sm:w-auto">
|
||||
Create Trackable QR Code
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-6 text-sm text-gray-600">
|
||||
<div className="flex items-center space-x-2">
|
||||
<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" />
|
||||
</svg>
|
||||
<span>No credit card required</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<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" />
|
||||
</svg>
|
||||
<span>Unlimited scans</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analytics Preview */}
|
||||
<div className="relative">
|
||||
<Card className="p-6 shadow-2xl">
|
||||
<h3 className="font-semibold text-lg mb-4">Live Analytics Dashboard</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center pb-3 border-b">
|
||||
<span className="text-gray-600">Total Scans</span>
|
||||
<span className="text-2xl font-bold text-primary-600">12,547</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center pb-3 border-b">
|
||||
<span className="text-gray-600">Unique Users</span>
|
||||
<span className="text-2xl font-bold text-primary-600">8,392</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center pb-3 border-b">
|
||||
<span className="text-gray-600">Top Location</span>
|
||||
<span className="font-semibold">🇩🇪 Germany</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">Top Device</span>
|
||||
<span className="font-semibold">📱 iPhone</span>
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
Live Updates
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tracking Features */}
|
||||
<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="text-center mb-16">
|
||||
<h2 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
Powerful QR Code Tracking Features
|
||||
</h2>
|
||||
<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
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{trackingFeatures.map((feature, index) => (
|
||||
<Card key={index} className="p-6 hover:shadow-lg transition-shadow">
|
||||
<div className="text-4xl mb-4">{feature.icon}</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
{feature.description}
|
||||
</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Use Cases */}
|
||||
<section className="py-20">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
QR Code Tracking Use Cases
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||
See how businesses use QR code tracking to improve their operations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
{useCases.map((useCase, index) => (
|
||||
<Card key={index} className="p-8">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-3">
|
||||
{useCase.title}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{useCase.description}
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{useCase.benefits.map((benefit, idx) => (
|
||||
<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">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-gray-700">{benefit}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Comparison Table */}
|
||||
<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="text-center mb-16">
|
||||
<h2 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
QR Master vs Free Tools
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600">
|
||||
See why businesses choose QR Master for QR code tracking
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<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-primary-600 font-semibold">QR Master</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{comparisonData.map((row, 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-center">
|
||||
{typeof row.free === 'boolean' ? (
|
||||
row.free ? (
|
||||
<span className="text-green-500 text-2xl">✓</span>
|
||||
) : (
|
||||
<span className="text-red-500 text-2xl">✗</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-gray-600">{row.free}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-center">
|
||||
{typeof row.qrMaster === 'boolean' ? (
|
||||
<span className="text-green-500 text-2xl">✓</span>
|
||||
) : (
|
||||
<span className="text-primary-600 font-semibold">{row.qrMaster}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<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">
|
||||
<h2 className="text-4xl font-bold mb-6">
|
||||
Start Tracking Your QR Codes Today
|
||||
</h2>
|
||||
<p className="text-xl mb-8 text-primary-100">
|
||||
Join thousands of businesses using QR Master to track and optimize their QR code campaigns
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<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">
|
||||
Create Free Account
|
||||
</Button>
|
||||
</Link>
|
||||
<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">
|
||||
View Pricing
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import React from 'react';
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import SeoJsonLd from '@/components/SeoJsonLd';
|
||||
import Breadcrumbs, { BreadcrumbItem } from '@/components/Breadcrumbs';
|
||||
import { breadcrumbSchema } from '@/lib/schema';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'QR Code Tracking & Analytics - Track Scans | 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.',
|
||||
keywords: 'qr code tracking, qr code analytics, track qr scans, qr code statistics, free qr tracking, qr code monitoring',
|
||||
alternates: {
|
||||
canonical: 'https://www.qrmaster.net/qr-code-tracking',
|
||||
languages: {
|
||||
'x-default': 'https://www.qrmaster.net/qr-code-tracking',
|
||||
en: 'https://www.qrmaster.net/qr-code-tracking',
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: 'QR Code Tracking & Analytics - Track Scans | QR Master',
|
||||
description: 'Track QR code scans with real-time analytics. Monitor location, device, time, and user behavior.',
|
||||
url: 'https://www.qrmaster.net/qr-code-tracking',
|
||||
type: 'website',
|
||||
images: [
|
||||
{
|
||||
url: 'https://www.qrmaster.net/og-image.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'QR Code Tracking & Analytics - QR Master',
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
title: 'QR Code Tracking & Analytics - Track Scans | QR Master',
|
||||
description: 'Track QR code scans with real-time analytics. Monitor location, device, time, and user behavior.',
|
||||
},
|
||||
};
|
||||
|
||||
export default function QRCodeTrackingPage() {
|
||||
const trackingFeatures = [
|
||||
{
|
||||
icon: '📊',
|
||||
title: 'Real-Time Analytics',
|
||||
description: 'See scan data instantly as it happens. Monitor your QR code performance in real-time with live dashboards.',
|
||||
},
|
||||
{
|
||||
icon: '🌍',
|
||||
title: 'Location Tracking',
|
||||
description: 'Know exactly where your QR codes are being scanned. Track by country, city, and region.',
|
||||
},
|
||||
{
|
||||
icon: '📱',
|
||||
title: 'Device Detection',
|
||||
description: 'Identify which devices scan your codes. Track iOS, Android, desktop, and browser types.',
|
||||
},
|
||||
{
|
||||
icon: '🕐',
|
||||
title: 'Time-Based Reports',
|
||||
description: 'Analyze scan patterns by hour, day, week, or month. Optimize your campaigns with timing insights.',
|
||||
},
|
||||
{
|
||||
icon: '👥',
|
||||
title: 'Unique vs Total Scans',
|
||||
description: 'Distinguish between unique users and repeat scans. Measure true reach and engagement.',
|
||||
},
|
||||
{
|
||||
icon: '📈',
|
||||
title: 'Campaign Performance',
|
||||
description: 'Track ROI with UTM parameters. Measure conversion rates and campaign effectiveness.',
|
||||
},
|
||||
];
|
||||
|
||||
const useCases = [
|
||||
{
|
||||
title: 'Marketing Campaigns',
|
||||
description: 'Track print ads, billboards, and product packaging to measure marketing ROI.',
|
||||
benefits: ['Measure ad performance', 'A/B test campaigns', 'Track conversions'],
|
||||
},
|
||||
{
|
||||
title: 'Event Management',
|
||||
description: 'Monitor event check-ins, booth visits, and attendee engagement in real-time.',
|
||||
benefits: ['Live attendance tracking', 'Booth analytics', 'Engagement metrics'],
|
||||
},
|
||||
{
|
||||
title: 'Product Labels',
|
||||
description: 'Track product authenticity scans, manual downloads, and warranty registrations.',
|
||||
benefits: ['Anti-counterfeiting', 'User registration tracking', 'Product analytics'],
|
||||
},
|
||||
{
|
||||
title: 'Restaurant Menus',
|
||||
description: 'See how many customers scan your menu QR codes and when peak times occur.',
|
||||
benefits: ['Customer insights', 'Peak time analysis', 'Menu engagement'],
|
||||
},
|
||||
];
|
||||
|
||||
const comparisonData = [
|
||||
{ feature: 'Real-Time Analytics', free: true, qrMaster: true },
|
||||
{ feature: 'Location Tracking', free: false, qrMaster: true },
|
||||
{ feature: 'Device Detection', free: false, qrMaster: true },
|
||||
{ feature: 'Unlimited Scans', free: false, qrMaster: true },
|
||||
{ feature: 'Historical Data', free: '7 days', qrMaster: 'Unlimited' },
|
||||
{ feature: 'Export Reports', free: false, qrMaster: true },
|
||||
{ feature: 'API Access', free: false, qrMaster: true },
|
||||
];
|
||||
|
||||
const softwareSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
'@id': 'https://www.qrmaster.net/qr-code-tracking#software',
|
||||
name: 'QR Master - QR Code Tracking & Analytics',
|
||||
applicationCategory: 'BusinessApplication',
|
||||
operatingSystem: 'Web Browser, iOS, Android',
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
price: '0',
|
||||
priceCurrency: 'USD',
|
||||
availability: 'https://schema.org/InStock',
|
||||
},
|
||||
aggregateRating: {
|
||||
'@type': 'AggregateRating',
|
||||
ratingValue: '4.8',
|
||||
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.',
|
||||
features: [
|
||||
'Real-time analytics dashboard',
|
||||
'Location tracking by country and city',
|
||||
'Device detection (iOS, Android, Desktop)',
|
||||
'Time-based scan reports',
|
||||
'Unique vs total scan tracking',
|
||||
'Campaign performance metrics',
|
||||
'Unlimited scans',
|
||||
'Export detailed reports',
|
||||
],
|
||||
};
|
||||
|
||||
const howToSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'HowTo',
|
||||
'@id': 'https://www.qrmaster.net/qr-code-tracking#howto',
|
||||
name: 'How to Track QR Code Scans',
|
||||
description: 'Learn how to track and analyze QR code scans with real-time analytics',
|
||||
totalTime: 'PT5M',
|
||||
step: [
|
||||
{
|
||||
'@type': 'HowToStep',
|
||||
position: 1,
|
||||
name: 'Create QR Code',
|
||||
text: 'Sign up for free and create a dynamic QR code with tracking enabled',
|
||||
url: 'https://www.qrmaster.net/signup',
|
||||
},
|
||||
{
|
||||
'@type': 'HowToStep',
|
||||
position: 2,
|
||||
name: 'Deploy QR Code',
|
||||
text: 'Download and place your QR code on marketing materials, products, or digital platforms',
|
||||
},
|
||||
{
|
||||
'@type': 'HowToStep',
|
||||
position: 3,
|
||||
name: 'Monitor Analytics',
|
||||
text: 'View real-time scan data including location, device, and time patterns in your dashboard',
|
||||
url: 'https://www.qrmaster.net/signup',
|
||||
},
|
||||
{
|
||||
'@type': 'HowToStep',
|
||||
position: 4,
|
||||
name: 'Optimize Campaigns',
|
||||
text: 'Use insights to optimize placement, timing, and targeting of your QR code campaigns',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const breadcrumbItems: BreadcrumbItem[] = [
|
||||
{ name: 'Home', url: '/' },
|
||||
{ name: 'QR Code Tracking', url: '/qr-code-tracking' },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SeoJsonLd data={[softwareSchema, howToSchema, breadcrumbSchema(breadcrumbItems)]} />
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Hero Section */}
|
||||
<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">
|
||||
<Breadcrumbs items={breadcrumbItems} />
|
||||
<div className="grid lg:grid-cols-2 gap-12 items-center">
|
||||
<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">
|
||||
<span>📊</span>
|
||||
<span>Free QR Code Tracking</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl lg:text-6xl font-bold text-gray-900 leading-tight">
|
||||
Track Every QR Code Scan with Powerful Analytics
|
||||
</h1>
|
||||
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<Link href="/signup">
|
||||
<Button size="lg" className="text-lg px-8 py-4 w-full sm:w-auto">
|
||||
Start Tracking Free
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/signup">
|
||||
<Button variant="outline" size="lg" className="text-lg px-8 py-4 w-full sm:w-auto">
|
||||
Create Trackable QR Code
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-6 text-sm text-gray-600">
|
||||
<div className="flex items-center space-x-2">
|
||||
<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" />
|
||||
</svg>
|
||||
<span>No credit card required</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<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" />
|
||||
</svg>
|
||||
<span>Unlimited scans</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analytics Preview */}
|
||||
<div className="relative">
|
||||
<Card className="p-6 shadow-2xl">
|
||||
<h3 className="font-semibold text-lg mb-4">Live Analytics Dashboard</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center pb-3 border-b">
|
||||
<span className="text-gray-600">Total Scans</span>
|
||||
<span className="text-2xl font-bold text-primary-600">12,547</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center pb-3 border-b">
|
||||
<span className="text-gray-600">Unique Users</span>
|
||||
<span className="text-2xl font-bold text-primary-600">8,392</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center pb-3 border-b">
|
||||
<span className="text-gray-600">Top Location</span>
|
||||
<span className="font-semibold">🇩🇪 Germany</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">Top Device</span>
|
||||
<span className="font-semibold">📱 iPhone</span>
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
Live Updates
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tracking Features */}
|
||||
<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="text-center mb-16">
|
||||
<h2 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
Powerful QR Code Tracking Features
|
||||
</h2>
|
||||
<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
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{trackingFeatures.map((feature, index) => (
|
||||
<Card key={index} className="p-6 hover:shadow-lg transition-shadow">
|
||||
<div className="text-4xl mb-4">{feature.icon}</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
{feature.description}
|
||||
</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Use Cases */}
|
||||
<section className="py-20">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
QR Code Tracking Use Cases
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||
See how businesses use QR code tracking to improve their operations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
{useCases.map((useCase, index) => (
|
||||
<Card key={index} className="p-8">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-3">
|
||||
{useCase.title}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{useCase.description}
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{useCase.benefits.map((benefit, idx) => (
|
||||
<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">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-gray-700">{benefit}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Comparison Table */}
|
||||
<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="text-center mb-16">
|
||||
<h2 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
QR Master vs Free Tools
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600">
|
||||
See why businesses choose QR Master for QR code tracking
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<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-primary-600 font-semibold">QR Master</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{comparisonData.map((row, 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-center">
|
||||
{typeof row.free === 'boolean' ? (
|
||||
row.free ? (
|
||||
<span className="text-green-500 text-2xl">✓</span>
|
||||
) : (
|
||||
<span className="text-red-500 text-2xl">✗</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-gray-600">{row.free}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-center">
|
||||
{typeof row.qrMaster === 'boolean' ? (
|
||||
<span className="text-green-500 text-2xl">✓</span>
|
||||
) : (
|
||||
<span className="text-primary-600 font-semibold">{row.qrMaster}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
{/* CTA Section */}
|
||||
<section className="py-24 bg-slate-900 relative overflow-hidden">
|
||||
{/* Background Decorations */}
|
||||
<div className="absolute top-0 right-0 -mr-20 -mt-20 w-96 h-96 bg-primary-500/20 rounded-full blur-3xl opacity-50" />
|
||||
<div className="absolute bottom-0 left-0 -ml-20 -mb-20 w-80 h-80 bg-purple-500/20 rounded-full blur-3xl opacity-50" />
|
||||
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-4xl text-center relative z-10">
|
||||
<h2 className="text-4xl lg:text-5xl font-bold mb-6 text-white tracking-tight">
|
||||
Start Tracking Your <span className="text-transparent bg-clip-text bg-gradient-to-r from-primary-400 to-purple-400">QR Codes Today</span>
|
||||
</h2>
|
||||
<p className="text-xl mb-10 text-slate-300 leading-relaxed max-w-2xl mx-auto">
|
||||
Join thousands of businesses using QR Master to optimize their campaigns with real-time analytics.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-5 justify-center">
|
||||
<Link href="/signup">
|
||||
<Button size="lg" className="text-lg px-8 py-6 h-auto w-full sm:w-auto bg-white text-slate-900 hover:bg-slate-50 font-bold shadow-xl shadow-primary-900/20 transition-all hover:-translate-y-1">
|
||||
Create Free Account
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/pricing">
|
||||
<Button size="lg" variant="outline" className="text-lg px-8 py-6 h-auto w-full sm:w-auto border-slate-700 text-white hover:bg-slate-800 hover:border-slate-600 transition-all">
|
||||
View Pricing
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<p className="mt-8 text-sm text-slate-500">
|
||||
Full analytics accessible on free plan.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,41 +0,0 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
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] : '',
|
||||
};
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
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,234 +1,227 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { db } from '@/lib/db';
|
||||
import { generateSlug } from '@/lib/hash';
|
||||
import { createQRSchema, validateRequest } from '@/lib/validationSchemas';
|
||||
import { csrfProtection } from '@/lib/csrf';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
|
||||
// GET /api/qrs - List user's QR codes
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const userId = cookies().get('userId')?.value;
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const qrCodes = await db.qRCode.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
_count: {
|
||||
select: { scans: true },
|
||||
},
|
||||
scans: {
|
||||
where: { isUnique: true },
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// Transform the data
|
||||
const transformed = qrCodes.map(qr => ({
|
||||
...qr,
|
||||
scans: qr._count.scans,
|
||||
uniqueScans: qr.scans.length, // Count of scans where isUnique=true
|
||||
_count: undefined,
|
||||
}));
|
||||
|
||||
return NextResponse.json(transformed);
|
||||
} catch (error) {
|
||||
console.error('Error fetching QR codes:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Plan limits
|
||||
const PLAN_LIMITS = {
|
||||
FREE: 3,
|
||||
PRO: 50,
|
||||
BUSINESS: 500,
|
||||
};
|
||||
|
||||
// POST /api/qrs - Create a new QR code
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// CSRF Protection
|
||||
const csrfCheck = csrfProtection(request);
|
||||
if (!csrfCheck.valid) {
|
||||
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
|
||||
}
|
||||
|
||||
const userId = cookies().get('userId')?.value;
|
||||
|
||||
// Rate Limiting (user-based)
|
||||
const clientId = userId || getClientIdentifier(request);
|
||||
const rateLimitResult = rateLimit(clientId, RateLimits.QR_CREATE);
|
||||
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Too many requests. Please try again later.',
|
||||
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
||||
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
||||
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized - no userId cookie' }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { plan: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: `User not found: ${userId}` }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// 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
|
||||
if (!body.isStatic) {
|
||||
const validation = await validateRequest(createQRSchema, body);
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(validation.error, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is a static QR request
|
||||
const isStatic = body.isStatic === true;
|
||||
|
||||
// Only check limits for DYNAMIC QR codes (static QR codes are unlimited)
|
||||
if (!isStatic) {
|
||||
// Count existing dynamic QR codes
|
||||
const dynamicQRCount = await db.qRCode.count({
|
||||
where: {
|
||||
userId,
|
||||
type: 'DYNAMIC',
|
||||
},
|
||||
});
|
||||
|
||||
const userPlan = user.plan || 'FREE';
|
||||
const limit = PLAN_LIMITS[userPlan as keyof typeof PLAN_LIMITS] || PLAN_LIMITS.FREE;
|
||||
|
||||
if (dynamicQRCount >= limit) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Limit reached',
|
||||
message: `You have reached the limit of ${limit} dynamic QR codes for your ${userPlan} plan. Please upgrade to create more.`,
|
||||
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 = '';
|
||||
switch (body.contentType) {
|
||||
case 'URL':
|
||||
qrContent = body.content.url;
|
||||
break;
|
||||
case 'PHONE':
|
||||
qrContent = `tel:${body.content.phone}`;
|
||||
break;
|
||||
case 'SMS':
|
||||
qrContent = `sms:${body.content.phone}${body.content.message ? `?body=${encodeURIComponent(body.content.message)}` : ''}`;
|
||||
break;
|
||||
case 'VCARD':
|
||||
qrContent = `BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
FN:${body.content.firstName || ''} ${body.content.lastName || ''}
|
||||
N:${body.content.lastName || ''};${body.content.firstName || ''};;;
|
||||
${body.content.organization ? `ORG:${body.content.organization}` : ''}
|
||||
${body.content.title ? `TITLE:${body.content.title}` : ''}
|
||||
${body.content.email ? `EMAIL:${body.content.email}` : ''}
|
||||
${body.content.phone ? `TEL:${body.content.phone}` : ''}
|
||||
END:VCARD`;
|
||||
break;
|
||||
case 'GEO':
|
||||
const lat = body.content.latitude || 0;
|
||||
const lon = body.content.longitude || 0;
|
||||
const label = body.content.label ? `?q=${encodeURIComponent(body.content.label)}` : '';
|
||||
qrContent = `geo:${lat},${lon}${label}`;
|
||||
break;
|
||||
case 'TEXT':
|
||||
qrContent = body.content.text;
|
||||
break;
|
||||
case 'WHATSAPP':
|
||||
qrContent = `https://wa.me/${body.content.phone}${body.content.message ? `?text=${encodeURIComponent(body.content.message)}` : ''}`;
|
||||
break;
|
||||
case 'PDF':
|
||||
qrContent = body.content.fileUrl || 'https://example.com/file.pdf';
|
||||
break;
|
||||
case 'APP':
|
||||
qrContent = body.content.fallbackUrl || body.content.iosUrl || body.content.androidUrl || 'https://example.com';
|
||||
break;
|
||||
case 'COUPON':
|
||||
qrContent = `Coupon: ${body.content.code || 'CODE'} - ${body.content.discount || 'Discount'}`;
|
||||
break;
|
||||
case 'FEEDBACK':
|
||||
qrContent = body.content.feedbackUrl || 'https://example.com/feedback';
|
||||
break;
|
||||
default:
|
||||
qrContent = body.content.url || 'https://example.com';
|
||||
}
|
||||
|
||||
// Add qrContent to the content object
|
||||
enrichedContent = {
|
||||
...body.content,
|
||||
qrContent // This is what the QR code should actually contain
|
||||
};
|
||||
}
|
||||
|
||||
// Generate slug for the QR code
|
||||
const slug = generateSlug(body.title);
|
||||
|
||||
// Create QR code
|
||||
const qrCode = await db.qRCode.create({
|
||||
data: {
|
||||
userId,
|
||||
title: body.title,
|
||||
type: isStatic ? 'STATIC' : 'DYNAMIC',
|
||||
contentType: body.contentType,
|
||||
content: enrichedContent,
|
||||
tags: body.tags || [],
|
||||
style: body.style || {
|
||||
foregroundColor: '#000000',
|
||||
backgroundColor: '#FFFFFF',
|
||||
cornerStyle: 'square',
|
||||
size: 200,
|
||||
},
|
||||
slug,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
|
||||
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 }
|
||||
);
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { db } from '@/lib/db';
|
||||
import { generateSlug } from '@/lib/hash';
|
||||
import { createQRSchema, validateRequest } from '@/lib/validationSchemas';
|
||||
import { csrfProtection } from '@/lib/csrf';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
|
||||
// GET /api/qrs - List user's QR codes
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const userId = cookies().get('userId')?.value;
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const qrCodes = await db.qRCode.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
_count: {
|
||||
select: { scans: true },
|
||||
},
|
||||
scans: {
|
||||
where: { isUnique: true },
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// Transform the data
|
||||
const transformed = qrCodes.map(qr => ({
|
||||
...qr,
|
||||
scans: qr._count.scans,
|
||||
uniqueScans: qr.scans.length, // Count of scans where isUnique=true
|
||||
_count: undefined,
|
||||
}));
|
||||
|
||||
return NextResponse.json(transformed);
|
||||
} catch (error) {
|
||||
console.error('Error fetching QR codes:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Plan limits
|
||||
const PLAN_LIMITS = {
|
||||
FREE: 3,
|
||||
PRO: 50,
|
||||
BUSINESS: 500,
|
||||
};
|
||||
|
||||
// POST /api/qrs - Create a new QR code
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// CSRF Protection
|
||||
const csrfCheck = csrfProtection(request);
|
||||
if (!csrfCheck.valid) {
|
||||
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
|
||||
}
|
||||
|
||||
const userId = cookies().get('userId')?.value;
|
||||
console.log('POST /api/qrs - userId from cookie:', userId);
|
||||
|
||||
// Rate Limiting (user-based)
|
||||
const clientId = userId || getClientIdentifier(request);
|
||||
const rateLimitResult = rateLimit(clientId, RateLimits.QR_CREATE);
|
||||
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Too many requests. Please try again later.',
|
||||
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
||||
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
||||
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized - no userId cookie' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check if user exists and get their plan
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { plan: true },
|
||||
});
|
||||
|
||||
console.log('User exists:', !!user);
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: `User not found: ${userId}` }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
console.log('Request body:', body);
|
||||
|
||||
// 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
|
||||
if (!body.isStatic) {
|
||||
const validation = await validateRequest(createQRSchema, body);
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(validation.error, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is a static QR request
|
||||
const isStatic = body.isStatic === true;
|
||||
|
||||
// Only check limits for DYNAMIC QR codes (static QR codes are unlimited)
|
||||
if (!isStatic) {
|
||||
// Count existing dynamic QR codes
|
||||
const dynamicQRCount = await db.qRCode.count({
|
||||
where: {
|
||||
userId,
|
||||
type: 'DYNAMIC',
|
||||
},
|
||||
});
|
||||
|
||||
const userPlan = user.plan || 'FREE';
|
||||
const limit = PLAN_LIMITS[userPlan as keyof typeof PLAN_LIMITS] || PLAN_LIMITS.FREE;
|
||||
|
||||
if (dynamicQRCount >= limit) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Limit reached',
|
||||
message: `You have reached the limit of ${limit} dynamic QR codes for your ${userPlan} plan. Please upgrade to create more.`,
|
||||
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 = '';
|
||||
switch (body.contentType) {
|
||||
case 'URL':
|
||||
qrContent = body.content.url;
|
||||
break;
|
||||
case 'PHONE':
|
||||
qrContent = `tel:${body.content.phone}`;
|
||||
break;
|
||||
case 'SMS':
|
||||
qrContent = `sms:${body.content.phone}${body.content.message ? `?body=${encodeURIComponent(body.content.message)}` : ''}`;
|
||||
break;
|
||||
case 'VCARD':
|
||||
qrContent = `BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
FN:${body.content.firstName || ''} ${body.content.lastName || ''}
|
||||
N:${body.content.lastName || ''};${body.content.firstName || ''};;;
|
||||
${body.content.organization ? `ORG:${body.content.organization}` : ''}
|
||||
${body.content.title ? `TITLE:${body.content.title}` : ''}
|
||||
${body.content.email ? `EMAIL:${body.content.email}` : ''}
|
||||
${body.content.phone ? `TEL:${body.content.phone}` : ''}
|
||||
END:VCARD`;
|
||||
break;
|
||||
case 'GEO':
|
||||
const lat = body.content.latitude || 0;
|
||||
const lon = body.content.longitude || 0;
|
||||
const label = body.content.label ? `?q=${encodeURIComponent(body.content.label)}` : '';
|
||||
qrContent = `geo:${lat},${lon}${label}`;
|
||||
break;
|
||||
case 'TEXT':
|
||||
qrContent = body.content.text;
|
||||
break;
|
||||
case 'WHATSAPP':
|
||||
qrContent = `https://wa.me/${body.content.phone}${body.content.message ? `?text=${encodeURIComponent(body.content.message)}` : ''}`;
|
||||
break;
|
||||
default:
|
||||
qrContent = body.content.url || 'https://example.com';
|
||||
}
|
||||
|
||||
// Add qrContent to the content object
|
||||
enrichedContent = {
|
||||
...body.content,
|
||||
qrContent // This is what the QR code should actually contain
|
||||
};
|
||||
}
|
||||
|
||||
// Generate slug for the QR code
|
||||
const slug = generateSlug(body.title);
|
||||
|
||||
// Create QR code
|
||||
const qrCode = await db.qRCode.create({
|
||||
data: {
|
||||
userId,
|
||||
title: body.title,
|
||||
type: isStatic ? 'STATIC' : 'DYNAMIC',
|
||||
contentType: body.contentType,
|
||||
content: enrichedContent,
|
||||
tags: body.tags || [],
|
||||
style: body.style || {
|
||||
foregroundColor: '#000000',
|
||||
backgroundColor: '#FFFFFF',
|
||||
cornerStyle: 'square',
|
||||
size: 200,
|
||||
},
|
||||
slug,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,167 +0,0 @@
|
|||
'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,195 +0,0 @@
|
|||
'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 +0,0 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { Suspense } from 'react';
|
||||
import '@/styles/globals.css';
|
||||
import { ToastContainer } from '@/components/ui/Toast';
|
||||
import AuthProvider from '@/components/SessionProvider';
|
||||
import { PostHogProvider } from '@/components/PostHogProvider';
|
||||
import CookieBanner from '@/components/CookieBanner';
|
||||
|
||||
const isIndexable = process.env.NEXT_PUBLIC_INDEXABLE === 'true';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL('https://www.qrmaster.net'),
|
||||
title: {
|
||||
default: 'QR Master – Smart QR Generator & Analytics',
|
||||
template: '%s | QR Master',
|
||||
},
|
||||
description: 'Create dynamic QR codes, track scans, and scale campaigns with secure analytics.',
|
||||
keywords: 'QR code, QR generator, dynamic QR, QR tracking, QR analytics, branded QR, bulk QR generator',
|
||||
robots: isIndexable
|
||||
? { index: true, follow: true }
|
||||
: { index: false, follow: false },
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/favicon.svg', type: 'image/svg+xml' },
|
||||
{ url: '/logo.svg', type: 'image/svg+xml' },
|
||||
],
|
||||
apple: '/logo.svg',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
site: '@qrmaster',
|
||||
images: ['https://www.qrmaster.net/static/og-image.png'],
|
||||
},
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
siteName: 'QR Master',
|
||||
title: 'QR Master – Smart QR Generator & Analytics',
|
||||
description: 'Create dynamic QR codes, track scans, and scale campaigns with secure analytics.',
|
||||
url: 'https://www.qrmaster.net',
|
||||
images: [
|
||||
{
|
||||
url: 'https://www.qrmaster.net/static/og-image.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'QR Master - Dynamic QR Code Generator and Analytics Platform',
|
||||
},
|
||||
],
|
||||
locale: 'en_US',
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="font-sans">
|
||||
<Suspense fallback={null}>
|
||||
<PostHogProvider>
|
||||
<AuthProvider>
|
||||
{children}
|
||||
</AuthProvider>
|
||||
<CookieBanner />
|
||||
<ToastContainer />
|
||||
</PostHogProvider>
|
||||
</Suspense>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,240 +1,212 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { hashIP } from '@/lib/hash';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string }> }
|
||||
) {
|
||||
try {
|
||||
const { slug } = await params;
|
||||
|
||||
// Fetch QR code by slug
|
||||
const qrCode = await db.qRCode.findUnique({
|
||||
where: { slug },
|
||||
select: {
|
||||
id: true,
|
||||
content: true,
|
||||
contentType: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!qrCode) {
|
||||
return new NextResponse('QR Code not found', { status: 404 });
|
||||
}
|
||||
|
||||
// Track scan (fire and forget)
|
||||
trackScan(qrCode.id, request).catch(console.error);
|
||||
|
||||
// Determine destination URL
|
||||
let destination = '';
|
||||
const content = qrCode.content as any;
|
||||
|
||||
switch (qrCode.contentType) {
|
||||
case 'URL':
|
||||
destination = content.url || 'https://example.com';
|
||||
break;
|
||||
case 'PHONE':
|
||||
destination = `tel:${content.phone}`;
|
||||
break;
|
||||
case 'SMS':
|
||||
destination = `sms:${content.phone}${content.message ? `?body=${encodeURIComponent(content.message)}` : ''}`;
|
||||
break;
|
||||
case 'WHATSAPP':
|
||||
destination = `https://wa.me/${content.phone}${content.message ? `?text=${encodeURIComponent(content.message)}` : ''}`;
|
||||
break;
|
||||
case 'VCARD':
|
||||
// For vCard, redirect to display page
|
||||
const baseUrlVcard = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050';
|
||||
destination = `${baseUrlVcard}/vcard?firstName=${encodeURIComponent(content.firstName || '')}&lastName=${encodeURIComponent(content.lastName || '')}&email=${encodeURIComponent(content.email || '')}&phone=${encodeURIComponent(content.phone || '')}&organization=${encodeURIComponent(content.organization || '')}&title=${encodeURIComponent(content.title || '')}`;
|
||||
break;
|
||||
case 'GEO':
|
||||
// For location, redirect to Google Maps (works on desktop and mobile)
|
||||
const lat = content.latitude || 0;
|
||||
const lon = content.longitude || 0;
|
||||
destination = `https://maps.google.com/?q=${lat},${lon}`;
|
||||
break;
|
||||
case 'TEXT':
|
||||
// For plain text, redirect to a display page
|
||||
const baseUrlText = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050';
|
||||
destination = `${baseUrlText}/display?text=${encodeURIComponent(content.text || '')}`;
|
||||
break;
|
||||
case 'PDF':
|
||||
// Direct link to file
|
||||
destination = content.fileUrl || 'https://example.com/file.pdf';
|
||||
break;
|
||||
case 'APP':
|
||||
// Smart device detection for app stores
|
||||
const userAgent = request.headers.get('user-agent') || '';
|
||||
const isIOS = /iphone|ipad|ipod/i.test(userAgent);
|
||||
const isAndroid = /android/i.test(userAgent);
|
||||
|
||||
if (isIOS && content.iosUrl) {
|
||||
destination = content.iosUrl;
|
||||
} else if (isAndroid && content.androidUrl) {
|
||||
destination = content.androidUrl;
|
||||
} else {
|
||||
destination = content.fallbackUrl || content.iosUrl || content.androidUrl || 'https://example.com';
|
||||
}
|
||||
break;
|
||||
case 'COUPON':
|
||||
// Redirect to coupon display page
|
||||
const baseUrlCoupon = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050';
|
||||
destination = `${baseUrlCoupon}/coupon/${slug}`;
|
||||
break;
|
||||
case 'FEEDBACK':
|
||||
// Redirect to feedback form page
|
||||
const baseUrlFeedback = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050';
|
||||
destination = `${baseUrlFeedback}/feedback/${slug}`;
|
||||
break;
|
||||
default:
|
||||
destination = 'https://example.com';
|
||||
}
|
||||
|
||||
// Preserve UTM parameters
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const utmParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
|
||||
const preservedParams = new URLSearchParams();
|
||||
|
||||
utmParams.forEach(param => {
|
||||
const value = searchParams.get(param);
|
||||
if (value) {
|
||||
preservedParams.set(param, value);
|
||||
}
|
||||
});
|
||||
|
||||
// Add preserved params to destination
|
||||
if (preservedParams.toString() && destination.startsWith('http')) {
|
||||
const separator = destination.includes('?') ? '&' : '?';
|
||||
destination = `${destination}${separator}${preservedParams.toString()}`;
|
||||
}
|
||||
|
||||
// Return 307 redirect (temporary redirect that preserves method)
|
||||
return NextResponse.redirect(destination, { status: 307 });
|
||||
} catch (error) {
|
||||
console.error('QR redirect error:', error);
|
||||
return new NextResponse('Internal server error', { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function trackScan(qrId: string, request: NextRequest) {
|
||||
try {
|
||||
const userAgent = request.headers.get('user-agent') || '';
|
||||
const referer = request.headers.get('referer') || '';
|
||||
const ip = request.headers.get('x-forwarded-for') ||
|
||||
request.headers.get('x-real-ip') ||
|
||||
'unknown';
|
||||
|
||||
// Check DNT header
|
||||
const dnt = request.headers.get('dnt');
|
||||
if (dnt === '1') {
|
||||
// Respect Do Not Track - only increment counter
|
||||
await db.qRScan.create({
|
||||
data: {
|
||||
qrId,
|
||||
ipHash: 'dnt',
|
||||
isUnique: false,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Hash IP for privacy
|
||||
const ipHash = hashIP(ip);
|
||||
|
||||
// Device Detection Logic:
|
||||
// 1. Windows or Linux -> Always Desktop
|
||||
// 2. Explicit iPad/Tablet keywords -> Tablet
|
||||
// 3. Mac + Chrome browser -> Desktop (real Mac users often use Chrome)
|
||||
// 4. Mac + Safari + No Referrer -> Likely iPad scanning a QR code
|
||||
// 5. Mobile keywords -> Mobile
|
||||
// 6. Everything else -> Desktop
|
||||
|
||||
const isWindows = /windows/i.test(userAgent);
|
||||
const isLinux = /linux/i.test(userAgent) && !/android/i.test(userAgent);
|
||||
const isExplicitTablet = /tablet|ipad|playbook|silk/i.test(userAgent);
|
||||
const isAndroidTablet = /android/i.test(userAgent) && !/mobile/i.test(userAgent);
|
||||
const isMacintosh = /macintosh/i.test(userAgent);
|
||||
const isChrome = /chrome/i.test(userAgent);
|
||||
const isSafari = /safari/i.test(userAgent) && !isChrome;
|
||||
const hasReferrer = !!referer;
|
||||
|
||||
// iPad in desktop mode: Mac + Safari (no Chrome) + No Referrer (physical scan)
|
||||
const isLikelyiPadScan = isMacintosh && isSafari && !hasReferrer;
|
||||
|
||||
let device: string;
|
||||
if (isWindows || isLinux) {
|
||||
device = 'desktop';
|
||||
} else if (isExplicitTablet || isAndroidTablet || isLikelyiPadScan) {
|
||||
device = 'tablet';
|
||||
} else if (/mobile|iphone/i.test(userAgent)) {
|
||||
device = 'mobile';
|
||||
} else if (isMacintosh && isChrome) {
|
||||
device = 'desktop'; // Mac with Chrome = real desktop
|
||||
} else if (isMacintosh && hasReferrer) {
|
||||
device = 'desktop'; // Mac with referrer = probably clicked a link on desktop
|
||||
} else {
|
||||
device = 'desktop'; // Default fallback
|
||||
}
|
||||
|
||||
// Detect OS
|
||||
let os = 'unknown';
|
||||
if (/windows/i.test(userAgent)) os = 'Windows';
|
||||
else if (/mac/i.test(userAgent)) os = 'macOS';
|
||||
else if (/linux/i.test(userAgent)) os = 'Linux';
|
||||
else if (/android/i.test(userAgent)) os = 'Android';
|
||||
else if (/ios|iphone|ipad/i.test(userAgent)) os = 'iOS';
|
||||
|
||||
// Get country from header (Vercel/Cloudflare provide this)
|
||||
const country = request.headers.get('x-vercel-ip-country') ||
|
||||
request.headers.get('cf-ipcountry') ||
|
||||
'unknown';
|
||||
|
||||
// Extract UTM parameters
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const utmSource = searchParams.get('utm_source');
|
||||
const utmMedium = searchParams.get('utm_medium');
|
||||
const utmCampaign = searchParams.get('utm_campaign');
|
||||
|
||||
// Check if this is a unique scan (first scan from this IP + Device today)
|
||||
// We include a simplified device fingerprint so different devices on same IP count as unique
|
||||
const deviceFingerprint = hashIP(userAgent.substring(0, 100)); // Hash the user agent for privacy
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const existingScan = await db.qRScan.findFirst({
|
||||
where: {
|
||||
qrId,
|
||||
ipHash,
|
||||
userAgent: {
|
||||
startsWith: userAgent.substring(0, 50), // Match same device type
|
||||
},
|
||||
ts: {
|
||||
gte: today,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const isUnique = !existingScan;
|
||||
|
||||
// Create scan record
|
||||
await db.qRScan.create({
|
||||
data: {
|
||||
qrId,
|
||||
ipHash,
|
||||
userAgent: userAgent.substring(0, 255),
|
||||
device,
|
||||
os,
|
||||
country,
|
||||
referrer: referer.substring(0, 255),
|
||||
utmSource,
|
||||
utmMedium,
|
||||
utmCampaign,
|
||||
isUnique,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error tracking scan:', error);
|
||||
// Don't throw - this is fire and forget
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { hashIP } from '@/lib/hash';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string }> }
|
||||
) {
|
||||
try {
|
||||
const { slug } = await params;
|
||||
|
||||
// Fetch QR code by slug
|
||||
const qrCode = await db.qRCode.findUnique({
|
||||
where: { slug },
|
||||
select: {
|
||||
id: true,
|
||||
content: true,
|
||||
contentType: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!qrCode) {
|
||||
return new NextResponse('QR Code not found', { status: 404 });
|
||||
}
|
||||
|
||||
// Track scan (fire and forget)
|
||||
trackScan(qrCode.id, request).catch(console.error);
|
||||
|
||||
// Determine destination URL
|
||||
let destination = '';
|
||||
const content = qrCode.content as any;
|
||||
|
||||
switch (qrCode.contentType) {
|
||||
case 'URL':
|
||||
destination = content.url || 'https://example.com';
|
||||
break;
|
||||
case 'PHONE':
|
||||
destination = `tel:${content.phone}`;
|
||||
break;
|
||||
case 'SMS':
|
||||
destination = `sms:${content.phone}${content.message ? `?body=${encodeURIComponent(content.message)}` : ''}`;
|
||||
break;
|
||||
case 'WHATSAPP':
|
||||
destination = `https://wa.me/${content.phone}${content.message ? `?text=${encodeURIComponent(content.message)}` : ''}`;
|
||||
break;
|
||||
case 'VCARD':
|
||||
// For vCard, redirect to display page
|
||||
const baseUrlVcard = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050';
|
||||
destination = `${baseUrlVcard}/vcard?firstName=${encodeURIComponent(content.firstName || '')}&lastName=${encodeURIComponent(content.lastName || '')}&email=${encodeURIComponent(content.email || '')}&phone=${encodeURIComponent(content.phone || '')}&organization=${encodeURIComponent(content.organization || '')}&title=${encodeURIComponent(content.title || '')}`;
|
||||
break;
|
||||
case 'GEO':
|
||||
// For location, redirect to Google Maps (works on desktop and mobile)
|
||||
const lat = content.latitude || 0;
|
||||
const lon = content.longitude || 0;
|
||||
destination = `https://maps.google.com/?q=${lat},${lon}`;
|
||||
break;
|
||||
case 'TEXT':
|
||||
// For plain text, redirect to a display page
|
||||
const baseUrlText = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050';
|
||||
destination = `${baseUrlText}/display?text=${encodeURIComponent(content.text || '')}`;
|
||||
break;
|
||||
default:
|
||||
destination = 'https://example.com';
|
||||
}
|
||||
|
||||
// Preserve UTM parameters
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const utmParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
|
||||
const preservedParams = new URLSearchParams();
|
||||
|
||||
utmParams.forEach(param => {
|
||||
const value = searchParams.get(param);
|
||||
if (value) {
|
||||
preservedParams.set(param, value);
|
||||
}
|
||||
});
|
||||
|
||||
// Add preserved params to destination
|
||||
if (preservedParams.toString() && destination.startsWith('http')) {
|
||||
const separator = destination.includes('?') ? '&' : '?';
|
||||
destination = `${destination}${separator}${preservedParams.toString()}`;
|
||||
}
|
||||
|
||||
// Return 307 redirect (temporary redirect that preserves method)
|
||||
return NextResponse.redirect(destination, { status: 307 });
|
||||
} catch (error) {
|
||||
console.error('QR redirect error:', error);
|
||||
return new NextResponse('Internal server error', { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function trackScan(qrId: string, request: NextRequest) {
|
||||
try {
|
||||
const userAgent = request.headers.get('user-agent') || '';
|
||||
const referer = request.headers.get('referer') || '';
|
||||
const ip = request.headers.get('x-forwarded-for') ||
|
||||
request.headers.get('x-real-ip') ||
|
||||
'unknown';
|
||||
|
||||
// Check DNT header
|
||||
const dnt = request.headers.get('dnt');
|
||||
if (dnt === '1') {
|
||||
// Respect Do Not Track - only increment counter
|
||||
await db.qRScan.create({
|
||||
data: {
|
||||
qrId,
|
||||
ipHash: 'dnt',
|
||||
isUnique: false,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Hash IP for privacy
|
||||
const ipHash = hashIP(ip);
|
||||
|
||||
// Device Detection Logic:
|
||||
// 1. Windows or Linux -> Always Desktop
|
||||
// 2. Explicit iPad/Tablet keywords -> Tablet
|
||||
// 3. Mac + Chrome browser -> Desktop (real Mac users often use Chrome)
|
||||
// 4. Mac + Safari + No Referrer -> Likely iPad scanning a QR code
|
||||
// 5. Mobile keywords -> Mobile
|
||||
// 6. Everything else -> Desktop
|
||||
|
||||
const isWindows = /windows/i.test(userAgent);
|
||||
const isLinux = /linux/i.test(userAgent) && !/android/i.test(userAgent);
|
||||
const isExplicitTablet = /tablet|ipad|playbook|silk/i.test(userAgent);
|
||||
const isAndroidTablet = /android/i.test(userAgent) && !/mobile/i.test(userAgent);
|
||||
const isMacintosh = /macintosh/i.test(userAgent);
|
||||
const isChrome = /chrome/i.test(userAgent);
|
||||
const isSafari = /safari/i.test(userAgent) && !isChrome;
|
||||
const hasReferrer = !!referer;
|
||||
|
||||
// iPad in desktop mode: Mac + Safari (no Chrome) + No Referrer (physical scan)
|
||||
const isLikelyiPadScan = isMacintosh && isSafari && !hasReferrer;
|
||||
|
||||
let device: string;
|
||||
if (isWindows || isLinux) {
|
||||
device = 'desktop';
|
||||
} else if (isExplicitTablet || isAndroidTablet || isLikelyiPadScan) {
|
||||
device = 'tablet';
|
||||
} else if (/mobile|iphone/i.test(userAgent)) {
|
||||
device = 'mobile';
|
||||
} else if (isMacintosh && isChrome) {
|
||||
device = 'desktop'; // Mac with Chrome = real desktop
|
||||
} else if (isMacintosh && hasReferrer) {
|
||||
device = 'desktop'; // Mac with referrer = probably clicked a link on desktop
|
||||
} else {
|
||||
device = 'desktop'; // Default fallback
|
||||
}
|
||||
|
||||
// Detect OS
|
||||
let os = 'unknown';
|
||||
if (/windows/i.test(userAgent)) os = 'Windows';
|
||||
else if (/mac/i.test(userAgent)) os = 'macOS';
|
||||
else if (/linux/i.test(userAgent)) os = 'Linux';
|
||||
else if (/android/i.test(userAgent)) os = 'Android';
|
||||
else if (/ios|iphone|ipad/i.test(userAgent)) os = 'iOS';
|
||||
|
||||
// Get country from header (Vercel/Cloudflare provide this)
|
||||
const country = request.headers.get('x-vercel-ip-country') ||
|
||||
request.headers.get('cf-ipcountry') ||
|
||||
'unknown';
|
||||
|
||||
// Extract UTM parameters
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const utmSource = searchParams.get('utm_source');
|
||||
const utmMedium = searchParams.get('utm_medium');
|
||||
const utmCampaign = searchParams.get('utm_campaign');
|
||||
|
||||
// Check if this is a unique scan (first scan from this IP + Device today)
|
||||
// We include a simplified device fingerprint so different devices on same IP count as unique
|
||||
const deviceFingerprint = hashIP(userAgent.substring(0, 100)); // Hash the user agent for privacy
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const existingScan = await db.qRScan.findFirst({
|
||||
where: {
|
||||
qrId,
|
||||
ipHash,
|
||||
userAgent: {
|
||||
startsWith: userAgent.substring(0, 50), // Match same device type
|
||||
},
|
||||
ts: {
|
||||
gte: today,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const isUnique = !existingScan;
|
||||
|
||||
// Create scan record
|
||||
await db.qRScan.create({
|
||||
data: {
|
||||
qrId,
|
||||
ipHash,
|
||||
userAgent: userAgent.substring(0, 255),
|
||||
device,
|
||||
os,
|
||||
country,
|
||||
referrer: referer.substring(0, 255),
|
||||
utmSource,
|
||||
utmMedium,
|
||||
utmCampaign,
|
||||
isUnique,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error tracking scan:', error);
|
||||
// Don't throw - this is fire and forget
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +1,21 @@
|
|||
import { MetadataRoute } from 'next';
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
const baseUrl = 'https://www.qrmaster.net';
|
||||
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: [
|
||||
'/api/',
|
||||
'/dashboard/',
|
||||
'/create/',
|
||||
'/settings/',
|
||||
],
|
||||
},
|
||||
],
|
||||
sitemap: `${baseUrl}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
import { MetadataRoute } from 'next';
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
const baseUrl = 'https://www.qrmaster.net';
|
||||
|
||||
return {
|
||||
rules: {
|
||||
userAgent: '*',
|
||||
allow: ['/', '/_next/static/', '/_next/image/'],
|
||||
disallow: [
|
||||
'/api/',
|
||||
'/dashboard/',
|
||||
'/create/',
|
||||
'/settings/',
|
||||
'/newsletter/',
|
||||
'/cdn-cgi/',
|
||||
],
|
||||
},
|
||||
sitemap: `${baseUrl}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,274 +1,297 @@
|
|||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
export default function VCardPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [organization, setOrganization] = useState('');
|
||||
const [title, setTitle] = useState('');
|
||||
const [hasAutoDownloaded, setHasAutoDownloaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const firstNameParam = searchParams.get('firstName');
|
||||
const lastNameParam = searchParams.get('lastName');
|
||||
const emailParam = searchParams.get('email');
|
||||
const phoneParam = searchParams.get('phone');
|
||||
const organizationParam = searchParams.get('organization');
|
||||
const titleParam = searchParams.get('title');
|
||||
|
||||
if (firstNameParam) setFirstName(firstNameParam);
|
||||
if (lastNameParam) setLastName(lastNameParam);
|
||||
if (emailParam) setEmail(emailParam);
|
||||
if (phoneParam) setPhone(phoneParam);
|
||||
if (organizationParam) setOrganization(organizationParam);
|
||||
if (titleParam) setTitle(titleParam);
|
||||
|
||||
setIsLoading(false);
|
||||
}, [searchParams]);
|
||||
|
||||
// Auto-download after 500ms (only once)
|
||||
useEffect(() => {
|
||||
if ((firstName || lastName) && !hasAutoDownloaded) {
|
||||
const timer = setTimeout(() => {
|
||||
handleSaveContact();
|
||||
setHasAutoDownloaded(true);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [firstName, lastName, hasAutoDownloaded]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleSaveContact = () => {
|
||||
// Generate vCard format
|
||||
const vCard = `BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
FN:${firstName} ${lastName}
|
||||
N:${lastName};${firstName};;;
|
||||
${organization ? `ORG:${organization}` : ''}
|
||||
${title ? `TITLE:${title}` : ''}
|
||||
${email ? `EMAIL:${email}` : ''}
|
||||
${phone ? `TEL:${phone}` : ''}
|
||||
END:VCARD`;
|
||||
|
||||
// Create a blob and download
|
||||
const blob = new Blob([vCard], { type: 'text/vcard;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${firstName}_${lastName}.vcf`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Show loading or error state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
backgroundColor: '#ffffff'
|
||||
}}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>👤</div>
|
||||
<p style={{ color: '#666' }}>Loading contact...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!firstName && !lastName) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
padding: '20px',
|
||||
backgroundColor: '#ffffff'
|
||||
}}>
|
||||
<div style={{
|
||||
maxWidth: '420px',
|
||||
width: '100%',
|
||||
padding: '48px 32px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '64px',
|
||||
marginBottom: '24px',
|
||||
opacity: 0.3
|
||||
}}>👤</div>
|
||||
<h1 style={{
|
||||
fontSize: '22px',
|
||||
fontWeight: '600',
|
||||
marginBottom: '12px',
|
||||
color: '#1a1a1a'
|
||||
}}>No Contact Found</h1>
|
||||
<p style={{
|
||||
color: '#666',
|
||||
fontSize: '15px',
|
||||
lineHeight: '1.6'
|
||||
}}>This QR code doesn't contain any contact information.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
padding: '20px',
|
||||
backgroundColor: '#ffffff'
|
||||
}}>
|
||||
<div style={{
|
||||
maxWidth: '420px',
|
||||
width: '100%',
|
||||
padding: '48px 32px'
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{ textAlign: 'center', marginBottom: '32px' }}>
|
||||
<div style={{
|
||||
width: '80px',
|
||||
height: '80px',
|
||||
margin: '0 auto 20px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#f0f0f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '36px'
|
||||
}}>👤</div>
|
||||
|
||||
<h1 style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: '600',
|
||||
marginBottom: '8px',
|
||||
color: '#1a1a1a',
|
||||
letterSpacing: '-0.02em'
|
||||
}}>
|
||||
{firstName} {lastName}
|
||||
</h1>
|
||||
|
||||
{(title || organization) && (
|
||||
<p style={{
|
||||
color: '#666',
|
||||
fontSize: '16px',
|
||||
marginTop: '4px'
|
||||
}}>
|
||||
{title && organization && `${title} at ${organization}`}
|
||||
{title && !organization && title}
|
||||
{!title && organization && organization}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contact Details */}
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
{email && (
|
||||
<div style={{
|
||||
padding: '16px 20px',
|
||||
backgroundColor: '#f8f8f8',
|
||||
borderRadius: '12px',
|
||||
marginBottom: '12px',
|
||||
border: '1px solid #f0f0f0'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: '#888',
|
||||
marginBottom: '6px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
fontWeight: '500'
|
||||
}}>Email</div>
|
||||
<a href={`mailto:${email}`} style={{
|
||||
fontSize: '15px',
|
||||
fontWeight: '500',
|
||||
color: '#2563eb',
|
||||
textDecoration: 'none',
|
||||
wordBreak: 'break-all'
|
||||
}}>{email}</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phone && (
|
||||
<div style={{
|
||||
padding: '16px 20px',
|
||||
backgroundColor: '#f8f8f8',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #f0f0f0'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: '#888',
|
||||
marginBottom: '6px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
fontWeight: '500'
|
||||
}}>Phone</div>
|
||||
<a href={`tel:${phone}`} style={{
|
||||
fontSize: '15px',
|
||||
fontWeight: '500',
|
||||
color: '#2563eb',
|
||||
textDecoration: 'none'
|
||||
}}>{phone}</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<button
|
||||
onClick={handleSaveContact}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '16px',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
color: 'white',
|
||||
backgroundColor: '#2563eb',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
boxShadow: '0 2px 8px rgba(37, 99, 235, 0.2)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#1d4ed8';
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(37, 99, 235, 0.3)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#2563eb';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(37, 99, 235, 0.2)';
|
||||
}}
|
||||
>
|
||||
Save to Contacts
|
||||
</button>
|
||||
|
||||
<p style={{
|
||||
textAlign: 'center',
|
||||
fontSize: '13px',
|
||||
color: '#999',
|
||||
marginTop: '16px',
|
||||
lineHeight: '1.5'
|
||||
}}>
|
||||
Add this contact to your address book
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState, Suspense } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
function VCardContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [organization, setOrganization] = useState('');
|
||||
const [title, setTitle] = useState('');
|
||||
const [hasAutoDownloaded, setHasAutoDownloaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const firstNameParam = searchParams.get('firstName');
|
||||
const lastNameParam = searchParams.get('lastName');
|
||||
const emailParam = searchParams.get('email');
|
||||
const phoneParam = searchParams.get('phone');
|
||||
const organizationParam = searchParams.get('organization');
|
||||
const titleParam = searchParams.get('title');
|
||||
|
||||
if (firstNameParam) setFirstName(firstNameParam);
|
||||
if (lastNameParam) setLastName(lastNameParam);
|
||||
if (emailParam) setEmail(emailParam);
|
||||
if (phoneParam) setPhone(phoneParam);
|
||||
if (organizationParam) setOrganization(organizationParam);
|
||||
if (titleParam) setTitle(titleParam);
|
||||
|
||||
setIsLoading(false);
|
||||
}, [searchParams]);
|
||||
|
||||
// Auto-download after 500ms (only once)
|
||||
useEffect(() => {
|
||||
if ((firstName || lastName) && !hasAutoDownloaded) {
|
||||
const timer = setTimeout(() => {
|
||||
handleSaveContact();
|
||||
setHasAutoDownloaded(true);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [firstName, lastName, hasAutoDownloaded]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleSaveContact = () => {
|
||||
// Generate vCard format
|
||||
const vCard = `BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
FN:${firstName} ${lastName}
|
||||
N:${lastName};${firstName};;;
|
||||
${organization ? `ORG:${organization}` : ''}
|
||||
${title ? `TITLE:${title}` : ''}
|
||||
${email ? `EMAIL:${email}` : ''}
|
||||
${phone ? `TEL:${phone}` : ''}
|
||||
END:VCARD`;
|
||||
|
||||
// Create a blob and download
|
||||
const blob = new Blob([vCard], { type: 'text/vcard;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${firstName}_${lastName}.vcf`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Show loading or error state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
backgroundColor: '#ffffff'
|
||||
}}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>👤</div>
|
||||
<p style={{ color: '#666' }}>Loading contact...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!firstName && !lastName) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
padding: '20px',
|
||||
backgroundColor: '#ffffff'
|
||||
}}>
|
||||
<div style={{
|
||||
maxWidth: '420px',
|
||||
width: '100%',
|
||||
padding: '48px 32px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '64px',
|
||||
marginBottom: '24px',
|
||||
opacity: 0.3
|
||||
}}>👤</div>
|
||||
<h1 style={{
|
||||
fontSize: '22px',
|
||||
fontWeight: '600',
|
||||
marginBottom: '12px',
|
||||
color: '#1a1a1a'
|
||||
}}>No Contact Found</h1>
|
||||
<p style={{
|
||||
color: '#666',
|
||||
fontSize: '15px',
|
||||
lineHeight: '1.6'
|
||||
}}>This QR code doesn't contain any contact information.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
padding: '20px',
|
||||
backgroundColor: '#ffffff'
|
||||
}}>
|
||||
<div style={{
|
||||
maxWidth: '420px',
|
||||
width: '100%',
|
||||
padding: '48px 32px'
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{ textAlign: 'center', marginBottom: '32px' }}>
|
||||
<div style={{
|
||||
width: '80px',
|
||||
height: '80px',
|
||||
margin: '0 auto 20px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#f0f0f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '36px'
|
||||
}}>👤</div>
|
||||
|
||||
<h1 style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: '600',
|
||||
marginBottom: '8px',
|
||||
color: '#1a1a1a',
|
||||
letterSpacing: '-0.02em'
|
||||
}}>
|
||||
{firstName} {lastName}
|
||||
</h1>
|
||||
|
||||
{(title || organization) && (
|
||||
<p style={{
|
||||
color: '#666',
|
||||
fontSize: '16px',
|
||||
marginTop: '4px'
|
||||
}}>
|
||||
{title && organization && `${title} at ${organization}`}
|
||||
{title && !organization && title}
|
||||
{!title && organization && organization}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contact Details */}
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
{email && (
|
||||
<div style={{
|
||||
padding: '16px 20px',
|
||||
backgroundColor: '#f8f8f8',
|
||||
borderRadius: '12px',
|
||||
marginBottom: '12px',
|
||||
border: '1px solid #f0f0f0'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: '#888',
|
||||
marginBottom: '6px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
fontWeight: '500'
|
||||
}}>Email</div>
|
||||
<a href={`mailto:${email}`} style={{
|
||||
fontSize: '15px',
|
||||
fontWeight: '500',
|
||||
color: '#2563eb',
|
||||
textDecoration: 'none',
|
||||
wordBreak: 'break-all'
|
||||
}}>{email}</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phone && (
|
||||
<div style={{
|
||||
padding: '16px 20px',
|
||||
backgroundColor: '#f8f8f8',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #f0f0f0'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: '#888',
|
||||
marginBottom: '6px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
fontWeight: '500'
|
||||
}}>Phone</div>
|
||||
<a href={`tel:${phone}`} style={{
|
||||
fontSize: '15px',
|
||||
fontWeight: '500',
|
||||
color: '#2563eb',
|
||||
textDecoration: 'none'
|
||||
}}>{phone}</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<button
|
||||
onClick={handleSaveContact}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '16px',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
color: 'white',
|
||||
backgroundColor: '#2563eb',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
boxShadow: '0 2px 8px rgba(37, 99, 235, 0.2)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#1d4ed8';
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(37, 99, 235, 0.3)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#2563eb';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(37, 99, 235, 0.2)';
|
||||
}}
|
||||
>
|
||||
Save to Contacts
|
||||
</button>
|
||||
|
||||
<p style={{
|
||||
textAlign: 'center',
|
||||
fontSize: '13px',
|
||||
color: '#999',
|
||||
marginTop: '16px',
|
||||
lineHeight: '1.5'
|
||||
}}>
|
||||
Add this contact to your address book
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VCardPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
backgroundColor: '#ffffff'
|
||||
}}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>👤</div>
|
||||
<p style={{ color: '#666' }}>Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<VCardContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,104 +1,102 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import posthog from 'posthog-js';
|
||||
|
||||
export function PostHogProvider({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const initializationAttempted = useRef(false);
|
||||
|
||||
// Initialize PostHog once
|
||||
useEffect(() => {
|
||||
// Prevent double initialization in React Strict Mode
|
||||
if (initializationAttempted.current) return;
|
||||
initializationAttempted.current = true;
|
||||
|
||||
const cookieConsent = localStorage.getItem('cookieConsent');
|
||||
|
||||
if (cookieConsent === 'accepted') {
|
||||
const apiKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
|
||||
const apiHost = process.env.NEXT_PUBLIC_POSTHOG_HOST;
|
||||
|
||||
if (!apiKey) {
|
||||
console.warn('PostHog API key not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already initialized (using _loaded property)
|
||||
if (!(posthog as any)._loaded) {
|
||||
posthog.init(apiKey, {
|
||||
api_host: apiHost || 'https://us.i.posthog.com',
|
||||
person_profiles: 'identified_only',
|
||||
capture_pageview: false, // Manual pageview tracking
|
||||
capture_pageleave: true,
|
||||
autocapture: true,
|
||||
respect_dnt: true,
|
||||
opt_out_capturing_by_default: false,
|
||||
});
|
||||
|
||||
// Enable debug mode in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
posthog.debug();
|
||||
}
|
||||
|
||||
// Set initialized immediately after init
|
||||
setIsInitialized(true);
|
||||
} else {
|
||||
setIsInitialized(true); // Already loaded
|
||||
}
|
||||
}
|
||||
|
||||
// NO cleanup function - PostHog should persist across page navigation
|
||||
}, []);
|
||||
|
||||
// Track page views ONLY after PostHog is initialized
|
||||
useEffect(() => {
|
||||
const cookieConsent = localStorage.getItem('cookieConsent');
|
||||
|
||||
if (cookieConsent === 'accepted' && pathname && isInitialized) {
|
||||
let url = window.origin + pathname;
|
||||
if (searchParams && searchParams.toString()) {
|
||||
url = url + `?${searchParams.toString()}`;
|
||||
}
|
||||
|
||||
posthog.capture('$pageview', {
|
||||
$current_url: url,
|
||||
});
|
||||
}
|
||||
}, [pathname, searchParams, isInitialized]); // Added isInitialized dependency
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to identify user after login
|
||||
*/
|
||||
export function identifyUser(userId: string, traits?: Record<string, any>) {
|
||||
const cookieConsent = localStorage.getItem('cookieConsent');
|
||||
if (cookieConsent === 'accepted' && (posthog as any)._loaded) {
|
||||
posthog.identify(userId, traits);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to track custom events
|
||||
*/
|
||||
export function trackEvent(eventName: string, properties?: Record<string, any>) {
|
||||
const cookieConsent = localStorage.getItem('cookieConsent');
|
||||
if (cookieConsent === 'accepted' && (posthog as any)._loaded) {
|
||||
posthog.capture(eventName, properties);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to reset user on logout
|
||||
*/
|
||||
export function resetUser() {
|
||||
const cookieConsent = localStorage.getItem('cookieConsent');
|
||||
if (cookieConsent === 'accepted' && (posthog as any)._loaded) {
|
||||
posthog.reset();
|
||||
}
|
||||
}
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import posthog from 'posthog-js';
|
||||
|
||||
export function PostHogPageView() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Track page views
|
||||
useEffect(() => {
|
||||
const cookieConsent = localStorage.getItem('cookieConsent');
|
||||
|
||||
if (cookieConsent === 'accepted' && pathname) {
|
||||
let url = window.origin + pathname;
|
||||
if (searchParams && searchParams.toString()) {
|
||||
url = url + `?${searchParams.toString()}`;
|
||||
}
|
||||
|
||||
posthog.capture('$pageview', {
|
||||
$current_url: url,
|
||||
});
|
||||
}
|
||||
}, [pathname, searchParams]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function PostHogProvider({ children }: { children: React.ReactNode }) {
|
||||
const initializationAttempted = useRef(false);
|
||||
|
||||
// Initialize PostHog once
|
||||
useEffect(() => {
|
||||
// Prevent double initialization in React Strict Mode
|
||||
if (initializationAttempted.current) return;
|
||||
initializationAttempted.current = true;
|
||||
|
||||
const cookieConsent = localStorage.getItem('cookieConsent');
|
||||
|
||||
// Check if we should initialize based on consent
|
||||
// If not accepted yet, we don't init. CookieBanner deals with setting 'accepted' and reloading or calling init.
|
||||
// Ideally we should listen to consent changes, but for now matching previous behavior.
|
||||
if (cookieConsent === 'accepted') {
|
||||
const apiKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
|
||||
const apiHost = process.env.NEXT_PUBLIC_POSTHOG_HOST;
|
||||
|
||||
if (!apiKey) {
|
||||
console.warn('PostHog API key not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(posthog as any)._loaded) {
|
||||
posthog.init(apiKey, {
|
||||
api_host: apiHost || 'https://us.i.posthog.com',
|
||||
person_profiles: 'identified_only',
|
||||
capture_pageview: false, // We handle this manually
|
||||
capture_pageleave: true,
|
||||
autocapture: true,
|
||||
respect_dnt: true,
|
||||
opt_out_capturing_by_default: false,
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
posthog.debug();
|
||||
}
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to identify user after login
|
||||
*/
|
||||
export function identifyUser(userId: string, traits?: Record<string, any>) {
|
||||
const cookieConsent = localStorage.getItem('cookieConsent');
|
||||
if (cookieConsent === 'accepted' && (posthog as any)._loaded) {
|
||||
posthog.identify(userId, traits);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to track custom events
|
||||
*/
|
||||
export function trackEvent(eventName: string, properties?: Record<string, any>) {
|
||||
const cookieConsent = localStorage.getItem('cookieConsent');
|
||||
if (cookieConsent === 'accepted' && (posthog as any)._loaded) {
|
||||
posthog.capture(eventName, properties);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to reset user on logout
|
||||
*/
|
||||
export function resetUser() {
|
||||
const cookieConsent = localStorage.getItem('cookieConsent');
|
||||
if (cookieConsent === 'accepted' && (posthog as any)._loaded) {
|
||||
posthog.reset();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,30 @@
|
|||
import React from 'react';
|
||||
|
||||
interface SeoJsonLdProps {
|
||||
data: object | object[];
|
||||
}
|
||||
|
||||
export default function SeoJsonLd({ data }: SeoJsonLdProps) {
|
||||
const jsonLdArray = Array.isArray(data) ? data : [data];
|
||||
|
||||
return (
|
||||
<>
|
||||
{jsonLdArray.map((item, index) => (
|
||||
<script
|
||||
key={index}
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(item, null, 0),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
import React from 'react';
|
||||
|
||||
interface SeoJsonLdProps {
|
||||
data: object | object[];
|
||||
}
|
||||
|
||||
export default function SeoJsonLd({ data }: SeoJsonLdProps) {
|
||||
const jsonLdArray = Array.isArray(data) ? data : [data];
|
||||
|
||||
return (
|
||||
<>
|
||||
{jsonLdArray.map((item, index) => {
|
||||
// Only add @context if it doesn't already exist in the item
|
||||
const schema = (item as any)['@context']
|
||||
? item
|
||||
: { '@context': 'https://schema.org', ...item };
|
||||
|
||||
return (
|
||||
<script
|
||||
key={index}
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(schema, null, 0),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -168,7 +168,6 @@ END:VCARD`;
|
|||
</button>
|
||||
}
|
||||
>
|
||||
<DropdownItem onClick={() => window.location.href = `/qr/${qr.id}`}>View Details</DropdownItem>
|
||||
<DropdownItem onClick={() => downloadQR('png')}>Download PNG</DropdownItem>
|
||||
<DropdownItem onClick={() => downloadQR('svg')}>Download SVG</DropdownItem>
|
||||
{qr.type === 'DYNAMIC' && (
|
||||
|
|
@ -247,15 +246,6 @@ END:VCARD`;
|
|||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Feedback Button - only for FEEDBACK type */}
|
||||
{qr.contentType === 'FEEDBACK' && (
|
||||
<button
|
||||
onClick={() => window.location.href = `/qr/${qr.id}/feedback`}
|
||||
className="w-full mt-3 py-2 px-3 bg-amber-50 hover:bg-amber-100 text-amber-700 rounded-lg text-sm font-medium flex items-center justify-center gap-2 transition-colors"
|
||||
>
|
||||
⭐ View Customer Feedback
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -89,4 +89,4 @@ export const FAQ: React.FC<FAQProps> = ({ t }) => {
|
|||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -82,4 +82,4 @@ export const Features: React.FC<FeaturesProps> = ({ t }) => {
|
|||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -222,18 +222,9 @@ export function FreeToolsGrid() {
|
|||
transition={{ duration: 0.5 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row items-center justify-center gap-3 mb-4">
|
||||
<h2 className="text-3xl lg:text-4xl font-bold text-slate-900">
|
||||
More Free QR Code Tools
|
||||
</h2>
|
||||
<div className="bg-gradient-to-r from-emerald-500 to-green-500 text-white px-3 py-1 rounded-full text-xs md:text-sm font-semibold shadow-lg shadow-emerald-500/20 flex items-center gap-2">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<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 Forever
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-3xl lg:text-4xl font-bold text-slate-900 mb-4">
|
||||
More Free QR Code Tools
|
||||
</h2>
|
||||
<p className="text-lg text-slate-600 max-w-2xl mx-auto">
|
||||
Create specialized QR codes for every need. Completely free and no signup required.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -6,77 +6,19 @@ import { Button } from '@/components/ui/Button';
|
|||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Globe, User, MapPin, Phone, CheckCircle2, ArrowRight, FileText, Ticket, Smartphone, Star } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
// Sub-component for the flipping effect
|
||||
const FlippingCard = ({ front, back, delay }: { front: any, back: any, delay: number }) => {
|
||||
const [isFlipped, setIsFlipped] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Initial delay
|
||||
const initialTimeout = setTimeout(() => {
|
||||
setIsFlipped(true); // First flip
|
||||
|
||||
// Setup interval for subsequent flips
|
||||
const interval = setInterval(() => {
|
||||
setIsFlipped(prev => !prev);
|
||||
}, 8000); // Toggle every 8 seconds to prevent overlap (4 cards * 2s gap)
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, delay * 1000);
|
||||
|
||||
return () => clearTimeout(initialTimeout);
|
||||
}, [delay]);
|
||||
|
||||
return (
|
||||
<div className="relative h-32 w-full perspective-[1000px] group cursor-pointer">
|
||||
<motion.div
|
||||
animate={{ rotateY: isFlipped ? 180 : 0 }}
|
||||
transition={{ duration: 0.6, type: "spring", stiffness: 260, damping: 20 }}
|
||||
className="relative w-full h-full preserve-3d"
|
||||
style={{ transformStyle: 'preserve-3d' }}
|
||||
>
|
||||
{/* Front Face */}
|
||||
<div
|
||||
className="absolute inset-0 backface-hidden"
|
||||
style={{ backfaceVisibility: 'hidden', WebkitBackfaceVisibility: 'hidden' }}
|
||||
>
|
||||
<Card className="w-full h-full backdrop-blur-xl bg-white/70 border-white/50 shadow-xl shadow-gray-200/50 p-4 flex flex-col items-center justify-center hover:scale-105 transition-all duration-300">
|
||||
<div className={`w-10 h-10 mb-3 rounded-xl ${front.color} flex items-center justify-center`}>
|
||||
<front.icon className="w-5 h-5" />
|
||||
</div>
|
||||
<p className="font-semibold text-gray-800 text-sm">{front.title}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Back Face */}
|
||||
<div
|
||||
className="absolute inset-0 backface-hidden"
|
||||
style={{
|
||||
backfaceVisibility: 'hidden',
|
||||
WebkitBackfaceVisibility: 'hidden',
|
||||
transform: 'rotateY(180deg)'
|
||||
}}
|
||||
>
|
||||
<Card className="w-full h-full backdrop-blur-xl bg-white/80 border-white/60 shadow-xl shadow-blue-200/50 p-4 flex flex-col items-center justify-center hover:scale-105 transition-all duration-300">
|
||||
<div className={`w-10 h-10 mb-3 rounded-xl ${back.color} flex items-center justify-center`}>
|
||||
<back.icon className="w-5 h-5" />
|
||||
</div>
|
||||
<p className="font-semibold text-gray-900 text-sm">{back.title}</p>
|
||||
</Card>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import { Globe, User, MapPin, Phone, CheckCircle2, ArrowRight } from 'lucide-react';
|
||||
|
||||
interface HeroProps {
|
||||
t: any; // i18n translation function
|
||||
}
|
||||
|
||||
export const Hero: React.FC<HeroProps> = ({ t }) => {
|
||||
|
||||
const templateCards = [
|
||||
{ title: 'URL/Website', color: 'bg-blue-500/10 text-blue-600', icon: Globe },
|
||||
{ title: 'Contact Card', color: 'bg-purple-500/10 text-purple-600', icon: User },
|
||||
{ title: 'Location', color: 'bg-green-500/10 text-green-600', icon: MapPin },
|
||||
{ title: 'Phone Number', color: 'bg-pink-500/10 text-pink-600', icon: Phone },
|
||||
];
|
||||
|
||||
const containerjs = {
|
||||
hidden: { opacity: 0 },
|
||||
|
|
@ -124,9 +66,9 @@ export const Hero: React.FC<HeroProps> = ({ t }) => {
|
|||
transition={{ duration: 0.5 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<h1 className="text-5xl lg:text-6xl font-bold text-gray-900 leading-tight">
|
||||
<h2 className="text-5xl lg:text-6xl font-bold text-gray-900 leading-tight">
|
||||
{t.hero.title}
|
||||
</h1>
|
||||
</h2>
|
||||
|
||||
<p className="text-xl text-gray-600 leading-relaxed max-w-2xl">
|
||||
{t.hero.subtitle}
|
||||
|
|
@ -171,34 +113,37 @@ export const Hero: React.FC<HeroProps> = ({ t }) => {
|
|||
|
||||
{/* Right Preview Widget */}
|
||||
<div className="relative">
|
||||
<div className="relative perspective-[1000px]">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[
|
||||
{
|
||||
front: { title: 'URL/Website', color: 'bg-blue-500/10 text-blue-600', icon: Globe },
|
||||
back: { title: 'PDF / Menu', color: 'bg-orange-500/10 text-orange-600', icon: FileText },
|
||||
delay: 3 // Starts at 3s
|
||||
},
|
||||
{
|
||||
front: { title: 'Contact Card', color: 'bg-purple-500/10 text-purple-600', icon: User },
|
||||
back: { title: 'Coupon / Deals', color: 'bg-red-500/10 text-red-600', icon: Ticket },
|
||||
delay: 5 // +2s
|
||||
},
|
||||
{
|
||||
front: { title: 'Location', color: 'bg-green-500/10 text-green-600', icon: MapPin },
|
||||
back: { title: 'App Store', color: 'bg-sky-500/10 text-sky-600', icon: Smartphone },
|
||||
delay: 7 // +2s
|
||||
},
|
||||
{
|
||||
front: { title: 'Phone Number', color: 'bg-pink-500/10 text-pink-600', icon: Phone },
|
||||
back: { title: 'Feedback', color: 'bg-yellow-500/10 text-yellow-600', icon: Star },
|
||||
delay: 9 // +2s
|
||||
},
|
||||
].map((card, index) => (
|
||||
<FlippingCard key={index} {...card} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<motion.div
|
||||
variants={containerjs}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid grid-cols-2 gap-4"
|
||||
>
|
||||
{templateCards.map((card, index) => (
|
||||
<motion.div key={index} variants={itemjs}>
|
||||
<Card className={`backdrop-blur-xl bg-white/70 border-white/50 shadow-xl shadow-gray-200/50 p-6 text-center hover:scale-105 transition-all duration-300 group cursor-pointer`}>
|
||||
<div className={`w-12 h-12 mx-auto mb-4 rounded-xl ${card.color} flex items-center justify-center group-hover:scale-110 transition-transform duration-300`}>
|
||||
<card.icon className="w-6 h-6" />
|
||||
</div>
|
||||
<p className="font-semibold text-gray-800 group-hover:text-gray-900">{card.title}</p>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Floating Badge */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.8 }}
|
||||
className="absolute -top-4 -right-4 bg-gradient-to-r from-success-500 to-emerald-500 text-white px-4 py-2 rounded-full text-sm font-semibold shadow-lg shadow-success-500/30 flex items-center gap-2"
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<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>
|
||||
{t.hero.engagement_badge}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -207,4 +152,4 @@ export const Hero: React.FC<HeroProps> = ({ t }) => {
|
|||
<div className="absolute bottom-0 left-0 w-full h-32 bg-gradient-to-b from-transparent to-gray-50 pointer-events-none" />
|
||||
</section >
|
||||
);
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { Input } from '@/components/ui/Input';
|
|||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { calculateContrast } from '@/lib/utils';
|
||||
import AdBanner from '@/components/ads/AdBanner';
|
||||
|
||||
interface InstantGeneratorProps {
|
||||
t: any; // i18n translation function
|
||||
|
|
@ -279,4 +280,4 @@ export const InstantGenerator: React.FC<InstantGeneratorProps> = ({ t }) => {
|
|||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -138,4 +138,4 @@ export const Pricing: React.FC<PricingProps> = ({ t }) => {
|
|||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -95,4 +95,4 @@ export const StaticVsDynamic: React.FC<StaticVsDynamicProps> = ({ t }) => {
|
|||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -32,4 +32,4 @@ export const StatsStrip: React.FC<StatsStripProps> = ({ t }) => {
|
|||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -67,4 +67,4 @@ export const TemplateCards: React.FC<TemplateCardsProps> = ({ t }) => {
|
|||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@
|
|||
"create_qr": "QR erstellen",
|
||||
"bulk_creation": "Massen-Erstellung",
|
||||
"analytics": "Analytik",
|
||||
"settings": "Einstellungen"
|
||||
"settings": "Einstellungen",
|
||||
"cta": "Kostenlos loslegen",
|
||||
"tools": "Kostenlose Tools",
|
||||
"all_free": "Alle Generatoren sind 100% kostenlos"
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Kostenloser QR-Code-Generator",
|
||||
|
|
@ -64,6 +67,8 @@
|
|||
"demo_note": "Dies ist ein Demo-QR-Code"
|
||||
},
|
||||
"static_vs_dynamic": {
|
||||
"title": "Warum dynamische QR-Codes Geld sparen",
|
||||
"description": "Hören Sie auf, Materialien neu zu drucken. Ändern Sie Zielorte sofort und verfolgen Sie jeden Scan.",
|
||||
"static": {
|
||||
"title": "Statische QR-Codes",
|
||||
"subtitle": "Immer kostenlos",
|
||||
|
|
@ -97,6 +102,10 @@
|
|||
"title": "Vollständige Anpassung",
|
||||
"description": "Branden Sie Ihre QR-Codes mit individuellen Farben, Logos und Styling-Optionen."
|
||||
},
|
||||
"unlimited": {
|
||||
"title": "Unbegrenzte statische QR-Codes",
|
||||
"description": "Erstellen Sie so viele statische QR-Codes wie Sie möchten. Für immer kostenlos, ohne Limits."
|
||||
},
|
||||
"bulk": {
|
||||
"title": "Bulk-Operationen",
|
||||
"description": "Erstellen Sie hunderte von QR-Codes auf einmal mit CSV-Import und Batch-Verarbeitung."
|
||||
|
|
@ -370,5 +379,22 @@
|
|||
"loading": "Lädt...",
|
||||
"error": "Ein Fehler ist aufgetreten",
|
||||
"success": "Erfolgreich!"
|
||||
},
|
||||
"footer": {
|
||||
"product": "Produkt",
|
||||
"features": "Funktionen",
|
||||
"pricing": "Preise",
|
||||
"faq": "FAQ",
|
||||
"blog": "Blog",
|
||||
"resources": "Ressourcen",
|
||||
"full_pricing": "Alle Preise",
|
||||
"all_questions": "Alle Fragen",
|
||||
"all_articles": "Alle Artikel",
|
||||
"get_started": "Loslegen",
|
||||
"legal": "Rechtliches",
|
||||
"privacy_policy": "Datenschutzerklärung",
|
||||
"tagline": "Erstellen Sie individuelle QR-Codes in Sekunden mit erweitertem Tracking und Analytik.",
|
||||
"newsletter": "Newsletter Anmeldung",
|
||||
"rights_reserved": "QR Master. Alle Rechte vorbehalten."
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,8 @@ const globalForPrisma = globalThis as unknown as {
|
|||
|
||||
export const db =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient();
|
||||
new PrismaClient({
|
||||
log: ['query'],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;
|
||||
|
|
@ -1,35 +1,27 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
PORT: z.string().default('3000'),
|
||||
DATABASE_URL: z.string().default('postgresql://postgres:postgres@localhost:5432/qrmaster?schema=public'),
|
||||
NEXTAUTH_URL: z.string().default('http://localhost:3050'),
|
||||
NEXTAUTH_SECRET: z.string().default('development-secret-change-in-production'),
|
||||
GOOGLE_CLIENT_ID: z.string().optional(),
|
||||
GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||||
REDIS_URL: z.string().optional(),
|
||||
IP_SALT: z.string().default('development-salt-change-in-production'),
|
||||
ENABLE_DEMO: z.string().default('false'),
|
||||
|
||||
// Cloudflare R2 (S3 Compatible)
|
||||
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_UPLOAD_SIZE: z.string().default('10485760'), // 10MB default
|
||||
});
|
||||
|
||||
// During build, we might not have all env vars, so we'll use defaults
|
||||
const isBuildTime = process.env.NEXT_PHASE === 'phase-production-build' || !process.env.DATABASE_URL;
|
||||
|
||||
export const env = isBuildTime
|
||||
? envSchema.parse({
|
||||
...process.env,
|
||||
DATABASE_URL: process.env.DATABASE_URL || 'postgresql://postgres:postgres@db:5432/qrmaster?schema=public',
|
||||
NEXTAUTH_URL: process.env.NEXTAUTH_URL || 'http://localhost:3050',
|
||||
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET || 'development-secret-change-in-production',
|
||||
IP_SALT: process.env.IP_SALT || 'development-salt-change-in-production',
|
||||
})
|
||||
import { z } from 'zod';
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
PORT: z.string().default('3000'),
|
||||
DATABASE_URL: z.string().default('postgresql://postgres:postgres@localhost:5432/qrmaster?schema=public'),
|
||||
NEXTAUTH_URL: z.string().default('http://localhost:3050'),
|
||||
NEXTAUTH_SECRET: z.string().default('development-secret-change-in-production'),
|
||||
GOOGLE_CLIENT_ID: z.string().optional(),
|
||||
GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||||
REDIS_URL: z.string().optional(),
|
||||
IP_SALT: z.string().default('development-salt-change-in-production'),
|
||||
ENABLE_DEMO: z.string().default('false'),
|
||||
});
|
||||
|
||||
// During build, we might not have all env vars, so we'll use defaults
|
||||
const isBuildTime = process.env.NEXT_PHASE === 'phase-production-build' || !process.env.DATABASE_URL;
|
||||
|
||||
export const env = isBuildTime
|
||||
? envSchema.parse({
|
||||
...process.env,
|
||||
DATABASE_URL: process.env.DATABASE_URL || 'postgresql://postgres:postgres@db:5432/qrmaster?schema=public',
|
||||
NEXTAUTH_URL: process.env.NEXTAUTH_URL || 'http://localhost:3050',
|
||||
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET || 'development-secret-change-in-production',
|
||||
IP_SALT: process.env.IP_SALT || 'development-salt-change-in-production',
|
||||
})
|
||||
: envSchema.parse(process.env);
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { env } from './env';
|
||||
import crypto from 'crypto';
|
||||
|
||||
// Initialize S3 client for Cloudflare R2
|
||||
const r2Client = new S3Client({
|
||||
region: 'auto',
|
||||
endpoint: `https://${env.R2_ACCOUNT_ID || 'placeholder'}.r2.cloudflarestorage.com`,
|
||||
credentials: {
|
||||
accessKeyId: env.R2_ACCESS_KEY_ID || '',
|
||||
secretAccessKey: env.R2_SECRET_ACCESS_KEY || '',
|
||||
},
|
||||
});
|
||||
|
||||
export async function uploadFileToR2(
|
||||
file: Buffer,
|
||||
filename: string,
|
||||
contentType: string = 'application/pdf'
|
||||
): Promise<string> {
|
||||
// Generate a unique key for the file
|
||||
const ext = filename.split('.').pop() || 'pdf';
|
||||
const randomId = crypto.randomBytes(8).toString('hex');
|
||||
const timestamp = Date.now();
|
||||
const key = `uploads/${timestamp}_${randomId}.${ext}`;
|
||||
|
||||
await r2Client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: env.R2_BUCKET_NAME,
|
||||
Key: key,
|
||||
Body: file,
|
||||
ContentType: contentType,
|
||||
ContentDisposition: `inline; filename="${filename}"`,
|
||||
// Cache for 1 year, as these are static files
|
||||
CacheControl: 'public, max-age=31536000',
|
||||
})
|
||||
);
|
||||
|
||||
// Return the public URL
|
||||
// If R2_PUBLIC_URL is set, use it (custom domain or r2.dev subdomain)
|
||||
// Otherwise, construct a fallback (which might not work without public access enabled on bucket)
|
||||
const publicUrl = env.R2_PUBLIC_URL
|
||||
? `${env.R2_PUBLIC_URL}/${key}`
|
||||
: `https://${env.R2_BUCKET_NAME}.r2.dev/${key}`;
|
||||
|
||||
return publicUrl;
|
||||
}
|
||||
|
||||
export async function deleteFileFromR2(fileUrl: string): Promise<void> {
|
||||
try {
|
||||
// Extract key from URL
|
||||
// URL format: https://domain.com/uploads/filename.pdf
|
||||
const url = new URL(fileUrl);
|
||||
const key = url.pathname.substring(1); // Remove leading slash
|
||||
|
||||
await r2Client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: env.R2_BUCKET_NAME,
|
||||
Key: key,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error deleting file from R2:', error);
|
||||
// Suppress error, as deletion failure shouldn't block main flow
|
||||
}
|
||||
}
|
||||
|
|
@ -1,245 +1,269 @@
|
|||
export interface BreadcrumbItem {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface BlogPost {
|
||||
title: string;
|
||||
description: string;
|
||||
slug: string;
|
||||
author: string;
|
||||
authorUrl: string;
|
||||
datePublished: string;
|
||||
dateModified: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
export interface FAQItem {
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
export interface ProductOffer {
|
||||
name: string;
|
||||
price: string;
|
||||
priceCurrency: string;
|
||||
availability: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface HowToStep {
|
||||
name: string;
|
||||
text: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface HowToTask {
|
||||
name: string;
|
||||
description: string;
|
||||
steps: HowToStep[];
|
||||
totalTime?: string;
|
||||
}
|
||||
|
||||
export function organizationSchema() {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
'@id': 'https://www.qrmaster.net/#organization',
|
||||
name: 'QR Master',
|
||||
alternateName: 'QRMaster',
|
||||
url: 'https://www.qrmaster.net',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: 'https://www.qrmaster.net/static/og-image.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
},
|
||||
image: 'https://www.qrmaster.net/static/og-image.png',
|
||||
sameAs: [
|
||||
'https://twitter.com/qrmaster',
|
||||
],
|
||||
contactPoint: {
|
||||
'@type': 'ContactPoint',
|
||||
contactType: 'Customer Support',
|
||||
email: 'support@qrmaster.net',
|
||||
availableLanguage: ['English', 'German'],
|
||||
},
|
||||
description: 'B2B SaaS platform for dynamic QR code generation with analytics, branding, and bulk generation for enterprise marketing campaigns.',
|
||||
slogan: 'Dynamic QR codes that work smarter',
|
||||
foundingDate: '2025',
|
||||
areaServed: 'Worldwide',
|
||||
serviceType: 'Software as a Service',
|
||||
priceRange: '$0 - $29',
|
||||
knowsAbout: [
|
||||
'QR Code Generation',
|
||||
'Marketing Analytics',
|
||||
'Campaign Tracking',
|
||||
'Dynamic QR Codes',
|
||||
'Bulk QR Generation',
|
||||
],
|
||||
hasOfferCatalog: {
|
||||
'@type': 'OfferCatalog',
|
||||
name: 'QR Master Plans',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'Offer',
|
||||
itemOffered: {
|
||||
'@type': 'SoftwareApplication',
|
||||
name: 'QR Master Free',
|
||||
applicationCategory: 'BusinessApplication',
|
||||
operatingSystem: 'Web Browser',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Offer',
|
||||
itemOffered: {
|
||||
'@type': 'SoftwareApplication',
|
||||
name: 'QR Master Pro',
|
||||
applicationCategory: 'BusinessApplication',
|
||||
operatingSystem: 'Web Browser',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
inLanguage: 'en',
|
||||
mainEntityOfPage: 'https://www.qrmaster.net',
|
||||
};
|
||||
}
|
||||
|
||||
export function websiteSchema() {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
'@id': 'https://www.qrmaster.net/#website',
|
||||
name: 'QR Master',
|
||||
url: 'https://www.qrmaster.net',
|
||||
inLanguage: 'en',
|
||||
mainEntityOfPage: 'https://www.qrmaster.net',
|
||||
publisher: {
|
||||
'@id': 'https://www.qrmaster.net/#organization',
|
||||
},
|
||||
potentialAction: {
|
||||
'@type': 'SearchAction',
|
||||
target: {
|
||||
'@type': 'EntryPoint',
|
||||
urlTemplate: 'https://www.qrmaster.net/blog?q={search_term_string}',
|
||||
},
|
||||
'query-input': 'required name=search_term_string',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function breadcrumbSchema(items: BreadcrumbItem[]) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
'@id': `https://www.qrmaster.net${items[items.length - 1]?.url}#breadcrumb`,
|
||||
inLanguage: 'en',
|
||||
mainEntityOfPage: `https://www.qrmaster.net${items[items.length - 1]?.url}`,
|
||||
itemListElement: items.map((item, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
name: item.name,
|
||||
item: `https://www.qrmaster.net${item.url}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function blogPostingSchema(post: BlogPost) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BlogPosting',
|
||||
'@id': `https://www.qrmaster.net/blog/${post.slug}#article`,
|
||||
headline: post.title,
|
||||
description: post.description,
|
||||
image: post.image,
|
||||
datePublished: post.datePublished,
|
||||
dateModified: post.dateModified,
|
||||
inLanguage: 'en',
|
||||
mainEntityOfPage: `https://www.qrmaster.net/blog/${post.slug}`,
|
||||
author: {
|
||||
'@type': 'Person',
|
||||
name: post.author,
|
||||
url: post.authorUrl,
|
||||
},
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'QR Master',
|
||||
url: 'https://www.qrmaster.net',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: 'https://www.qrmaster.net/static/og-image.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
},
|
||||
},
|
||||
isPartOf: {
|
||||
'@type': 'Blog',
|
||||
'@id': 'https://www.qrmaster.net/blog#blog',
|
||||
name: 'QR Master Blog',
|
||||
url: 'https://www.qrmaster.net/blog',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function faqPageSchema(faqs: FAQItem[]) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
'@id': 'https://www.qrmaster.net/faq#faqpage',
|
||||
inLanguage: 'en',
|
||||
mainEntityOfPage: 'https://www.qrmaster.net/faq',
|
||||
mainEntity: faqs.map((faq) => ({
|
||||
'@type': 'Question',
|
||||
name: faq.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: faq.answer,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function productSchema(product: { name: string; description: string; offers: ProductOffer[] }) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Product',
|
||||
'@id': 'https://www.qrmaster.net/pricing#product',
|
||||
name: product.name,
|
||||
description: product.description,
|
||||
inLanguage: 'en',
|
||||
mainEntityOfPage: 'https://www.qrmaster.net/pricing',
|
||||
brand: {
|
||||
'@type': 'Organization',
|
||||
name: 'QR Master',
|
||||
},
|
||||
offers: product.offers.map((offer) => ({
|
||||
'@type': 'Offer',
|
||||
name: offer.name,
|
||||
price: offer.price,
|
||||
priceCurrency: offer.priceCurrency,
|
||||
availability: offer.availability,
|
||||
url: offer.url,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function howToSchema(task: HowToTask) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'HowTo',
|
||||
'@id': `https://www.qrmaster.net/blog/${task.name.toLowerCase().replace(/\s+/g, '-')}#howto`,
|
||||
name: task.name,
|
||||
description: task.description,
|
||||
inLanguage: 'en',
|
||||
mainEntityOfPage: `https://www.qrmaster.net/blog/${task.name.toLowerCase().replace(/\s+/g, '-')}`,
|
||||
totalTime: task.totalTime || 'PT5M',
|
||||
step: task.steps.map((step, index) => ({
|
||||
'@type': 'HowToStep',
|
||||
position: index + 1,
|
||||
name: step.name,
|
||||
text: step.text,
|
||||
url: step.url,
|
||||
})),
|
||||
};
|
||||
}
|
||||
export interface BreadcrumbItem {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface BlogPost {
|
||||
title: string;
|
||||
description: string;
|
||||
slug: string;
|
||||
author: string;
|
||||
authorUrl: string;
|
||||
datePublished: string;
|
||||
dateModified: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
export interface FAQItem {
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
export interface ProductOffer {
|
||||
name: string;
|
||||
price: string;
|
||||
priceCurrency: string;
|
||||
availability: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface HowToStep {
|
||||
name: string;
|
||||
text: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface HowToTask {
|
||||
name: string;
|
||||
description: string;
|
||||
steps: HowToStep[];
|
||||
totalTime?: string;
|
||||
}
|
||||
|
||||
const BASE_URL = 'https://www.qrmaster.net';
|
||||
|
||||
function toAbsoluteUrl(path: string): string {
|
||||
if (path.startsWith('http')) return path;
|
||||
return `${BASE_URL}${path.startsWith('/') ? '' : '/'}${path}`;
|
||||
}
|
||||
|
||||
export function organizationSchema() {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
'@id': `${BASE_URL}/#organization`,
|
||||
name: 'QR Master',
|
||||
alternateName: 'QRMaster',
|
||||
url: BASE_URL,
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: `${BASE_URL}/og-image.png`,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
},
|
||||
image: `${BASE_URL}/og-image.png`,
|
||||
sameAs: [
|
||||
'https://twitter.com/qrmaster',
|
||||
],
|
||||
contactPoint: {
|
||||
'@type': 'ContactPoint',
|
||||
contactType: 'Customer Support',
|
||||
email: 'support@qrmaster.net',
|
||||
availableLanguage: ['English', 'German'],
|
||||
},
|
||||
description: 'B2B SaaS platform for dynamic QR code generation with analytics, branding, and bulk generation for enterprise marketing campaigns.',
|
||||
slogan: 'Dynamic QR codes that work smarter',
|
||||
foundingDate: '2025',
|
||||
areaServed: 'Worldwide',
|
||||
knowsAbout: [
|
||||
'QR Code Generation',
|
||||
'Marketing Analytics',
|
||||
'Campaign Tracking',
|
||||
'Dynamic QR Codes',
|
||||
'Bulk QR Generation',
|
||||
],
|
||||
hasOfferCatalog: {
|
||||
'@type': 'OfferCatalog',
|
||||
name: 'QR Master Plans',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'Offer',
|
||||
itemOffered: {
|
||||
'@type': 'SoftwareApplication',
|
||||
name: 'QR Master Free',
|
||||
applicationCategory: 'BusinessApplication',
|
||||
operatingSystem: 'Web Browser',
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
price: '0',
|
||||
priceCurrency: 'EUR',
|
||||
},
|
||||
aggregateRating: {
|
||||
'@type': 'AggregateRating',
|
||||
ratingValue: '4.8',
|
||||
ratingCount: '1250',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Offer',
|
||||
itemOffered: {
|
||||
'@type': 'SoftwareApplication',
|
||||
name: 'QR Master Pro',
|
||||
applicationCategory: 'BusinessApplication',
|
||||
operatingSystem: 'Web Browser',
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
price: '9',
|
||||
priceCurrency: 'EUR',
|
||||
},
|
||||
aggregateRating: {
|
||||
'@type': 'AggregateRating',
|
||||
ratingValue: '4.9',
|
||||
ratingCount: '850',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
mainEntityOfPage: BASE_URL,
|
||||
};
|
||||
}
|
||||
|
||||
export function websiteSchema() {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
'@id': `${BASE_URL}/#website`,
|
||||
name: 'QR Master',
|
||||
url: BASE_URL,
|
||||
inLanguage: 'en',
|
||||
mainEntityOfPage: BASE_URL,
|
||||
publisher: {
|
||||
'@id': `${BASE_URL}/#organization`,
|
||||
},
|
||||
potentialAction: {
|
||||
'@type': 'SearchAction',
|
||||
target: {
|
||||
'@type': 'EntryPoint',
|
||||
urlTemplate: `${BASE_URL}/blog?q={search_term_string}`,
|
||||
},
|
||||
'query-input': 'required name=search_term_string',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function breadcrumbSchema(items: BreadcrumbItem[]) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
'@id': `${BASE_URL}${items[items.length - 1]?.url}#breadcrumb`,
|
||||
inLanguage: 'en',
|
||||
mainEntityOfPage: `${BASE_URL}${items[items.length - 1]?.url}`,
|
||||
itemListElement: items.map((item, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
name: item.name,
|
||||
item: toAbsoluteUrl(item.url),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function blogPostingSchema(post: BlogPost) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BlogPosting',
|
||||
'@id': `${BASE_URL}/blog/${post.slug}#article`,
|
||||
headline: post.title,
|
||||
description: post.description,
|
||||
image: toAbsoluteUrl(post.image),
|
||||
datePublished: post.datePublished,
|
||||
dateModified: post.dateModified,
|
||||
inLanguage: 'en',
|
||||
mainEntityOfPage: `${BASE_URL}/blog/${post.slug}`,
|
||||
author: {
|
||||
'@type': 'Person',
|
||||
name: post.author,
|
||||
url: post.authorUrl,
|
||||
},
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'QR Master',
|
||||
url: BASE_URL,
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: `${BASE_URL}/og-image.png`,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
},
|
||||
},
|
||||
isPartOf: {
|
||||
'@type': 'Blog',
|
||||
'@id': `${BASE_URL}/blog#blog`,
|
||||
name: 'QR Master Blog',
|
||||
url: `${BASE_URL}/blog`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function faqPageSchema(faqs: FAQItem[]) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
'@id': `${BASE_URL}/faq#faqpage`,
|
||||
inLanguage: 'en',
|
||||
mainEntityOfPage: `${BASE_URL}/faq`,
|
||||
mainEntity: faqs.map((faq) => ({
|
||||
'@type': 'Question',
|
||||
name: faq.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: faq.answer,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function productSchema(product: { name: string; description: string; offers: ProductOffer[] }) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Product',
|
||||
'@id': `${BASE_URL}/pricing#product`,
|
||||
name: product.name,
|
||||
description: product.description,
|
||||
inLanguage: 'en',
|
||||
mainEntityOfPage: `${BASE_URL}/pricing`,
|
||||
brand: {
|
||||
'@type': 'Organization',
|
||||
name: 'QR Master',
|
||||
},
|
||||
offers: product.offers.map((offer) => ({
|
||||
'@type': 'Offer',
|
||||
name: offer.name,
|
||||
price: offer.price,
|
||||
priceCurrency: offer.priceCurrency,
|
||||
availability: offer.availability,
|
||||
url: toAbsoluteUrl(offer.url),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function howToSchema(task: HowToTask) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'HowTo',
|
||||
'@id': `${BASE_URL}/blog/${task.name.toLowerCase().replace(/\s+/g, '-')}#howto`,
|
||||
name: task.name,
|
||||
description: task.description,
|
||||
inLanguage: 'en',
|
||||
mainEntityOfPage: `${BASE_URL}/blog/${task.name.toLowerCase().replace(/\s+/g, '-')}`,
|
||||
totalTime: task.totalTime || 'PT5M',
|
||||
step: task.steps.map((step, index) => ({
|
||||
'@type': 'HowToStep',
|
||||
position: index + 1,
|
||||
name: step.name,
|
||||
text: step.text,
|
||||
url: step.url ? toAbsoluteUrl(step.url) : undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,186 +1,186 @@
|
|||
/**
|
||||
* Zod Validation Schemas for API endpoints
|
||||
* Centralized validation logic for type-safety and security
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ==========================================
|
||||
// QR Code Schemas
|
||||
// ==========================================
|
||||
|
||||
export const qrStyleSchema = z.object({
|
||||
fgColor: z.string().regex(/^#[0-9A-F]{6}$/i, 'Invalid foreground color format').optional(),
|
||||
bgColor: z.string().regex(/^#[0-9A-F]{6}$/i, 'Invalid background color format').optional(),
|
||||
cornerStyle: z.enum(['square', 'rounded']).optional(),
|
||||
size: z.number().min(100).max(1000).optional(),
|
||||
});
|
||||
|
||||
export const createQRSchema = z.object({
|
||||
title: z.string()
|
||||
.min(1, 'Title is required')
|
||||
.max(100, 'Title must be less than 100 characters'),
|
||||
|
||||
content: z.record(z.any()), // Accept any object structure for content
|
||||
|
||||
isStatic: z.boolean().optional(),
|
||||
|
||||
contentType: z.enum(['URL', 'VCARD', 'GEO', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT', 'PDF', 'APP', 'COUPON', 'FEEDBACK'], {
|
||||
errorMap: () => ({ message: 'Invalid content type' })
|
||||
}),
|
||||
|
||||
tags: z.array(z.string()).optional(),
|
||||
|
||||
style: z.object({
|
||||
foregroundColor: z.string().optional(),
|
||||
backgroundColor: z.string().optional(),
|
||||
cornerStyle: z.enum(['square', 'rounded']).optional(),
|
||||
size: z.number().optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export const updateQRSchema = z.object({
|
||||
title: z.string()
|
||||
.min(1, 'Title is required')
|
||||
.max(100, 'Title must be less than 100 characters')
|
||||
.optional(),
|
||||
|
||||
content: z.string()
|
||||
.min(1, 'Content is required')
|
||||
.max(5000, 'Content must be less than 5000 characters')
|
||||
.optional(),
|
||||
|
||||
style: qrStyleSchema.optional(),
|
||||
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const bulkQRSchema = z.object({
|
||||
qrs: z.array(
|
||||
z.object({
|
||||
title: z.string().min(1).max(100),
|
||||
content: z.string().min(1).max(5000),
|
||||
contentType: z.enum(['URL', 'VCARD', 'GEO', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT', 'PDF', 'APP', 'COUPON', 'FEEDBACK']),
|
||||
})
|
||||
).min(1, 'At least one QR code is required')
|
||||
.max(100, 'Maximum 100 QR codes per bulk creation'),
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// Authentication Schemas
|
||||
// ==========================================
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.string()
|
||||
.email('Invalid email format')
|
||||
.toLowerCase(),
|
||||
|
||||
password: z.string()
|
||||
.min(1, 'Password is required'),
|
||||
});
|
||||
|
||||
export const signupSchema = z.object({
|
||||
name: z.string()
|
||||
.min(2, 'Name must be at least 2 characters')
|
||||
.max(100, 'Name must be less than 100 characters')
|
||||
.trim(),
|
||||
|
||||
email: z.string()
|
||||
.email('Invalid email format')
|
||||
.toLowerCase()
|
||||
.trim(),
|
||||
|
||||
password: z.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.max(100, 'Password must be less than 100 characters'),
|
||||
// Password complexity rules removed for easier testing
|
||||
});
|
||||
|
||||
export const forgotPasswordSchema = z.object({
|
||||
email: z.string()
|
||||
.email('Invalid email format')
|
||||
.toLowerCase()
|
||||
.trim(),
|
||||
});
|
||||
|
||||
export const resetPasswordSchema = z.object({
|
||||
token: z.string().min(1, 'Reset token is required'),
|
||||
password: z.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.max(100, 'Password must be less than 100 characters'),
|
||||
// Password complexity rules removed for easier testing
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// Settings Schemas
|
||||
// ==========================================
|
||||
|
||||
export const updateProfileSchema = z.object({
|
||||
name: z.string()
|
||||
.min(2, 'Name must be at least 2 characters')
|
||||
.max(100, 'Name must be less than 100 characters')
|
||||
.trim(),
|
||||
});
|
||||
|
||||
export const changePasswordSchema = z.object({
|
||||
currentPassword: z.string()
|
||||
.min(1, 'Current password is required'),
|
||||
|
||||
newPassword: z.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.max(100, 'Password must be less than 100 characters'),
|
||||
// Password complexity rules removed for easier testing
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// Stripe Schemas
|
||||
// ==========================================
|
||||
|
||||
export const createCheckoutSchema = z.object({
|
||||
priceId: z.string().min(1, 'Price ID is required'),
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// Newsletter Schemas
|
||||
// ==========================================
|
||||
|
||||
export const newsletterSubscribeSchema = z.object({
|
||||
email: z.string()
|
||||
.email('Invalid email format')
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.max(255, 'Email must be less than 255 characters'),
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// Helper: Format Zod Errors
|
||||
// ==========================================
|
||||
|
||||
export function formatZodError(error: z.ZodError) {
|
||||
return {
|
||||
error: 'Validation failed',
|
||||
details: error.errors.map(err => ({
|
||||
field: err.path.join('.'),
|
||||
message: err.message,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Helper: Validate with Zod
|
||||
// ==========================================
|
||||
|
||||
export async function validateRequest<T>(
|
||||
schema: z.ZodSchema<T>,
|
||||
data: unknown
|
||||
): Promise<{ success: true; data: T } | { success: false; error: any }> {
|
||||
try {
|
||||
const validatedData = schema.parse(data);
|
||||
return { success: true, data: validatedData };
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return { success: false, error: formatZodError(error) };
|
||||
}
|
||||
return { success: false, error: { error: 'Invalid request data' } };
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Zod Validation Schemas for API endpoints
|
||||
* Centralized validation logic for type-safety and security
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ==========================================
|
||||
// QR Code Schemas
|
||||
// ==========================================
|
||||
|
||||
export const qrStyleSchema = z.object({
|
||||
fgColor: z.string().regex(/^#[0-9A-F]{6}$/i, 'Invalid foreground color format').optional(),
|
||||
bgColor: z.string().regex(/^#[0-9A-F]{6}$/i, 'Invalid background color format').optional(),
|
||||
cornerStyle: z.enum(['square', 'rounded']).optional(),
|
||||
size: z.number().min(100).max(1000).optional(),
|
||||
});
|
||||
|
||||
export const createQRSchema = z.object({
|
||||
title: z.string()
|
||||
.min(1, 'Title is required')
|
||||
.max(100, 'Title must be less than 100 characters'),
|
||||
|
||||
content: z.record(z.any()), // Accept any object structure for content
|
||||
|
||||
isStatic: z.boolean().optional(),
|
||||
|
||||
contentType: z.enum(['URL', 'VCARD', 'GEO', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT'], {
|
||||
errorMap: () => ({ message: 'Invalid content type' })
|
||||
}),
|
||||
|
||||
tags: z.array(z.string()).optional(),
|
||||
|
||||
style: z.object({
|
||||
foregroundColor: z.string().optional(),
|
||||
backgroundColor: z.string().optional(),
|
||||
cornerStyle: z.enum(['square', 'rounded']).optional(),
|
||||
size: z.number().optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export const updateQRSchema = z.object({
|
||||
title: z.string()
|
||||
.min(1, 'Title is required')
|
||||
.max(100, 'Title must be less than 100 characters')
|
||||
.optional(),
|
||||
|
||||
content: z.string()
|
||||
.min(1, 'Content is required')
|
||||
.max(5000, 'Content must be less than 5000 characters')
|
||||
.optional(),
|
||||
|
||||
style: qrStyleSchema.optional(),
|
||||
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const bulkQRSchema = z.object({
|
||||
qrs: z.array(
|
||||
z.object({
|
||||
title: z.string().min(1).max(100),
|
||||
content: z.string().min(1).max(5000),
|
||||
contentType: z.enum(['URL', 'VCARD', 'GEO', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT']),
|
||||
})
|
||||
).min(1, 'At least one QR code is required')
|
||||
.max(100, 'Maximum 100 QR codes per bulk creation'),
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// Authentication Schemas
|
||||
// ==========================================
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.string()
|
||||
.email('Invalid email format')
|
||||
.toLowerCase(),
|
||||
|
||||
password: z.string()
|
||||
.min(1, 'Password is required'),
|
||||
});
|
||||
|
||||
export const signupSchema = z.object({
|
||||
name: z.string()
|
||||
.min(2, 'Name must be at least 2 characters')
|
||||
.max(100, 'Name must be less than 100 characters')
|
||||
.trim(),
|
||||
|
||||
email: z.string()
|
||||
.email('Invalid email format')
|
||||
.toLowerCase()
|
||||
.trim(),
|
||||
|
||||
password: z.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.max(100, 'Password must be less than 100 characters'),
|
||||
// Password complexity rules removed for easier testing
|
||||
});
|
||||
|
||||
export const forgotPasswordSchema = z.object({
|
||||
email: z.string()
|
||||
.email('Invalid email format')
|
||||
.toLowerCase()
|
||||
.trim(),
|
||||
});
|
||||
|
||||
export const resetPasswordSchema = z.object({
|
||||
token: z.string().min(1, 'Reset token is required'),
|
||||
password: z.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.max(100, 'Password must be less than 100 characters'),
|
||||
// Password complexity rules removed for easier testing
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// Settings Schemas
|
||||
// ==========================================
|
||||
|
||||
export const updateProfileSchema = z.object({
|
||||
name: z.string()
|
||||
.min(2, 'Name must be at least 2 characters')
|
||||
.max(100, 'Name must be less than 100 characters')
|
||||
.trim(),
|
||||
});
|
||||
|
||||
export const changePasswordSchema = z.object({
|
||||
currentPassword: z.string()
|
||||
.min(1, 'Current password is required'),
|
||||
|
||||
newPassword: z.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.max(100, 'Password must be less than 100 characters'),
|
||||
// Password complexity rules removed for easier testing
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// Stripe Schemas
|
||||
// ==========================================
|
||||
|
||||
export const createCheckoutSchema = z.object({
|
||||
priceId: z.string().min(1, 'Price ID is required'),
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// Newsletter Schemas
|
||||
// ==========================================
|
||||
|
||||
export const newsletterSubscribeSchema = z.object({
|
||||
email: z.string()
|
||||
.email('Invalid email format')
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.max(255, 'Email must be less than 255 characters'),
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// Helper: Format Zod Errors
|
||||
// ==========================================
|
||||
|
||||
export function formatZodError(error: z.ZodError) {
|
||||
return {
|
||||
error: 'Validation failed',
|
||||
details: error.errors.map(err => ({
|
||||
field: err.path.join('.'),
|
||||
message: err.message,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Helper: Validate with Zod
|
||||
// ==========================================
|
||||
|
||||
export async function validateRequest<T>(
|
||||
schema: z.ZodSchema<T>,
|
||||
data: unknown
|
||||
): Promise<{ success: true; data: T } | { success: false; error: any }> {
|
||||
try {
|
||||
const validatedData = schema.parse(data);
|
||||
return { success: true, data: validatedData };
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return { success: false, error: formatZodError(error) };
|
||||
}
|
||||
return { success: false, error: { error: 'Invalid request data' } };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import { DefaultSession } from 'next-auth';
|
||||
|
||||
declare module 'next-auth' {
|
||||
interface Session {
|
||||
user: {
|
||||
id: string;
|
||||
} & DefaultSession['user'];
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
}
|
||||
import { DefaultSession } from 'next-auth';
|
||||
|
||||
declare module 'next-auth' {
|
||||
interface Session {
|
||||
user: {
|
||||
id: string;
|
||||
plan?: string | null;
|
||||
} & DefaultSession['user'];
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
plan?: string | null;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue