fix: Optimize flipping card animation backface and timing

This commit is contained in:
Timo Knuth 2026-01-22 15:46:31 +01:00
parent 05531cda3f
commit efb1654370
50 changed files with 12232 additions and 9632 deletions

100
.gitignore vendored
View File

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

View File

@ -1,180 +1,180 @@
# Claude Artifact Prompts for Parasite SEO # Claude Artifact Prompts for Parasite SEO
**Goal:** Publish educational content on claude.ai that naturally links to qrmaster.net **Goal:** Publish educational content on claude.ai that naturally links to qrmaster.net
**Strategy:** Informative, helpful content that does NOT look like advertising **Strategy:** Informative, helpful content that does NOT look like advertising
--- ---
## 🎯 Prompt 1: Restaurant QR Menu Guide ## 🎯 Prompt 1: Restaurant QR Menu Guide
``` ```
Create an informative HTML article titled "Restaurant Menu QR Codes: A Practical Guide for Restaurant Owners (2025)" Create an informative HTML article titled "Restaurant Menu QR Codes: A Practical Guide for Restaurant Owners (2025)"
The article should: The article should:
- Be 1500-2000 words long - Be 1500-2000 words long
- Contain practical tips for restaurant owners - Contain practical tips for restaurant owners
- Cover the following topics: - Cover the following topics:
1. Why digital menus are the new standard 1. Why digital menus are the new standard
2. PDF vs. online menu - pros and cons 2. PDF vs. online menu - pros and cons
3. Optimal placement of QR codes in restaurants 3. Optimal placement of QR codes in restaurants
4. Mistakes restaurants should avoid 4. Mistakes restaurants should avoid
5. Using tracking and analytics 5. Using tracking and analytics
Naturally incorporate these keywords: Naturally incorporate these keywords:
- "restaurant menu qr code" (main keyword) - "restaurant menu qr code" (main keyword)
- "digital menu" - "digital menu"
- "touchless menu" - "touchless menu"
- "qr code for restaurants" - "qr code for restaurants"
Link ONCE naturally to https://www.qrmaster.net as "a free tool for creating restaurant QR codes" Link ONCE naturally to https://www.qrmaster.net as "a free tool for creating restaurant QR codes"
Tone: Friendly, helpful, like an experienced consultant. NOT salesy. Tone: Friendly, helpful, like an experienced consultant. NOT salesy.
HTML with clean CSS, mobile-friendly. No external dependencies. HTML with clean CSS, mobile-friendly. No external dependencies.
``` ```
--- ---
## 🎯 Prompt 2: Dynamic vs Static QR Comparison ## 🎯 Prompt 2: Dynamic vs Static QR Comparison
``` ```
Create a technical comparison article as HTML: "Static vs Dynamic QR Codes: What's the Difference and When to Use Each" Create a technical comparison article as HTML: "Static vs Dynamic QR Codes: What's the Difference and When to Use Each"
Structure: Structure:
1. Brief explanation of what QR codes are technically 1. Brief explanation of what QR codes are technically
2. Static QR codes - how they work 2. Static QR codes - how they work
3. Dynamic QR codes - how they work 3. Dynamic QR codes - how they work
4. Comparison table (very important!) 4. Comparison table (very important!)
5. Decision guide: When to use which type 5. Decision guide: When to use which type
6. Realistic use cases for both 6. Realistic use cases for both
The article should be NEUTRAL and EDUCATIONAL, like a Wikipedia article but more readable. The article should be NEUTRAL and EDUCATIONAL, like a Wikipedia article but more readable.
Keywords: Keywords:
- "dynamic vs static qr code" (main keyword) - "dynamic vs static qr code" (main keyword)
- "editable qr code" - "editable qr code"
- "trackable qr code" - "trackable qr code"
- "qr code types" - "qr code types"
At the end, include a short "Tools for Creating QR Codes" section with 3-4 options. One of them is https://www.qrmaster.net - listed as an equal entry, NOT as a recommendation. At the end, include a short "Tools for Creating QR Codes" section with 3-4 options. One of them is https://www.qrmaster.net - listed as an equal entry, NOT as a recommendation.
HTML with professional, minimalist design. HTML with professional, minimalist design.
``` ```
--- ---
## 🎯 Prompt 3: Small Business Marketing Guide ## 🎯 Prompt 3: Small Business Marketing Guide
``` ```
Create a comprehensive HTML guide: "10 Ways Small Businesses Can Use QR Codes in 2025" Create a comprehensive HTML guide: "10 Ways Small Businesses Can Use QR Codes in 2025"
The article is aimed at small businesses without technical knowledge. The article is aimed at small businesses without technical knowledge.
The 10 use cases: The 10 use cases:
1. Digital business cards (vCard) 1. Digital business cards (vCard)
2. Collecting Google reviews 2. Collecting Google reviews
3. Contactless payments 3. Contactless payments
4. Sharing Wi-Fi access 4. Sharing Wi-Fi access
5. Growing social media followers 5. Growing social media followers
6. Linking product information 6. Linking product information
7. Simplifying appointment booking 7. Simplifying appointment booking
8. Discount promotions & coupons 8. Discount promotions & coupons
9. Event tickets & check-in 9. Event tickets & check-in
10. Feedback & surveys 10. Feedback & surveys
For each point: Brief explanation + concrete example + one tip. For each point: Brief explanation + concrete example + one tip.
Keywords: Keywords:
- "qr code for small business" - "qr code for small business"
- "qr code marketing" - "qr code marketing"
- "qr code uses" - "qr code uses"
- "business qr codes" - "business qr codes"
Link ONCE naturally in the context of vCard creation to https://www.qrmaster.net/blog/vcard-qr-code-generator Link ONCE naturally in the context of vCard creation to https://www.qrmaster.net/blog/vcard-qr-code-generator
Tone: Enthusiastic but not over the top. Like a helpful friend explaining technology. Tone: Enthusiastic but not over the top. Like a helpful friend explaining technology.
``` ```
--- ---
## 🎯 Prompt 4: Print Size Technical Guide ## 🎯 Prompt 4: Print Size Technical Guide
``` ```
Create a technical reference article as HTML: "QR Code Print Size Guide: Minimum Dimensions for Reliable Scanning" Create a technical reference article as HTML: "QR Code Print Size Guide: Minimum Dimensions for Reliable Scanning"
This article should become THE reference for QR code print sizes. This article should become THE reference for QR code print sizes.
Content: Content:
1. The science behind QR scanning (brief) 1. The science behind QR scanning (brief)
2. The golden formula: Size = Distance ÷ 10 2. The golden formula: Size = Distance ÷ 10
3. LARGE table with applications, distances, min/recommended sizes 3. LARGE table with applications, distances, min/recommended sizes
4. Factors affecting scannability: 4. Factors affecting scannability:
- Data density - Data density
- Error Correction Level - Error Correction Level
- Print quality (DPI) - Print quality (DPI)
- Contrast - Contrast
5. Quiet zone requirements 5. Quiet zone requirements
6. File formats for printing (SVG vs PNG vs PDF) 6. File formats for printing (SVG vs PNG vs PDF)
7. Checklist before printing 7. Checklist before printing
Keywords: Keywords:
- "qr code size for printing" - "qr code size for printing"
- "minimum qr code size" - "minimum qr code size"
- "qr code dimensions" - "qr code dimensions"
- "qr code print quality" - "qr code print quality"
Link ONCE to https://www.qrmaster.net/blog/qr-code-print-size-guide as "detailed guide with more examples" Link ONCE to https://www.qrmaster.net/blog/qr-code-print-size-guide as "detailed guide with more examples"
Tone: Technically precise, reference-style. For designers and marketers. Tone: Technically precise, reference-style. For designers and marketers.
``` ```
--- ---
## 🎯 Prompt 5: QR Analytics Beginner Guide ## 🎯 Prompt 5: QR Analytics Beginner Guide
``` ```
Create a beginner's guide as HTML: "QR Code Analytics Explained: What You Can Track and Why It Matters" Create a beginner's guide as HTML: "QR Code Analytics Explained: What You Can Track and Why It Matters"
The article is aimed at marketing beginners who have never used QR tracking before. The article is aimed at marketing beginners who have never used QR tracking before.
Structure: Structure:
1. What is QR tracking and why is it important? 1. What is QR tracking and why is it important?
2. What data can you track? (list with explanations) 2. What data can you track? (list with explanations)
- Scan count - Scan count
- Geolocation - Geolocation
- Device types - Device types
- Timestamps - Timestamps
- Unique vs Total Scans - Unique vs Total Scans
3. How does it work technically? (simplified) 3. How does it work technically? (simplified)
4. Privacy & GDPR considerations 4. Privacy & GDPR considerations
5. Practical application: Measuring campaign ROI 5. Practical application: Measuring campaign ROI
6. Common mistakes in QR tracking 6. Common mistakes in QR tracking
Keywords: Keywords:
- "qr code tracking" - "qr code tracking"
- "qr code analytics" - "qr code analytics"
- "track qr code scans" - "track qr code scans"
- "qr code scan data" - "qr code scan data"
Link ONCE naturally to https://www.qrmaster.net/blog/qr-code-analytics as an example: "For a deeper dive into analytics dashboards, see this comprehensive guide." Link ONCE naturally to https://www.qrmaster.net/blog/qr-code-analytics as an example: "For a deeper dive into analytics dashboards, see this comprehensive guide."
Tone: Friendly and explanatory, like a teacher. No jargon without explanation. Tone: Friendly and explanatory, like a teacher. No jargon without explanation.
``` ```
--- ---
## 📋 Usage Instructions ## 📋 Usage Instructions
1. **Copy prompt** → Paste into claude.ai 1. **Copy prompt** → Paste into claude.ai
2. **Let it create the artifact** 2. **Let it create the artifact**
3. **Click "Publish"** in Claude 3. **Click "Publish"** in Claude
4. **Allowed Domain:** Add `www.qrmaster.net, qrmaster.net` 4. **Allowed Domain:** Add `www.qrmaster.net, qrmaster.net`
5. **Share link** - Google indexes these! 5. **Share link** - Google indexes these!
## 💡 Tips for Maximum Effectiveness ## 💡 Tips for Maximum Effectiveness
- **Don't publish all on the same day** - **Don't publish all on the same day**
- About **1 article per week** for natural growth - About **1 article per week** for natural growth
- Publish the **more neutral articles first** (Prompt 2 & 4) - Publish the **more neutral articles first** (Prompt 2 & 4)
- **Share on social media** for faster indexing - **Share on social media** for faster indexing
- Register the published URLs in Google Search Console - Register the published URLs in Google Search Console

464
claude_plan_restaurant.md Normal file
View File

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

View File

@ -1,42 +1,42 @@
/** @type {import('next-sitemap').IConfig} */ /** @type {import('next-sitemap').IConfig} */
module.exports = { module.exports = {
siteUrl: 'https://www.qrmaster.net', siteUrl: 'https://www.qrmaster.net',
generateRobotsTxt: true, generateRobotsTxt: true,
robotsTxtOptions: { robotsTxtOptions: {
policies: [ policies: [
{ {
userAgent: '*', userAgent: '*',
allow: '/', allow: '/',
}, },
], ],
}, },
transform: async (config, path) => { transform: async (config, path) => {
// Custom priority and changefreq based on path // Custom priority and changefreq based on path
let priority = 0.7; let priority = 0.7;
let changefreq = 'weekly'; let changefreq = 'weekly';
if (path === '/') { if (path === '/') {
priority = 0.9; priority = 0.9;
changefreq = 'daily'; changefreq = 'daily';
} else if (path === '/blog') { } else if (path === '/blog') {
priority = 0.7; priority = 0.7;
changefreq = 'daily'; changefreq = 'daily';
} else if (path === '/pricing') { } else if (path === '/pricing') {
priority = 0.8; priority = 0.8;
changefreq = 'weekly'; changefreq = 'weekly';
} else if (path === '/faq') { } else if (path === '/faq') {
priority = 0.6; priority = 0.6;
changefreq = 'weekly'; changefreq = 'weekly';
} else if (path.startsWith('/blog/')) { } else if (path.startsWith('/blog/')) {
priority = 0.6; priority = 0.6;
changefreq = 'weekly'; changefreq = 'weekly';
} }
return { return {
loc: path, loc: path,
changefreq, changefreq,
priority, priority,
lastmod: new Date().toISOString(), lastmod: new Date().toISOString(),
}; };
}, },
}; };

View File

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

1702
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -26,6 +26,8 @@
}, },
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^2.11.1", "@auth/prisma-adapter": "^2.11.1",
"@aws-sdk/client-s3": "^3.972.0",
"@aws-sdk/s3-request-presigner": "^3.972.0",
"@edge-runtime/cookies": "^6.0.0", "@edge-runtime/cookies": "^6.0.0",
"@prisma/client": "^5.7.0", "@prisma/client": "^5.7.0",
"@stripe/stripe-js": "^8.0.0", "@stripe/stripe-js": "^8.0.0",

View File

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

View File

@ -1,33 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url> <url>
<loc>https://www.qrmaster.net/</loc> <loc>https://www.qrmaster.net/</loc>
<lastmod>2025-10-16T00:00:00Z</lastmod> <lastmod>2025-10-16T00:00:00Z</lastmod>
<changefreq>daily</changefreq> <changefreq>daily</changefreq>
<priority>0.9</priority> <priority>0.9</priority>
</url> </url>
<url> <url>
<loc>https://www.qrmaster.net/blog</loc> <loc>https://www.qrmaster.net/blog</loc>
<lastmod>2025-10-16T00:00:00Z</lastmod> <lastmod>2025-10-16T00:00:00Z</lastmod>
<changefreq>daily</changefreq> <changefreq>daily</changefreq>
<priority>0.7</priority> <priority>0.7</priority>
</url> </url>
<url> <url>
<loc>https://www.qrmaster.net/pricing</loc> <loc>https://www.qrmaster.net/pricing</loc>
<lastmod>2025-10-16T00:00:00Z</lastmod> <lastmod>2025-10-16T00:00:00Z</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.8</priority> <priority>0.8</priority>
</url> </url>
<url> <url>
<loc>https://www.qrmaster.net/faq</loc> <loc>https://www.qrmaster.net/faq</loc>
<lastmod>2025-10-16T00:00:00Z</lastmod> <lastmod>2025-10-16T00:00:00Z</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.6</priority> <priority>0.6</priority>
</url> </url>
<url> <url>
<loc>https://www.qrmaster.net/blog/qr-code-analytics</loc> <loc>https://www.qrmaster.net/blog/qr-code-analytics</loc>
<lastmod>2025-10-16T00:00:00Z</lastmod> <lastmod>2025-10-16T00:00:00Z</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.6</priority> <priority>0.6</priority>
</url> </url>
</urlset> </urlset>

156
seo_2026_jan.md Normal file
View File

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

View File

@ -433,26 +433,95 @@ export default function CreatePage() {
</div> </div>
); );
case 'PDF': 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 ( return (
<> <>
<div> <div>
<div className="flex items-center mb-1"> <div className="flex items-center mb-1">
<label className="block text-sm font-medium text-gray-700">PDF/File URL</label> <label className="block text-sm font-medium text-gray-700">Upload Menu / PDF</label>
<Tooltip text="Paste a public link to your PDF (Google Drive, Dropbox, etc.)" /> <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>
<Input
value={content.fileUrl || ''}
onChange={(e) => setContent({ ...content, fileUrl: e.target.value })}
placeholder="https://drive.google.com/file/d/.../view"
required
/>
</div> </div>
<Input
label="File Name (optional)" {content.fileUrl && (
value={content.fileName || ''} <Input
onChange={(e) => setContent({ ...content, fileName: e.target.value })} label="File Name / Menu Title"
placeholder="Product Catalog 2026" value={content.fileName || ''}
/> onChange={(e) => setContent({ ...content, fileName: e.target.value })}
placeholder="Product Catalog 2026"
/>
)}
</> </>
); );
case 'APP': case 'APP':

View File

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

View File

@ -1,372 +1,372 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation'; import { useRouter, useParams } from 'next/navigation';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { showToast } from '@/components/ui/Toast'; import { showToast } from '@/components/ui/Toast';
import { useCsrf } from '@/hooks/useCsrf'; import { useCsrf } from '@/hooks/useCsrf';
export default function EditQRPage() { export default function EditQRPage() {
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const qrId = params.id as string; const qrId = params.id as string;
const { fetchWithCsrf, loading: csrfLoading } = useCsrf(); const { fetchWithCsrf, loading: csrfLoading } = useCsrf();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [qrCode, setQrCode] = useState<any>(null); const [qrCode, setQrCode] = useState<any>(null);
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [content, setContent] = useState<any>({}); const [content, setContent] = useState<any>({});
useEffect(() => { useEffect(() => {
const fetchQRCode = async () => { const fetchQRCode = async () => {
try { try {
const response = await fetch(`/api/qrs/${qrId}`); const response = await fetch(`/api/qrs/${qrId}`);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setQrCode(data); setQrCode(data);
setTitle(data.title); setTitle(data.title);
setContent(data.content || {}); setContent(data.content || {});
} else { } else {
showToast('Failed to load QR code', 'error'); showToast('Failed to load QR code', 'error');
router.push('/dashboard'); router.push('/dashboard');
} }
} catch (error) { } catch (error) {
console.error('Error fetching QR code:', error); console.error('Error fetching QR code:', error);
showToast('Failed to load QR code', 'error'); showToast('Failed to load QR code', 'error');
router.push('/dashboard'); router.push('/dashboard');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
fetchQRCode(); fetchQRCode();
}, [qrId, router]); }, [qrId, router]);
const handleSave = async () => { const handleSave = async () => {
setSaving(true); setSaving(true);
try { try {
const response = await fetchWithCsrf(`/api/qrs/${qrId}`, { const response = await fetchWithCsrf(`/api/qrs/${qrId}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify({ body: JSON.stringify({
title, title,
content, content,
}), }),
}); });
if (response.ok) { if (response.ok) {
showToast('QR code updated successfully!', 'success'); showToast('QR code updated successfully!', 'success');
router.push('/dashboard'); router.push('/dashboard');
} else { } else {
const error = await response.json(); const error = await response.json();
showToast(error.error || 'Failed to update QR code', 'error'); showToast(error.error || 'Failed to update QR code', 'error');
} }
} catch (error) { } catch (error) {
console.error('Error updating QR code:', error); console.error('Error updating QR code:', error);
showToast('Failed to update QR code', 'error'); showToast('Failed to update QR code', 'error');
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center min-h-screen"> <div className="flex items-center justify-center min-h-screen">
<div className="text-center"> <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> <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> <p className="text-gray-600">Loading QR code...</p>
</div> </div>
</div> </div>
); );
} }
if (!qrCode) { if (!qrCode) {
return null; return null;
} }
// Static QR codes cannot be edited // Static QR codes cannot be edited
if (qrCode.type === 'STATIC') { if (qrCode.type === 'STATIC') {
return ( return (
<div className="max-w-2xl mx-auto mt-12"> <div className="max-w-2xl mx-auto mt-12">
<Card> <Card>
<CardContent className="p-12 text-center"> <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"> <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"> <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" /> <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> </svg>
</div> </div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Static QR Code</h2> <h2 className="text-2xl font-bold text-gray-900 mb-2">Static QR Code</h2>
<p className="text-gray-600 mb-8"> <p className="text-gray-600 mb-8">
Static QR codes cannot be edited because their content is embedded directly in the QR code image. Static QR codes cannot be edited because their content is embedded directly in the QR code image.
</p> </p>
<Button onClick={() => router.push('/dashboard')}> <Button onClick={() => router.push('/dashboard')}>
Back to Dashboard Back to Dashboard
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
); );
} }
return ( return (
<div className="max-w-3xl mx-auto"> <div className="max-w-3xl mx-auto">
<div className="mb-8"> <div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Edit QR Code</h1> <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> <p className="text-gray-600 mt-2">Update your dynamic QR code content</p>
</div> </div>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>QR Code Details</CardTitle> <CardTitle>QR Code Details</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<Input <Input
label="Title" label="Title"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="Enter QR code title" placeholder="Enter QR code title"
required required
/> />
{qrCode.contentType === 'URL' && ( {qrCode.contentType === 'URL' && (
<Input <Input
label="URL" label="URL"
type="url" type="url"
value={content.url || ''} value={content.url || ''}
onChange={(e) => setContent({ ...content, url: e.target.value })} onChange={(e) => setContent({ ...content, url: e.target.value })}
placeholder="https://example.com" placeholder="https://example.com"
required required
/> />
)} )}
{qrCode.contentType === 'PHONE' && ( {qrCode.contentType === 'PHONE' && (
<Input <Input
label="Phone Number" label="Phone Number"
type="tel" type="tel"
value={content.phone || ''} value={content.phone || ''}
onChange={(e) => setContent({ ...content, phone: e.target.value })} onChange={(e) => setContent({ ...content, phone: e.target.value })}
placeholder="+1234567890" placeholder="+1234567890"
required required
/> />
)} )}
{qrCode.contentType === 'VCARD' && ( {qrCode.contentType === 'VCARD' && (
<> <>
<Input <Input
label="First Name" label="First Name"
value={content.firstName || ''} value={content.firstName || ''}
onChange={(e) => setContent({ ...content, firstName: e.target.value })} onChange={(e) => setContent({ ...content, firstName: e.target.value })}
placeholder="John" placeholder="John"
required required
/> />
<Input <Input
label="Last Name" label="Last Name"
value={content.lastName || ''} value={content.lastName || ''}
onChange={(e) => setContent({ ...content, lastName: e.target.value })} onChange={(e) => setContent({ ...content, lastName: e.target.value })}
placeholder="Doe" placeholder="Doe"
required required
/> />
<Input <Input
label="Email" label="Email"
type="email" type="email"
value={content.email || ''} value={content.email || ''}
onChange={(e) => setContent({ ...content, email: e.target.value })} onChange={(e) => setContent({ ...content, email: e.target.value })}
placeholder="john@example.com" placeholder="john@example.com"
/> />
<Input <Input
label="Phone" label="Phone"
value={content.phone || ''} value={content.phone || ''}
onChange={(e) => setContent({ ...content, phone: e.target.value })} onChange={(e) => setContent({ ...content, phone: e.target.value })}
placeholder="+1234567890" placeholder="+1234567890"
/> />
<Input <Input
label="Organization" label="Organization"
value={content.organization || ''} value={content.organization || ''}
onChange={(e) => setContent({ ...content, organization: e.target.value })} onChange={(e) => setContent({ ...content, organization: e.target.value })}
placeholder="Company Name" placeholder="Company Name"
/> />
<Input <Input
label="Job Title" label="Job Title"
value={content.title || ''} value={content.title || ''}
onChange={(e) => setContent({ ...content, title: e.target.value })} onChange={(e) => setContent({ ...content, title: e.target.value })}
placeholder="CEO" placeholder="CEO"
/> />
</> </>
)} )}
{qrCode.contentType === 'GEO' && ( {qrCode.contentType === 'GEO' && (
<> <>
<Input <Input
label="Latitude" label="Latitude"
type="number" type="number"
step="any" step="any"
value={content.latitude || ''} value={content.latitude || ''}
onChange={(e) => setContent({ ...content, latitude: parseFloat(e.target.value) || 0 })} onChange={(e) => setContent({ ...content, latitude: parseFloat(e.target.value) || 0 })}
placeholder="37.7749" placeholder="37.7749"
required required
/> />
<Input <Input
label="Longitude" label="Longitude"
type="number" type="number"
step="any" step="any"
value={content.longitude || ''} value={content.longitude || ''}
onChange={(e) => setContent({ ...content, longitude: parseFloat(e.target.value) || 0 })} onChange={(e) => setContent({ ...content, longitude: parseFloat(e.target.value) || 0 })}
placeholder="-122.4194" placeholder="-122.4194"
required required
/> />
<Input <Input
label="Location Label (Optional)" label="Location Label (Optional)"
value={content.label || ''} value={content.label || ''}
onChange={(e) => setContent({ ...content, label: e.target.value })} onChange={(e) => setContent({ ...content, label: e.target.value })}
placeholder="Golden Gate Bridge" placeholder="Golden Gate Bridge"
/> />
</> </>
)} )}
{qrCode.contentType === 'TEXT' && ( {qrCode.contentType === 'TEXT' && (
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Text Content Text Content
</label> </label>
<textarea <textarea
value={content.text || ''} value={content.text || ''}
onChange={(e) => setContent({ ...content, text: e.target.value })} 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" 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} rows={4}
placeholder="Enter your text content" placeholder="Enter your text content"
required required
/> />
</div> </div>
)} )}
{qrCode.contentType === 'PDF' && ( {qrCode.contentType === 'PDF' && (
<> <>
<Input <Input
label="PDF/File URL" label="PDF/File URL"
value={content.fileUrl || ''} value={content.fileUrl || ''}
onChange={(e) => setContent({ ...content, fileUrl: e.target.value })} onChange={(e) => setContent({ ...content, fileUrl: e.target.value })}
placeholder="https://drive.google.com/file/d/.../view" placeholder="https://drive.google.com/file/d/.../view"
required required
/> />
<Input <Input
label="File Name (optional)" label="File Name (optional)"
value={content.fileName || ''} value={content.fileName || ''}
onChange={(e) => setContent({ ...content, fileName: e.target.value })} onChange={(e) => setContent({ ...content, fileName: e.target.value })}
placeholder="Product Catalog 2026" placeholder="Product Catalog 2026"
/> />
</> </>
)} )}
{qrCode.contentType === 'APP' && ( {qrCode.contentType === 'APP' && (
<> <>
<Input <Input
label="iOS App Store URL" label="iOS App Store URL"
value={content.iosUrl || ''} value={content.iosUrl || ''}
onChange={(e) => setContent({ ...content, iosUrl: e.target.value })} onChange={(e) => setContent({ ...content, iosUrl: e.target.value })}
placeholder="https://apps.apple.com/app/..." placeholder="https://apps.apple.com/app/..."
/> />
<Input <Input
label="Android Play Store URL" label="Android Play Store URL"
value={content.androidUrl || ''} value={content.androidUrl || ''}
onChange={(e) => setContent({ ...content, androidUrl: e.target.value })} onChange={(e) => setContent({ ...content, androidUrl: e.target.value })}
placeholder="https://play.google.com/store/apps/..." placeholder="https://play.google.com/store/apps/..."
/> />
<Input <Input
label="Fallback URL (Desktop)" label="Fallback URL (Desktop)"
value={content.fallbackUrl || ''} value={content.fallbackUrl || ''}
onChange={(e) => setContent({ ...content, fallbackUrl: e.target.value })} onChange={(e) => setContent({ ...content, fallbackUrl: e.target.value })}
placeholder="https://yourapp.com" placeholder="https://yourapp.com"
/> />
</> </>
)} )}
{qrCode.contentType === 'COUPON' && ( {qrCode.contentType === 'COUPON' && (
<> <>
<Input <Input
label="Coupon Code" label="Coupon Code"
value={content.code || ''} value={content.code || ''}
onChange={(e) => setContent({ ...content, code: e.target.value })} onChange={(e) => setContent({ ...content, code: e.target.value })}
placeholder="SUMMER20" placeholder="SUMMER20"
required required
/> />
<Input <Input
label="Discount" label="Discount"
value={content.discount || ''} value={content.discount || ''}
onChange={(e) => setContent({ ...content, discount: e.target.value })} onChange={(e) => setContent({ ...content, discount: e.target.value })}
placeholder="20% OFF" placeholder="20% OFF"
required required
/> />
<Input <Input
label="Title" label="Title"
value={content.title || ''} value={content.title || ''}
onChange={(e) => setContent({ ...content, title: e.target.value })} onChange={(e) => setContent({ ...content, title: e.target.value })}
placeholder="Summer Sale 2026" placeholder="Summer Sale 2026"
/> />
<Input <Input
label="Description (optional)" label="Description (optional)"
value={content.description || ''} value={content.description || ''}
onChange={(e) => setContent({ ...content, description: e.target.value })} onChange={(e) => setContent({ ...content, description: e.target.value })}
placeholder="Valid on all products" placeholder="Valid on all products"
/> />
<Input <Input
label="Expiry Date (optional)" label="Expiry Date (optional)"
type="date" type="date"
value={content.expiryDate || ''} value={content.expiryDate || ''}
onChange={(e) => setContent({ ...content, expiryDate: e.target.value })} onChange={(e) => setContent({ ...content, expiryDate: e.target.value })}
/> />
<Input <Input
label="Redeem URL (optional)" label="Redeem URL (optional)"
value={content.redeemUrl || ''} value={content.redeemUrl || ''}
onChange={(e) => setContent({ ...content, redeemUrl: e.target.value })} onChange={(e) => setContent({ ...content, redeemUrl: e.target.value })}
placeholder="https://shop.example.com" placeholder="https://shop.example.com"
/> />
</> </>
)} )}
{qrCode.contentType === 'FEEDBACK' && ( {qrCode.contentType === 'FEEDBACK' && (
<> <>
<Input <Input
label="Business Name" label="Business Name"
value={content.businessName || ''} value={content.businessName || ''}
onChange={(e) => setContent({ ...content, businessName: e.target.value })} onChange={(e) => setContent({ ...content, businessName: e.target.value })}
placeholder="Your Restaurant Name" placeholder="Your Restaurant Name"
required required
/> />
<Input <Input
label="Google Review URL (optional)" label="Google Review URL (optional)"
value={content.googleReviewUrl || ''} value={content.googleReviewUrl || ''}
onChange={(e) => setContent({ ...content, googleReviewUrl: e.target.value })} onChange={(e) => setContent({ ...content, googleReviewUrl: e.target.value })}
placeholder="https://search.google.com/local/writereview?placeid=..." placeholder="https://search.google.com/local/writereview?placeid=..."
/> />
<Input <Input
label="Thank You Message" label="Thank You Message"
value={content.thankYouMessage || ''} value={content.thankYouMessage || ''}
onChange={(e) => setContent({ ...content, thankYouMessage: e.target.value })} onChange={(e) => setContent({ ...content, thankYouMessage: e.target.value })}
placeholder="Thanks for your feedback!" placeholder="Thanks for your feedback!"
/> />
</> </>
)} )}
<div className="flex justify-end space-x-4 pt-4"> <div className="flex justify-end space-x-4 pt-4">
<Button <Button
variant="outline" variant="outline"
onClick={() => router.push('/dashboard')} onClick={() => router.push('/dashboard')}
> >
Cancel Cancel
</Button> </Button>
<Button <Button
onClick={handleSave} onClick={handleSave}
loading={saving} loading={saving}
disabled={csrfLoading || saving} disabled={csrfLoading || saving}
> >
{csrfLoading ? 'Loading...' : 'Save Changes'} {csrfLoading ? 'Loading...' : 'Save Changes'}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
); );
} }

View File

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

View File

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

View File

@ -1,208 +1,208 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useSearchParams, useRouter } from 'next/navigation'; import { useSearchParams, useRouter } from 'next/navigation';
import { Card, CardContent } from '@/components/ui/Card'; import { Card, CardContent } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { useCsrf } from '@/hooks/useCsrf'; import { useCsrf } from '@/hooks/useCsrf';
export default function ResetPasswordPage() { export default function ResetPasswordPage() {
const { fetchWithCsrf, loading: csrfLoading } = useCsrf(); const { fetchWithCsrf, loading: csrfLoading } = useCsrf();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
const [token, setToken] = useState(''); const [token, setToken] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
useEffect(() => { useEffect(() => {
const tokenParam = searchParams.get('token'); const tokenParam = searchParams.get('token');
if (!tokenParam) { if (!tokenParam) {
setError('Invalid or missing reset token. Please request a new password reset link.'); setError('Invalid or missing reset token. Please request a new password reset link.');
} else { } else {
setToken(tokenParam); setToken(tokenParam);
} }
}, [searchParams]); }, [searchParams]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
setError(''); setError('');
// Validate passwords match // Validate passwords match
if (password !== confirmPassword) { if (password !== confirmPassword) {
setError('Passwords do not match'); setError('Passwords do not match');
setLoading(false); setLoading(false);
return; return;
} }
// Validate password length // Validate password length
if (password.length < 8) { if (password.length < 8) {
setError('Password must be at least 8 characters long'); setError('Password must be at least 8 characters long');
setLoading(false); setLoading(false);
return; return;
} }
try { try {
const response = await fetchWithCsrf('/api/auth/reset-password', { const response = await fetchWithCsrf('/api/auth/reset-password', {
method: 'POST', method: 'POST',
body: JSON.stringify({ token, password }), body: JSON.stringify({ token, password }),
}); });
const data = await response.json(); const data = await response.json();
if (response.ok) { if (response.ok) {
setSuccess(true); setSuccess(true);
// Redirect to login after 3 seconds // Redirect to login after 3 seconds
setTimeout(() => { setTimeout(() => {
router.push('/login'); router.push('/login');
}, 3000); }, 3000);
} else { } else {
setError(data.error || 'Failed to reset password'); setError(data.error || 'Failed to reset password');
} }
} catch (err) { } catch (err) {
setError('An error occurred. Please try again.'); setError('An error occurred. Please try again.');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
if (success) { if (success) {
return ( return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4"> <div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
<div className="w-full max-w-md"> <div className="w-full max-w-md">
<div className="text-center mb-8"> <div className="text-center mb-8">
<Link href="/" className="inline-flex items-center space-x-2 mb-6"> <Link href="/" className="inline-flex items-center space-x-2 mb-6">
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" /> <img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
<span className="text-2xl font-bold text-gray-900">QR Master</span> <span className="text-2xl font-bold text-gray-900">QR Master</span>
</Link> </Link>
<h1 className="text-3xl font-bold text-gray-900">Password Reset Successful</h1> <h1 className="text-3xl font-bold text-gray-900">Password Reset Successful</h1>
<p className="text-gray-600 mt-2">Your password has been updated</p> <p className="text-gray-600 mt-2">Your password has been updated</p>
</div> </div>
<Card> <Card>
<CardContent className="p-6"> <CardContent className="p-6">
<div className="text-center"> <div className="text-center">
<div className="inline-flex items-center justify-center w-16 h-16 bg-green-100 rounded-full mb-4"> <div className="inline-flex items-center justify-center w-16 h-16 bg-green-100 rounded-full mb-4">
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg> </svg>
</div> </div>
<p className="text-gray-700 mb-4"> <p className="text-gray-700 mb-4">
Your password has been successfully reset! Your password has been successfully reset!
</p> </p>
<p className="text-sm text-gray-600 mb-6"> <p className="text-sm text-gray-600 mb-6">
Redirecting you to the login page in 3 seconds... Redirecting you to the login page in 3 seconds...
</p> </p>
<Link href="/login" className="block"> <Link href="/login" className="block">
<Button variant="primary" className="w-full"> <Button variant="primary" className="w-full">
Go to Login Go to Login
</Button> </Button>
</Link> </Link>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div> </div>
); );
} }
return ( return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4"> <div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
<div className="w-full max-w-md"> <div className="w-full max-w-md">
<div className="text-center mb-8"> <div className="text-center mb-8">
<Link href="/" className="inline-flex items-center space-x-2 mb-6"> <Link href="/" className="inline-flex items-center space-x-2 mb-6">
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" /> <img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
<span className="text-2xl font-bold text-gray-900">QR Master</span> <span className="text-2xl font-bold text-gray-900">QR Master</span>
</Link> </Link>
<h1 className="text-3xl font-bold text-gray-900">Reset Your Password</h1> <h1 className="text-3xl font-bold text-gray-900">Reset Your Password</h1>
<p className="text-gray-600 mt-2">Enter your new password below</p> <p className="text-gray-600 mt-2">Enter your new password below</p>
</div> </div>
<Card> <Card>
<CardContent className="p-6"> <CardContent className="p-6">
{!token ? ( {!token ? (
<div className="text-center"> <div className="text-center">
<div className="inline-flex items-center justify-center w-16 h-16 bg-red-100 rounded-full mb-4"> <div className="inline-flex items-center justify-center w-16 h-16 bg-red-100 rounded-full mb-4">
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</div> </div>
<p className="text-red-600 mb-4">{error}</p> <p className="text-red-600 mb-4">{error}</p>
<Link href="/forgot-password" className="block"> <Link href="/forgot-password" className="block">
<Button variant="primary" className="w-full"> <Button variant="primary" className="w-full">
Request New Reset Link Request New Reset Link
</Button> </Button>
</Link> </Link>
</div> </div>
) : ( ) : (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{error && ( {error && (
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm"> <div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
{error} {error}
</div> </div>
)} )}
<Input <Input
label="New Password" label="New Password"
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="Enter new password" placeholder="Enter new password"
required required
disabled={loading || csrfLoading} disabled={loading || csrfLoading}
minLength={8} minLength={8}
/> />
<Input <Input
label="Confirm Password" label="Confirm Password"
type="password" type="password"
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm new password" placeholder="Confirm new password"
required required
disabled={loading || csrfLoading} disabled={loading || csrfLoading}
minLength={8} minLength={8}
/> />
<div className="text-xs text-gray-500"> <div className="text-xs text-gray-500">
Password must be at least 8 characters long Password must be at least 8 characters long
</div> </div>
<Button <Button
type="submit" type="submit"
className="w-full" className="w-full"
loading={loading} loading={loading}
disabled={csrfLoading || loading} disabled={csrfLoading || loading}
> >
{csrfLoading ? 'Loading...' : 'Reset Password'} {csrfLoading ? 'Loading...' : 'Reset Password'}
</Button> </Button>
<div className="text-center"> <div className="text-center">
<Link href="/login" className="text-sm text-primary-600 hover:text-primary-700 font-medium"> <Link href="/login" className="text-sm text-primary-600 hover:text-primary-700 font-medium">
Back to Login Back to Login
</Link> </Link>
</div> </div>
</form> </form>
)} )}
</CardContent> </CardContent>
</Card> </Card>
<p className="text-center text-sm text-gray-500 mt-6"> <p className="text-center text-sm text-gray-500 mt-6">
Remember your password?{' '} Remember your password?{' '}
<Link href="/login" className="text-primary-600 hover:text-primary-700 font-medium"> <Link href="/login" className="text-primary-600 hover:text-primary-700 font-medium">
Sign in Sign in
</Link> </Link>
</p> </p>
</div> </div>
</div> </div>
); );
} }

View File

@ -1,208 +1,208 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { useTranslation } from '@/hooks/useTranslation'; import { useTranslation } from '@/hooks/useTranslation';
import { useCsrf } from '@/hooks/useCsrf'; import { useCsrf } from '@/hooks/useCsrf';
export default function SignupPage() { export default function SignupPage() {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation(); const { t } = useTranslation();
const { fetchWithCsrf } = useCsrf(); const { fetchWithCsrf } = useCsrf();
const [name, setName] = useState(''); const [name, setName] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
setError(''); setError('');
if (password !== confirmPassword) { if (password !== confirmPassword) {
setError('Passwords do not match'); setError('Passwords do not match');
setLoading(false); setLoading(false);
return; return;
} }
if (password.length < 8) { if (password.length < 8) {
setError('Password must be at least 8 characters'); setError('Password must be at least 8 characters');
setLoading(false); setLoading(false);
return; return;
} }
try { try {
const response = await fetchWithCsrf('/api/auth/signup', { const response = await fetchWithCsrf('/api/auth/signup', {
method: 'POST', method: 'POST',
body: JSON.stringify({ name, email, password }), body: JSON.stringify({ name, email, password }),
}); });
const data = await response.json(); const data = await response.json();
if (response.ok && data.success) { if (response.ok && data.success) {
// Store user in localStorage for client-side // Store user in localStorage for client-side
localStorage.setItem('user', JSON.stringify(data.user)); localStorage.setItem('user', JSON.stringify(data.user));
// Track successful signup with PostHog // Track successful signup with PostHog
try { try {
const { identifyUser, trackEvent } = await import('@/components/PostHogProvider'); const { identifyUser, trackEvent } = await import('@/components/PostHogProvider');
identifyUser(data.user.id, { identifyUser(data.user.id, {
email: data.user.email, email: data.user.email,
name: data.user.name, name: data.user.name,
plan: data.user.plan || 'FREE', plan: data.user.plan || 'FREE',
signupMethod: 'email', signupMethod: 'email',
}); });
trackEvent('user_signup', { trackEvent('user_signup', {
method: 'email', method: 'email',
email: data.user.email, email: data.user.email,
}); });
} catch (error) { } catch (error) {
console.error('PostHog tracking error:', error); console.error('PostHog tracking error:', error);
} }
// Redirect to dashboard // Redirect to dashboard
router.push('/dashboard'); router.push('/dashboard');
router.refresh(); router.refresh();
} else { } else {
setError(data.error || 'Failed to create account'); setError(data.error || 'Failed to create account');
} }
} catch (err) { } catch (err) {
setError('An error occurred. Please try again.'); setError('An error occurred. Please try again.');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const handleGoogleSignIn = () => { const handleGoogleSignIn = () => {
// Redirect to Google OAuth API route // Redirect to Google OAuth API route
window.location.href = '/api/auth/google'; window.location.href = '/api/auth/google';
}; };
return ( return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4"> <div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
<div className="w-full max-w-md"> <div className="w-full max-w-md">
<div className="text-center mb-8"> <div className="text-center mb-8">
<Link href="/" className="inline-flex items-center space-x-2 mb-6"> <Link href="/" className="inline-flex items-center space-x-2 mb-6">
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" /> <img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
<span className="text-2xl font-bold text-gray-900">QR Master</span> <span className="text-2xl font-bold text-gray-900">QR Master</span>
</Link> </Link>
<h1 className="text-3xl font-bold text-gray-900">Create Account</h1> <h1 className="text-3xl font-bold text-gray-900">Create Account</h1>
<p className="text-gray-600 mt-2">Start creating QR codes in seconds</p> <p className="text-gray-600 mt-2">Start creating QR codes in seconds</p>
<Link href="/" className="text-sm text-primary-600 hover:text-primary-700 font-medium mt-2 inline-block border border-primary-600 hover:border-primary-700 px-4 py-2 rounded-lg transition-colors"> <Link href="/" className="text-sm text-primary-600 hover:text-primary-700 font-medium mt-2 inline-block border border-primary-600 hover:border-primary-700 px-4 py-2 rounded-lg transition-colors">
Back to Home Back to Home
</Link> </Link>
</div> </div>
<Card> <Card>
<CardContent className="p-6"> <CardContent className="p-6">
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{error && ( {error && (
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm"> <div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
{error} {error}
</div> </div>
)} )}
<Input <Input
label="Full Name" label="Full Name"
type="text" type="text"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="John Doe" placeholder="John Doe"
required required
/> />
<Input <Input
label="Email" label="Email"
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com" placeholder="you@example.com"
required required
/> />
<Input <Input
label="Password" label="Password"
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••" placeholder="••••••••"
required required
/> />
<Input <Input
label="Confirm Password" label="Confirm Password"
type="password" type="password"
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••" placeholder="••••••••"
required required
/> />
<Button type="submit" className="w-full" loading={loading}> <Button type="submit" className="w-full" loading={loading}>
Create Account Create Account
</Button> </Button>
<div className="relative my-6"> <div className="relative my-6">
<div className="absolute inset-0 flex items-center"> <div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300"></div> <div className="w-full border-t border-gray-300"></div>
</div> </div>
<div className="relative flex justify-center text-sm"> <div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">Or continue with</span> <span className="px-2 bg-white text-gray-500">Or continue with</span>
</div> </div>
</div> </div>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
className="w-full" className="w-full"
onClick={handleGoogleSignIn} onClick={handleGoogleSignIn}
> >
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24"> <svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
<path <path
fill="#4285F4" fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/> />
<path <path
fill="#34A853" fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/> />
<path <path
fill="#FBBC05" fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/> />
<path <path
fill="#EA4335" fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/> />
</svg> </svg>
Sign up with Google Sign up with Google
</Button> </Button>
</form> </form>
<div className="mt-6 text-center"> <div className="mt-6 text-center">
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">
Already have an account?{' '} Already have an account?{' '}
<Link href="/login" className="text-primary-600 hover:text-primary-700 font-medium"> <Link href="/login" className="text-primary-600 hover:text-primary-700 font-medium">
Sign in Sign in
</Link> </Link>
</p> </p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<p className="text-center text-sm text-gray-500 mt-6"> <p className="text-center text-sm text-gray-500 mt-6">
By signing up, you agree to our{' '} By signing up, you agree to our{' '}
<Link href="/privacy" className="text-primary-600 hover:text-primary-700"> <Link href="/privacy" className="text-primary-600 hover:text-primary-700">
Privacy Policy Privacy Policy
</Link> </Link>
</p> </p>
</div> </div>
</div> </div>
); );
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,182 +1,182 @@
import React from 'react'; import React from 'react';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import SeoJsonLd from '@/components/SeoJsonLd'; import SeoJsonLd from '@/components/SeoJsonLd';
import { websiteSchema, breadcrumbSchema } from '@/lib/schema'; import { websiteSchema, breadcrumbSchema } from '@/lib/schema';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import Breadcrumbs, { BreadcrumbItem } from '@/components/Breadcrumbs'; import Breadcrumbs, { BreadcrumbItem } from '@/components/Breadcrumbs';
function truncateAtWord(text: string, maxLength: number): string { function truncateAtWord(text: string, maxLength: number): string {
if (text.length <= maxLength) return text; if (text.length <= maxLength) return text;
const truncated = text.slice(0, maxLength); const truncated = text.slice(0, maxLength);
const lastSpace = truncated.lastIndexOf(' '); const lastSpace = truncated.lastIndexOf(' ');
return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated; return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated;
} }
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const title = truncateAtWord('QR Insights: Latest QR Strategies', 60); const title = truncateAtWord('QR Insights: Latest QR Strategies', 60);
const description = truncateAtWord( const description = truncateAtWord(
'Expert guides on QR analytics, dynamic codes & smart marketing uses.', 'Expert guides on QR analytics, dynamic codes & smart marketing uses.',
160 160
); );
return { return {
title, title,
description, description,
alternates: { alternates: {
canonical: 'https://www.qrmaster.net/blog', canonical: 'https://www.qrmaster.net/blog',
languages: { languages: {
'x-default': 'https://www.qrmaster.net/blog', 'x-default': 'https://www.qrmaster.net/blog',
en: 'https://www.qrmaster.net/blog', en: 'https://www.qrmaster.net/blog',
}, },
}, },
openGraph: { openGraph: {
title, title,
description, description,
url: 'https://www.qrmaster.net/blog', url: 'https://www.qrmaster.net/blog',
type: 'website', type: 'website',
}, },
twitter: { twitter: {
title, title,
description, description,
}, },
}; };
} }
const blogPosts = [ const blogPosts = [
// NEW POSTS (January 2026) // NEW POSTS (January 2026)
{ {
slug: 'qr-code-restaurant-menu', slug: 'qr-code-restaurant-menu',
title: 'How to Create a QR Code for Restaurant Menu', title: 'How to Create a QR Code for Restaurant Menu',
excerpt: 'Step-by-step guide to creating digital menu QR codes for your restaurant. Learn best practices for touchless menus, placement tips, and tracking.', excerpt: 'Step-by-step guide to creating digital menu QR codes for your restaurant. Learn best practices for touchless menus, placement tips, and tracking.',
date: 'January 5, 2026', date: 'January 5, 2026',
readTime: '12 Min', readTime: '12 Min',
category: 'Restaurant', category: 'Restaurant',
image: '/blog/restaurant-qr-menu.png', image: '/blog/restaurant-qr-menu.png',
}, },
{ {
slug: 'vcard-qr-code-generator', slug: 'vcard-qr-code-generator',
title: 'Free vCard QR Code Generator: Digital Business Cards', title: 'Free vCard QR Code Generator: Digital Business Cards',
excerpt: 'Create professional vCard QR codes for digital business cards. Share contact info instantly with a scan—includes templates and best practices.', excerpt: 'Create professional vCard QR codes for digital business cards. Share contact info instantly with a scan—includes templates and best practices.',
date: 'January 5, 2026', date: 'January 5, 2026',
readTime: '10 Min', readTime: '10 Min',
category: 'Business Cards', category: 'Business Cards',
image: '/blog/vcard-qr-code.png', image: '/blog/vcard-qr-code.png',
}, },
{ {
slug: 'qr-code-small-business', slug: 'qr-code-small-business',
title: 'Best QR Code Generator for Small Business: 2025 Guide', title: 'Best QR Code Generator for Small Business: 2025 Guide',
excerpt: 'Find the best QR code solution for your small business. Compare features, pricing, and use cases for marketing, payments, and operations.', excerpt: 'Find the best QR code solution for your small business. Compare features, pricing, and use cases for marketing, payments, and operations.',
date: 'January 5, 2026', date: 'January 5, 2026',
readTime: '14 Min', readTime: '14 Min',
category: 'Business', category: 'Business',
image: '/blog/small-business-qr.png', image: '/blog/small-business-qr.png',
}, },
{ {
slug: 'qr-code-print-size-guide', slug: 'qr-code-print-size-guide',
title: 'QR Code Print Size Guide: Minimum Sizes for Every Use Case', title: 'QR Code Print Size Guide: Minimum Sizes for Every Use Case',
excerpt: 'Complete guide to QR code print sizes. Learn minimum dimensions for business cards, posters, banners, and more to ensure reliable scanning.', excerpt: 'Complete guide to QR code print sizes. Learn minimum dimensions for business cards, posters, banners, and more to ensure reliable scanning.',
date: 'January 5, 2026', date: 'January 5, 2026',
readTime: '8 Min', readTime: '8 Min',
category: 'Printing', category: 'Printing',
image: '/blog/qr-print-sizes.png', image: '/blog/qr-print-sizes.png',
}, },
// EXISTING POSTS // EXISTING POSTS
{ {
slug: 'qr-code-tracking-guide-2025', slug: 'qr-code-tracking-guide-2025',
title: 'QR Code Tracking: Complete Guide 2025', title: 'QR Code Tracking: Complete Guide 2025',
excerpt: 'Learn how to track QR code scans with real-time analytics. Compare free vs paid tracking tools, setup Google Analytics, and measure ROI.', excerpt: 'Learn how to track QR code scans with real-time analytics. Compare free vs paid tracking tools, setup Google Analytics, and measure ROI.',
date: 'October 18, 2025', date: 'October 18, 2025',
readTime: '12 Min', readTime: '12 Min',
category: 'Tracking & Analytics', category: 'Tracking & Analytics',
image: '/blog/1-hero.png', image: '/blog/1-hero.png',
}, },
{ {
slug: 'dynamic-vs-static-qr-codes', slug: 'dynamic-vs-static-qr-codes',
title: 'Dynamic vs Static QR Codes: Which Should You Use?', title: 'Dynamic vs Static QR Codes: Which Should You Use?',
excerpt: 'Understand the difference between static and dynamic QR codes. Learn when to use each type, pros/cons, and how dynamic QR codes save money.', excerpt: 'Understand the difference between static and dynamic QR codes. Learn when to use each type, pros/cons, and how dynamic QR codes save money.',
date: 'October 17, 2025', date: 'October 17, 2025',
readTime: '10 Min', readTime: '10 Min',
category: 'QR Code Basics', category: 'QR Code Basics',
image: '/blog/2-hero.png', image: '/blog/2-hero.png',
}, },
{ {
slug: 'bulk-qr-code-generator-excel', slug: 'bulk-qr-code-generator-excel',
title: 'How to Generate Bulk QR Codes from Excel', title: 'How to Generate Bulk QR Codes from Excel',
excerpt: 'Generate hundreds of QR codes from Excel or CSV files in minutes. Step-by-step guide with templates, best practices, and free tools.', excerpt: 'Generate hundreds of QR codes from Excel or CSV files in minutes. Step-by-step guide with templates, best practices, and free tools.',
date: 'October 16, 2025', date: 'October 16, 2025',
readTime: '13 Min', readTime: '13 Min',
category: 'Bulk Generation', category: 'Bulk Generation',
image: '/blog/3-hero.png', image: '/blog/3-hero.png',
}, },
{ {
slug: 'qr-code-analytics', slug: 'qr-code-analytics',
title: 'QR Code Analytics: Track, Measure & Optimize Campaigns', title: 'QR Code Analytics: Track, Measure & Optimize Campaigns',
excerpt: 'Learn how to leverage scan analytics, campaign tracking, and dashboard insights to maximize QR code ROI.', excerpt: 'Learn how to leverage scan analytics, campaign tracking, and dashboard insights to maximize QR code ROI.',
date: 'October 16, 2025', date: 'October 16, 2025',
readTime: '15 Min', readTime: '15 Min',
category: 'Analytics', category: 'Analytics',
image: '/blog/4-hero.png', image: '/blog/4-hero.png',
}, },
]; ];
export default function BlogPage() { export default function BlogPage() {
const breadcrumbItems: BreadcrumbItem[] = [ const breadcrumbItems: BreadcrumbItem[] = [
{ name: 'Home', url: '/' }, { name: 'Home', url: '/' },
{ name: 'Blog', url: '/blog' }, { name: 'Blog', url: '/blog' },
]; ];
return ( return (
<> <>
<SeoJsonLd data={[websiteSchema(), breadcrumbSchema(breadcrumbItems)]} /> <SeoJsonLd data={[websiteSchema(), breadcrumbSchema(breadcrumbItems)]} />
<div className="py-20 bg-gradient-to-b from-gray-50 to-white"> <div className="py-20 bg-gradient-to-b from-gray-50 to-white">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<Breadcrumbs items={breadcrumbItems} /> <Breadcrumbs items={breadcrumbItems} />
<div className="text-center mb-16"> <div className="text-center mb-16">
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-6"> <h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-6">
QR Code Insights QR Code Insights
</h1> </h1>
<p className="text-xl text-gray-600 max-w-2xl mx-auto"> <p className="text-xl text-gray-600 max-w-2xl mx-auto">
Expert guides on dynamic QR codes, campaign tracking, UTM analytics, and smart marketing use cases. Expert guides on dynamic QR codes, campaign tracking, UTM analytics, and smart marketing use cases.
Discover how-to tutorials and best practices for QR code analytics. Discover how-to tutorials and best practices for QR code analytics.
</p> </p>
</div> </div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8 max-w-6xl mx-auto"> <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8 max-w-6xl mx-auto">
{blogPosts.map((post) => ( {blogPosts.map((post) => (
<Link key={post.slug} href={`/blog/${post.slug}`}> <Link key={post.slug} href={`/blog/${post.slug}`}>
<Card hover className="h-full overflow-hidden shadow-md hover:shadow-xl transition-all duration-300"> <Card hover className="h-full overflow-hidden shadow-md hover:shadow-xl transition-all duration-300">
<div className="relative h-56 overflow-hidden"> <div className="relative h-56 overflow-hidden">
<Image <Image
src={post.image} src={post.image}
alt={`${post.title} - QR code guide showing ${post.category.toLowerCase()} strategies`} alt={`${post.title} - QR code guide showing ${post.category.toLowerCase()} strategies`}
width={800} width={800}
height={600} height={600}
className="w-full h-full object-cover transition-transform duration-500 hover:scale-110" className="w-full h-full object-cover transition-transform duration-500 hover:scale-110"
/> />
</div> </div>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<Badge variant="info">{post.category}</Badge> <Badge variant="info">{post.category}</Badge>
<span className="text-sm text-gray-500 font-medium">{post.readTime} read</span> <span className="text-sm text-gray-500 font-medium">{post.readTime} read</span>
</div> </div>
<CardTitle className="text-xl leading-tight mb-3">{post.title}</CardTitle> <CardTitle className="text-xl leading-tight mb-3">{post.title}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pt-0"> <CardContent className="pt-0">
<p className="text-gray-600 mb-4 leading-relaxed">{post.excerpt}</p> <p className="text-gray-600 mb-4 leading-relaxed">{post.excerpt}</p>
<div className="flex items-center justify-between pt-4 border-t border-gray-100"> <div className="flex items-center justify-between pt-4 border-t border-gray-100">
<p className="text-sm text-gray-500">{post.date}</p> <p className="text-sm text-gray-500">{post.date}</p>
<span className="text-primary-600 text-sm font-medium">Read more </span> <span className="text-primary-600 text-sm font-medium">Read more </span>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</Link> </Link>
))} ))}
</div> </div>
</div> </div>
</div> </div>
</> </>
); );
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,143 +1,143 @@
import React from 'react'; import React from 'react';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import SeoJsonLd from '@/components/SeoJsonLd'; import SeoJsonLd from '@/components/SeoJsonLd';
import { faqPageSchema } from '@/lib/schema'; import { faqPageSchema } from '@/lib/schema';
import { Card, CardContent } from '@/components/ui/Card'; import { Card, CardContent } from '@/components/ui/Card';
function truncateAtWord(text: string, maxLength: number): string { function truncateAtWord(text: string, maxLength: number): string {
if (text.length <= maxLength) return text; if (text.length <= maxLength) return text;
const truncated = text.slice(0, maxLength); const truncated = text.slice(0, maxLength);
const lastSpace = truncated.lastIndexOf(' '); const lastSpace = truncated.lastIndexOf(' ');
return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated; return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated;
} }
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const title = truncateAtWord('QR Master FAQ: Dynamic & Bulk QR', 60); const title = truncateAtWord('QR Master FAQ: Dynamic & Bulk QR', 60);
const description = truncateAtWord( const description = truncateAtWord(
'All answers: dynamic QR, security, analytics, bulk, events & print.', 'All answers: dynamic QR, security, analytics, bulk, events & print.',
160 160
); );
return { return {
title, title,
description, description,
alternates: { alternates: {
canonical: 'https://www.qrmaster.net/faq', canonical: 'https://www.qrmaster.net/faq',
languages: { languages: {
'x-default': 'https://www.qrmaster.net/faq', 'x-default': 'https://www.qrmaster.net/faq',
en: 'https://www.qrmaster.net/faq', en: 'https://www.qrmaster.net/faq',
}, },
}, },
openGraph: { openGraph: {
title, title,
description, description,
url: 'https://www.qrmaster.net/faq', url: 'https://www.qrmaster.net/faq',
type: 'website', type: 'website',
}, },
twitter: { twitter: {
title, title,
description, description,
}, },
}; };
} }
const faqs = [ const faqs = [
{ {
question: 'What is a dynamic QR code?', question: 'What is a dynamic QR code?',
answer: 'A dynamic QR code allows you to change the destination URL after the code has been created and printed. Unlike static QR codes, dynamic codes redirect through a short URL that you control, enabling real-time updates, scan analytics, and campaign tracking without reprinting the code.', answer: 'A dynamic QR code allows you to change the destination URL after the code has been created and printed. Unlike static QR codes, dynamic codes redirect through a short URL that you control, enabling real-time updates, scan analytics, and campaign tracking without reprinting the code.',
}, },
{ {
question: 'How do I track QR scans?', question: 'How do I track QR scans?',
answer: 'QR Master provides a comprehensive analytics dashboard that tracks every scan in real-time. You can monitor scan rates, geographic locations, device types, timestamps, and user behavior. Enable UTM parameters to integrate with Google Analytics for advanced campaign tracking and conversion attribution.', answer: 'QR Master provides a comprehensive analytics dashboard that tracks every scan in real-time. You can monitor scan rates, geographic locations, device types, timestamps, and user behavior. Enable UTM parameters to integrate with Google Analytics for advanced campaign tracking and conversion attribution.',
}, },
{ {
question: 'What security features does QR Master offer?', question: 'What security features does QR Master offer?',
answer: 'QR Master employs enterprise-grade security including SSL encryption, link validation to prevent malicious redirects, fraud detection, and GDPR-compliant data handling. All scan analytics are stored securely and access is protected with multi-factor authentication for business accounts.', answer: 'QR Master employs enterprise-grade security including SSL encryption, link validation to prevent malicious redirects, fraud detection, and GDPR-compliant data handling. All scan analytics are stored securely and access is protected with multi-factor authentication for business accounts.',
}, },
{ {
question: 'Can I generate bulk QR codes for print?', question: 'Can I generate bulk QR codes for print?',
answer: 'Yes. Our bulk QR generation tool allows you to create thousands of QR codes at once by uploading a CSV file. Each code can be customized with unique URLs, UTM parameters, and branding. Download print-ready files in SVG, PNG, or PDF formats optimized for high-resolution printing.', answer: 'Yes. Our bulk QR generation tool allows you to create thousands of QR codes at once by uploading a CSV file. Each code can be customized with unique URLs, UTM parameters, and branding. Download print-ready files in SVG, PNG, or PDF formats optimized for high-resolution printing.',
}, },
{ {
question: 'How do I brand my QR codes?', question: 'How do I brand my QR codes?',
answer: 'QR Master offers customization options including custom colors, corner styles, and pattern designs. Branded QR codes maintain scannability while matching your brand identity. Choose your color palette and preview designs before downloading.', answer: 'QR Master offers customization options including custom colors, corner styles, and pattern designs. Branded QR codes maintain scannability while matching your brand identity. Choose your color palette and preview designs before downloading.',
}, },
{ {
question: 'Is scan analytics GDPR compliant?', question: 'Is scan analytics GDPR compliant?',
answer: 'Yes. All QR Master analytics are fully GDPR compliant. We collect only necessary data, provide transparent privacy policies, allow users to opt out, and store data securely in EU-compliant data centers. You maintain full control over data retention and deletion.', answer: 'Yes. All QR Master analytics are fully GDPR compliant. We collect only necessary data, provide transparent privacy policies, allow users to opt out, and store data securely in EU-compliant data centers. You maintain full control over data retention and deletion.',
}, },
{ {
question: 'Can QR Master track campaigns with UTM?', question: 'Can QR Master track campaigns with UTM?',
answer: 'Absolutely. QR Master supports UTM parameter integration for all dynamic QR codes. Automatically append source, medium, campaign, term, and content parameters to track QR performance in Google Analytics, Adobe Analytics, and other marketing platforms. UTM tracking enables multi-channel attribution and ROI measurement.', answer: 'Absolutely. QR Master supports UTM parameter integration for all dynamic QR codes. Automatically append source, medium, campaign, term, and content parameters to track QR performance in Google Analytics, Adobe Analytics, and other marketing platforms. UTM tracking enables multi-channel attribution and ROI measurement.',
}, },
{ {
question: 'Difference between static and dynamic QR codes?', question: 'Difference between static and dynamic QR codes?',
answer: 'Static QR codes encode the destination URL directly in the code pattern and cannot be changed after creation. Dynamic QR codes use a short redirect URL, allowing you to update destinations, track scans, enable/disable codes, and gather analytics—all without reprinting. Dynamic codes are essential for professional marketing campaigns.', answer: 'Static QR codes encode the destination URL directly in the code pattern and cannot be changed after creation. Dynamic QR codes use a short redirect URL, allowing you to update destinations, track scans, enable/disable codes, and gather analytics—all without reprinting. Dynamic codes are essential for professional marketing campaigns.',
}, },
{ {
question: 'How are QR codes used for events?', question: 'How are QR codes used for events?',
answer: 'QR codes streamline event check-ins, ticket validation, attendee tracking, and engagement measurement. Generate unique codes for each ticket, track scan times and locations, enable contactless entry, and analyze attendee behavior. Event organizers use QR analytics to measure session popularity and optimize future events.', answer: 'QR codes streamline event check-ins, ticket validation, attendee tracking, and engagement measurement. Generate unique codes for each ticket, track scan times and locations, enable contactless entry, and analyze attendee behavior. Event organizers use QR analytics to measure session popularity and optimize future events.',
}, },
{ {
question: 'Can I make QR codes for business cards?', question: 'Can I make QR codes for business cards?',
answer: 'Yes. QR codes on business cards provide instant contact sharing via vCard format, link to your portfolio or LinkedIn profile, and track networking effectiveness. Use branded QR codes that match your card design, and leverage scan analytics to see how many contacts engage and when they follow up.', answer: 'Yes. QR codes on business cards provide instant contact sharing via vCard format, link to your portfolio or LinkedIn profile, and track networking effectiveness. Use branded QR codes that match your card design, and leverage scan analytics to see how many contacts engage and when they follow up.',
}, },
{ {
question: 'How do I use QR codes for bulk marketing?', question: 'How do I use QR codes for bulk marketing?',
answer: 'Bulk QR codes enable scalable campaigns across print ads, packaging, direct mail, and retail displays. Generate thousands of codes with unique tracking URLs, distribute them across channels, and use analytics to measure which placements drive the highest engagement. Bulk generation supports CSV upload, API integration, and automated workflows.', answer: 'Bulk QR codes enable scalable campaigns across print ads, packaging, direct mail, and retail displays. Generate thousands of codes with unique tracking URLs, distribute them across channels, and use analytics to measure which placements drive the highest engagement. Bulk generation supports CSV upload, API integration, and automated workflows.',
}, },
{ {
question: 'Is API access available for bulk QR generation?', question: 'Is API access available for bulk QR generation?',
answer: 'Yes. QR Master offers a developer-friendly REST API for programmatic QR code generation, URL management, and analytics retrieval. Integrate QR creation into your CRM, marketing automation platform, or e-commerce system. API access is included in Business plans and supports bulk operations, webhooks, and real-time updates.', answer: 'Yes. QR Master offers a developer-friendly REST API for programmatic QR code generation, URL management, and analytics retrieval. Integrate QR creation into your CRM, marketing automation platform, or e-commerce system. API access is included in Business plans and supports bulk operations, webhooks, and real-time updates.',
}, },
]; ];
export default function FAQPage() { export default function FAQPage() {
return ( return (
<> <>
<SeoJsonLd data={faqPageSchema(faqs)} /> <SeoJsonLd data={faqPageSchema(faqs)} />
<div className="py-20 bg-gradient-to-b from-gray-50 to-white"> <div className="py-20 bg-gradient-to-b from-gray-50 to-white">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
<div className="text-center mb-16"> <div className="text-center mb-16">
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-6"> <h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-6">
Frequently Asked Questions Frequently Asked Questions
</h1> </h1>
<p className="text-xl text-gray-600"> <p className="text-xl text-gray-600">
Everything you need to know about dynamic QR codes, security, analytics, bulk generation, events, and print quality. Everything you need to know about dynamic QR codes, security, analytics, bulk generation, events, and print quality.
</p> </p>
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
{faqs.map((faq, index) => ( {faqs.map((faq, index) => (
<Card key={index} className="border-l-4 border-blue-500"> <Card key={index} className="border-l-4 border-blue-500">
<CardContent className="p-8"> <CardContent className="p-8">
<h2 className="text-2xl font-semibold mb-4 text-gray-900"> <h2 className="text-2xl font-semibold mb-4 text-gray-900">
{faq.question} {faq.question}
</h2> </h2>
<p className="text-lg text-gray-700 leading-relaxed"> <p className="text-lg text-gray-700 leading-relaxed">
{faq.answer} {faq.answer}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
</div> </div>
<div className="mt-16 bg-blue-50 border-l-4 border-blue-500 p-8 rounded-r-lg"> <div className="mt-16 bg-blue-50 border-l-4 border-blue-500 p-8 rounded-r-lg">
<h2 className="text-2xl font-bold mb-4 text-gray-900"> <h2 className="text-2xl font-bold mb-4 text-gray-900">
Still have questions? Still have questions?
</h2> </h2>
<p className="text-lg text-gray-700 mb-6 leading-relaxed"> <p className="text-lg text-gray-700 mb-6 leading-relaxed">
Our support team is here to help. Contact us at{' '} Our support team is here to help. Contact us at{' '}
<a href="mailto:support@qrmaster.net" className="text-blue-600 hover:text-blue-700 font-semibold"> <a href="mailto:support@qrmaster.net" className="text-blue-600 hover:text-blue-700 font-semibold">
support@qrmaster.net support@qrmaster.net
</a>{' '} </a>{' '}
or reach out through our live chat. or reach out through our live chat.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</> </>
); );
} }

View File

@ -1,71 +1,71 @@
import React from 'react'; import React from 'react';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import SeoJsonLd from '@/components/SeoJsonLd'; import SeoJsonLd from '@/components/SeoJsonLd';
import { organizationSchema, websiteSchema } from '@/lib/schema'; import { organizationSchema, websiteSchema } from '@/lib/schema';
import HomePageClient from '@/components/marketing/HomePageClient'; import HomePageClient from '@/components/marketing/HomePageClient';
function truncateAtWord(text: string, maxLength: number): string { function truncateAtWord(text: string, maxLength: number): string {
if (text.length <= maxLength) return text; if (text.length <= maxLength) return text;
const truncated = text.slice(0, maxLength); const truncated = text.slice(0, maxLength);
const lastSpace = truncated.lastIndexOf(' '); const lastSpace = truncated.lastIndexOf(' ');
return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated; return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated;
} }
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const title = truncateAtWord('QR Master: Dynamic QR Generator', 60); const title = truncateAtWord('QR Master: Dynamic QR Generator', 60);
const description = truncateAtWord( const description = truncateAtWord(
'Dynamic QR, branding, bulk generation & analytics for all campaigns.', 'Dynamic QR, branding, bulk generation & analytics for all campaigns.',
160 160
); );
return { return {
title, title,
description, description,
alternates: { alternates: {
canonical: 'https://www.qrmaster.net/', canonical: 'https://www.qrmaster.net/',
languages: { languages: {
'x-default': 'https://www.qrmaster.net/', 'x-default': 'https://www.qrmaster.net/',
en: 'https://www.qrmaster.net/', en: 'https://www.qrmaster.net/',
}, },
}, },
openGraph: { openGraph: {
title, title,
description, description,
url: 'https://www.qrmaster.net/', url: 'https://www.qrmaster.net/',
type: 'website', type: 'website',
}, },
twitter: { twitter: {
title, title,
description, description,
}, },
}; };
} }
export default function HomePage() { export default function HomePage() {
return ( return (
<> <>
<SeoJsonLd data={[organizationSchema(), websiteSchema()]} /> <SeoJsonLd data={[organizationSchema(), websiteSchema()]} />
{/* Server-rendered SEO content for crawlers */} {/* Server-rendered SEO content for crawlers */}
<div className="sr-only" aria-hidden="false"> <div className="sr-only" aria-hidden="false">
<h1>QR Master: Free Dynamic QR Code Generator with Tracking & Analytics</h1> <h1>QR Master: Free Dynamic QR Code Generator with Tracking & Analytics</h1>
<p> <p>
Create professional QR codes for your business with QR Master. Our dynamic QR code generator Create professional QR codes for your business with QR Master. Our dynamic QR code generator
lets you create trackable QR codes, edit destinations anytime, and view detailed analytics. lets you create trackable QR codes, edit destinations anytime, and view detailed analytics.
Perfect for restaurants, retail, events, and marketing campaigns. Perfect for restaurants, retail, events, and marketing campaigns.
</p> </p>
<p> <p>
Features include: Dynamic QR codes with real-time tracking, bulk QR code generation from Excel/CSV, Features include: Dynamic QR codes with real-time tracking, bulk QR code generation from Excel/CSV,
custom branding with colors and logos, advanced scan analytics showing device types and locations, custom branding with colors and logos, advanced scan analytics showing device types and locations,
vCard QR codes for digital business cards, and restaurant menu QR codes. vCard QR codes for digital business cards, and restaurant menu QR codes.
</p> </p>
<p> <p>
Start free with 3 dynamic QR codes and unlimited static codes. Upgrade to Pro for 50 codes Start free with 3 dynamic QR codes and unlimited static codes. Upgrade to Pro for 50 codes
with advanced analytics, or Business for 500 codes with bulk creation and priority support. with advanced analytics, or Business for 500 codes with bulk creation and priority support.
</p> </p>
</div> </div>
<HomePageClient /> <HomePageClient />
</> </>
); );
} }

View File

@ -1,133 +1,133 @@
import React from 'react'; import React from 'react';
import Link from 'next/link'; import Link from 'next/link';
export const metadata = { export const metadata = {
title: 'Privacy Policy | QR Master', title: 'Privacy Policy | QR Master',
description: 'Privacy Policy and data protection information for QR Master', description: 'Privacy Policy and data protection information for QR Master',
}; };
export default function PrivacyPage() { export default function PrivacyPage() {
return ( return (
<div className="min-h-screen bg-white py-12"> <div className="min-h-screen bg-white py-12">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-4xl"> <div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-4xl">
<div className="mb-8"> <div className="mb-8">
<Link href="/" className="text-primary-600 hover:text-primary-700 font-medium"> <Link href="/" className="text-primary-600 hover:text-primary-700 font-medium">
Back to Home Back to Home
</Link> </Link>
</div> </div>
<h1 className="text-4xl font-bold text-gray-900 mb-4">Privacy Policy</h1> <h1 className="text-4xl font-bold text-gray-900 mb-4">Privacy Policy</h1>
<p className="text-gray-600 mb-8">Last updated: January 2025</p> <p className="text-gray-600 mb-8">Last updated: January 2025</p>
<div className="prose prose-lg max-w-none"> <div className="prose prose-lg max-w-none">
<section className="mb-8"> <section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">1. Introduction</h2> <h2 className="text-2xl font-bold text-gray-900 mb-4">1. Introduction</h2>
<p className="text-gray-700 mb-4"> <p className="text-gray-700 mb-4">
Welcome to QR Master ("we," "our," or "us"). We respect your privacy and are committed to protecting your personal data. Welcome to QR Master ("we," "our," or "us"). We respect your privacy and are committed to protecting your personal data.
This privacy policy explains how we collect, use, and protect your information when you use our services. This privacy policy explains how we collect, use, and protect your information when you use our services.
</p> </p>
<p className="text-gray-700 mb-4"> <p className="text-gray-700 mb-4">
We implement appropriate security measures including secure HTTPS transmission, password hashing, database access controls, We implement appropriate security measures including secure HTTPS transmission, password hashing, database access controls,
and CSRF protection to keep your data safe. and CSRF protection to keep your data safe.
</p> </p>
</section> </section>
<section className="mb-8"> <section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">2. Information We Collect</h2> <h2 className="text-2xl font-bold text-gray-900 mb-4">2. Information We Collect</h2>
<h3 className="text-xl font-semibold text-gray-900 mb-3">Information You Provide</h3> <h3 className="text-xl font-semibold text-gray-900 mb-3">Information You Provide</h3>
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2"> <ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
<li><strong>Account Information:</strong> Name, email address, and password</li> <li><strong>Account Information:</strong> Name, email address, and password</li>
<li><strong>Payment Information:</strong> Processed securely through Stripe (we do not store credit card information)</li> <li><strong>Payment Information:</strong> Processed securely through Stripe (we do not store credit card information)</li>
<li><strong>QR Code Content:</strong> URLs, text, and customization settings for your QR codes</li> <li><strong>QR Code Content:</strong> URLs, text, and customization settings for your QR codes</li>
</ul> </ul>
<h3 className="text-xl font-semibold text-gray-900 mb-3">Information Collected Automatically</h3> <h3 className="text-xl font-semibold text-gray-900 mb-3">Information Collected Automatically</h3>
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2"> <ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
<li><strong>Usage Data:</strong> QR code scans and analytics</li> <li><strong>Usage Data:</strong> QR code scans and analytics</li>
<li><strong>Technical Data:</strong> IP address, browser type, and device information</li> <li><strong>Technical Data:</strong> IP address, browser type, and device information</li>
<li><strong>Cookies:</strong> Essential cookies for authentication and optional analytics cookies (PostHog) with your consent</li> <li><strong>Cookies:</strong> Essential cookies for authentication and optional analytics cookies (PostHog) with your consent</li>
</ul> </ul>
</section> </section>
<section className="mb-8"> <section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">3. How We Use Your Information</h2> <h2 className="text-2xl font-bold text-gray-900 mb-4">3. How We Use Your Information</h2>
<p className="text-gray-700 mb-4">We use your data to:</p> <p className="text-gray-700 mb-4">We use your data to:</p>
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2"> <ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
<li>Provide and maintain our QR code services</li> <li>Provide and maintain our QR code services</li>
<li>Process payments and manage subscriptions</li> <li>Process payments and manage subscriptions</li>
<li>Provide customer support</li> <li>Provide customer support</li>
<li>Improve our services and develop new features</li> <li>Improve our services and develop new features</li>
<li>Detect and prevent fraud</li> <li>Detect and prevent fraud</li>
</ul> </ul>
<p className="text-gray-700 mb-4"> <p className="text-gray-700 mb-4">
We retain your data while your account is active. Upon account deletion, most data is removed immediately, We retain your data while your account is active. Upon account deletion, most data is removed immediately,
though some may be retained for legal compliance. Aggregated, anonymized analytics may be kept indefinitely. though some may be retained for legal compliance. Aggregated, anonymized analytics may be kept indefinitely.
</p> </p>
</section> </section>
<section className="mb-8"> <section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">4. Data Sharing</h2> <h2 className="text-2xl font-bold text-gray-900 mb-4">4. Data Sharing</h2>
<p className="text-gray-700 mb-4">We may share your data with:</p> <p className="text-gray-700 mb-4">We may share your data with:</p>
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2"> <ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
<li><strong>Stripe:</strong> Payment processing</li> <li><strong>Stripe:</strong> Payment processing</li>
<li><strong>PostHog:</strong> Analytics (only with your consent, respects Do Not Track)</li> <li><strong>PostHog:</strong> Analytics (only with your consent, respects Do Not Track)</li>
<li><strong>Vercel:</strong> Cloud hosting provider</li> <li><strong>Vercel:</strong> Cloud hosting provider</li>
<li><strong>Legal Requirements:</strong> When required by law</li> <li><strong>Legal Requirements:</strong> When required by law</li>
</ul> </ul>
<p className="text-gray-700 mb-4"> <p className="text-gray-700 mb-4">
We do not sell your personal data. Analytics are only activated if you accept optional cookies. We do not sell your personal data. Analytics are only activated if you accept optional cookies.
</p> </p>
</section> </section>
<section className="mb-8"> <section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">5. Your Rights (GDPR)</h2> <h2 className="text-2xl font-bold text-gray-900 mb-4">5. Your Rights (GDPR)</h2>
<p className="text-gray-700 mb-4">You have the right to:</p> <p className="text-gray-700 mb-4">You have the right to:</p>
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2"> <ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
<li><strong>Access:</strong> Request a copy of your personal data</li> <li><strong>Access:</strong> Request a copy of your personal data</li>
<li><strong>Rectification:</strong> Correct inaccurate data (update in account settings)</li> <li><strong>Rectification:</strong> Correct inaccurate data (update in account settings)</li>
<li><strong>Erasure:</strong> Delete your data (account deletion available in settings)</li> <li><strong>Erasure:</strong> Delete your data (account deletion available in settings)</li>
<li><strong>Data Portability:</strong> Receive your data in a portable format</li> <li><strong>Data Portability:</strong> Receive your data in a portable format</li>
<li><strong>Object:</strong> Object to processing based on legitimate interests</li> <li><strong>Object:</strong> Object to processing based on legitimate interests</li>
<li><strong>Withdraw Consent:</strong> Withdraw cookie consent at any time</li> <li><strong>Withdraw Consent:</strong> Withdraw cookie consent at any time</li>
</ul> </ul>
<p className="text-gray-700 mb-4"> <p className="text-gray-700 mb-4">
To exercise these rights, contact us at{' '} To exercise these rights, contact us at{' '}
<a href="mailto:support@qrmaster.net" className="text-primary-600 hover:text-primary-700"> <a href="mailto:support@qrmaster.net" className="text-primary-600 hover:text-primary-700">
support@qrmaster.net support@qrmaster.net
</a> </a>
</p> </p>
<p className="text-gray-700 mb-4"> <p className="text-gray-700 mb-4">
Our service is for users 16 years and older. If you're in the EEA and have concerns, Our service is for users 16 years and older. If you're in the EEA and have concerns,
you may lodge a complaint with your local data protection authority. you may lodge a complaint with your local data protection authority.
</p> </p>
</section> </section>
<section className="mb-8"> <section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">6. Contact Us</h2> <h2 className="text-2xl font-bold text-gray-900 mb-4">6. Contact Us</h2>
<p className="text-gray-700 mb-4"> <p className="text-gray-700 mb-4">
If you have questions about this privacy policy, please contact us: If you have questions about this privacy policy, please contact us:
</p> </p>
<div className="bg-gray-50 p-6 rounded-lg"> <div className="bg-gray-50 p-6 rounded-lg">
<p className="text-gray-700 mb-2"> <p className="text-gray-700 mb-2">
<strong>Email:</strong>{' '} <strong>Email:</strong>{' '}
<a href="mailto:support@qrmaster.net" className="text-primary-600 hover:text-primary-700"> <a href="mailto:support@qrmaster.net" className="text-primary-600 hover:text-primary-700">
support@qrmaster.net support@qrmaster.net
</a> </a>
</p> </p>
<p className="text-gray-700 mb-2"><strong>Website:</strong> <a href="/" className="text-primary-600 hover:text-primary-700">qrmaster.net</a></p> <p className="text-gray-700 mb-2"><strong>Website:</strong> <a href="/" className="text-primary-600 hover:text-primary-700">qrmaster.net</a></p>
</div> </div>
</section> </section>
</div> </div>
<div className="mt-12 pt-8 border-t border-gray-200"> <div className="mt-12 pt-8 border-t border-gray-200">
<p className="text-gray-600 text-center"> <p className="text-gray-600 text-center">
<Link href="/" className="text-primary-600 hover:text-primary-700"> <Link href="/" className="text-primary-600 hover:text-primary-700">
Back to Home Back to Home
</Link> </Link>
</p> </p>
</div> </div>
</div> </div>
</div> </div>
); );
} }

View File

@ -1,398 +1,398 @@
import React from 'react'; import React from 'react';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import SeoJsonLd from '@/components/SeoJsonLd'; import SeoJsonLd from '@/components/SeoJsonLd';
import Breadcrumbs, { BreadcrumbItem } from '@/components/Breadcrumbs'; import Breadcrumbs, { BreadcrumbItem } from '@/components/Breadcrumbs';
import { breadcrumbSchema } from '@/lib/schema'; import { breadcrumbSchema } from '@/lib/schema';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'QR Code Tracking & Analytics - Track Every Scan | QR Master', title: 'QR Code Tracking & Analytics - Track Every Scan | QR Master',
description: 'Track QR code scans with real-time analytics. Monitor location, device, time, and user behavior. Free QR code tracking software with detailed reports.', description: 'Track QR code scans with real-time analytics. Monitor location, device, time, and user behavior. Free QR code tracking software with detailed reports.',
keywords: 'qr code tracking, qr code analytics, track qr scans, qr code statistics, free qr tracking, qr code monitoring', keywords: 'qr code tracking, qr code analytics, track qr scans, qr code statistics, free qr tracking, qr code monitoring',
alternates: { alternates: {
canonical: 'https://www.qrmaster.net/qr-code-tracking', canonical: 'https://www.qrmaster.net/qr-code-tracking',
languages: { languages: {
'x-default': 'https://www.qrmaster.net/qr-code-tracking', 'x-default': 'https://www.qrmaster.net/qr-code-tracking',
en: 'https://www.qrmaster.net/qr-code-tracking', en: 'https://www.qrmaster.net/qr-code-tracking',
}, },
}, },
openGraph: { openGraph: {
title: 'QR Code Tracking & Analytics - Track Every Scan | QR Master', title: 'QR Code Tracking & Analytics - Track Every Scan | QR Master',
description: 'Track QR code scans with real-time analytics. Monitor location, device, time, and user behavior.', description: 'Track QR code scans with real-time analytics. Monitor location, device, time, and user behavior.',
url: 'https://www.qrmaster.net/qr-code-tracking', url: 'https://www.qrmaster.net/qr-code-tracking',
type: 'website', type: 'website',
}, },
twitter: { twitter: {
title: 'QR Code Tracking & Analytics - Track Every Scan | QR Master', title: 'QR Code Tracking & Analytics - Track Every Scan | QR Master',
description: 'Track QR code scans with real-time analytics. Monitor location, device, time, and user behavior.', description: 'Track QR code scans with real-time analytics. Monitor location, device, time, and user behavior.',
}, },
}; };
export default function QRCodeTrackingPage() { export default function QRCodeTrackingPage() {
const trackingFeatures = [ const trackingFeatures = [
{ {
icon: '📊', icon: '📊',
title: 'Real-Time Analytics', title: 'Real-Time Analytics',
description: 'See scan data instantly as it happens. Monitor your QR code performance in real-time with live dashboards.', description: 'See scan data instantly as it happens. Monitor your QR code performance in real-time with live dashboards.',
}, },
{ {
icon: '🌍', icon: '🌍',
title: 'Location Tracking', title: 'Location Tracking',
description: 'Know exactly where your QR codes are being scanned. Track by country, city, and region.', description: 'Know exactly where your QR codes are being scanned. Track by country, city, and region.',
}, },
{ {
icon: '📱', icon: '📱',
title: 'Device Detection', title: 'Device Detection',
description: 'Identify which devices scan your codes. Track iOS, Android, desktop, and browser types.', description: 'Identify which devices scan your codes. Track iOS, Android, desktop, and browser types.',
}, },
{ {
icon: '🕐', icon: '🕐',
title: 'Time-Based Reports', title: 'Time-Based Reports',
description: 'Analyze scan patterns by hour, day, week, or month. Optimize your campaigns with timing insights.', description: 'Analyze scan patterns by hour, day, week, or month. Optimize your campaigns with timing insights.',
}, },
{ {
icon: '👥', icon: '👥',
title: 'Unique vs Total Scans', title: 'Unique vs Total Scans',
description: 'Distinguish between unique users and repeat scans. Measure true reach and engagement.', description: 'Distinguish between unique users and repeat scans. Measure true reach and engagement.',
}, },
{ {
icon: '📈', icon: '📈',
title: 'Campaign Performance', title: 'Campaign Performance',
description: 'Track ROI with UTM parameters. Measure conversion rates and campaign effectiveness.', description: 'Track ROI with UTM parameters. Measure conversion rates and campaign effectiveness.',
}, },
]; ];
const useCases = [ const useCases = [
{ {
title: 'Marketing Campaigns', title: 'Marketing Campaigns',
description: 'Track print ads, billboards, and product packaging to measure marketing ROI.', description: 'Track print ads, billboards, and product packaging to measure marketing ROI.',
benefits: ['Measure ad performance', 'A/B test campaigns', 'Track conversions'], benefits: ['Measure ad performance', 'A/B test campaigns', 'Track conversions'],
}, },
{ {
title: 'Event Management', title: 'Event Management',
description: 'Monitor event check-ins, booth visits, and attendee engagement in real-time.', description: 'Monitor event check-ins, booth visits, and attendee engagement in real-time.',
benefits: ['Live attendance tracking', 'Booth analytics', 'Engagement metrics'], benefits: ['Live attendance tracking', 'Booth analytics', 'Engagement metrics'],
}, },
{ {
title: 'Product Labels', title: 'Product Labels',
description: 'Track product authenticity scans, manual downloads, and warranty registrations.', description: 'Track product authenticity scans, manual downloads, and warranty registrations.',
benefits: ['Anti-counterfeiting', 'User registration tracking', 'Product analytics'], benefits: ['Anti-counterfeiting', 'User registration tracking', 'Product analytics'],
}, },
{ {
title: 'Restaurant Menus', title: 'Restaurant Menus',
description: 'See how many customers scan your menu QR codes and when peak times occur.', description: 'See how many customers scan your menu QR codes and when peak times occur.',
benefits: ['Customer insights', 'Peak time analysis', 'Menu engagement'], benefits: ['Customer insights', 'Peak time analysis', 'Menu engagement'],
}, },
]; ];
const comparisonData = [ const comparisonData = [
{ feature: 'Real-Time Analytics', free: true, qrMaster: true }, { feature: 'Real-Time Analytics', free: true, qrMaster: true },
{ feature: 'Location Tracking', free: false, qrMaster: true }, { feature: 'Location Tracking', free: false, qrMaster: true },
{ feature: 'Device Detection', free: false, qrMaster: true }, { feature: 'Device Detection', free: false, qrMaster: true },
{ feature: 'Unlimited Scans', free: false, qrMaster: true }, { feature: 'Unlimited Scans', free: false, qrMaster: true },
{ feature: 'Historical Data', free: '7 days', qrMaster: 'Unlimited' }, { feature: 'Historical Data', free: '7 days', qrMaster: 'Unlimited' },
{ feature: 'Export Reports', free: false, qrMaster: true }, { feature: 'Export Reports', free: false, qrMaster: true },
{ feature: 'API Access', free: false, qrMaster: true }, { feature: 'API Access', free: false, qrMaster: true },
]; ];
const softwareSchema = { const softwareSchema = {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'SoftwareApplication', '@type': 'SoftwareApplication',
'@id': 'https://www.qrmaster.net/qr-code-tracking#software', '@id': 'https://www.qrmaster.net/qr-code-tracking#software',
name: 'QR Master - QR Code Tracking & Analytics', name: 'QR Master - QR Code Tracking & Analytics',
applicationCategory: 'BusinessApplication', applicationCategory: 'BusinessApplication',
operatingSystem: 'Web Browser, iOS, Android', operatingSystem: 'Web Browser, iOS, Android',
offers: { offers: {
'@type': 'Offer', '@type': 'Offer',
price: '0', price: '0',
priceCurrency: 'USD', priceCurrency: 'USD',
availability: 'https://schema.org/InStock', availability: 'https://schema.org/InStock',
}, },
aggregateRating: { aggregateRating: {
'@type': 'AggregateRating', '@type': 'AggregateRating',
ratingValue: '4.8', ratingValue: '4.8',
ratingCount: '1250', ratingCount: '1250',
}, },
description: 'Track QR code scans with real-time analytics. Monitor location, device, time, and user behavior with our free QR code tracking software.', description: 'Track QR code scans with real-time analytics. Monitor location, device, time, and user behavior with our free QR code tracking software.',
features: [ features: [
'Real-time analytics dashboard', 'Real-time analytics dashboard',
'Location tracking by country and city', 'Location tracking by country and city',
'Device detection (iOS, Android, Desktop)', 'Device detection (iOS, Android, Desktop)',
'Time-based scan reports', 'Time-based scan reports',
'Unique vs total scan tracking', 'Unique vs total scan tracking',
'Campaign performance metrics', 'Campaign performance metrics',
'Unlimited scans', 'Unlimited scans',
'Export detailed reports', 'Export detailed reports',
], ],
}; };
const howToSchema = { const howToSchema = {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'HowTo', '@type': 'HowTo',
'@id': 'https://www.qrmaster.net/qr-code-tracking#howto', '@id': 'https://www.qrmaster.net/qr-code-tracking#howto',
name: 'How to Track QR Code Scans', name: 'How to Track QR Code Scans',
description: 'Learn how to track and analyze QR code scans with real-time analytics', description: 'Learn how to track and analyze QR code scans with real-time analytics',
totalTime: 'PT5M', totalTime: 'PT5M',
step: [ step: [
{ {
'@type': 'HowToStep', '@type': 'HowToStep',
position: 1, position: 1,
name: 'Create QR Code', name: 'Create QR Code',
text: 'Sign up for free and create a dynamic QR code with tracking enabled', text: 'Sign up for free and create a dynamic QR code with tracking enabled',
url: 'https://www.qrmaster.net/signup', url: 'https://www.qrmaster.net/signup',
}, },
{ {
'@type': 'HowToStep', '@type': 'HowToStep',
position: 2, position: 2,
name: 'Deploy QR Code', name: 'Deploy QR Code',
text: 'Download and place your QR code on marketing materials, products, or digital platforms', text: 'Download and place your QR code on marketing materials, products, or digital platforms',
}, },
{ {
'@type': 'HowToStep', '@type': 'HowToStep',
position: 3, position: 3,
name: 'Monitor Analytics', name: 'Monitor Analytics',
text: 'View real-time scan data including location, device, and time patterns in your dashboard', text: 'View real-time scan data including location, device, and time patterns in your dashboard',
url: 'https://www.qrmaster.net/analytics', url: 'https://www.qrmaster.net/analytics',
}, },
{ {
'@type': 'HowToStep', '@type': 'HowToStep',
position: 4, position: 4,
name: 'Optimize Campaigns', name: 'Optimize Campaigns',
text: 'Use insights to optimize placement, timing, and targeting of your QR code campaigns', text: 'Use insights to optimize placement, timing, and targeting of your QR code campaigns',
}, },
], ],
}; };
const breadcrumbItems: BreadcrumbItem[] = [ const breadcrumbItems: BreadcrumbItem[] = [
{ name: 'Home', url: '/' }, { name: 'Home', url: '/' },
{ name: 'QR Code Tracking', url: '/qr-code-tracking' }, { name: 'QR Code Tracking', url: '/qr-code-tracking' },
]; ];
return ( return (
<> <>
<SeoJsonLd data={[softwareSchema, howToSchema, breadcrumbSchema(breadcrumbItems)]} /> <SeoJsonLd data={[softwareSchema, howToSchema, breadcrumbSchema(breadcrumbItems)]} />
<div className="min-h-screen bg-white"> <div className="min-h-screen bg-white">
{/* Hero Section */} {/* Hero Section */}
<section className="relative overflow-hidden bg-gradient-to-br from-blue-50 via-white to-purple-50 py-20"> <section className="relative overflow-hidden bg-gradient-to-br from-blue-50 via-white to-purple-50 py-20">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl"> <div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<Breadcrumbs items={breadcrumbItems} /> <Breadcrumbs items={breadcrumbItems} />
<div className="grid lg:grid-cols-2 gap-12 items-center"> <div className="grid lg:grid-cols-2 gap-12 items-center">
<div className="space-y-8"> <div className="space-y-8">
<div className="inline-flex items-center space-x-2 bg-blue-100 text-blue-800 px-4 py-2 rounded-full text-sm font-semibold"> <div className="inline-flex items-center space-x-2 bg-blue-100 text-blue-800 px-4 py-2 rounded-full text-sm font-semibold">
<span>📊</span> <span>📊</span>
<span>Free QR Code Tracking</span> <span>Free QR Code Tracking</span>
</div> </div>
<h1 className="text-5xl lg:text-6xl font-bold text-gray-900 leading-tight"> <h1 className="text-5xl lg:text-6xl font-bold text-gray-900 leading-tight">
Track Every QR Code Scan with Powerful Analytics Track Every QR Code Scan with Powerful Analytics
</h1> </h1>
<p className="text-xl text-gray-600 leading-relaxed"> <p className="text-xl text-gray-600 leading-relaxed">
Monitor your QR code performance in real-time. Get detailed insights on location, device, time, and user behavior. Make data-driven decisions with our free tracking software. Monitor your QR code performance in real-time. Get detailed insights on location, device, time, and user behavior. Make data-driven decisions with our free tracking software.
</p> </p>
<div className="flex flex-col sm:flex-row gap-4"> <div className="flex flex-col sm:flex-row gap-4">
<Link href="/signup"> <Link href="/signup">
<Button size="lg" className="text-lg px-8 py-4 w-full sm:w-auto"> <Button size="lg" className="text-lg px-8 py-4 w-full sm:w-auto">
Start Tracking Free Start Tracking Free
</Button> </Button>
</Link> </Link>
<Link href="/create"> <Link href="/create">
<Button variant="outline" size="lg" className="text-lg px-8 py-4 w-full sm:w-auto"> <Button variant="outline" size="lg" className="text-lg px-8 py-4 w-full sm:w-auto">
Create Trackable QR Code Create Trackable QR Code
</Button> </Button>
</Link> </Link>
</div> </div>
<div className="flex items-center space-x-6 text-sm text-gray-600"> <div className="flex items-center space-x-6 text-sm text-gray-600">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<svg className="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" /> <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg> </svg>
<span>No credit card required</span> <span>No credit card required</span>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<svg className="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" /> <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg> </svg>
<span>Unlimited scans</span> <span>Unlimited scans</span>
</div> </div>
</div> </div>
</div> </div>
{/* Analytics Preview */} {/* Analytics Preview */}
<div className="relative"> <div className="relative">
<Card className="p-6 shadow-2xl"> <Card className="p-6 shadow-2xl">
<h3 className="font-semibold text-lg mb-4">Live Analytics Dashboard</h3> <h3 className="font-semibold text-lg mb-4">Live Analytics Dashboard</h3>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex justify-between items-center pb-3 border-b"> <div className="flex justify-between items-center pb-3 border-b">
<span className="text-gray-600">Total Scans</span> <span className="text-gray-600">Total Scans</span>
<span className="text-2xl font-bold text-primary-600">12,547</span> <span className="text-2xl font-bold text-primary-600">12,547</span>
</div> </div>
<div className="flex justify-between items-center pb-3 border-b"> <div className="flex justify-between items-center pb-3 border-b">
<span className="text-gray-600">Unique Users</span> <span className="text-gray-600">Unique Users</span>
<span className="text-2xl font-bold text-primary-600">8,392</span> <span className="text-2xl font-bold text-primary-600">8,392</span>
</div> </div>
<div className="flex justify-between items-center pb-3 border-b"> <div className="flex justify-between items-center pb-3 border-b">
<span className="text-gray-600">Top Location</span> <span className="text-gray-600">Top Location</span>
<span className="font-semibold">🇩🇪 Germany</span> <span className="font-semibold">🇩🇪 Germany</span>
</div> </div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-gray-600">Top Device</span> <span className="text-gray-600">Top Device</span>
<span className="font-semibold">📱 iPhone</span> <span className="font-semibold">📱 iPhone</span>
</div> </div>
</div> </div>
</Card> </Card>
<div className="absolute -top-4 -right-4 bg-green-500 text-white px-4 py-2 rounded-full text-sm font-semibold shadow-lg animate-pulse"> <div className="absolute -top-4 -right-4 bg-green-500 text-white px-4 py-2 rounded-full text-sm font-semibold shadow-lg animate-pulse">
Live Updates Live Updates
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
{/* Tracking Features */} {/* Tracking Features */}
<section className="py-20 bg-gray-50"> <section className="py-20 bg-gray-50">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl"> <div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<div className="text-center mb-16"> <div className="text-center mb-16">
<h2 className="text-4xl font-bold text-gray-900 mb-4"> <h2 className="text-4xl font-bold text-gray-900 mb-4">
Powerful QR Code Tracking Features Powerful QR Code Tracking Features
</h2> </h2>
<p className="text-xl text-gray-600 max-w-3xl mx-auto"> <p className="text-xl text-gray-600 max-w-3xl mx-auto">
Get complete visibility into your QR code performance with our comprehensive analytics suite Get complete visibility into your QR code performance with our comprehensive analytics suite
</p> </p>
</div> </div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8"> <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{trackingFeatures.map((feature, index) => ( {trackingFeatures.map((feature, index) => (
<Card key={index} className="p-6 hover:shadow-lg transition-shadow"> <Card key={index} className="p-6 hover:shadow-lg transition-shadow">
<div className="text-4xl mb-4">{feature.icon}</div> <div className="text-4xl mb-4">{feature.icon}</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2"> <h3 className="text-xl font-semibold text-gray-900 mb-2">
{feature.title} {feature.title}
</h3> </h3>
<p className="text-gray-600"> <p className="text-gray-600">
{feature.description} {feature.description}
</p> </p>
</Card> </Card>
))} ))}
</div> </div>
</div> </div>
</section> </section>
{/* Use Cases */} {/* Use Cases */}
<section className="py-20"> <section className="py-20">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl"> <div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<div className="text-center mb-16"> <div className="text-center mb-16">
<h2 className="text-4xl font-bold text-gray-900 mb-4"> <h2 className="text-4xl font-bold text-gray-900 mb-4">
QR Code Tracking Use Cases QR Code Tracking Use Cases
</h2> </h2>
<p className="text-xl text-gray-600 max-w-3xl mx-auto"> <p className="text-xl text-gray-600 max-w-3xl mx-auto">
See how businesses use QR code tracking to improve their operations See how businesses use QR code tracking to improve their operations
</p> </p>
</div> </div>
<div className="grid md:grid-cols-2 gap-8"> <div className="grid md:grid-cols-2 gap-8">
{useCases.map((useCase, index) => ( {useCases.map((useCase, index) => (
<Card key={index} className="p-8"> <Card key={index} className="p-8">
<h3 className="text-2xl font-bold text-gray-900 mb-3"> <h3 className="text-2xl font-bold text-gray-900 mb-3">
{useCase.title} {useCase.title}
</h3> </h3>
<p className="text-gray-600 mb-6"> <p className="text-gray-600 mb-6">
{useCase.description} {useCase.description}
</p> </p>
<ul className="space-y-2"> <ul className="space-y-2">
{useCase.benefits.map((benefit, idx) => ( {useCase.benefits.map((benefit, idx) => (
<li key={idx} className="flex items-center space-x-2"> <li key={idx} className="flex items-center space-x-2">
<svg className="w-5 h-5 text-green-500 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-5 h-5 text-green-500 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" /> <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg> </svg>
<span className="text-gray-700">{benefit}</span> <span className="text-gray-700">{benefit}</span>
</li> </li>
))} ))}
</ul> </ul>
</Card> </Card>
))} ))}
</div> </div>
</div> </div>
</section> </section>
{/* Comparison Table */} {/* Comparison Table */}
<section className="py-20 bg-gray-50"> <section className="py-20 bg-gray-50">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-5xl"> <div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-5xl">
<div className="text-center mb-16"> <div className="text-center mb-16">
<h2 className="text-4xl font-bold text-gray-900 mb-4"> <h2 className="text-4xl font-bold text-gray-900 mb-4">
QR Master vs Free Tools QR Master vs Free Tools
</h2> </h2>
<p className="text-xl text-gray-600"> <p className="text-xl text-gray-600">
See why businesses choose QR Master for QR code tracking See why businesses choose QR Master for QR code tracking
</p> </p>
</div> </div>
<Card className="overflow-hidden"> <Card className="overflow-hidden">
<table className="w-full"> <table className="w-full">
<thead className="bg-gray-100"> <thead className="bg-gray-100">
<tr> <tr>
<th className="px-6 py-4 text-left text-gray-900 font-semibold">Feature</th> <th className="px-6 py-4 text-left text-gray-900 font-semibold">Feature</th>
<th className="px-6 py-4 text-center text-gray-900 font-semibold">Free Tools</th> <th className="px-6 py-4 text-center text-gray-900 font-semibold">Free Tools</th>
<th className="px-6 py-4 text-center text-primary-600 font-semibold">QR Master</th> <th className="px-6 py-4 text-center text-primary-600 font-semibold">QR Master</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-200"> <tbody className="divide-y divide-gray-200">
{comparisonData.map((row, index) => ( {comparisonData.map((row, index) => (
<tr key={index}> <tr key={index}>
<td className="px-6 py-4 text-gray-900 font-medium">{row.feature}</td> <td className="px-6 py-4 text-gray-900 font-medium">{row.feature}</td>
<td className="px-6 py-4 text-center"> <td className="px-6 py-4 text-center">
{typeof row.free === 'boolean' ? ( {typeof row.free === 'boolean' ? (
row.free ? ( row.free ? (
<span className="text-green-500 text-2xl"></span> <span className="text-green-500 text-2xl"></span>
) : ( ) : (
<span className="text-red-500 text-2xl"></span> <span className="text-red-500 text-2xl"></span>
) )
) : ( ) : (
<span className="text-gray-600">{row.free}</span> <span className="text-gray-600">{row.free}</span>
)} )}
</td> </td>
<td className="px-6 py-4 text-center"> <td className="px-6 py-4 text-center">
{typeof row.qrMaster === 'boolean' ? ( {typeof row.qrMaster === 'boolean' ? (
<span className="text-green-500 text-2xl"></span> <span className="text-green-500 text-2xl"></span>
) : ( ) : (
<span className="text-primary-600 font-semibold">{row.qrMaster}</span> <span className="text-primary-600 font-semibold">{row.qrMaster}</span>
)} )}
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</Card> </Card>
</div> </div>
</section> </section>
{/* CTA Section */} {/* CTA Section */}
<section className="py-20 bg-gradient-to-r from-primary-600 to-purple-600 text-white"> <section className="py-20 bg-gradient-to-r from-primary-600 to-purple-600 text-white">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-4xl text-center"> <div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-4xl text-center">
<h2 className="text-4xl font-bold mb-6"> <h2 className="text-4xl font-bold mb-6">
Start Tracking Your QR Codes Today Start Tracking Your QR Codes Today
</h2> </h2>
<p className="text-xl mb-8 text-primary-100"> <p className="text-xl mb-8 text-primary-100">
Join thousands of businesses using QR Master to track and optimize their QR code campaigns Join thousands of businesses using QR Master to track and optimize their QR code campaigns
</p> </p>
<div className="flex flex-col sm:flex-row gap-4 justify-center"> <div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link href="/signup"> <Link href="/signup">
<Button size="lg" variant="secondary" className="text-lg px-8 py-4 w-full sm:w-auto bg-white text-primary-600 hover:bg-gray-100"> <Button size="lg" variant="secondary" className="text-lg px-8 py-4 w-full sm:w-auto bg-white text-primary-600 hover:bg-gray-100">
Create Free Account Create Free Account
</Button> </Button>
</Link> </Link>
<Link href="/pricing"> <Link href="/pricing">
<Button size="lg" variant="outline" className="text-lg px-8 py-4 w-full sm:w-auto border-white text-white hover:bg-white/10"> <Button size="lg" variant="outline" className="text-lg px-8 py-4 w-full sm:w-auto border-white text-white hover:bg-white/10">
View Pricing View Pricing
</Button> </Button>
</Link> </Link>
</div> </div>
</div> </div>
</section> </section>
</div> </div>
</> </>
); );
} }

View File

@ -1,239 +1,239 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { generateSlug } from '@/lib/hash'; import { generateSlug } from '@/lib/hash';
import { createQRSchema, validateRequest } from '@/lib/validationSchemas'; import { createQRSchema, validateRequest } from '@/lib/validationSchemas';
import { csrfProtection } from '@/lib/csrf'; import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
// GET /api/qrs - List user's QR codes // GET /api/qrs - List user's QR codes
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const userId = cookies().get('userId')?.value; const userId = cookies().get('userId')?.value;
if (!userId) { if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
const qrCodes = await db.qRCode.findMany({ const qrCodes = await db.qRCode.findMany({
where: { userId }, where: { userId },
include: { include: {
_count: { _count: {
select: { scans: true }, select: { scans: true },
}, },
scans: { scans: {
where: { isUnique: true }, where: { isUnique: true },
select: { id: true }, select: { id: true },
}, },
}, },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
// Transform the data // Transform the data
const transformed = qrCodes.map(qr => ({ const transformed = qrCodes.map(qr => ({
...qr, ...qr,
scans: qr._count.scans, scans: qr._count.scans,
uniqueScans: qr.scans.length, // Count of scans where isUnique=true uniqueScans: qr.scans.length, // Count of scans where isUnique=true
_count: undefined, _count: undefined,
})); }));
return NextResponse.json(transformed); return NextResponse.json(transformed);
} catch (error) { } catch (error) {
console.error('Error fetching QR codes:', error); console.error('Error fetching QR codes:', error);
return NextResponse.json( return NextResponse.json(
{ error: 'Internal server error' }, { error: 'Internal server error' },
{ status: 500 } { status: 500 }
); );
} }
} }
// Plan limits // Plan limits
const PLAN_LIMITS = { const PLAN_LIMITS = {
FREE: 3, FREE: 3,
PRO: 50, PRO: 50,
BUSINESS: 500, BUSINESS: 500,
}; };
// POST /api/qrs - Create a new QR code // POST /api/qrs - Create a new QR code
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
// CSRF Protection // CSRF Protection
const csrfCheck = csrfProtection(request); const csrfCheck = csrfProtection(request);
if (!csrfCheck.valid) { if (!csrfCheck.valid) {
return NextResponse.json({ error: csrfCheck.error }, { status: 403 }); return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
} }
const userId = cookies().get('userId')?.value; const userId = cookies().get('userId')?.value;
console.log('POST /api/qrs - userId from cookie:', userId); console.log('POST /api/qrs - userId from cookie:', userId);
// Rate Limiting (user-based) // Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request); const clientId = userId || getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.QR_CREATE); const rateLimitResult = rateLimit(clientId, RateLimits.QR_CREATE);
if (!rateLimitResult.success) { if (!rateLimitResult.success) {
return NextResponse.json( return NextResponse.json(
{ {
error: 'Too many requests. Please try again later.', error: 'Too many requests. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000) retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
}, },
{ {
status: 429, status: 429,
headers: { headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(), 'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(), 'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(), 'X-RateLimit-Reset': rateLimitResult.reset.toString(),
} }
} }
); );
} }
if (!userId) { if (!userId) {
return NextResponse.json({ error: 'Unauthorized - no userId cookie' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized - no userId cookie' }, { status: 401 });
} }
// Check if user exists and get their plan // Check if user exists and get their plan
const user = await db.user.findUnique({ const user = await db.user.findUnique({
where: { id: userId }, where: { id: userId },
select: { plan: true }, select: { plan: true },
}); });
console.log('User exists:', !!user); console.log('User exists:', !!user);
if (!user) { if (!user) {
return NextResponse.json({ error: `User not found: ${userId}` }, { status: 404 }); return NextResponse.json({ error: `User not found: ${userId}` }, { status: 404 });
} }
const body = await request.json(); const body = await request.json();
console.log('Request body:', body); console.log('Request body:', body);
// Validate request body with Zod (only for non-static QRs or simplified validation) // 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 // Note: Static QRs have complex nested content structure, so we do basic validation
if (!body.isStatic) { if (!body.isStatic) {
const validation = await validateRequest(createQRSchema, body); const validation = await validateRequest(createQRSchema, body);
if (!validation.success) { if (!validation.success) {
return NextResponse.json(validation.error, { status: 400 }); return NextResponse.json(validation.error, { status: 400 });
} }
} }
// Check if this is a static QR request // Check if this is a static QR request
const isStatic = body.isStatic === true; const isStatic = body.isStatic === true;
// Only check limits for DYNAMIC QR codes (static QR codes are unlimited) // Only check limits for DYNAMIC QR codes (static QR codes are unlimited)
if (!isStatic) { if (!isStatic) {
// Count existing dynamic QR codes // Count existing dynamic QR codes
const dynamicQRCount = await db.qRCode.count({ const dynamicQRCount = await db.qRCode.count({
where: { where: {
userId, userId,
type: 'DYNAMIC', type: 'DYNAMIC',
}, },
}); });
const userPlan = user.plan || 'FREE'; const userPlan = user.plan || 'FREE';
const limit = PLAN_LIMITS[userPlan as keyof typeof PLAN_LIMITS] || PLAN_LIMITS.FREE; const limit = PLAN_LIMITS[userPlan as keyof typeof PLAN_LIMITS] || PLAN_LIMITS.FREE;
if (dynamicQRCount >= limit) { if (dynamicQRCount >= limit) {
return NextResponse.json( return NextResponse.json(
{ {
error: 'Limit reached', error: 'Limit reached',
message: `You have reached the limit of ${limit} dynamic QR codes for your ${userPlan} plan. Please upgrade to create more.`, message: `You have reached the limit of ${limit} dynamic QR codes for your ${userPlan} plan. Please upgrade to create more.`,
currentCount: dynamicQRCount, currentCount: dynamicQRCount,
limit, limit,
plan: userPlan, plan: userPlan,
}, },
{ status: 403 } { status: 403 }
); );
} }
} }
let enrichedContent = body.content; let enrichedContent = body.content;
// For STATIC QR codes, calculate what the QR should contain // For STATIC QR codes, calculate what the QR should contain
if (isStatic) { if (isStatic) {
let qrContent = ''; let qrContent = '';
switch (body.contentType) { switch (body.contentType) {
case 'URL': case 'URL':
qrContent = body.content.url; qrContent = body.content.url;
break; break;
case 'PHONE': case 'PHONE':
qrContent = `tel:${body.content.phone}`; qrContent = `tel:${body.content.phone}`;
break; break;
case 'SMS': case 'SMS':
qrContent = `sms:${body.content.phone}${body.content.message ? `?body=${encodeURIComponent(body.content.message)}` : ''}`; qrContent = `sms:${body.content.phone}${body.content.message ? `?body=${encodeURIComponent(body.content.message)}` : ''}`;
break; break;
case 'VCARD': case 'VCARD':
qrContent = `BEGIN:VCARD qrContent = `BEGIN:VCARD
VERSION:3.0 VERSION:3.0
FN:${body.content.firstName || ''} ${body.content.lastName || ''} FN:${body.content.firstName || ''} ${body.content.lastName || ''}
N:${body.content.lastName || ''};${body.content.firstName || ''};;; N:${body.content.lastName || ''};${body.content.firstName || ''};;;
${body.content.organization ? `ORG:${body.content.organization}` : ''} ${body.content.organization ? `ORG:${body.content.organization}` : ''}
${body.content.title ? `TITLE:${body.content.title}` : ''} ${body.content.title ? `TITLE:${body.content.title}` : ''}
${body.content.email ? `EMAIL:${body.content.email}` : ''} ${body.content.email ? `EMAIL:${body.content.email}` : ''}
${body.content.phone ? `TEL:${body.content.phone}` : ''} ${body.content.phone ? `TEL:${body.content.phone}` : ''}
END:VCARD`; END:VCARD`;
break; break;
case 'GEO': case 'GEO':
const lat = body.content.latitude || 0; const lat = body.content.latitude || 0;
const lon = body.content.longitude || 0; const lon = body.content.longitude || 0;
const label = body.content.label ? `?q=${encodeURIComponent(body.content.label)}` : ''; const label = body.content.label ? `?q=${encodeURIComponent(body.content.label)}` : '';
qrContent = `geo:${lat},${lon}${label}`; qrContent = `geo:${lat},${lon}${label}`;
break; break;
case 'TEXT': case 'TEXT':
qrContent = body.content.text; qrContent = body.content.text;
break; break;
case 'WHATSAPP': case 'WHATSAPP':
qrContent = `https://wa.me/${body.content.phone}${body.content.message ? `?text=${encodeURIComponent(body.content.message)}` : ''}`; qrContent = `https://wa.me/${body.content.phone}${body.content.message ? `?text=${encodeURIComponent(body.content.message)}` : ''}`;
break; break;
case 'PDF': case 'PDF':
qrContent = body.content.fileUrl || 'https://example.com/file.pdf'; qrContent = body.content.fileUrl || 'https://example.com/file.pdf';
break; break;
case 'APP': case 'APP':
qrContent = body.content.fallbackUrl || body.content.iosUrl || body.content.androidUrl || 'https://example.com'; qrContent = body.content.fallbackUrl || body.content.iosUrl || body.content.androidUrl || 'https://example.com';
break; break;
case 'COUPON': case 'COUPON':
qrContent = `Coupon: ${body.content.code || 'CODE'} - ${body.content.discount || 'Discount'}`; qrContent = `Coupon: ${body.content.code || 'CODE'} - ${body.content.discount || 'Discount'}`;
break; break;
case 'FEEDBACK': case 'FEEDBACK':
qrContent = body.content.feedbackUrl || 'https://example.com/feedback'; qrContent = body.content.feedbackUrl || 'https://example.com/feedback';
break; break;
default: default:
qrContent = body.content.url || 'https://example.com'; qrContent = body.content.url || 'https://example.com';
} }
// Add qrContent to the content object // Add qrContent to the content object
enrichedContent = { enrichedContent = {
...body.content, ...body.content,
qrContent // This is what the QR code should actually contain qrContent // This is what the QR code should actually contain
}; };
} }
// Generate slug for the QR code // Generate slug for the QR code
const slug = generateSlug(body.title); const slug = generateSlug(body.title);
// Create QR code // Create QR code
const qrCode = await db.qRCode.create({ const qrCode = await db.qRCode.create({
data: { data: {
userId, userId,
title: body.title, title: body.title,
type: isStatic ? 'STATIC' : 'DYNAMIC', type: isStatic ? 'STATIC' : 'DYNAMIC',
contentType: body.contentType, contentType: body.contentType,
content: enrichedContent, content: enrichedContent,
tags: body.tags || [], tags: body.tags || [],
style: body.style || { style: body.style || {
foregroundColor: '#000000', foregroundColor: '#000000',
backgroundColor: '#FFFFFF', backgroundColor: '#FFFFFF',
cornerStyle: 'square', cornerStyle: 'square',
size: 200, size: 200,
}, },
slug, slug,
status: 'ACTIVE', status: 'ACTIVE',
}, },
}); });
return NextResponse.json(qrCode); return NextResponse.json(qrCode);
} catch (error) { } catch (error) {
console.error('Error creating QR code:', error); console.error('Error creating QR code:', error);
return NextResponse.json( return NextResponse.json(
{ error: 'Internal server error', details: String(error) }, { error: 'Internal server error', details: String(error) },
{ status: 500 } { status: 500 }
); );
} }
} }

View File

@ -0,0 +1,64 @@
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';
export async function POST(request: NextRequest) {
try {
// 1. Authentication Check
const session = await getServerSession(authOptions);
if (!session || !session.user) {
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 }
);
}
}

View File

@ -1,119 +1,119 @@
'use client'; 'use client';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
export default function Error({ export default function Error({
error, error,
reset, reset,
}: { }: {
error: Error & { digest?: string }; error: Error & { digest?: string };
reset: () => void; reset: () => void;
}) { }) {
useEffect(() => { useEffect(() => {
// Log the error to an error reporting service // Log the error to an error reporting service
console.error('Error:', error); console.error('Error:', error);
}, [error]); }, [error]);
return ( return (
<div className="min-h-screen bg-white flex items-center justify-center px-4"> <div className="min-h-screen bg-white flex items-center justify-center px-4">
<div className="max-w-2xl w-full text-center"> <div className="max-w-2xl w-full text-center">
{/* Error Icon */} {/* Error Icon */}
<div className="mb-8"> <div className="mb-8">
<div className="inline-flex items-center justify-center w-24 h-24 bg-red-100 rounded-full mb-6"> <div className="inline-flex items-center justify-center w-24 h-24 bg-red-100 rounded-full mb-6">
<svg <svg
className="w-12 h-12 text-red-600" className="w-12 h-12 text-red-600"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/> />
</svg> </svg>
</div> </div>
{/* Error Text */} {/* Error Text */}
<h1 className="text-6xl md:text-8xl font-bold text-gray-900 mb-4">500</h1> <h1 className="text-6xl md:text-8xl font-bold text-gray-900 mb-4">500</h1>
<h2 className="text-2xl md:text-3xl font-semibold text-gray-700 mb-4"> <h2 className="text-2xl md:text-3xl font-semibold text-gray-700 mb-4">
Something Went Wrong Something Went Wrong
</h2> </h2>
<p className="text-lg text-gray-600 mb-8 max-w-md mx-auto"> <p className="text-lg text-gray-600 mb-8 max-w-md mx-auto">
We're sorry, but something unexpected happened. Our team has been notified and is working on a fix. We're sorry, but something unexpected happened. Our team has been notified and is working on a fix.
</p> </p>
{/* Error Details (only in development) */} {/* Error Details (only in development) */}
{process.env.NODE_ENV === 'development' && error.message && ( {process.env.NODE_ENV === 'development' && error.message && (
<div className="mb-8 p-4 bg-red-50 border border-red-200 rounded-lg text-left"> <div className="mb-8 p-4 bg-red-50 border border-red-200 rounded-lg text-left">
<p className="text-sm font-mono text-red-800 break-all"> <p className="text-sm font-mono text-red-800 break-all">
<strong>Error:</strong> {error.message} <strong>Error:</strong> {error.message}
</p> </p>
{error.digest && ( {error.digest && (
<p className="text-sm font-mono text-red-600 mt-2"> <p className="text-sm font-mono text-red-600 mt-2">
<strong>Digest:</strong> {error.digest} <strong>Digest:</strong> {error.digest}
</p> </p>
)} )}
</div> </div>
)} )}
</div> </div>
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center"> <div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
<Button size="lg" onClick={reset}> <Button size="lg" onClick={reset}>
<svg <svg
className="w-5 h-5 mr-2" className="w-5 h-5 mr-2"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/> />
</svg> </svg>
Try Again Try Again
</Button> </Button>
<Link href="/"> <Link href="/">
<Button variant="outline" size="lg"> <Button variant="outline" size="lg">
<svg <svg
className="w-5 h-5 mr-2" className="w-5 h-5 mr-2"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
/> />
</svg> </svg>
Go Home Go Home
</Button> </Button>
</Link> </Link>
</div> </div>
{/* Help Text */} {/* Help Text */}
<div className="mt-12 pt-8 border-t border-gray-200"> <div className="mt-12 pt-8 border-t border-gray-200">
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
If this problem persists, please{' '} If this problem persists, please{' '}
<Link href="/#faq" className="text-primary-600 hover:text-primary-700 font-medium"> <Link href="/#faq" className="text-primary-600 hover:text-primary-700 font-medium">
check our FAQ check our FAQ
</Link> </Link>
{' '}or contact support. {' '}or contact support.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
); );
} }

View File

@ -1,72 +1,72 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { Suspense } from 'react'; import { Suspense } from 'react';
import '@/styles/globals.css'; import '@/styles/globals.css';
import { ToastContainer } from '@/components/ui/Toast'; import { ToastContainer } from '@/components/ui/Toast';
import AuthProvider from '@/components/SessionProvider'; import AuthProvider from '@/components/SessionProvider';
import { PostHogProvider } from '@/components/PostHogProvider'; import { PostHogProvider } from '@/components/PostHogProvider';
import CookieBanner from '@/components/CookieBanner'; import CookieBanner from '@/components/CookieBanner';
const isIndexable = process.env.NEXT_PUBLIC_INDEXABLE === 'true'; const isIndexable = process.env.NEXT_PUBLIC_INDEXABLE === 'true';
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL('https://www.qrmaster.net'), metadataBase: new URL('https://www.qrmaster.net'),
title: { title: {
default: 'QR Master Smart QR Generator & Analytics', default: 'QR Master Smart QR Generator & Analytics',
template: '%s | QR Master', template: '%s | QR Master',
}, },
description: 'Create dynamic QR codes, track scans, and scale campaigns with secure analytics.', description: 'Create dynamic QR codes, track scans, and scale campaigns with secure analytics.',
keywords: 'QR code, QR generator, dynamic QR, QR tracking, QR analytics, branded QR, bulk QR generator', keywords: 'QR code, QR generator, dynamic QR, QR tracking, QR analytics, branded QR, bulk QR generator',
robots: isIndexable robots: isIndexable
? { index: true, follow: true } ? { index: true, follow: true }
: { index: false, follow: false }, : { index: false, follow: false },
icons: { icons: {
icon: [ icon: [
{ url: '/favicon.svg', type: 'image/svg+xml' }, { url: '/favicon.svg', type: 'image/svg+xml' },
{ url: '/logo.svg', type: 'image/svg+xml' }, { url: '/logo.svg', type: 'image/svg+xml' },
], ],
apple: '/logo.svg', apple: '/logo.svg',
}, },
twitter: { twitter: {
card: 'summary_large_image', card: 'summary_large_image',
site: '@qrmaster', site: '@qrmaster',
images: ['https://www.qrmaster.net/static/og-image.png'], images: ['https://www.qrmaster.net/static/og-image.png'],
}, },
openGraph: { openGraph: {
type: 'website', type: 'website',
siteName: 'QR Master', siteName: 'QR Master',
title: 'QR Master Smart QR Generator & Analytics', title: 'QR Master Smart QR Generator & Analytics',
description: 'Create dynamic QR codes, track scans, and scale campaigns with secure analytics.', description: 'Create dynamic QR codes, track scans, and scale campaigns with secure analytics.',
url: 'https://www.qrmaster.net', url: 'https://www.qrmaster.net',
images: [ images: [
{ {
url: 'https://www.qrmaster.net/static/og-image.png', url: 'https://www.qrmaster.net/static/og-image.png',
width: 1200, width: 1200,
height: 630, height: 630,
alt: 'QR Master - Dynamic QR Code Generator and Analytics Platform', alt: 'QR Master - Dynamic QR Code Generator and Analytics Platform',
}, },
], ],
locale: 'en_US', locale: 'en_US',
}, },
}; };
export default function RootLayout({ export default function RootLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<html lang="en"> <html lang="en">
<body className="font-sans"> <body className="font-sans">
<Suspense fallback={null}> <Suspense fallback={null}>
<PostHogProvider> <PostHogProvider>
<AuthProvider> <AuthProvider>
{children} {children}
</AuthProvider> </AuthProvider>
<CookieBanner /> <CookieBanner />
<ToastContainer /> <ToastContainer />
</PostHogProvider> </PostHogProvider>
</Suspense> </Suspense>
</body> </body>
</html> </html>
); );
} }

View File

@ -1,63 +1,63 @@
import React from 'react'; import React from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
export default function NotFound() { export default function NotFound() {
return ( return (
<div className="min-h-screen bg-white flex items-center justify-center px-4"> <div className="min-h-screen bg-white flex items-center justify-center px-4">
<div className="max-w-2xl w-full text-center"> <div className="max-w-2xl w-full text-center">
{/* 404 Icon */} {/* 404 Icon */}
<div className="mb-8"> <div className="mb-8">
<div className="inline-flex items-center justify-center w-24 h-24 bg-primary-100 rounded-full mb-6"> <div className="inline-flex items-center justify-center w-24 h-24 bg-primary-100 rounded-full mb-6">
<svg <svg
className="w-12 h-12 text-primary-600" className="w-12 h-12 text-primary-600"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" 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> </svg>
</div> </div>
{/* 404 Text */} {/* 404 Text */}
<h1 className="text-6xl md:text-8xl font-bold text-gray-900 mb-4">404</h1> <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"> <h2 className="text-2xl md:text-3xl font-semibold text-gray-700 mb-4">
Page Not Found Page Not Found
</h2> </h2>
<p className="text-lg text-gray-600 mb-8 max-w-md mx-auto"> <p className="text-lg text-gray-600 mb-8 max-w-md mx-auto">
Sorry, we couldn't find the page you're looking for. It might have been moved or deleted. Sorry, we couldn't find the page you're looking for. It might have been moved or deleted.
</p> </p>
</div> </div>
{/* Action Button */} {/* Action Button */}
<div className="flex justify-center"> <div className="flex justify-center">
<Link href="/"> <Link href="/">
<Button size="lg"> <Button size="lg">
<svg <svg
className="w-5 h-5 mr-2" className="w-5 h-5 mr-2"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
/> />
</svg> </svg>
Back to Home Back to Home
</Button> </Button>
</Link> </Link>
</div> </div>
</div> </div>
</div> </div>
); );
} }

View File

@ -1,240 +1,240 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { hashIP } from '@/lib/hash'; import { hashIP } from '@/lib/hash';
export async function GET( export async function GET(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ slug: string }> } { params }: { params: Promise<{ slug: string }> }
) { ) {
try { try {
const { slug } = await params; const { slug } = await params;
// Fetch QR code by slug // Fetch QR code by slug
const qrCode = await db.qRCode.findUnique({ const qrCode = await db.qRCode.findUnique({
where: { slug }, where: { slug },
select: { select: {
id: true, id: true,
content: true, content: true,
contentType: true, contentType: true,
}, },
}); });
if (!qrCode) { if (!qrCode) {
return new NextResponse('QR Code not found', { status: 404 }); return new NextResponse('QR Code not found', { status: 404 });
} }
// Track scan (fire and forget) // Track scan (fire and forget)
trackScan(qrCode.id, request).catch(console.error); trackScan(qrCode.id, request).catch(console.error);
// Determine destination URL // Determine destination URL
let destination = ''; let destination = '';
const content = qrCode.content as any; const content = qrCode.content as any;
switch (qrCode.contentType) { switch (qrCode.contentType) {
case 'URL': case 'URL':
destination = content.url || 'https://example.com'; destination = content.url || 'https://example.com';
break; break;
case 'PHONE': case 'PHONE':
destination = `tel:${content.phone}`; destination = `tel:${content.phone}`;
break; break;
case 'SMS': case 'SMS':
destination = `sms:${content.phone}${content.message ? `?body=${encodeURIComponent(content.message)}` : ''}`; destination = `sms:${content.phone}${content.message ? `?body=${encodeURIComponent(content.message)}` : ''}`;
break; break;
case 'WHATSAPP': case 'WHATSAPP':
destination = `https://wa.me/${content.phone}${content.message ? `?text=${encodeURIComponent(content.message)}` : ''}`; destination = `https://wa.me/${content.phone}${content.message ? `?text=${encodeURIComponent(content.message)}` : ''}`;
break; break;
case 'VCARD': case 'VCARD':
// For vCard, redirect to display page // For vCard, redirect to display page
const baseUrlVcard = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050'; 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 || '')}`; 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; break;
case 'GEO': case 'GEO':
// For location, redirect to Google Maps (works on desktop and mobile) // For location, redirect to Google Maps (works on desktop and mobile)
const lat = content.latitude || 0; const lat = content.latitude || 0;
const lon = content.longitude || 0; const lon = content.longitude || 0;
destination = `https://maps.google.com/?q=${lat},${lon}`; destination = `https://maps.google.com/?q=${lat},${lon}`;
break; break;
case 'TEXT': case 'TEXT':
// For plain text, redirect to a display page // For plain text, redirect to a display page
const baseUrlText = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050'; const baseUrlText = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050';
destination = `${baseUrlText}/display?text=${encodeURIComponent(content.text || '')}`; destination = `${baseUrlText}/display?text=${encodeURIComponent(content.text || '')}`;
break; break;
case 'PDF': case 'PDF':
// Direct link to file // Direct link to file
destination = content.fileUrl || 'https://example.com/file.pdf'; destination = content.fileUrl || 'https://example.com/file.pdf';
break; break;
case 'APP': case 'APP':
// Smart device detection for app stores // Smart device detection for app stores
const userAgent = request.headers.get('user-agent') || ''; const userAgent = request.headers.get('user-agent') || '';
const isIOS = /iphone|ipad|ipod/i.test(userAgent); const isIOS = /iphone|ipad|ipod/i.test(userAgent);
const isAndroid = /android/i.test(userAgent); const isAndroid = /android/i.test(userAgent);
if (isIOS && content.iosUrl) { if (isIOS && content.iosUrl) {
destination = content.iosUrl; destination = content.iosUrl;
} else if (isAndroid && content.androidUrl) { } else if (isAndroid && content.androidUrl) {
destination = content.androidUrl; destination = content.androidUrl;
} else { } else {
destination = content.fallbackUrl || content.iosUrl || content.androidUrl || 'https://example.com'; destination = content.fallbackUrl || content.iosUrl || content.androidUrl || 'https://example.com';
} }
break; break;
case 'COUPON': case 'COUPON':
// Redirect to coupon display page // Redirect to coupon display page
const baseUrlCoupon = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050'; const baseUrlCoupon = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050';
destination = `${baseUrlCoupon}/coupon/${slug}`; destination = `${baseUrlCoupon}/coupon/${slug}`;
break; break;
case 'FEEDBACK': case 'FEEDBACK':
// Redirect to feedback form page // Redirect to feedback form page
const baseUrlFeedback = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050'; const baseUrlFeedback = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050';
destination = `${baseUrlFeedback}/feedback/${slug}`; destination = `${baseUrlFeedback}/feedback/${slug}`;
break; break;
default: default:
destination = 'https://example.com'; destination = 'https://example.com';
} }
// Preserve UTM parameters // Preserve UTM parameters
const searchParams = request.nextUrl.searchParams; const searchParams = request.nextUrl.searchParams;
const utmParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']; const utmParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
const preservedParams = new URLSearchParams(); const preservedParams = new URLSearchParams();
utmParams.forEach(param => { utmParams.forEach(param => {
const value = searchParams.get(param); const value = searchParams.get(param);
if (value) { if (value) {
preservedParams.set(param, value); preservedParams.set(param, value);
} }
}); });
// Add preserved params to destination // Add preserved params to destination
if (preservedParams.toString() && destination.startsWith('http')) { if (preservedParams.toString() && destination.startsWith('http')) {
const separator = destination.includes('?') ? '&' : '?'; const separator = destination.includes('?') ? '&' : '?';
destination = `${destination}${separator}${preservedParams.toString()}`; destination = `${destination}${separator}${preservedParams.toString()}`;
} }
// Return 307 redirect (temporary redirect that preserves method) // Return 307 redirect (temporary redirect that preserves method)
return NextResponse.redirect(destination, { status: 307 }); return NextResponse.redirect(destination, { status: 307 });
} catch (error) { } catch (error) {
console.error('QR redirect error:', error); console.error('QR redirect error:', error);
return new NextResponse('Internal server error', { status: 500 }); return new NextResponse('Internal server error', { status: 500 });
} }
} }
async function trackScan(qrId: string, request: NextRequest) { async function trackScan(qrId: string, request: NextRequest) {
try { try {
const userAgent = request.headers.get('user-agent') || ''; const userAgent = request.headers.get('user-agent') || '';
const referer = request.headers.get('referer') || ''; const referer = request.headers.get('referer') || '';
const ip = request.headers.get('x-forwarded-for') || const ip = request.headers.get('x-forwarded-for') ||
request.headers.get('x-real-ip') || request.headers.get('x-real-ip') ||
'unknown'; 'unknown';
// Check DNT header // Check DNT header
const dnt = request.headers.get('dnt'); const dnt = request.headers.get('dnt');
if (dnt === '1') { if (dnt === '1') {
// Respect Do Not Track - only increment counter // Respect Do Not Track - only increment counter
await db.qRScan.create({ await db.qRScan.create({
data: { data: {
qrId, qrId,
ipHash: 'dnt', ipHash: 'dnt',
isUnique: false, isUnique: false,
}, },
}); });
return; return;
} }
// Hash IP for privacy // Hash IP for privacy
const ipHash = hashIP(ip); const ipHash = hashIP(ip);
// Device Detection Logic: // Device Detection Logic:
// 1. Windows or Linux -> Always Desktop // 1. Windows or Linux -> Always Desktop
// 2. Explicit iPad/Tablet keywords -> Tablet // 2. Explicit iPad/Tablet keywords -> Tablet
// 3. Mac + Chrome browser -> Desktop (real Mac users often use Chrome) // 3. Mac + Chrome browser -> Desktop (real Mac users often use Chrome)
// 4. Mac + Safari + No Referrer -> Likely iPad scanning a QR code // 4. Mac + Safari + No Referrer -> Likely iPad scanning a QR code
// 5. Mobile keywords -> Mobile // 5. Mobile keywords -> Mobile
// 6. Everything else -> Desktop // 6. Everything else -> Desktop
const isWindows = /windows/i.test(userAgent); const isWindows = /windows/i.test(userAgent);
const isLinux = /linux/i.test(userAgent) && !/android/i.test(userAgent); const isLinux = /linux/i.test(userAgent) && !/android/i.test(userAgent);
const isExplicitTablet = /tablet|ipad|playbook|silk/i.test(userAgent); const isExplicitTablet = /tablet|ipad|playbook|silk/i.test(userAgent);
const isAndroidTablet = /android/i.test(userAgent) && !/mobile/i.test(userAgent); const isAndroidTablet = /android/i.test(userAgent) && !/mobile/i.test(userAgent);
const isMacintosh = /macintosh/i.test(userAgent); const isMacintosh = /macintosh/i.test(userAgent);
const isChrome = /chrome/i.test(userAgent); const isChrome = /chrome/i.test(userAgent);
const isSafari = /safari/i.test(userAgent) && !isChrome; const isSafari = /safari/i.test(userAgent) && !isChrome;
const hasReferrer = !!referer; const hasReferrer = !!referer;
// iPad in desktop mode: Mac + Safari (no Chrome) + No Referrer (physical scan) // iPad in desktop mode: Mac + Safari (no Chrome) + No Referrer (physical scan)
const isLikelyiPadScan = isMacintosh && isSafari && !hasReferrer; const isLikelyiPadScan = isMacintosh && isSafari && !hasReferrer;
let device: string; let device: string;
if (isWindows || isLinux) { if (isWindows || isLinux) {
device = 'desktop'; device = 'desktop';
} else if (isExplicitTablet || isAndroidTablet || isLikelyiPadScan) { } else if (isExplicitTablet || isAndroidTablet || isLikelyiPadScan) {
device = 'tablet'; device = 'tablet';
} else if (/mobile|iphone/i.test(userAgent)) { } else if (/mobile|iphone/i.test(userAgent)) {
device = 'mobile'; device = 'mobile';
} else if (isMacintosh && isChrome) { } else if (isMacintosh && isChrome) {
device = 'desktop'; // Mac with Chrome = real desktop device = 'desktop'; // Mac with Chrome = real desktop
} else if (isMacintosh && hasReferrer) { } else if (isMacintosh && hasReferrer) {
device = 'desktop'; // Mac with referrer = probably clicked a link on desktop device = 'desktop'; // Mac with referrer = probably clicked a link on desktop
} else { } else {
device = 'desktop'; // Default fallback device = 'desktop'; // Default fallback
} }
// Detect OS // Detect OS
let os = 'unknown'; let os = 'unknown';
if (/windows/i.test(userAgent)) os = 'Windows'; if (/windows/i.test(userAgent)) os = 'Windows';
else if (/mac/i.test(userAgent)) os = 'macOS'; else if (/mac/i.test(userAgent)) os = 'macOS';
else if (/linux/i.test(userAgent)) os = 'Linux'; else if (/linux/i.test(userAgent)) os = 'Linux';
else if (/android/i.test(userAgent)) os = 'Android'; else if (/android/i.test(userAgent)) os = 'Android';
else if (/ios|iphone|ipad/i.test(userAgent)) os = 'iOS'; else if (/ios|iphone|ipad/i.test(userAgent)) os = 'iOS';
// Get country from header (Vercel/Cloudflare provide this) // Get country from header (Vercel/Cloudflare provide this)
const country = request.headers.get('x-vercel-ip-country') || const country = request.headers.get('x-vercel-ip-country') ||
request.headers.get('cf-ipcountry') || request.headers.get('cf-ipcountry') ||
'unknown'; 'unknown';
// Extract UTM parameters // Extract UTM parameters
const searchParams = request.nextUrl.searchParams; const searchParams = request.nextUrl.searchParams;
const utmSource = searchParams.get('utm_source'); const utmSource = searchParams.get('utm_source');
const utmMedium = searchParams.get('utm_medium'); const utmMedium = searchParams.get('utm_medium');
const utmCampaign = searchParams.get('utm_campaign'); const utmCampaign = searchParams.get('utm_campaign');
// Check if this is a unique scan (first scan from this IP + Device today) // 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 // 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 deviceFingerprint = hashIP(userAgent.substring(0, 100)); // Hash the user agent for privacy
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
const existingScan = await db.qRScan.findFirst({ const existingScan = await db.qRScan.findFirst({
where: { where: {
qrId, qrId,
ipHash, ipHash,
userAgent: { userAgent: {
startsWith: userAgent.substring(0, 50), // Match same device type startsWith: userAgent.substring(0, 50), // Match same device type
}, },
ts: { ts: {
gte: today, gte: today,
}, },
}, },
}); });
const isUnique = !existingScan; const isUnique = !existingScan;
// Create scan record // Create scan record
await db.qRScan.create({ await db.qRScan.create({
data: { data: {
qrId, qrId,
ipHash, ipHash,
userAgent: userAgent.substring(0, 255), userAgent: userAgent.substring(0, 255),
device, device,
os, os,
country, country,
referrer: referer.substring(0, 255), referrer: referer.substring(0, 255),
utmSource, utmSource,
utmMedium, utmMedium,
utmCampaign, utmCampaign,
isUnique, isUnique,
}, },
}); });
} catch (error) { } catch (error) {
console.error('Error tracking scan:', error); console.error('Error tracking scan:', error);
// Don't throw - this is fire and forget // Don't throw - this is fire and forget
} }
} }

View File

@ -1,21 +1,21 @@
import { MetadataRoute } from 'next'; import { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots { export default function robots(): MetadataRoute.Robots {
const baseUrl = 'https://www.qrmaster.net'; const baseUrl = 'https://www.qrmaster.net';
return { return {
rules: [ rules: [
{ {
userAgent: '*', userAgent: '*',
allow: '/', allow: '/',
disallow: [ disallow: [
'/api/', '/api/',
'/dashboard/', '/dashboard/',
'/create/', '/create/',
'/settings/', '/settings/',
], ],
}, },
], ],
sitemap: `${baseUrl}/sitemap.xml`, sitemap: `${baseUrl}/sitemap.xml`,
}; };
} }

View File

@ -1,274 +1,274 @@
'use client'; 'use client';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
export default function VCardPage() { export default function VCardPage() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [firstName, setFirstName] = useState(''); const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState(''); const [lastName, setLastName] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [phone, setPhone] = useState(''); const [phone, setPhone] = useState('');
const [organization, setOrganization] = useState(''); const [organization, setOrganization] = useState('');
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [hasAutoDownloaded, setHasAutoDownloaded] = useState(false); const [hasAutoDownloaded, setHasAutoDownloaded] = useState(false);
useEffect(() => { useEffect(() => {
const firstNameParam = searchParams.get('firstName'); const firstNameParam = searchParams.get('firstName');
const lastNameParam = searchParams.get('lastName'); const lastNameParam = searchParams.get('lastName');
const emailParam = searchParams.get('email'); const emailParam = searchParams.get('email');
const phoneParam = searchParams.get('phone'); const phoneParam = searchParams.get('phone');
const organizationParam = searchParams.get('organization'); const organizationParam = searchParams.get('organization');
const titleParam = searchParams.get('title'); const titleParam = searchParams.get('title');
if (firstNameParam) setFirstName(firstNameParam); if (firstNameParam) setFirstName(firstNameParam);
if (lastNameParam) setLastName(lastNameParam); if (lastNameParam) setLastName(lastNameParam);
if (emailParam) setEmail(emailParam); if (emailParam) setEmail(emailParam);
if (phoneParam) setPhone(phoneParam); if (phoneParam) setPhone(phoneParam);
if (organizationParam) setOrganization(organizationParam); if (organizationParam) setOrganization(organizationParam);
if (titleParam) setTitle(titleParam); if (titleParam) setTitle(titleParam);
setIsLoading(false); setIsLoading(false);
}, [searchParams]); }, [searchParams]);
// Auto-download after 500ms (only once) // Auto-download after 500ms (only once)
useEffect(() => { useEffect(() => {
if ((firstName || lastName) && !hasAutoDownloaded) { if ((firstName || lastName) && !hasAutoDownloaded) {
const timer = setTimeout(() => { const timer = setTimeout(() => {
handleSaveContact(); handleSaveContact();
setHasAutoDownloaded(true); setHasAutoDownloaded(true);
}, 500); }, 500);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
}, [firstName, lastName, hasAutoDownloaded]); // eslint-disable-line react-hooks/exhaustive-deps }, [firstName, lastName, hasAutoDownloaded]); // eslint-disable-line react-hooks/exhaustive-deps
const handleSaveContact = () => { const handleSaveContact = () => {
// Generate vCard format // Generate vCard format
const vCard = `BEGIN:VCARD const vCard = `BEGIN:VCARD
VERSION:3.0 VERSION:3.0
FN:${firstName} ${lastName} FN:${firstName} ${lastName}
N:${lastName};${firstName};;; N:${lastName};${firstName};;;
${organization ? `ORG:${organization}` : ''} ${organization ? `ORG:${organization}` : ''}
${title ? `TITLE:${title}` : ''} ${title ? `TITLE:${title}` : ''}
${email ? `EMAIL:${email}` : ''} ${email ? `EMAIL:${email}` : ''}
${phone ? `TEL:${phone}` : ''} ${phone ? `TEL:${phone}` : ''}
END:VCARD`; END:VCARD`;
// Create a blob and download // Create a blob and download
const blob = new Blob([vCard], { type: 'text/vcard;charset=utf-8' }); const blob = new Blob([vCard], { type: 'text/vcard;charset=utf-8' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const link = document.createElement('a'); const link = document.createElement('a');
link.href = url; link.href = url;
link.download = `${firstName}_${lastName}.vcf`; link.download = `${firstName}_${lastName}.vcf`;
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };
// Show loading or error state // Show loading or error state
if (isLoading) { if (isLoading) {
return ( return (
<div style={{ <div style={{
minHeight: '100vh', minHeight: '100vh',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
backgroundColor: '#ffffff' backgroundColor: '#ffffff'
}}> }}>
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>👤</div> <div style={{ fontSize: '48px', marginBottom: '16px' }}>👤</div>
<p style={{ color: '#666' }}>Loading contact...</p> <p style={{ color: '#666' }}>Loading contact...</p>
</div> </div>
</div> </div>
); );
} }
if (!firstName && !lastName) { if (!firstName && !lastName) {
return ( return (
<div style={{ <div style={{
minHeight: '100vh', minHeight: '100vh',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
padding: '20px', padding: '20px',
backgroundColor: '#ffffff' backgroundColor: '#ffffff'
}}> }}>
<div style={{ <div style={{
maxWidth: '420px', maxWidth: '420px',
width: '100%', width: '100%',
padding: '48px 32px', padding: '48px 32px',
textAlign: 'center' textAlign: 'center'
}}> }}>
<div style={{ <div style={{
fontSize: '64px', fontSize: '64px',
marginBottom: '24px', marginBottom: '24px',
opacity: 0.3 opacity: 0.3
}}>👤</div> }}>👤</div>
<h1 style={{ <h1 style={{
fontSize: '22px', fontSize: '22px',
fontWeight: '600', fontWeight: '600',
marginBottom: '12px', marginBottom: '12px',
color: '#1a1a1a' color: '#1a1a1a'
}}>No Contact Found</h1> }}>No Contact Found</h1>
<p style={{ <p style={{
color: '#666', color: '#666',
fontSize: '15px', fontSize: '15px',
lineHeight: '1.6' lineHeight: '1.6'
}}>This QR code doesn't contain any contact information.</p> }}>This QR code doesn't contain any contact information.</p>
</div> </div>
</div> </div>
); );
} }
return ( return (
<div style={{ <div style={{
minHeight: '100vh', minHeight: '100vh',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
padding: '20px', padding: '20px',
backgroundColor: '#ffffff' backgroundColor: '#ffffff'
}}> }}>
<div style={{ <div style={{
maxWidth: '420px', maxWidth: '420px',
width: '100%', width: '100%',
padding: '48px 32px' padding: '48px 32px'
}}> }}>
{/* Header */} {/* Header */}
<div style={{ textAlign: 'center', marginBottom: '32px' }}> <div style={{ textAlign: 'center', marginBottom: '32px' }}>
<div style={{ <div style={{
width: '80px', width: '80px',
height: '80px', height: '80px',
margin: '0 auto 20px', margin: '0 auto 20px',
borderRadius: '50%', borderRadius: '50%',
backgroundColor: '#f0f0f0', backgroundColor: '#f0f0f0',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
fontSize: '36px' fontSize: '36px'
}}>👤</div> }}>👤</div>
<h1 style={{ <h1 style={{
fontSize: '28px', fontSize: '28px',
fontWeight: '600', fontWeight: '600',
marginBottom: '8px', marginBottom: '8px',
color: '#1a1a1a', color: '#1a1a1a',
letterSpacing: '-0.02em' letterSpacing: '-0.02em'
}}> }}>
{firstName} {lastName} {firstName} {lastName}
</h1> </h1>
{(title || organization) && ( {(title || organization) && (
<p style={{ <p style={{
color: '#666', color: '#666',
fontSize: '16px', fontSize: '16px',
marginTop: '4px' marginTop: '4px'
}}> }}>
{title && organization && `${title} at ${organization}`} {title && organization && `${title} at ${organization}`}
{title && !organization && title} {title && !organization && title}
{!title && organization && organization} {!title && organization && organization}
</p> </p>
)} )}
</div> </div>
{/* Contact Details */} {/* Contact Details */}
<div style={{ marginBottom: '32px' }}> <div style={{ marginBottom: '32px' }}>
{email && ( {email && (
<div style={{ <div style={{
padding: '16px 20px', padding: '16px 20px',
backgroundColor: '#f8f8f8', backgroundColor: '#f8f8f8',
borderRadius: '12px', borderRadius: '12px',
marginBottom: '12px', marginBottom: '12px',
border: '1px solid #f0f0f0' border: '1px solid #f0f0f0'
}}> }}>
<div style={{ <div style={{
fontSize: '11px', fontSize: '11px',
color: '#888', color: '#888',
marginBottom: '6px', marginBottom: '6px',
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: '0.5px', letterSpacing: '0.5px',
fontWeight: '500' fontWeight: '500'
}}>Email</div> }}>Email</div>
<a href={`mailto:${email}`} style={{ <a href={`mailto:${email}`} style={{
fontSize: '15px', fontSize: '15px',
fontWeight: '500', fontWeight: '500',
color: '#2563eb', color: '#2563eb',
textDecoration: 'none', textDecoration: 'none',
wordBreak: 'break-all' wordBreak: 'break-all'
}}>{email}</a> }}>{email}</a>
</div> </div>
)} )}
{phone && ( {phone && (
<div style={{ <div style={{
padding: '16px 20px', padding: '16px 20px',
backgroundColor: '#f8f8f8', backgroundColor: '#f8f8f8',
borderRadius: '12px', borderRadius: '12px',
border: '1px solid #f0f0f0' border: '1px solid #f0f0f0'
}}> }}>
<div style={{ <div style={{
fontSize: '11px', fontSize: '11px',
color: '#888', color: '#888',
marginBottom: '6px', marginBottom: '6px',
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: '0.5px', letterSpacing: '0.5px',
fontWeight: '500' fontWeight: '500'
}}>Phone</div> }}>Phone</div>
<a href={`tel:${phone}`} style={{ <a href={`tel:${phone}`} style={{
fontSize: '15px', fontSize: '15px',
fontWeight: '500', fontWeight: '500',
color: '#2563eb', color: '#2563eb',
textDecoration: 'none' textDecoration: 'none'
}}>{phone}</a> }}>{phone}</a>
</div> </div>
)} )}
</div> </div>
{/* Save Button */} {/* Save Button */}
<button <button
onClick={handleSaveContact} onClick={handleSaveContact}
style={{ style={{
width: '100%', width: '100%',
padding: '16px', padding: '16px',
fontSize: '16px', fontSize: '16px',
fontWeight: '600', fontWeight: '600',
color: 'white', color: 'white',
backgroundColor: '#2563eb', backgroundColor: '#2563eb',
border: 'none', border: 'none',
borderRadius: '12px', borderRadius: '12px',
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.2s', transition: 'all 0.2s',
boxShadow: '0 2px 8px rgba(37, 99, 235, 0.2)' boxShadow: '0 2px 8px rgba(37, 99, 235, 0.2)'
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#1d4ed8'; e.currentTarget.style.backgroundColor = '#1d4ed8';
e.currentTarget.style.transform = 'translateY(-1px)'; e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(37, 99, 235, 0.3)'; e.currentTarget.style.boxShadow = '0 4px 12px rgba(37, 99, 235, 0.3)';
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#2563eb'; e.currentTarget.style.backgroundColor = '#2563eb';
e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(37, 99, 235, 0.2)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(37, 99, 235, 0.2)';
}} }}
> >
Save to Contacts Save to Contacts
</button> </button>
<p style={{ <p style={{
textAlign: 'center', textAlign: 'center',
fontSize: '13px', fontSize: '13px',
color: '#999', color: '#999',
marginTop: '16px', marginTop: '16px',
lineHeight: '1.5' lineHeight: '1.5'
}}> }}>
Add this contact to your address book Add this contact to your address book
</p> </p>
</div> </div>
</div> </div>
); );
} }

View File

@ -1,104 +1,104 @@
'use client'; 'use client';
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef } from 'react';
import { usePathname, useSearchParams } from 'next/navigation'; import { usePathname, useSearchParams } from 'next/navigation';
import posthog from 'posthog-js'; import posthog from 'posthog-js';
export function PostHogProvider({ children }: { children: React.ReactNode }) { export function PostHogProvider({ children }: { children: React.ReactNode }) {
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [isInitialized, setIsInitialized] = useState(false); const [isInitialized, setIsInitialized] = useState(false);
const initializationAttempted = useRef(false); const initializationAttempted = useRef(false);
// Initialize PostHog once // Initialize PostHog once
useEffect(() => { useEffect(() => {
// Prevent double initialization in React Strict Mode // Prevent double initialization in React Strict Mode
if (initializationAttempted.current) return; if (initializationAttempted.current) return;
initializationAttempted.current = true; initializationAttempted.current = true;
const cookieConsent = localStorage.getItem('cookieConsent'); const cookieConsent = localStorage.getItem('cookieConsent');
if (cookieConsent === 'accepted') { if (cookieConsent === 'accepted') {
const apiKey = process.env.NEXT_PUBLIC_POSTHOG_KEY; const apiKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
const apiHost = process.env.NEXT_PUBLIC_POSTHOG_HOST; const apiHost = process.env.NEXT_PUBLIC_POSTHOG_HOST;
if (!apiKey) { if (!apiKey) {
console.warn('PostHog API key not configured'); console.warn('PostHog API key not configured');
return; return;
} }
// Check if already initialized (using _loaded property) // Check if already initialized (using _loaded property)
if (!(posthog as any)._loaded) { if (!(posthog as any)._loaded) {
posthog.init(apiKey, { posthog.init(apiKey, {
api_host: apiHost || 'https://us.i.posthog.com', api_host: apiHost || 'https://us.i.posthog.com',
person_profiles: 'identified_only', person_profiles: 'identified_only',
capture_pageview: false, // Manual pageview tracking capture_pageview: false, // Manual pageview tracking
capture_pageleave: true, capture_pageleave: true,
autocapture: true, autocapture: true,
respect_dnt: true, respect_dnt: true,
opt_out_capturing_by_default: false, opt_out_capturing_by_default: false,
}); });
// Enable debug mode in development // Enable debug mode in development
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
posthog.debug(); posthog.debug();
} }
// Set initialized immediately after init // Set initialized immediately after init
setIsInitialized(true); setIsInitialized(true);
} else { } else {
setIsInitialized(true); // Already loaded setIsInitialized(true); // Already loaded
} }
} }
// NO cleanup function - PostHog should persist across page navigation // NO cleanup function - PostHog should persist across page navigation
}, []); }, []);
// Track page views ONLY after PostHog is initialized // Track page views ONLY after PostHog is initialized
useEffect(() => { useEffect(() => {
const cookieConsent = localStorage.getItem('cookieConsent'); const cookieConsent = localStorage.getItem('cookieConsent');
if (cookieConsent === 'accepted' && pathname && isInitialized) { if (cookieConsent === 'accepted' && pathname && isInitialized) {
let url = window.origin + pathname; let url = window.origin + pathname;
if (searchParams && searchParams.toString()) { if (searchParams && searchParams.toString()) {
url = url + `?${searchParams.toString()}`; url = url + `?${searchParams.toString()}`;
} }
posthog.capture('$pageview', { posthog.capture('$pageview', {
$current_url: url, $current_url: url,
}); });
} }
}, [pathname, searchParams, isInitialized]); // Added isInitialized dependency }, [pathname, searchParams, isInitialized]); // Added isInitialized dependency
return <>{children}</>; return <>{children}</>;
} }
/** /**
* Helper function to identify user after login * Helper function to identify user after login
*/ */
export function identifyUser(userId: string, traits?: Record<string, any>) { export function identifyUser(userId: string, traits?: Record<string, any>) {
const cookieConsent = localStorage.getItem('cookieConsent'); const cookieConsent = localStorage.getItem('cookieConsent');
if (cookieConsent === 'accepted' && (posthog as any)._loaded) { if (cookieConsent === 'accepted' && (posthog as any)._loaded) {
posthog.identify(userId, traits); posthog.identify(userId, traits);
} }
} }
/** /**
* Helper function to track custom events * Helper function to track custom events
*/ */
export function trackEvent(eventName: string, properties?: Record<string, any>) { export function trackEvent(eventName: string, properties?: Record<string, any>) {
const cookieConsent = localStorage.getItem('cookieConsent'); const cookieConsent = localStorage.getItem('cookieConsent');
if (cookieConsent === 'accepted' && (posthog as any)._loaded) { if (cookieConsent === 'accepted' && (posthog as any)._loaded) {
posthog.capture(eventName, properties); posthog.capture(eventName, properties);
} }
} }
/** /**
* Helper function to reset user on logout * Helper function to reset user on logout
*/ */
export function resetUser() { export function resetUser() {
const cookieConsent = localStorage.getItem('cookieConsent'); const cookieConsent = localStorage.getItem('cookieConsent');
if (cookieConsent === 'accepted' && (posthog as any)._loaded) { if (cookieConsent === 'accepted' && (posthog as any)._loaded) {
posthog.reset(); posthog.reset();
} }
} }

View File

@ -1,23 +1,23 @@
import React from 'react'; import React from 'react';
interface SeoJsonLdProps { interface SeoJsonLdProps {
data: object | object[]; data: object | object[];
} }
export default function SeoJsonLd({ data }: SeoJsonLdProps) { export default function SeoJsonLd({ data }: SeoJsonLdProps) {
const jsonLdArray = Array.isArray(data) ? data : [data]; const jsonLdArray = Array.isArray(data) ? data : [data];
return ( return (
<> <>
{jsonLdArray.map((item, index) => ( {jsonLdArray.map((item, index) => (
<script <script
key={index} key={index}
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: JSON.stringify(item, null, 0), __html: JSON.stringify(item, null, 0),
}} }}
/> />
))} ))}
</> </>
); );
} }

View File

@ -1,216 +1,216 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Sparkles, Brain, TrendingUp, MessageSquare, Palette, ArrowRight, Mail, CheckCircle2, Lock } from 'lucide-react'; import { Sparkles, Brain, TrendingUp, MessageSquare, Palette, ArrowRight, Mail, CheckCircle2, Lock } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
const AIComingSoonBanner = () => { const AIComingSoonBanner = () => {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
const response = await fetch('/api/newsletter/subscribe', { const response = await fetch('/api/newsletter/subscribe', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ email }), body: JSON.stringify({ email }),
}); });
const data = await response.json(); const data = await response.json();
if (!response.ok) { if (!response.ok) {
throw new Error(data.error || 'Failed to subscribe'); throw new Error(data.error || 'Failed to subscribe');
} }
setSubmitted(true); setSubmitted(true);
setEmail(''); setEmail('');
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong. Please try again.'); setError(err instanceof Error ? err.message : 'Something went wrong. Please try again.');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const features = [ const features = [
{ {
icon: Brain, icon: Brain,
category: 'Smart QR Generation', category: 'Smart QR Generation',
items: [ items: [
'AI-powered content optimization', 'AI-powered content optimization',
'Intelligent design suggestions', 'Intelligent design suggestions',
'Auto-generate vCard from LinkedIn', 'Auto-generate vCard from LinkedIn',
'Smart URL shortening with SEO', 'Smart URL shortening with SEO',
], ],
}, },
{ {
icon: TrendingUp, icon: TrendingUp,
category: 'Advanced Analytics & Insights', category: 'Advanced Analytics & Insights',
items: [ items: [
'AI-powered scan predictions', 'AI-powered scan predictions',
'Anomaly detection', 'Anomaly detection',
'Natural language analytics queries', 'Natural language analytics queries',
'Automated insights reports', 'Automated insights reports',
], ],
}, },
{ {
icon: MessageSquare, icon: MessageSquare,
category: 'Smart Content Management', category: 'Smart Content Management',
items: [ items: [
'AI chatbot for instant support', 'AI chatbot for instant support',
'Automated QR categorization', 'Automated QR categorization',
'Smart bulk QR generation', 'Smart bulk QR generation',
'Content recommendations', 'Content recommendations',
], ],
}, },
{ {
icon: Palette, icon: Palette,
category: 'Creative & Marketing', category: 'Creative & Marketing',
items: [ items: [
'AI-generated custom QR designs', 'AI-generated custom QR designs',
'Marketing copy generation', 'Marketing copy generation',
'A/B testing suggestions', 'A/B testing suggestions',
'Campaign optimization', 'Campaign optimization',
], ],
}, },
]; ];
return ( return (
<section className="relative overflow-hidden pt-12 pb-20 px-4 sm:px-6 lg:px-8 bg-gradient-to-br from-blue-50 via-white to-purple-50"> <section className="relative overflow-hidden pt-12 pb-20 px-4 sm:px-6 lg:px-8 bg-gradient-to-br from-blue-50 via-white to-purple-50">
{/* Animated Background Orbs (matching Hero) */} {/* Animated Background Orbs (matching Hero) */}
<div className="absolute inset-0 overflow-hidden pointer-events-none"> <div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-0 left-1/4 w-96 h-96 bg-blue-400/20 rounded-full blur-3xl animate-blob" /> <div className="absolute top-0 left-1/4 w-96 h-96 bg-blue-400/20 rounded-full blur-3xl animate-blob" />
<div className="absolute top-1/2 right-1/4 w-80 h-80 bg-purple-400/20 rounded-full blur-3xl animate-blob animation-delay-2000" /> <div className="absolute top-1/2 right-1/4 w-80 h-80 bg-purple-400/20 rounded-full blur-3xl animate-blob animation-delay-2000" />
<div className="absolute bottom-0 left-1/2 w-96 h-96 bg-cyan-400/15 rounded-full blur-3xl animate-blob animation-delay-4000" /> <div className="absolute bottom-0 left-1/2 w-96 h-96 bg-cyan-400/15 rounded-full blur-3xl animate-blob animation-delay-4000" />
</div> </div>
<div className="max-w-6xl mx-auto relative z-10"> <div className="max-w-6xl mx-auto relative z-10">
{/* Header */} {/* Header */}
{/* Header */} {/* Header */}
<motion.div <motion.div
initial={{ opacity: 0, scale: 0.95 }} initial={{ opacity: 0, scale: 0.95 }}
whileInView={{ opacity: 1, scale: 1 }} whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ duration: 0.5 }} transition={{ duration: 0.5 }}
className="text-center mb-12" className="text-center mb-12"
> >
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-blue-100 mb-4 animate-pulse"> <div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-blue-100 mb-4 animate-pulse">
<Sparkles className="w-4 h-4 text-blue-600" /> <Sparkles className="w-4 h-4 text-blue-600" />
<span className="text-sm font-medium text-blue-700"> <span className="text-sm font-medium text-blue-700">
Coming Soon Coming Soon
</span> </span>
</div> </div>
<h2 className="text-3xl sm:text-4xl font-bold text-gray-900 mb-3"> <h2 className="text-3xl sm:text-4xl font-bold text-gray-900 mb-3">
The Future of QR Codes is{' '} The Future of QR Codes is{' '}
<span className="bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent"> <span className="bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
AI-Powered AI-Powered
</span> </span>
</h2> </h2>
<p className="text-gray-600 text-lg max-w-2xl mx-auto"> <p className="text-gray-600 text-lg max-w-2xl mx-auto">
Revolutionary AI features to transform how you create, manage, and optimize QR codes Revolutionary AI features to transform how you create, manage, and optimize QR codes
</p> </p>
</motion.div> </motion.div>
{/* Features Grid */} {/* Features Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
{features.map((feature, index) => ( {features.map((feature, index) => (
<motion.div <motion.div
key={index} key={index}
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }} transition={{ duration: 0.5, delay: index * 0.1 }}
className="bg-white/80 backdrop-blur rounded-xl p-6 border border-gray-100 hover:shadow-lg transition-all hover:scale-105" className="bg-white/80 backdrop-blur rounded-xl p-6 border border-gray-100 hover:shadow-lg transition-all hover:scale-105"
> >
<div className="w-12 h-12 bg-gradient-to-br from-blue-100 to-purple-100 rounded-lg flex items-center justify-center mb-4"> <div className="w-12 h-12 bg-gradient-to-br from-blue-100 to-purple-100 rounded-lg flex items-center justify-center mb-4">
<feature.icon className="w-6 h-6 text-blue-600" /> <feature.icon className="w-6 h-6 text-blue-600" />
</div> </div>
<h3 className="font-semibold text-gray-900 mb-3"> <h3 className="font-semibold text-gray-900 mb-3">
{feature.category} {feature.category}
</h3> </h3>
<ul className="space-y-2"> <ul className="space-y-2">
{feature.items.map((item, itemIndex) => ( {feature.items.map((item, itemIndex) => (
<li key={itemIndex} className="flex items-start gap-2 text-sm text-gray-600"> <li key={itemIndex} className="flex items-start gap-2 text-sm text-gray-600">
<div className="mt-1.5 w-1.5 h-1.5 rounded-full bg-blue-500 flex-shrink-0" /> <div className="mt-1.5 w-1.5 h-1.5 rounded-full bg-blue-500 flex-shrink-0" />
<span>{item}</span> <span>{item}</span>
</li> </li>
))} ))}
</ul> </ul>
</motion.div> </motion.div>
))} ))}
</div> </div>
{/* Email Capture */} {/* Email Capture */}
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.4 }} transition={{ duration: 0.5, delay: 0.4 }}
className="max-w-2xl mx-auto bg-gradient-to-br from-blue-50 to-purple-50 rounded-2xl p-8 border border-gray-100" className="max-w-2xl mx-auto bg-gradient-to-br from-blue-50 to-purple-50 rounded-2xl p-8 border border-gray-100"
> >
{!submitted ? ( {!submitted ? (
<> <>
<form onSubmit={handleSubmit} className="flex flex-col sm:flex-row gap-3 mb-3"> <form onSubmit={handleSubmit} className="flex flex-col sm:flex-row gap-3 mb-3">
<div className="flex-1 relative"> <div className="flex-1 relative">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 pointer-events-none" /> <Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 pointer-events-none" />
<input <input
type="email" type="email"
value={email} value={email}
onChange={(e) => { onChange={(e) => {
setEmail(e.target.value); setEmail(e.target.value);
setError(''); setError('');
}} }}
placeholder="your@email.com" placeholder="your@email.com"
required required
disabled={loading} disabled={loading}
className="w-full pl-12 pr-4 py-3 rounded-xl bg-white border border-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 outline-none transition-all" className="w-full pl-12 pr-4 py-3 rounded-xl bg-white border border-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 outline-none transition-all"
/> />
</div> </div>
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-xl transition-all disabled:opacity-50 whitespace-nowrap flex items-center justify-center gap-2 group" className="px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-xl transition-all disabled:opacity-50 whitespace-nowrap flex items-center justify-center gap-2 group"
> >
{loading ? 'Subscribing...' : ( {loading ? 'Subscribing...' : (
<> <>
Notify Me Notify Me
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" /> <ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</> </>
)} )}
</button> </button>
</form> </form>
{error && ( {error && (
<p className="text-sm text-red-600 mb-2">{error}</p> <p className="text-sm text-red-600 mb-2">{error}</p>
)} )}
<p className="text-xs text-gray-500 text-center"> <p className="text-xs text-gray-500 text-center">
Be the first to know when AI features launch Be the first to know when AI features launch
</p> </p>
</> </>
) : ( ) : (
<div className="flex items-center justify-center gap-2 text-green-600"> <div className="flex items-center justify-center gap-2 text-green-600">
<CheckCircle2 className="w-5 h-5" /> <CheckCircle2 className="w-5 h-5" />
<span className="font-medium"> <span className="font-medium">
You're on the list! We'll notify you when AI features launch. You're on the list! We'll notify you when AI features launch.
</span> </span>
</div> </div>
)} )}
</motion.div> </motion.div>
</div> </div>
<div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-b from-transparent to-white pointer-events-none" /> <div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-b from-transparent to-white pointer-events-none" />
</section> </section>
); );
}; };
export default AIComingSoonBanner; export default AIComingSoonBanner;

View File

@ -1,92 +1,92 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
interface FAQProps { interface FAQProps {
t: any; // i18n translation function t: any; // i18n translation function
} }
export const FAQ: React.FC<FAQProps> = ({ t }) => { export const FAQ: React.FC<FAQProps> = ({ t }) => {
const [openIndex, setOpenIndex] = useState<number | null>(null); const [openIndex, setOpenIndex] = useState<number | null>(null);
const questions = [ const questions = [
'account', 'account',
'static_vs_dynamic', 'static_vs_dynamic',
'forever', 'forever',
'file_type', 'file_type',
'analytics', 'analytics',
]; ];
return ( return (
<section id="faq" className="py-16 bg-gray-50"> <section id="faq" className="py-16 bg-gray-50">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl"> <div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ duration: 0.5 }} transition={{ duration: 0.5 }}
className="text-center mb-12" className="text-center mb-12"
> >
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4"> <h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
{t.faq.title} {t.faq.title}
</h2> </h2>
</motion.div> </motion.div>
<div className="max-w-3xl mx-auto space-y-4"> <div className="max-w-3xl mx-auto space-y-4">
{questions.map((key, index) => ( {questions.map((key, index) => (
<motion.div <motion.div
key={key} key={key}
initial={{ opacity: 0, x: -20 }} initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }} whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }} transition={{ duration: 0.5, delay: index * 0.1 }}
> >
<Card className="cursor-pointer border-gray-200 hover:border-gray-300 transition-colors" onClick={() => setOpenIndex(openIndex === index ? null : index)}> <Card className="cursor-pointer border-gray-200 hover:border-gray-300 transition-colors" onClick={() => setOpenIndex(openIndex === index ? null : index)}>
<div className="p-6"> <div className="p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900"> <h3 className="text-lg font-semibold text-gray-900">
{t.faq.questions[key].question} {t.faq.questions[key].question}
</h3> </h3>
<svg <svg
className={`w-5 h-5 text-gray-500 transition-transform duration-300 ${openIndex === index ? 'rotate-180' : ''}`} className={`w-5 h-5 text-gray-500 transition-transform duration-300 ${openIndex === index ? 'rotate-180' : ''}`}
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
aria-hidden="true" aria-hidden="true"
> >
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg> </svg>
</div> </div>
<AnimatePresence> <AnimatePresence>
{openIndex === index && ( {openIndex === index && (
<motion.div <motion.div
initial={{ height: 0, opacity: 0, marginTop: 0 }} initial={{ height: 0, opacity: 0, marginTop: 0 }}
animate={{ height: 'auto', opacity: 1, marginTop: 16 }} animate={{ height: 'auto', opacity: 1, marginTop: 16 }}
exit={{ height: 0, opacity: 0, marginTop: 0 }} exit={{ height: 0, opacity: 0, marginTop: 0 }}
transition={{ duration: 0.3 }} transition={{ duration: 0.3 }}
className="overflow-hidden" className="overflow-hidden"
> >
<div className="text-gray-600"> <div className="text-gray-600">
{t.faq.questions[key].answer} {t.faq.questions[key].answer}
</div> </div>
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
</div> </div>
</Card> </Card>
</motion.div> </motion.div>
))} ))}
</div> </div>
<div className="text-center mt-8"> <div className="text-center mt-8">
<a href="/faq" className="text-primary-600 hover:text-primary-700 font-medium"> <a href="/faq" className="text-primary-600 hover:text-primary-700 font-medium">
View All Questions View All Questions
</a> </a>
</div> </div>
</div> </div>
</section> </section>
); );
}; };

View File

@ -1,85 +1,85 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
interface FeaturesProps { interface FeaturesProps {
t: any; // i18n translation function t: any; // i18n translation function
} }
export const Features: React.FC<FeaturesProps> = ({ t }) => { export const Features: React.FC<FeaturesProps> = ({ t }) => {
const features = [ const features = [
{ {
key: 'analytics', key: 'analytics',
icon: ( icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"> <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg> </svg>
), ),
color: 'text-blue-600 bg-blue-100', color: 'text-blue-600 bg-blue-100',
}, },
{ {
key: 'customization', key: 'customization',
icon: ( icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"> <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg> </svg>
), ),
color: 'text-purple-600 bg-purple-100', color: 'text-purple-600 bg-purple-100',
}, },
{ {
key: 'unlimited', key: 'unlimited',
icon: ( icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"> <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
), ),
color: 'text-green-600 bg-green-100', color: 'text-green-600 bg-green-100',
}, },
]; ];
return ( return (
<section className="py-16 bg-gray-50"> <section className="py-16 bg-gray-50">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl"> <div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ duration: 0.5 }} transition={{ duration: 0.5 }}
className="text-center mb-12" className="text-center mb-12"
> >
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4"> <h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
{t.features.title} {t.features.title}
</h2> </h2>
</motion.div> </motion.div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto"> <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto">
{features.map((feature, index) => ( {features.map((feature, index) => (
<motion.div <motion.div
key={feature.key} key={feature.key}
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }} transition={{ duration: 0.5, delay: index * 0.1 }}
> >
<Card hover className="h-full border-gray-100 hover:border-primary-100 hover:shadow-lg transition-all"> <Card hover className="h-full border-gray-100 hover:border-primary-100 hover:shadow-lg transition-all">
<CardHeader> <CardHeader>
<div className={`w-12 h-12 rounded-lg ${feature.color} flex items-center justify-center mb-4`}> <div className={`w-12 h-12 rounded-lg ${feature.color} flex items-center justify-center mb-4`}>
{feature.icon} {feature.icon}
</div> </div>
<CardTitle>{t.features[feature.key].title}</CardTitle> <CardTitle>{t.features[feature.key].title}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-gray-600"> <p className="text-gray-600">
{t.features[feature.key].description} {t.features[feature.key].description}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
</motion.div> </motion.div>
))} ))}
</div> </div>
</div> </div>
</section> </section>
); );
}; };

View File

@ -1,155 +1,225 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Globe, User, MapPin, Phone, CheckCircle2, ArrowRight } from 'lucide-react'; import { Globe, User, MapPin, Phone, CheckCircle2, ArrowRight, FileText, Ticket, Smartphone, Star } from 'lucide-react';
import { useState, useEffect } from 'react';
interface HeroProps {
t: any; // i18n translation function // Sub-component for the flipping effect
} // Sub-component for the flipping effect
const FlippingCard = ({ front, back, delay }: { front: any, back: any, delay: number }) => {
export const Hero: React.FC<HeroProps> = ({ t }) => { const [isFlipped, setIsFlipped] = useState(false);
const templateCards = [
{ title: 'URL/Website', color: 'bg-blue-500/10 text-blue-600', icon: Globe }, useEffect(() => {
{ title: 'Contact Card', color: 'bg-purple-500/10 text-purple-600', icon: User }, // Initial delay
{ title: 'Location', color: 'bg-green-500/10 text-green-600', icon: MapPin }, const initialTimeout = setTimeout(() => {
{ title: 'Phone Number', color: 'bg-pink-500/10 text-pink-600', icon: Phone }, setIsFlipped(true); // First flip
];
// Setup interval for subsequent flips
const containerjs = { const interval = setInterval(() => {
hidden: { opacity: 0 }, setIsFlipped(prev => !prev);
show: { }, 8000); // Toggle every 8 seconds to prevent overlap (4 cards * 2s gap)
opacity: 1,
transition: { return () => clearInterval(interval);
staggerChildren: 0.1 }, delay * 1000);
}
} return () => clearTimeout(initialTimeout);
}; }, [delay]);
const itemjs = { return (
hidden: { opacity: 0, y: 20 }, <div className="relative h-32 w-full perspective-[1000px] group cursor-pointer">
show: { opacity: 1, y: 0 } <motion.div
}; animate={{ rotateY: isFlipped ? 180 : 0 }}
transition={{ duration: 0.6, type: "spring", stiffness: 260, damping: 20 }}
return ( className="relative w-full h-full preserve-3d"
<section className="relative overflow-hidden bg-gradient-to-br from-blue-50 via-white to-purple-50 pt-12 pb-20"> style={{ transformStyle: 'preserve-3d' }}
{/* Animated Background Orbs */} >
<div className="absolute inset-0 overflow-hidden pointer-events-none"> {/* Front Face */}
{/* Orb 1 - Blue (top-left) */} <div
<div className="absolute -top-24 -left-24 w-96 h-96 bg-blue-400/30 rounded-full blur-3xl animate-blob" /> className="absolute inset-0 backface-hidden"
style={{ backfaceVisibility: 'hidden', WebkitBackfaceVisibility: 'hidden' }}
{/* Orb 2 - Purple (top-right) */} >
<div className="absolute -top-12 -right-12 w-96 h-96 bg-purple-400/30 rounded-full blur-3xl animate-blob animation-delay-2000" /> <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`}>
{/* Orb 3 - Pink (bottom-left) */} <front.icon className="w-5 h-5" />
<div className="absolute -bottom-24 -left-12 w-96 h-96 bg-pink-400/20 rounded-full blur-3xl animate-blob animation-delay-4000" /> </div>
<p className="font-semibold text-gray-800 text-sm">{front.title}</p>
{/* Orb 4 - Cyan (center-right) */} </Card>
<div className="absolute top-1/2 -right-24 w-80 h-80 bg-cyan-400/20 rounded-full blur-3xl animate-blob animation-delay-6000" /> </div>
</div>
{/* Back Face */}
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl relative z-10"> <div
<div className="grid lg:grid-cols-2 gap-12 items-center"> className="absolute inset-0 backface-hidden"
{/* Left Content */} style={{
<div className="space-y-8"> backfaceVisibility: 'hidden',
<Badge variant="info" className="inline-flex items-center space-x-2"> WebkitBackfaceVisibility: 'hidden',
<span>{t.hero.badge}</span> transform: 'rotateY(180deg)'
</Badge> }}
>
<motion.div <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">
initial={{ opacity: 0, y: 20 }} <div className={`w-10 h-10 mb-3 rounded-xl ${back.color} flex items-center justify-center`}>
animate={{ opacity: 1, y: 0 }} <back.icon className="w-5 h-5" />
transition={{ duration: 0.5 }} </div>
className="space-y-6" <p className="font-semibold text-gray-900 text-sm">{back.title}</p>
> </Card>
<h1 className="text-5xl lg:text-6xl font-bold text-gray-900 leading-tight"> </div>
{t.hero.title} </motion.div>
</h1> </div>
);
<p className="text-xl text-gray-600 leading-relaxed max-w-2xl"> };
{t.hero.subtitle}
</p> interface HeroProps {
t: any; // i18n translation function
<div className="space-y-3 pt-2"> }
{t.hero.features.map((feature: string, index: number) => (
<motion.div export const Hero: React.FC<HeroProps> = ({ t }) => {
key={index}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }} const containerjs = {
transition={{ delay: 0.2 + (index * 0.1) }} hidden: { opacity: 0 },
className="flex items-center space-x-3" show: {
> opacity: 1,
<div className="flex-shrink-0 w-6 h-6 bg-emerald-100 rounded-full flex items-center justify-center"> transition: {
<CheckCircle2 className="w-4 h-4 text-emerald-600" /> staggerChildren: 0.1
</div> }
<span className="text-gray-700 font-medium">{feature}</span> }
</motion.div> };
))}
</div> const itemjs = {
</motion.div> hidden: { opacity: 0, y: 20 },
show: { opacity: 1, y: 0 }
<motion.div };
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} return (
transition={{ delay: 0.5 }} <section className="relative overflow-hidden bg-gradient-to-br from-blue-50 via-white to-purple-50 pt-12 pb-20">
className="flex flex-col sm:flex-row gap-4 pt-4" {/* Animated Background Orbs */}
> <div className="absolute inset-0 overflow-hidden pointer-events-none">
<Link href="/signup"> {/* Orb 1 - Blue (top-left) */}
<Button size="lg" className="text-lg px-8 py-6 w-full sm:w-auto shadow-lg shadow-blue-500/25 hover:shadow-blue-500/40 transition-all duration-300"> <div className="absolute -top-24 -left-24 w-96 h-96 bg-blue-400/30 rounded-full blur-3xl animate-blob" />
{t.hero.cta_primary}
</Button> {/* Orb 2 - Purple (top-right) */}
</Link> <div className="absolute -top-12 -right-12 w-96 h-96 bg-purple-400/30 rounded-full blur-3xl animate-blob animation-delay-2000" />
<Link href="/#pricing">
<Button variant="outline" size="lg" className="text-lg px-8 py-6 w-full sm:w-auto backdrop-blur-sm bg-white/50 border-gray-200 hover:bg-white/80 transition-all duration-300"> {/* Orb 3 - Pink (bottom-left) */}
{t.hero.cta_secondary} <div className="absolute -bottom-24 -left-12 w-96 h-96 bg-pink-400/20 rounded-full blur-3xl animate-blob animation-delay-4000" />
</Button>
</Link> {/* Orb 4 - Cyan (center-right) */}
</motion.div> <div className="absolute top-1/2 -right-24 w-80 h-80 bg-cyan-400/20 rounded-full blur-3xl animate-blob animation-delay-6000" />
</div> </div>
{/* Right Preview Widget */} <div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl relative z-10">
<div className="relative"> <div className="grid lg:grid-cols-2 gap-12 items-center">
<motion.div {/* Left Content */}
variants={containerjs} <div className="space-y-8">
initial="hidden" <Badge variant="info" className="inline-flex items-center space-x-2">
animate="show" <span>{t.hero.badge}</span>
className="grid grid-cols-2 gap-4" </Badge>
>
{templateCards.map((card, index) => ( <motion.div
<motion.div key={index} variants={itemjs}> initial={{ opacity: 0, y: 20 }}
<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`}> animate={{ opacity: 1, y: 0 }}
<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`}> transition={{ duration: 0.5 }}
<card.icon className="w-6 h-6" /> className="space-y-6"
</div> >
<p className="font-semibold text-gray-800 group-hover:text-gray-900">{card.title}</p> <h1 className="text-5xl lg:text-6xl font-bold text-gray-900 leading-tight">
</Card> {t.hero.title}
</motion.div> </h1>
))}
</motion.div> <p className="text-xl text-gray-600 leading-relaxed max-w-2xl">
{t.hero.subtitle}
{/* Floating Badge */} </p>
<motion.div
initial={{ opacity: 0, scale: 0.8 }} <div className="space-y-3 pt-2">
animate={{ opacity: 1, scale: 1 }} {t.hero.features.map((feature: string, index: number) => (
transition={{ delay: 0.8 }} <motion.div
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" key={index}
> initial={{ opacity: 0, x: -20 }}
<span className="relative flex h-2 w-2"> animate={{ opacity: 1, x: 0 }}
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-white opacity-75"></span> transition={{ delay: 0.2 + (index * 0.1) }}
<span className="relative inline-flex rounded-full h-2 w-2 bg-white"></span> className="flex items-center space-x-3"
</span> >
{t.hero.engagement_badge} <div className="flex-shrink-0 w-6 h-6 bg-emerald-100 rounded-full flex items-center justify-center">
</motion.div> <CheckCircle2 className="w-4 h-4 text-emerald-600" />
</div> </div>
</div> <span className="text-gray-700 font-medium">{feature}</span>
</div> </motion.div>
))}
{/* Smooth Gradient Fade Transition */} </div>
<div className="absolute bottom-0 left-0 w-full h-32 bg-gradient-to-b from-transparent to-gray-50 pointer-events-none" /> </motion.div>
</section >
); <motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="flex flex-col sm:flex-row gap-4 pt-4"
>
<Link href="/signup">
<Button size="lg" className="text-lg px-8 py-6 w-full sm:w-auto shadow-lg shadow-blue-500/25 hover:shadow-blue-500/40 transition-all duration-300">
{t.hero.cta_primary}
</Button>
</Link>
<Link href="/#pricing">
<Button variant="outline" size="lg" className="text-lg px-8 py-6 w-full sm:w-auto backdrop-blur-sm bg-white/50 border-gray-200 hover:bg-white/80 transition-all duration-300">
{t.hero.cta_secondary}
</Button>
</Link>
</motion.div>
</div>
{/* 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>
{/* 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>
{/* Smooth Gradient Fade Transition */}
<div className="absolute bottom-0 left-0 w-full h-32 bg-gradient-to-b from-transparent to-gray-50 pointer-events-none" />
</section >
);
}; };

View File

@ -1,282 +1,282 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { QRCodeSVG } from 'qrcode.react'; import { QRCodeSVG } from 'qrcode.react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { calculateContrast } from '@/lib/utils'; import { calculateContrast } from '@/lib/utils';
interface InstantGeneratorProps { interface InstantGeneratorProps {
t: any; // i18n translation function t: any; // i18n translation function
} }
export const InstantGenerator: React.FC<InstantGeneratorProps> = ({ t }) => { export const InstantGenerator: React.FC<InstantGeneratorProps> = ({ t }) => {
const [url, setUrl] = useState('https://example.com'); const [url, setUrl] = useState('https://example.com');
const [foregroundColor, setForegroundColor] = useState('#000000'); const [foregroundColor, setForegroundColor] = useState('#000000');
const [backgroundColor, setBackgroundColor] = useState('#FFFFFF'); const [backgroundColor, setBackgroundColor] = useState('#FFFFFF');
const [cornerStyle, setCornerStyle] = useState('square'); const [cornerStyle, setCornerStyle] = useState('square');
const [size, setSize] = useState(200); const [size, setSize] = useState(200);
const contrast = calculateContrast(foregroundColor, backgroundColor); const contrast = calculateContrast(foregroundColor, backgroundColor);
const hasGoodContrast = contrast >= 4.5; const hasGoodContrast = contrast >= 4.5;
const downloadQR = (format: 'svg' | 'png') => { const downloadQR = (format: 'svg' | 'png') => {
const svg = document.querySelector('#instant-qr-preview svg'); const svg = document.querySelector('#instant-qr-preview svg');
if (!svg || !url) return; if (!svg || !url) return;
if (format === 'svg') { if (format === 'svg') {
const svgData = new XMLSerializer().serializeToString(svg); const svgData = new XMLSerializer().serializeToString(svg);
const blob = new Blob([svgData], { type: 'image/svg+xml' }); const blob = new Blob([svgData], { type: 'image/svg+xml' });
const downloadUrl = URL.createObjectURL(blob); const downloadUrl = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = downloadUrl; a.href = downloadUrl;
a.download = 'qrcode.svg'; a.download = 'qrcode.svg';
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(downloadUrl); URL.revokeObjectURL(downloadUrl);
} else { } else {
// Convert SVG to PNG using Canvas // Convert SVG to PNG using Canvas
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
const img = new Image(); const img = new Image();
const svgData = new XMLSerializer().serializeToString(svg); const svgData = new XMLSerializer().serializeToString(svg);
const blob = new Blob([svgData], { type: 'image/svg+xml' }); const blob = new Blob([svgData], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
img.onload = () => { img.onload = () => {
canvas.width = size; canvas.width = size;
canvas.height = size; canvas.height = size;
if (ctx) { if (ctx) {
ctx.fillStyle = backgroundColor; ctx.fillStyle = backgroundColor;
ctx.fillRect(0, 0, size, size); ctx.fillRect(0, 0, size, size);
ctx.drawImage(img, 0, 0, size, size); ctx.drawImage(img, 0, 0, size, size);
} }
canvas.toBlob((blob) => { canvas.toBlob((blob) => {
if (blob) { if (blob) {
const downloadUrl = URL.createObjectURL(blob); const downloadUrl = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = downloadUrl; a.href = downloadUrl;
a.download = 'qrcode.png'; a.download = 'qrcode.png';
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(downloadUrl); URL.revokeObjectURL(downloadUrl);
} }
}); });
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };
img.src = url; img.src = url;
} }
}; };
return ( return (
<section className="pt-16 pb-32 bg-gray-50 border-t border-gray-100 relative"> <section className="pt-16 pb-32 bg-gray-50 border-t border-gray-100 relative">
<div <div
className="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-r from-blue-50 to-white pointer-events-none" className="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-r from-blue-50 to-white pointer-events-none"
style={{ maskImage: 'linear-gradient(to bottom, transparent, black)', WebkitMaskImage: 'linear-gradient(to bottom, transparent, black)' }} style={{ maskImage: 'linear-gradient(to bottom, transparent, black)', WebkitMaskImage: 'linear-gradient(to bottom, transparent, black)' }}
/> />
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl"> <div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ duration: 0.5 }} transition={{ duration: 0.5 }}
className="text-center mb-12" className="text-center mb-12"
> >
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4"> <h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
{t.generator.title} {t.generator.title}
</h2> </h2>
</motion.div> </motion.div>
<div className="grid lg:grid-cols-2 gap-12 max-w-6xl mx-auto"> <div className="grid lg:grid-cols-2 gap-12 max-w-6xl mx-auto">
{/* Left Form */} {/* Left Form */}
<motion.div <motion.div
initial={{ opacity: 0, x: -20 }} initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }} whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }} transition={{ duration: 0.5, delay: 0.2 }}
> >
<Card className="space-y-6 shadow-xl shadow-gray-200/50 border-gray-100"> <Card className="space-y-6 shadow-xl shadow-gray-200/50 border-gray-100">
<Input <Input
label="URL" label="URL"
value={url} value={url}
onChange={(e) => setUrl(e.target.value)} onChange={(e) => setUrl(e.target.value)}
placeholder={t.generator.url_placeholder} placeholder={t.generator.url_placeholder}
className="transition-all focus:ring-2 focus:ring-primary-500/20" className="transition-all focus:ring-2 focus:ring-primary-500/20"
/> />
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label htmlFor="foreground-color" className="block text-sm font-medium text-gray-700 mb-2"> <label htmlFor="foreground-color" className="block text-sm font-medium text-gray-700 mb-2">
{t.generator.foreground} {t.generator.foreground}
</label> </label>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<input <input
id="foreground-color" id="foreground-color"
type="color" type="color"
value={foregroundColor} value={foregroundColor}
onChange={(e) => setForegroundColor(e.target.value)} onChange={(e) => setForegroundColor(e.target.value)}
className="w-14 h-12 rounded border border-gray-300 cursor-pointer" className="w-14 h-12 rounded border border-gray-300 cursor-pointer"
aria-label="Foreground color picker" aria-label="Foreground color picker"
/> />
<Input <Input
id="foreground-color-text" id="foreground-color-text"
value={foregroundColor} value={foregroundColor}
onChange={(e) => setForegroundColor(e.target.value)} onChange={(e) => setForegroundColor(e.target.value)}
className="flex-1" className="flex-1"
aria-label="Foreground color hex value" aria-label="Foreground color hex value"
/> />
</div> </div>
</div> </div>
<div> <div>
<label htmlFor="background-color" className="block text-sm font-medium text-gray-700 mb-2"> <label htmlFor="background-color" className="block text-sm font-medium text-gray-700 mb-2">
{t.generator.background} {t.generator.background}
</label> </label>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<input <input
id="background-color" id="background-color"
type="color" type="color"
value={backgroundColor} value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)} onChange={(e) => setBackgroundColor(e.target.value)}
className="w-14 h-12 rounded border border-gray-300 cursor-pointer" className="w-14 h-12 rounded border border-gray-300 cursor-pointer"
aria-label="Background color picker" aria-label="Background color picker"
/> />
<Input <Input
id="background-color-text" id="background-color-text"
value={backgroundColor} value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)} onChange={(e) => setBackgroundColor(e.target.value)}
className="flex-1" className="flex-1"
aria-label="Background color hex value" aria-label="Background color hex value"
/> />
</div> </div>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label htmlFor="corner-style" className="block text-sm font-medium text-gray-700 mb-2"> <label htmlFor="corner-style" className="block text-sm font-medium text-gray-700 mb-2">
{t.generator.corners} {t.generator.corners}
</label> </label>
<select <select
id="corner-style" id="corner-style"
value={cornerStyle} value={cornerStyle}
onChange={(e) => setCornerStyle(e.target.value)} onChange={(e) => setCornerStyle(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
> >
<option value="square">Square</option> <option value="square">Square</option>
<option value="rounded">Rounded</option> <option value="rounded">Rounded</option>
</select> </select>
</div> </div>
<div> <div>
<label htmlFor="qr-size" className="block text-sm font-medium text-gray-700 mb-2"> <label htmlFor="qr-size" className="block text-sm font-medium text-gray-700 mb-2">
{t.generator.size} {t.generator.size}
</label> </label>
<input <input
id="qr-size" id="qr-size"
type="range" type="range"
min="100" min="100"
max="400" max="400"
value={size} value={size}
onChange={(e) => setSize(Number(e.target.value))} onChange={(e) => setSize(Number(e.target.value))}
className="w-full accent-primary-600" className="w-full accent-primary-600"
aria-label={`QR code size: ${size} pixels`} aria-label={`QR code size: ${size} pixels`}
/> />
<div className="text-sm text-gray-500 text-center mt-1" aria-hidden="true">{size}px</div> <div className="text-sm text-gray-500 text-center mt-1" aria-hidden="true">{size}px</div>
</div> </div>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Badge variant={hasGoodContrast ? 'success' : 'warning'}> <Badge variant={hasGoodContrast ? 'success' : 'warning'}>
{hasGoodContrast ? t.generator.contrast_good : 'Low contrast'} {hasGoodContrast ? t.generator.contrast_good : 'Low contrast'}
</Badge> </Badge>
<div className="text-sm text-gray-500"> <div className="text-sm text-gray-500">
Contrast: {contrast.toFixed(1)}:1 Contrast: {contrast.toFixed(1)}:1
</div> </div>
</div> </div>
<div className="flex space-x-3"> <div className="flex space-x-3">
<Button variant="outline" className="flex-1 hover:bg-gray-50" onClick={() => downloadQR('svg')}> <Button variant="outline" className="flex-1 hover:bg-gray-50" onClick={() => downloadQR('svg')}>
{t.generator.download_svg} {t.generator.download_svg}
</Button> </Button>
<Button variant="outline" className="flex-1 hover:bg-gray-50" onClick={() => downloadQR('png')}> <Button variant="outline" className="flex-1 hover:bg-gray-50" onClick={() => downloadQR('png')}>
{t.generator.download_png} {t.generator.download_png}
</Button> </Button>
</div> </div>
<Button className="w-full text-lg py-6 shadow-lg shadow-primary-500/20 hover:shadow-primary-500/40 transition-all" onClick={() => window.location.href = '/login'}> <Button className="w-full text-lg py-6 shadow-lg shadow-primary-500/20 hover:shadow-primary-500/40 transition-all" onClick={() => window.location.href = '/login'}>
{t.generator.save_track} {t.generator.save_track}
</Button> </Button>
</Card> </Card>
</motion.div> </motion.div>
{/* Right Preview */} {/* Right Preview */}
<motion.div <motion.div
initial={{ opacity: 0, x: 20 }} initial={{ opacity: 0, x: 20 }}
whileInView={{ opacity: 1, x: 0 }} whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.4 }} transition={{ duration: 0.5, delay: 0.4 }}
className="flex flex-col items-center justify-center p-8 bg-gradient-to-br from-blue-50/50 to-purple-50/50 rounded-2xl border border-blue-100/50 shadow-lg shadow-blue-500/5 relative overflow-hidden backdrop-blur-sm" className="flex flex-col items-center justify-center p-8 bg-gradient-to-br from-blue-50/50 to-purple-50/50 rounded-2xl border border-blue-100/50 shadow-lg shadow-blue-500/5 relative overflow-hidden backdrop-blur-sm"
> >
{/* Artistic Curved Lines Background */} {/* Artistic Curved Lines Background */}
<div className="absolute inset-0 opacity-[0.4]"> <div className="absolute inset-0 opacity-[0.4]">
<svg className="h-full w-full" viewBox="0 0 100 100" preserveAspectRatio="none"> <svg className="h-full w-full" viewBox="0 0 100 100" preserveAspectRatio="none">
<defs> <defs>
<linearGradient id="gradient1" x1="0%" y1="0%" x2="100%" y2="100%"> <linearGradient id="gradient1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#60a5fa" stopOpacity="0.4" /> <stop offset="0%" stopColor="#60a5fa" stopOpacity="0.4" />
<stop offset="100%" stopColor="#c084fc" stopOpacity="0.4" /> <stop offset="100%" stopColor="#c084fc" stopOpacity="0.4" />
</linearGradient> </linearGradient>
<linearGradient id="gradient2" x1="100%" y1="0%" x2="0%" y2="100%"> <linearGradient id="gradient2" x1="100%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#818cf8" stopOpacity="0.4" /> <stop offset="0%" stopColor="#818cf8" stopOpacity="0.4" />
<stop offset="100%" stopColor="#38bdf8" stopOpacity="0.4" /> <stop offset="100%" stopColor="#38bdf8" stopOpacity="0.4" />
</linearGradient> </linearGradient>
</defs> </defs>
<path d="M0 100 Q 25 30 50 70 T 100 0" fill="none" stroke="url(#gradient1)" strokeWidth="0.8" className="opacity-60" /> <path d="M0 100 Q 25 30 50 70 T 100 0" fill="none" stroke="url(#gradient1)" strokeWidth="0.8" className="opacity-60" />
<path d="M0 50 Q 40 80 70 30 T 100 50" fill="none" stroke="url(#gradient2)" strokeWidth="0.8" className="opacity-60" /> <path d="M0 50 Q 40 80 70 30 T 100 50" fill="none" stroke="url(#gradient2)" strokeWidth="0.8" className="opacity-60" />
<path d="M0 0 Q 30 60 60 20 T 100 80" fill="none" stroke="url(#gradient1)" strokeWidth="0.6" className="opacity-40" /> <path d="M0 0 Q 30 60 60 20 T 100 80" fill="none" stroke="url(#gradient1)" strokeWidth="0.6" className="opacity-40" />
</svg> </svg>
<div className="absolute inset-0 bg-[url('https://grainy-gradients.vercel.app/noise.svg')] opacity-15 brightness-100 contrast-150 mix-blend-overlay"></div> <div className="absolute inset-0 bg-[url('https://grainy-gradients.vercel.app/noise.svg')] opacity-15 brightness-100 contrast-150 mix-blend-overlay"></div>
</div> </div>
{/* Decorative Orbs */} {/* Decorative Orbs */}
<div className="absolute -top-20 -right-20 w-64 h-64 bg-purple-200/30 rounded-full blur-3xl animate-blob"></div> <div className="absolute -top-20 -right-20 w-64 h-64 bg-purple-200/30 rounded-full blur-3xl animate-blob"></div>
<div className="absolute -bottom-20 -left-20 w-64 h-64 bg-blue-200/30 rounded-full blur-3xl animate-blob animation-delay-2000"></div> <div className="absolute -bottom-20 -left-20 w-64 h-64 bg-blue-200/30 rounded-full blur-3xl animate-blob animation-delay-2000"></div>
<div className="text-center w-full relative z-10"> <div className="text-center w-full relative z-10">
<h3 className="text-xl font-bold mb-8 text-gray-800">{t.generator.live_preview}</h3> <h3 className="text-xl font-bold mb-8 text-gray-800">{t.generator.live_preview}</h3>
<div id="instant-qr-preview" className="flex justify-center mb-8 transform hover:scale-105 transition-transform duration-300"> <div id="instant-qr-preview" className="flex justify-center mb-8 transform hover:scale-105 transition-transform duration-300">
{url ? ( {url ? (
<div className={`${cornerStyle === 'rounded' ? 'rounded-lg overflow-hidden' : ''} p-4 bg-white shadow-lg rounded-xl`}> <div className={`${cornerStyle === 'rounded' ? 'rounded-lg overflow-hidden' : ''} p-4 bg-white shadow-lg rounded-xl`}>
<QRCodeSVG <QRCodeSVG
value={url} value={url}
size={size} size={size}
fgColor={foregroundColor} fgColor={foregroundColor}
bgColor={backgroundColor} bgColor={backgroundColor}
level="M" level="M"
/> />
</div> </div>
) : ( ) : (
<div <div
className="bg-gray-100 rounded-xl flex items-center justify-center text-gray-500 animate-pulse" className="bg-gray-100 rounded-xl flex items-center justify-center text-gray-500 animate-pulse"
style={{ width: 200, height: 200 }} style={{ width: 200, height: 200 }}
> >
Enter URL Enter URL
</div> </div>
)} )}
</div> </div>
<div className="text-sm font-medium text-gray-600 mb-2 bg-gray-50 py-2 px-4 rounded-full inline-block"> <div className="text-sm font-medium text-gray-600 mb-2 bg-gray-50 py-2 px-4 rounded-full inline-block">
{url || 'https://example.com'} {url || 'https://example.com'}
</div> </div>
<div className="text-xs text-gray-400 mt-2">{t.generator.demo_note}</div> <div className="text-xs text-gray-400 mt-2">{t.generator.demo_note}</div>
</div> </div>
</motion.div> </motion.div>
</div> </div>
</div> </div>
</section> </section>
); );
}; };

View File

@ -1,141 +1,141 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import Link from 'next/link'; import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { BillingToggle } from '@/components/ui/BillingToggle'; import { BillingToggle } from '@/components/ui/BillingToggle';
interface PricingProps { interface PricingProps {
t: any; // i18n translation function t: any; // i18n translation function
} }
export const Pricing: React.FC<PricingProps> = ({ t }) => { export const Pricing: React.FC<PricingProps> = ({ t }) => {
const [billingPeriod, setBillingPeriod] = useState<'month' | 'year'>('month'); const [billingPeriod, setBillingPeriod] = useState<'month' | 'year'>('month');
const plans = [ const plans = [
{ {
key: 'free', key: 'free',
popular: false, popular: false,
}, },
{ {
key: 'pro', key: 'pro',
popular: true, popular: true,
}, },
{ {
key: 'business', key: 'business',
popular: false, popular: false,
}, },
]; ];
return ( return (
<section id="pricing" className="py-16"> <section id="pricing" className="py-16">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl"> <div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ duration: 0.5 }} transition={{ duration: 0.5 }}
className="text-center mb-12" className="text-center mb-12"
> >
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4"> <h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
{t.pricing.title} {t.pricing.title}
</h2> </h2>
<p className="text-xl text-gray-600"> <p className="text-xl text-gray-600">
{t.pricing.subtitle} {t.pricing.subtitle}
</p> </p>
</motion.div> </motion.div>
<div className="flex justify-center mb-8"> <div className="flex justify-center mb-8">
<BillingToggle value={billingPeriod} onChange={setBillingPeriod} /> <BillingToggle value={billingPeriod} onChange={setBillingPeriod} />
</div> </div>
<div className="grid md:grid-cols-3 gap-8 max-w-5xl mx-auto"> <div className="grid md:grid-cols-3 gap-8 max-w-5xl mx-auto">
{plans.map((plan, index) => ( {plans.map((plan, index) => (
<motion.div <motion.div
key={plan.key} key={plan.key}
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }} transition={{ duration: 0.5, delay: index * 0.1 }}
className="h-full" className="h-full"
> >
<Card <Card
className={`h-full flex flex-col ${plan.popular className={`h-full flex flex-col ${plan.popular
? 'border-primary-500 shadow-xl relative scale-105 z-10' ? 'border-primary-500 shadow-xl relative scale-105 z-10'
: 'border-gray-200 hover:border-gray-300 hover:shadow-lg transition-all' : 'border-gray-200 hover:border-gray-300 hover:shadow-lg transition-all'
}`} }`}
> >
{plan.popular && ( {plan.popular && (
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2 w-full text-center"> <div className="absolute -top-4 left-1/2 transform -translate-x-1/2 w-full text-center">
<Badge variant="info" className="px-4 py-1.5 shadow-sm"> <Badge variant="info" className="px-4 py-1.5 shadow-sm">
{t.pricing[plan.key].badge} {t.pricing[plan.key].badge}
</Badge> </Badge>
</div> </div>
)} )}
<CardHeader className="text-center pb-8"> <CardHeader className="text-center pb-8">
<CardTitle className="text-2xl mb-4"> <CardTitle className="text-2xl mb-4">
{t.pricing[plan.key].title} {t.pricing[plan.key].title}
</CardTitle> </CardTitle>
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div className="flex items-baseline justify-center"> <div className="flex items-baseline justify-center">
<span className="text-4xl font-bold"> <span className="text-4xl font-bold">
{plan.key === 'free' {plan.key === 'free'
? t.pricing[plan.key].price ? t.pricing[plan.key].price
: billingPeriod === 'month' : billingPeriod === 'month'
? t.pricing[plan.key].price ? t.pricing[plan.key].price
: plan.key === 'pro' : plan.key === 'pro'
? '€90' ? '€90'
: '€290'} : '€290'}
</span> </span>
<span className="text-gray-600 ml-2"> <span className="text-gray-600 ml-2">
{plan.key === 'free' {plan.key === 'free'
? t.pricing[plan.key].period ? t.pricing[plan.key].period
: billingPeriod === 'month' : billingPeriod === 'month'
? t.pricing[plan.key].period ? t.pricing[plan.key].period
: 'per year'} : 'per year'}
</span> </span>
</div> </div>
{billingPeriod === 'year' && plan.key !== 'free' && ( {billingPeriod === 'year' && plan.key !== 'free' && (
<Badge variant="success" className="mt-2"> <Badge variant="success" className="mt-2">
Save 16% Save 16%
</Badge> </Badge>
)} )}
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-8 flex-1 flex flex-col"> <CardContent className="space-y-8 flex-1 flex flex-col">
<ul className="space-y-3 flex-1"> <ul className="space-y-3 flex-1">
{t.pricing[plan.key].features.map((feature: string, index: number) => ( {t.pricing[plan.key].features.map((feature: string, index: number) => (
<li key={index} className="flex items-start space-x-3"> <li key={index} className="flex items-start space-x-3">
<svg className="w-5 h-5 text-success-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-5 h-5 text-success-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" /> <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg> </svg>
<span className="text-gray-700">{feature}</span> <span className="text-gray-700">{feature}</span>
</li> </li>
))} ))}
</ul> </ul>
<div className="mt-8 pt-8 border-t border-gray-100"> <div className="mt-8 pt-8 border-t border-gray-100">
<Link href="/signup"> <Link href="/signup">
<Button <Button
variant={plan.popular ? 'primary' : 'outline'} variant={plan.popular ? 'primary' : 'outline'}
className="w-full" className="w-full"
size="lg" size="lg"
> >
Get Started Get Started
</Button> </Button>
</Link> </Link>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</motion.div> </motion.div>
))} ))}
</div> </div>
</div> </div>
</section> </section>
); );
}; };

View File

@ -1,98 +1,98 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { CheckCircle2 } from 'lucide-react'; import { CheckCircle2 } from 'lucide-react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
interface StaticVsDynamicProps { interface StaticVsDynamicProps {
t: any; // i18n translation function t: any; // i18n translation function
} }
export const StaticVsDynamic: React.FC<StaticVsDynamicProps> = ({ t }) => { export const StaticVsDynamic: React.FC<StaticVsDynamicProps> = ({ t }) => {
return ( return (
<section className="py-16"> <section className="py-16">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl"> <div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<div className="text-center mb-12"> <div className="text-center mb-12">
<h2 className="text-3xl font-bold text-gray-900 sm:text-4xl mb-4"> <h2 className="text-3xl font-bold text-gray-900 sm:text-4xl mb-4">
{t.static_vs_dynamic.title} {t.static_vs_dynamic.title}
</h2> </h2>
<p className="text-lg text-gray-600 max-w-2xl mx-auto"> <p className="text-lg text-gray-600 max-w-2xl mx-auto">
{t.static_vs_dynamic.description} {t.static_vs_dynamic.description}
</p> </p>
</div> </div>
<div className="grid lg:grid-cols-2 gap-8 max-w-6xl mx-auto"> <div className="grid lg:grid-cols-2 gap-8 max-w-6xl mx-auto">
{/* Static QR Codes */} {/* Static QR Codes */}
<motion.div <motion.div
initial={{ opacity: 0, x: -20 }} initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }} whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }} transition={{ duration: 0.5, delay: 0.2 }}
> >
<Card className="relative h-full border-gray-200 shadow-sm hover:shadow-lg transition-all duration-300"> <Card className="relative h-full border-gray-200 shadow-sm hover:shadow-lg transition-all duration-300">
<CardHeader> <CardHeader>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<CardTitle className="text-2xl font-bold text-gray-700">{t.static_vs_dynamic.static.title}</CardTitle> <CardTitle className="text-2xl font-bold text-gray-700">{t.static_vs_dynamic.static.title}</CardTitle>
<Badge variant="success" className="bg-gray-100 text-gray-700 hover:bg-gray-200">{t.static_vs_dynamic.static.subtitle}</Badge> <Badge variant="success" className="bg-gray-100 text-gray-700 hover:bg-gray-200">{t.static_vs_dynamic.static.subtitle}</Badge>
</div> </div>
<p className="text-gray-500">{t.static_vs_dynamic.static.description}</p> <p className="text-gray-500">{t.static_vs_dynamic.static.description}</p>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ul className="space-y-4"> <ul className="space-y-4">
{t.static_vs_dynamic.static.features.map((feature: string, index: number) => ( {t.static_vs_dynamic.static.features.map((feature: string, index: number) => (
<li key={index} className="flex items-start space-x-3"> <li key={index} className="flex items-start space-x-3">
<div className="flex-shrink-0 w-6 h-6 bg-gray-100 rounded-full flex items-center justify-center mt-0.5"> <div className="flex-shrink-0 w-6 h-6 bg-gray-100 rounded-full flex items-center justify-center mt-0.5">
<CheckCircle2 className="w-4 h-4 text-gray-500" /> <CheckCircle2 className="w-4 h-4 text-gray-500" />
</div> </div>
<span className="text-gray-600">{feature}</span> <span className="text-gray-600">{feature}</span>
</li> </li>
))} ))}
</ul> </ul>
</CardContent> </CardContent>
</Card> </Card>
</motion.div> </motion.div>
{/* Dynamic QR Codes */} {/* Dynamic QR Codes */}
<motion.div <motion.div
initial={{ opacity: 0, x: 20 }} initial={{ opacity: 0, x: 20 }}
whileInView={{ opacity: 1, x: 0 }} whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }} transition={{ duration: 0.5, delay: 0.2 }}
> >
<Card className="relative h-full border-2 border-primary-500/20 bg-gradient-to-br from-white to-primary-50/50 shadow-xl shadow-primary-500/10 hover:shadow-2xl hover:shadow-primary-500/20 transition-all duration-300"> <Card className="relative h-full border-2 border-primary-500/20 bg-gradient-to-br from-white to-primary-50/50 shadow-xl shadow-primary-500/10 hover:shadow-2xl hover:shadow-primary-500/20 transition-all duration-300">
<div className="absolute top-0 right-0 p-4"> <div className="absolute top-0 right-0 p-4">
<div className="absolute -top-3 -right-3"> <div className="absolute -top-3 -right-3">
<span className="relative flex h-4 w-4"> <span className="relative flex h-4 w-4">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary-400 opacity-75"></span> <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-4 w-4 bg-primary-500"></span> <span className="relative inline-flex rounded-full h-4 w-4 bg-primary-500"></span>
</span> </span>
</div> </div>
</div> </div>
<CardHeader> <CardHeader>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<CardTitle className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary-600 to-purple-600">{t.static_vs_dynamic.dynamic.title}</CardTitle> <CardTitle className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary-600 to-purple-600">{t.static_vs_dynamic.dynamic.title}</CardTitle>
<Badge variant="info" className="bg-primary-100 text-primary-700">{t.static_vs_dynamic.dynamic.subtitle}</Badge> <Badge variant="info" className="bg-primary-100 text-primary-700">{t.static_vs_dynamic.dynamic.subtitle}</Badge>
</div> </div>
<p className="text-gray-600 font-medium">{t.static_vs_dynamic.dynamic.description}</p> <p className="text-gray-600 font-medium">{t.static_vs_dynamic.dynamic.description}</p>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ul className="space-y-4"> <ul className="space-y-4">
{t.static_vs_dynamic.dynamic.features.map((feature: string, index: number) => ( {t.static_vs_dynamic.dynamic.features.map((feature: string, index: number) => (
<li key={index} className="flex items-start space-x-3"> <li key={index} className="flex items-start space-x-3">
<div className="flex-shrink-0 w-6 h-6 bg-primary-100 rounded-full flex items-center justify-center mt-0.5"> <div className="flex-shrink-0 w-6 h-6 bg-primary-100 rounded-full flex items-center justify-center mt-0.5">
<CheckCircle2 className="w-4 h-4 text-primary-600" /> <CheckCircle2 className="w-4 h-4 text-primary-600" />
</div> </div>
<span className="text-gray-900 font-medium">{feature}</span> <span className="text-gray-900 font-medium">{feature}</span>
</li> </li>
))} ))}
</ul> </ul>
</CardContent> </CardContent>
</Card> </Card>
</motion.div> </motion.div>
</div> </div>
</div> </div>
</section> </section>
); );
}; };

View File

@ -1,35 +1,35 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
interface StatsStripProps { interface StatsStripProps {
t: any; // i18n translation function t: any; // i18n translation function
} }
export const StatsStrip: React.FC<StatsStripProps> = ({ t }) => { export const StatsStrip: React.FC<StatsStripProps> = ({ t }) => {
const stats = [ const stats = [
{ key: 'users', value: '1,240+', label: t.trust.users }, { key: 'users', value: '1,240+', label: t.trust.users },
{ key: 'codes', value: '8,500+', label: t.trust.codes }, { key: 'codes', value: '8,500+', label: t.trust.codes },
{ key: 'scans', value: '1.2M+', label: t.trust.scans }, { key: 'scans', value: '1.2M+', label: t.trust.scans },
{ key: 'countries', value: '120+', label: t.trust.countries }, { key: 'countries', value: '120+', label: t.trust.countries },
]; ];
return ( return (
<section className="py-20 bg-white border-y border-gray-100"> <section className="py-20 bg-white border-y border-gray-100">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl"> <div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
{stats.map((stat, index) => ( {stats.map((stat, index) => (
<div key={stat.key} className="text-center group hover:-translate-y-1 transition-transform duration-300"> <div key={stat.key} className="text-center group hover:-translate-y-1 transition-transform duration-300">
<div className="text-4xl lg:text-5xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-600 to-purple-600 mb-2"> <div className="text-4xl lg:text-5xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-600 to-purple-600 mb-2">
{stat.value} {stat.value}
</div> </div>
<div className="text-gray-500 font-medium uppercase tracking-wider text-sm"> <div className="text-gray-500 font-medium uppercase tracking-wider text-sm">
{stat.label} {stat.label}
</div> </div>
</div> </div>
))} ))}
</div> </div>
</div> </div>
</section> </section>
); );
}; };

View File

@ -1,70 +1,70 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
interface TemplateCardsProps { interface TemplateCardsProps {
t: any; // i18n translation function t: any; // i18n translation function
} }
export const TemplateCards: React.FC<TemplateCardsProps> = ({ t }) => { export const TemplateCards: React.FC<TemplateCardsProps> = ({ t }) => {
const templates = [ const templates = [
{ {
key: 'restaurant', key: 'restaurant',
title: t.templates.restaurant, title: t.templates.restaurant,
icon: '🍽️', icon: '🍽️',
color: 'bg-red-50 border-red-200', color: 'bg-red-50 border-red-200',
iconBg: 'bg-red-100', iconBg: 'bg-red-100',
}, },
{ {
key: 'business', key: 'business',
title: t.templates.business, title: t.templates.business,
icon: '💼', icon: '💼',
color: 'bg-blue-50 border-blue-200', color: 'bg-blue-50 border-blue-200',
iconBg: 'bg-blue-100', iconBg: 'bg-blue-100',
}, },
{ {
key: 'vcard', key: 'vcard',
title: t.templates.vcard, title: t.templates.vcard,
icon: '👤', icon: '👤',
color: 'bg-purple-50 border-purple-200', color: 'bg-purple-50 border-purple-200',
iconBg: 'bg-purple-100', iconBg: 'bg-purple-100',
}, },
{ {
key: 'event', key: 'event',
title: t.templates.event, title: t.templates.event,
icon: '🎫', icon: '🎫',
color: 'bg-green-50 border-green-200', color: 'bg-green-50 border-green-200',
iconBg: 'bg-green-100', iconBg: 'bg-green-100',
}, },
]; ];
return ( return (
<section className="py-16"> <section className="py-16">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl"> <div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<div className="text-center mb-12"> <div className="text-center mb-12">
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4"> <h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
{t.templates.title} {t.templates.title}
</h2> </h2>
</div> </div>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
{templates.map((template) => ( {templates.map((template) => (
<Card key={template.key} className={`${template.color} text-center hover:scale-105 transition-transform cursor-pointer`}> <Card key={template.key} className={`${template.color} text-center hover:scale-105 transition-transform cursor-pointer`}>
<div className={`w-16 h-16 ${template.iconBg} rounded-full flex items-center justify-center mx-auto mb-4`}> <div className={`w-16 h-16 ${template.iconBg} rounded-full flex items-center justify-center mx-auto mb-4`}>
<span className="text-2xl">{template.icon}</span> <span className="text-2xl">{template.icon}</span>
</div> </div>
<h3 className="text-lg font-semibold text-gray-900 mb-4"> <h3 className="text-lg font-semibold text-gray-900 mb-4">
{template.title} {template.title}
</h3> </h3>
<Button variant="outline" size="sm" className="w-full"> <Button variant="outline" size="sm" className="w-full">
{t.templates.use_template} {t.templates.use_template}
</Button> </Button>
</Card> </Card>
))} ))}
</div> </div>
</div> </div>
</section> </section>
); );
}; };

View File

@ -1,374 +1,374 @@
{ {
"nav": { "nav": {
"features": "Funktionen", "features": "Funktionen",
"pricing": "Preise", "pricing": "Preise",
"faq": "FAQ", "faq": "FAQ",
"blog": "Blog", "blog": "Blog",
"login": "Anmelden", "login": "Anmelden",
"dashboard": "Dashboard", "dashboard": "Dashboard",
"create_qr": "QR erstellen", "create_qr": "QR erstellen",
"bulk_creation": "Massen-Erstellung", "bulk_creation": "Massen-Erstellung",
"analytics": "Analytik", "analytics": "Analytik",
"settings": "Einstellungen" "settings": "Einstellungen"
}, },
"hero": { "hero": {
"badge": "Kostenloser QR-Code-Generator", "badge": "Kostenloser QR-Code-Generator",
"title": "Erstellen Sie QR-Codes, die überall funktionieren", "title": "Erstellen Sie QR-Codes, die überall funktionieren",
"subtitle": "Generieren Sie statische und dynamische QR-Codes mit Tracking, individuellem Branding und Massen-Erstellung. Kostenlos für immer.", "subtitle": "Generieren Sie statische und dynamische QR-Codes mit Tracking, individuellem Branding und Massen-Erstellung. Kostenlos für immer.",
"features": [ "features": [
"Keine Kreditkarte zum Starten erforderlich", "Keine Kreditkarte zum Starten erforderlich",
"QR-Codes für immer kostenlos erstellen", "QR-Codes für immer kostenlos erstellen",
"Erweiterte Verfolgung und Analytik", "Erweiterte Verfolgung und Analytik",
"Individuelle Farben und Stile" "Individuelle Farben und Stile"
], ],
"cta_primary": "QR-Code kostenlos erstellen", "cta_primary": "QR-Code kostenlos erstellen",
"cta_secondary": "Preise ansehen", "cta_secondary": "Preise ansehen",
"engagement_badge": "Kostenlos für immer", "engagement_badge": "Kostenlos für immer",
"get_started": "Loslegen", "get_started": "Loslegen",
"view_full_pricing": "Alle Preisdetails ansehen →" "view_full_pricing": "Alle Preisdetails ansehen →"
}, },
"trust": { "trust": {
"users": "10.000+ Aktive Nutzer", "users": "10.000+ Aktive Nutzer",
"codes": "500.000+ QR-Codes erstellt", "codes": "500.000+ QR-Codes erstellt",
"scans": "50M+ Scans verfolgt", "scans": "50M+ Scans verfolgt",
"countries": "120+ Länder" "countries": "120+ Länder"
}, },
"industries": { "industries": {
"restaurant": "Restaurant-Kette", "restaurant": "Restaurant-Kette",
"tech": "Tech-Startup", "tech": "Tech-Startup",
"realestate": "Immobilien", "realestate": "Immobilien",
"events": "Event-Agentur", "events": "Event-Agentur",
"retail": "Einzelhandel", "retail": "Einzelhandel",
"healthcare": "Gesundheitswesen" "healthcare": "Gesundheitswesen"
}, },
"templates": { "templates": {
"title": "Mit einer Vorlage beginnen", "title": "Mit einer Vorlage beginnen",
"restaurant": "Restaurant-Menü", "restaurant": "Restaurant-Menü",
"business": "Visitenkarte", "business": "Visitenkarte",
"vcard": "Kontaktkarte", "vcard": "Kontaktkarte",
"event": "Event-Ticket", "event": "Event-Ticket",
"use_template": "Vorlage verwenden →" "use_template": "Vorlage verwenden →"
}, },
"generator": { "generator": {
"title": "Sofortiger QR-Code-Generator", "title": "Sofortiger QR-Code-Generator",
"url_placeholder": "Geben Sie hier Ihre URL ein...", "url_placeholder": "Geben Sie hier Ihre URL ein...",
"foreground": "Vordergrund", "foreground": "Vordergrund",
"background": "Hintergrund", "background": "Hintergrund",
"corners": "Ecken", "corners": "Ecken",
"size": "Größe", "size": "Größe",
"contrast_good": "Guter Kontrast", "contrast_good": "Guter Kontrast",
"download_svg": "SVG herunterladen", "download_svg": "SVG herunterladen",
"download_png": "PNG herunterladen", "download_png": "PNG herunterladen",
"save_track": "Speichern & Verfolgen", "save_track": "Speichern & Verfolgen",
"live_preview": "Live-Vorschau", "live_preview": "Live-Vorschau",
"demo_note": "Dies ist ein Demo-QR-Code" "demo_note": "Dies ist ein Demo-QR-Code"
}, },
"static_vs_dynamic": { "static_vs_dynamic": {
"static": { "static": {
"title": "Statische QR-Codes", "title": "Statische QR-Codes",
"subtitle": "Immer kostenlos", "subtitle": "Immer kostenlos",
"description": "Perfekt für permanente Inhalte, die sich nie ändern", "description": "Perfekt für permanente Inhalte, die sich nie ändern",
"features": [ "features": [
"Inhalt kann nicht bearbeitet werden", "Inhalt kann nicht bearbeitet werden",
"Keine Scan-Verfolgung", "Keine Scan-Verfolgung",
"Funktioniert für immer", "Funktioniert für immer",
"Kein Konto erforderlich" "Kein Konto erforderlich"
] ]
}, },
"dynamic": { "dynamic": {
"title": "Dynamische QR-Codes", "title": "Dynamische QR-Codes",
"subtitle": "Empfohlen", "subtitle": "Empfohlen",
"description": "Volle Kontrolle mit Tracking- und Bearbeitungsfunktionen", "description": "Volle Kontrolle mit Tracking- und Bearbeitungsfunktionen",
"features": [ "features": [
"Inhalt jederzeit bearbeiten", "Inhalt jederzeit bearbeiten",
"Erweiterte Analytik", "Erweiterte Analytik",
"Individuelles Branding", "Individuelles Branding",
"Bulk-Operationen" "Bulk-Operationen"
] ]
} }
}, },
"features": { "features": {
"title": "Alles was Sie brauchen, um professionelle QR-Codes zu erstellen", "title": "Alles was Sie brauchen, um professionelle QR-Codes zu erstellen",
"analytics": { "analytics": {
"title": "Erweiterte Analytik", "title": "Erweiterte Analytik",
"description": "Verfolgen Sie Scans, Standorte, Geräte und Nutzerverhalten mit detaillierten Einblicken." "description": "Verfolgen Sie Scans, Standorte, Geräte und Nutzerverhalten mit detaillierten Einblicken."
}, },
"customization": { "customization": {
"title": "Vollständige Anpassung", "title": "Vollständige Anpassung",
"description": "Branden Sie Ihre QR-Codes mit individuellen Farben, Logos und Styling-Optionen." "description": "Branden Sie Ihre QR-Codes mit individuellen Farben, Logos und Styling-Optionen."
}, },
"bulk": { "bulk": {
"title": "Bulk-Operationen", "title": "Bulk-Operationen",
"description": "Erstellen Sie hunderte von QR-Codes auf einmal mit CSV-Import und Batch-Verarbeitung." "description": "Erstellen Sie hunderte von QR-Codes auf einmal mit CSV-Import und Batch-Verarbeitung."
}, },
"integrations": { "integrations": {
"title": "Integrationen", "title": "Integrationen",
"description": "Verbinden Sie sich mit Zapier, Airtable, Google Sheets und weiteren beliebten Tools." "description": "Verbinden Sie sich mit Zapier, Airtable, Google Sheets und weiteren beliebten Tools."
}, },
"api": { "api": {
"title": "Entwickler-API", "title": "Entwickler-API",
"description": "Integrieren Sie QR-Code-Generierung in Ihre Anwendungen mit unserer REST-API." "description": "Integrieren Sie QR-Code-Generierung in Ihre Anwendungen mit unserer REST-API."
}, },
"support": { "support": {
"title": "24/7 Support", "title": "24/7 Support",
"description": "Erhalten Sie Hilfe, wenn Sie sie brauchen, mit unserem dedizierten Kundensupport-Team." "description": "Erhalten Sie Hilfe, wenn Sie sie brauchen, mit unserem dedizierten Kundensupport-Team."
} }
}, },
"pricing": { "pricing": {
"title": "Wählen Sie Ihren Plan", "title": "Wählen Sie Ihren Plan",
"subtitle": "Wählen Sie den perfekten Plan für Ihre QR-Code-Bedürfnisse", "subtitle": "Wählen Sie den perfekten Plan für Ihre QR-Code-Bedürfnisse",
"choose_plan": "Wählen Sie Ihren Plan", "choose_plan": "Wählen Sie Ihren Plan",
"select_plan": "Wählen Sie den perfekten Plan für Ihre QR-Code-Bedürfnisse", "select_plan": "Wählen Sie den perfekten Plan für Ihre QR-Code-Bedürfnisse",
"current_plan": "Aktueller Plan", "current_plan": "Aktueller Plan",
"upgrade_to": "Upgrade auf", "upgrade_to": "Upgrade auf",
"downgrade_to_free": "Zu Kostenlos zurückstufen", "downgrade_to_free": "Zu Kostenlos zurückstufen",
"most_popular": "Beliebteste", "most_popular": "Beliebteste",
"all_plans_note": "Alle Pläne beinhalten unbegrenzte statische QR-Codes und Basis-Anpassung.", "all_plans_note": "Alle Pläne beinhalten unbegrenzte statische QR-Codes und Basis-Anpassung.",
"free": { "free": {
"title": "Kostenlos", "title": "Kostenlos",
"name": "Free", "name": "Free",
"price": "€0", "price": "€0",
"period": "für immer", "period": "für immer",
"features": [ "features": [
"3 dynamische QR-Codes", "3 dynamische QR-Codes",
"Unbegrenzte statische QR-Codes", "Unbegrenzte statische QR-Codes",
"Basis-Scan-Tracking", "Basis-Scan-Tracking",
"Standard QR-Design-Vorlagen" "Standard QR-Design-Vorlagen"
] ]
}, },
"pro": { "pro": {
"title": "Pro", "title": "Pro",
"name": "Pro", "name": "Pro",
"price": "€9", "price": "€9",
"period": "pro Monat", "period": "pro Monat",
"badge": "Beliebteste", "badge": "Beliebteste",
"features": [ "features": [
"50 dynamische QR-Codes", "50 dynamische QR-Codes",
"Unbegrenzte statische QR-Codes", "Unbegrenzte statische QR-Codes",
"Erweiterte Analytik (Scans, Geräte, Standorte)", "Erweiterte Analytik (Scans, Geräte, Standorte)",
"Individuelles Branding (Farben)", "Individuelles Branding (Farben)",
"Download als SVG/PNG" "Download als SVG/PNG"
] ]
}, },
"business": { "business": {
"title": "Business", "title": "Business",
"name": "Business", "name": "Business",
"price": "€29", "price": "€29",
"period": "pro Monat", "period": "pro Monat",
"features": [ "features": [
"500 dynamische QR-Codes", "500 dynamische QR-Codes",
"Unbegrenzte statische QR-Codes", "Unbegrenzte statische QR-Codes",
"Alles aus Pro", "Alles aus Pro",
"Massen-QR-Erstellung (bis zu 1.000)", "Massen-QR-Erstellung (bis zu 1.000)",
"Prioritäts-E-Mail-Support", "Prioritäts-E-Mail-Support",
"Erweiterte Tracking & Insights" "Erweiterte Tracking & Insights"
] ]
} }
}, },
"faq": { "faq": {
"title": "Häufig gestellte Fragen", "title": "Häufig gestellte Fragen",
"questions": { "questions": {
"account": { "account": {
"question": "Benötige ich ein Konto, um QR-Codes zu erstellen?", "question": "Benötige ich ein Konto, um QR-Codes zu erstellen?",
"answer": "Für statische QR-Codes ist kein Konto erforderlich. Dynamische QR-Codes mit Tracking- und Bearbeitungsfunktionen erfordern jedoch ein kostenloses Konto." "answer": "Für statische QR-Codes ist kein Konto erforderlich. Dynamische QR-Codes mit Tracking- und Bearbeitungsfunktionen erfordern jedoch ein kostenloses Konto."
}, },
"static_vs_dynamic": { "static_vs_dynamic": {
"question": "Was ist der Unterschied zwischen statischen und dynamischen QR-Codes?", "question": "Was ist der Unterschied zwischen statischen und dynamischen QR-Codes?",
"answer": "Statische QR-Codes enthalten feste Inhalte, die nicht geändert werden können. Dynamische QR-Codes können jederzeit bearbeitet werden und bieten detaillierte Analytik." "answer": "Statische QR-Codes enthalten feste Inhalte, die nicht geändert werden können. Dynamische QR-Codes können jederzeit bearbeitet werden und bieten detaillierte Analytik."
}, },
"forever": { "forever": {
"question": "Funktionieren meine QR-Codes für immer?", "question": "Funktionieren meine QR-Codes für immer?",
"answer": "Statische QR-Codes funktionieren für immer, da der Inhalt direkt eingebettet ist. Dynamische QR-Codes funktionieren, solange Ihr Konto aktiv ist." "answer": "Statische QR-Codes funktionieren für immer, da der Inhalt direkt eingebettet ist. Dynamische QR-Codes funktionieren, solange Ihr Konto aktiv ist."
}, },
"file_type": { "file_type": {
"question": "Welchen Dateityp sollte ich zum Drucken verwenden?", "question": "Welchen Dateityp sollte ich zum Drucken verwenden?",
"answer": "Für Druckmaterialien empfehlen wir das SVG-Format für Skalierbarkeit oder hochauflösendes PNG (300+ DPI) für beste Qualität." "answer": "Für Druckmaterialien empfehlen wir das SVG-Format für Skalierbarkeit oder hochauflösendes PNG (300+ DPI) für beste Qualität."
}, },
"password": { "password": {
"question": "Kann ich einen QR-Code mit einem Passwort schützen?", "question": "Kann ich einen QR-Code mit einem Passwort schützen?",
"answer": "Ja, Pro- und Business-Pläne beinhalten Passwortschutz und Zugriffskontrollfunktionen für Ihre QR-Codes." "answer": "Ja, Pro- und Business-Pläne beinhalten Passwortschutz und Zugriffskontrollfunktionen für Ihre QR-Codes."
}, },
"analytics": { "analytics": {
"question": "Wie funktioniert die Analytik?", "question": "Wie funktioniert die Analytik?",
"answer": "Wir verfolgen Scans, Standorte, Geräte und Referrer unter Beachtung der Privatsphäre der Nutzer. Keine persönlichen Daten werden gespeichert." "answer": "Wir verfolgen Scans, Standorte, Geräte und Referrer unter Beachtung der Privatsphäre der Nutzer. Keine persönlichen Daten werden gespeichert."
}, },
"privacy": { "privacy": {
"question": "Verfolgen Sie persönliche Daten?", "question": "Verfolgen Sie persönliche Daten?",
"answer": "Wir respektieren die Privatsphäre und sammeln nur anonyme Nutzungsdaten. IP-Adressen werden gehasht und wir respektieren Do-Not-Track-Header." "answer": "Wir respektieren die Privatsphäre und sammeln nur anonyme Nutzungsdaten. IP-Adressen werden gehasht und wir respektieren Do-Not-Track-Header."
}, },
"bulk": { "bulk": {
"question": "Kann ich Codes in großen Mengen mit meinen eigenen Daten erstellen?", "question": "Kann ich Codes in großen Mengen mit meinen eigenen Daten erstellen?",
"answer": "Ja, Sie können CSV- oder Excel-Dateien hochladen, um mehrere QR-Codes auf einmal mit individueller Datenzuordnung zu erstellen." "answer": "Ja, Sie können CSV- oder Excel-Dateien hochladen, um mehrere QR-Codes auf einmal mit individueller Datenzuordnung zu erstellen."
} }
} }
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
"subtitle": "Verwalten Sie Ihre QR-Codes und verfolgen Sie Ihre Performance", "subtitle": "Verwalten Sie Ihre QR-Codes und verfolgen Sie Ihre Performance",
"stats": { "stats": {
"total_scans": "Gesamte Scans", "total_scans": "Gesamte Scans",
"active_codes": "Aktive QR-Codes", "active_codes": "Aktive QR-Codes",
"conversion_rate": "Konversionsrate" "conversion_rate": "Konversionsrate"
}, },
"recent_codes": "Aktuelle QR-Codes", "recent_codes": "Aktuelle QR-Codes",
"blog_resources": "Blog & Ressourcen", "blog_resources": "Blog & Ressourcen",
"menu": { "menu": {
"edit": "Bearbeiten", "edit": "Bearbeiten",
"duplicate": "Duplizieren", "duplicate": "Duplizieren",
"pause": "Pausieren", "pause": "Pausieren",
"delete": "Löschen" "delete": "Löschen"
} }
}, },
"create": { "create": {
"title": "QR-Code erstellen", "title": "QR-Code erstellen",
"subtitle": "Generieren Sie dynamische und statische QR-Codes mit individuellem Branding", "subtitle": "Generieren Sie dynamische und statische QR-Codes mit individuellem Branding",
"content": "Inhalt", "content": "Inhalt",
"type": "QR-Code-Typ", "type": "QR-Code-Typ",
"style": "Stil & Branding", "style": "Stil & Branding",
"preview": "Live-Vorschau", "preview": "Live-Vorschau",
"title_label": "Titel", "title_label": "Titel",
"title_placeholder": "Mein QR-Code", "title_placeholder": "Mein QR-Code",
"content_type": "Inhaltstyp", "content_type": "Inhaltstyp",
"url_label": "URL", "url_label": "URL",
"url_placeholder": "https://beispiel.de", "url_placeholder": "https://beispiel.de",
"tags_label": "Tags (durch Komma getrennt)", "tags_label": "Tags (durch Komma getrennt)",
"tags_placeholder": "marketing, kampagne, 2025", "tags_placeholder": "marketing, kampagne, 2025",
"qr_code_type": "QR-Code-Typ", "qr_code_type": "QR-Code-Typ",
"dynamic": "Dynamisch", "dynamic": "Dynamisch",
"static": "Statisch", "static": "Statisch",
"recommended": "Empfohlen", "recommended": "Empfohlen",
"dynamic_description": "Dynamisch: Scans verfolgen, URL später bearbeiten, Analytik ansehen. QR enthält Tracking-Link.", "dynamic_description": "Dynamisch: Scans verfolgen, URL später bearbeiten, Analytik ansehen. QR enthält Tracking-Link.",
"static_description": "Statisch: Direkt zum Inhalt, kein Tracking, nicht bearbeitbar. QR enthält tatsächlichen Inhalt.", "static_description": "Statisch: Direkt zum Inhalt, kein Tracking, nicht bearbeitbar. QR enthält tatsächlichen Inhalt.",
"foreground_color": "Vordergrundfarbe", "foreground_color": "Vordergrundfarbe",
"background_color": "Hintergrundfarbe", "background_color": "Hintergrundfarbe",
"corner_style": "Eckenstil", "corner_style": "Eckenstil",
"size": "Größe", "size": "Größe",
"good_contrast": "Guter Kontrast", "good_contrast": "Guter Kontrast",
"contrast_ratio": "Kontrastverhältnis", "contrast_ratio": "Kontrastverhältnis",
"download_svg": "SVG herunterladen", "download_svg": "SVG herunterladen",
"download_png": "PNG herunterladen", "download_png": "PNG herunterladen",
"save_qr_code": "QR-Code speichern" "save_qr_code": "QR-Code speichern"
}, },
"analytics": { "analytics": {
"title": "Analytik", "title": "Analytik",
"subtitle": "Verfolgen und analysieren Sie die Performance Ihrer QR-Codes", "subtitle": "Verfolgen und analysieren Sie die Performance Ihrer QR-Codes",
"export_report": "Bericht exportieren", "export_report": "Bericht exportieren",
"from_last_period": "vom letzten Zeitraum", "from_last_period": "vom letzten Zeitraum",
"no_mobile_scans": "Keine mobilen Scans", "no_mobile_scans": "Keine mobilen Scans",
"of_total": "der Gesamtmenge", "of_total": "der Gesamtmenge",
"ranges": { "ranges": {
"7d": "7 Tage", "7d": "7 Tage",
"30d": "30 Tage", "30d": "30 Tage",
"90d": "90 Tage" "90d": "90 Tage"
}, },
"kpis": { "kpis": {
"total_scans": "Gesamte Scans", "total_scans": "Gesamte Scans",
"avg_scans": "Ø Scans/QR", "avg_scans": "Ø Scans/QR",
"mobile_usage": "Mobile Nutzung", "mobile_usage": "Mobile Nutzung",
"top_country": "Top Land" "top_country": "Top Land"
}, },
"charts": { "charts": {
"scans_over_time": "Scans über Zeit", "scans_over_time": "Scans über Zeit",
"device_types": "Gerätetypen", "device_types": "Gerätetypen",
"top_countries": "Top Länder" "top_countries": "Top Länder"
}, },
"table": { "table": {
"qr_code": "QR-Code", "qr_code": "QR-Code",
"type": "Typ", "type": "Typ",
"total_scans": "Gesamte Scans", "total_scans": "Gesamte Scans",
"unique_scans": "Einzigartige Scans", "unique_scans": "Einzigartige Scans",
"conversion": "Konversion", "conversion": "Konversion",
"trend": "Trend", "trend": "Trend",
"scans": "Scans", "scans": "Scans",
"percentage": "Prozent", "percentage": "Prozent",
"country": "Land", "country": "Land",
"performance": "Performance", "performance": "Performance",
"created": "Erstellt", "created": "Erstellt",
"status": "Status" "status": "Status"
}, },
"performance_title": "QR-Code-Performance" "performance_title": "QR-Code-Performance"
}, },
"bulk": { "bulk": {
"title": "Massen-Erstellung", "title": "Massen-Erstellung",
"subtitle": "Erstellen Sie mehrere QR-Codes gleichzeitig aus CSV- oder Excel-Dateien", "subtitle": "Erstellen Sie mehrere QR-Codes gleichzeitig aus CSV- oder Excel-Dateien",
"template_warning_title": "Bitte folgen Sie dem Vorlagenformat", "template_warning_title": "Bitte folgen Sie dem Vorlagenformat",
"template_warning_text": "Laden Sie die Vorlage unten herunter und folgen Sie dem Format genau. Ihre CSV muss Spalten für Titel und Inhalt (URL) enthalten.", "template_warning_text": "Laden Sie die Vorlage unten herunter und folgen Sie dem Format genau. Ihre CSV muss Spalten für Titel und Inhalt (URL) enthalten.",
"static_only_title": "Nur statische QR-Codes", "static_only_title": "Nur statische QR-Codes",
"static_only_text": "Massen-Erstellung generiert statische QR-Codes, die nach der Erstellung nicht bearbeitet werden können. Diese QR-Codes beinhalten kein Tracking oder Analytik. Perfekt für Druckmaterialien und Offline-Nutzung.", "static_only_text": "Massen-Erstellung generiert statische QR-Codes, die nach der Erstellung nicht bearbeitet werden können. Diese QR-Codes beinhalten kein Tracking oder Analytik. Perfekt für Druckmaterialien und Offline-Nutzung.",
"download_template": "Vorlage herunterladen", "download_template": "Vorlage herunterladen",
"no_file_selected": "Keine ausgewählt", "no_file_selected": "Keine ausgewählt",
"simple_format": "Einfaches Format", "simple_format": "Einfaches Format",
"just_title_url": "Nur Titel & URL", "just_title_url": "Nur Titel & URL",
"static_qr_codes": "Statische QR-Codes", "static_qr_codes": "Statische QR-Codes",
"no_tracking": "Kein Tracking enthalten", "no_tracking": "Kein Tracking enthalten",
"instant_download": "Sofortiger Download", "instant_download": "Sofortiger Download",
"get_zip": "ZIP mit allen SVGs erhalten", "get_zip": "ZIP mit allen SVGs erhalten",
"max_rows": "max 1.000 Zeilen", "max_rows": "max 1.000 Zeilen",
"steps": { "steps": {
"upload": "Datei hochladen", "upload": "Datei hochladen",
"preview": "Vorschau & Zuordnung", "preview": "Vorschau & Zuordnung",
"download": "Herunterladen" "download": "Herunterladen"
}, },
"drag_drop": "Datei hier hinziehen", "drag_drop": "Datei hier hinziehen",
"or_click": "oder klicken zum Durchsuchen", "or_click": "oder klicken zum Durchsuchen",
"supported_formats": "Unterstützt CSV, XLS, XLSX (max 1.000 Zeilen)" "supported_formats": "Unterstützt CSV, XLS, XLSX (max 1.000 Zeilen)"
}, },
"integrations": { "integrations": {
"title": "Integrationen", "title": "Integrationen",
"metrics": { "metrics": {
"total_codes": "QR-Codes Gesamt", "total_codes": "QR-Codes Gesamt",
"active_integrations": "Aktive Integrationen", "active_integrations": "Aktive Integrationen",
"sync_status": "Sync-Status", "sync_status": "Sync-Status",
"available_services": "Verfügbare Services" "available_services": "Verfügbare Services"
}, },
"zapier": { "zapier": {
"title": "Zapier", "title": "Zapier",
"description": "Automatisieren Sie QR-Code-Erstellung mit 5000+ Apps", "description": "Automatisieren Sie QR-Code-Erstellung mit 5000+ Apps",
"features": [ "features": [
"Trigger bei neuen QR-Codes", "Trigger bei neuen QR-Codes",
"Codes aus anderen Apps erstellen", "Codes aus anderen Apps erstellen",
"Scan-Daten synchronisieren" "Scan-Daten synchronisieren"
] ]
}, },
"airtable": { "airtable": {
"title": "Airtable", "title": "Airtable",
"description": "Synchronisieren Sie QR-Codes mit Ihren Airtable-Basen", "description": "Synchronisieren Sie QR-Codes mit Ihren Airtable-Basen",
"features": [ "features": [
"Bidirektionale Synchronisation", "Bidirektionale Synchronisation",
"Individuelle Feldzuordnung", "Individuelle Feldzuordnung",
"Echtzeit-Updates" "Echtzeit-Updates"
] ]
}, },
"sheets": { "sheets": {
"title": "Google Sheets", "title": "Google Sheets",
"description": "Exportieren Sie Daten automatisch zu Google Sheets", "description": "Exportieren Sie Daten automatisch zu Google Sheets",
"features": [ "features": [
"Automatisierte Exporte", "Automatisierte Exporte",
"Individuelle Vorlagen", "Individuelle Vorlagen",
"Geplante Updates" "Geplante Updates"
] ]
}, },
"activate": "Aktivieren & Konfigurieren" "activate": "Aktivieren & Konfigurieren"
}, },
"settings": { "settings": {
"title": "Einstellungen", "title": "Einstellungen",
"subtitle": "Verwalten Sie Ihre Kontoeinstellungen und Präferenzen", "subtitle": "Verwalten Sie Ihre Kontoeinstellungen und Präferenzen",
"tabs": { "tabs": {
"profile": "Profil", "profile": "Profil",
"billing": "Abrechnung", "billing": "Abrechnung",
"team": "Team & Rollen", "team": "Team & Rollen",
"api": "API-Schlüssel", "api": "API-Schlüssel",
"workspace": "Arbeitsbereich" "workspace": "Arbeitsbereich"
} }
}, },
"common": { "common": {
"save": "Speichern", "save": "Speichern",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"delete": "Löschen", "delete": "Löschen",
"edit": "Bearbeiten", "edit": "Bearbeiten",
"create": "Erstellen", "create": "Erstellen",
"loading": "Lädt...", "loading": "Lädt...",
"error": "Ein Fehler ist aufgetreten", "error": "Ein Fehler ist aufgetreten",
"success": "Erfolgreich!" "success": "Erfolgreich!"
} }
} }

View File

@ -11,6 +11,14 @@ const envSchema = z.object({
REDIS_URL: z.string().optional(), REDIS_URL: z.string().optional(),
IP_SALT: z.string().default('development-salt-change-in-production'), IP_SALT: z.string().default('development-salt-change-in-production'),
ENABLE_DEMO: z.string().default('false'), 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 // During build, we might not have all env vars, so we'll use defaults

65
src/lib/r2.ts Normal file
View File

@ -0,0 +1,65 @@
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
}
}

View File

@ -1,245 +1,245 @@
export interface BreadcrumbItem { export interface BreadcrumbItem {
name: string; name: string;
url: string; url: string;
} }
export interface BlogPost { export interface BlogPost {
title: string; title: string;
description: string; description: string;
slug: string; slug: string;
author: string; author: string;
authorUrl: string; authorUrl: string;
datePublished: string; datePublished: string;
dateModified: string; dateModified: string;
image: string; image: string;
} }
export interface FAQItem { export interface FAQItem {
question: string; question: string;
answer: string; answer: string;
} }
export interface ProductOffer { export interface ProductOffer {
name: string; name: string;
price: string; price: string;
priceCurrency: string; priceCurrency: string;
availability: string; availability: string;
url: string; url: string;
} }
export interface HowToStep { export interface HowToStep {
name: string; name: string;
text: string; text: string;
url?: string; url?: string;
} }
export interface HowToTask { export interface HowToTask {
name: string; name: string;
description: string; description: string;
steps: HowToStep[]; steps: HowToStep[];
totalTime?: string; totalTime?: string;
} }
export function organizationSchema() { export function organizationSchema() {
return { return {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'Organization', '@type': 'Organization',
'@id': 'https://www.qrmaster.net/#organization', '@id': 'https://www.qrmaster.net/#organization',
name: 'QR Master', name: 'QR Master',
alternateName: 'QRMaster', alternateName: 'QRMaster',
url: 'https://www.qrmaster.net', url: 'https://www.qrmaster.net',
logo: { logo: {
'@type': 'ImageObject', '@type': 'ImageObject',
url: 'https://www.qrmaster.net/static/og-image.png', url: 'https://www.qrmaster.net/static/og-image.png',
width: 1200, width: 1200,
height: 630, height: 630,
}, },
image: 'https://www.qrmaster.net/static/og-image.png', image: 'https://www.qrmaster.net/static/og-image.png',
sameAs: [ sameAs: [
'https://twitter.com/qrmaster', 'https://twitter.com/qrmaster',
], ],
contactPoint: { contactPoint: {
'@type': 'ContactPoint', '@type': 'ContactPoint',
contactType: 'Customer Support', contactType: 'Customer Support',
email: 'support@qrmaster.net', email: 'support@qrmaster.net',
availableLanguage: ['English', 'German'], availableLanguage: ['English', 'German'],
}, },
description: 'B2B SaaS platform for dynamic QR code generation with analytics, branding, and bulk generation for enterprise marketing campaigns.', 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', slogan: 'Dynamic QR codes that work smarter',
foundingDate: '2025', foundingDate: '2025',
areaServed: 'Worldwide', areaServed: 'Worldwide',
serviceType: 'Software as a Service', serviceType: 'Software as a Service',
priceRange: '$0 - $29', priceRange: '$0 - $29',
knowsAbout: [ knowsAbout: [
'QR Code Generation', 'QR Code Generation',
'Marketing Analytics', 'Marketing Analytics',
'Campaign Tracking', 'Campaign Tracking',
'Dynamic QR Codes', 'Dynamic QR Codes',
'Bulk QR Generation', 'Bulk QR Generation',
], ],
hasOfferCatalog: { hasOfferCatalog: {
'@type': 'OfferCatalog', '@type': 'OfferCatalog',
name: 'QR Master Plans', name: 'QR Master Plans',
itemListElement: [ itemListElement: [
{ {
'@type': 'Offer', '@type': 'Offer',
itemOffered: { itemOffered: {
'@type': 'SoftwareApplication', '@type': 'SoftwareApplication',
name: 'QR Master Free', name: 'QR Master Free',
applicationCategory: 'BusinessApplication', applicationCategory: 'BusinessApplication',
operatingSystem: 'Web Browser', operatingSystem: 'Web Browser',
}, },
}, },
{ {
'@type': 'Offer', '@type': 'Offer',
itemOffered: { itemOffered: {
'@type': 'SoftwareApplication', '@type': 'SoftwareApplication',
name: 'QR Master Pro', name: 'QR Master Pro',
applicationCategory: 'BusinessApplication', applicationCategory: 'BusinessApplication',
operatingSystem: 'Web Browser', operatingSystem: 'Web Browser',
}, },
}, },
], ],
}, },
inLanguage: 'en', inLanguage: 'en',
mainEntityOfPage: 'https://www.qrmaster.net', mainEntityOfPage: 'https://www.qrmaster.net',
}; };
} }
export function websiteSchema() { export function websiteSchema() {
return { return {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'WebSite', '@type': 'WebSite',
'@id': 'https://www.qrmaster.net/#website', '@id': 'https://www.qrmaster.net/#website',
name: 'QR Master', name: 'QR Master',
url: 'https://www.qrmaster.net', url: 'https://www.qrmaster.net',
inLanguage: 'en', inLanguage: 'en',
mainEntityOfPage: 'https://www.qrmaster.net', mainEntityOfPage: 'https://www.qrmaster.net',
publisher: { publisher: {
'@id': 'https://www.qrmaster.net/#organization', '@id': 'https://www.qrmaster.net/#organization',
}, },
potentialAction: { potentialAction: {
'@type': 'SearchAction', '@type': 'SearchAction',
target: { target: {
'@type': 'EntryPoint', '@type': 'EntryPoint',
urlTemplate: 'https://www.qrmaster.net/blog?q={search_term_string}', urlTemplate: 'https://www.qrmaster.net/blog?q={search_term_string}',
}, },
'query-input': 'required name=search_term_string', 'query-input': 'required name=search_term_string',
}, },
}; };
} }
export function breadcrumbSchema(items: BreadcrumbItem[]) { export function breadcrumbSchema(items: BreadcrumbItem[]) {
return { return {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'BreadcrumbList', '@type': 'BreadcrumbList',
'@id': `https://www.qrmaster.net${items[items.length - 1]?.url}#breadcrumb`, '@id': `https://www.qrmaster.net${items[items.length - 1]?.url}#breadcrumb`,
inLanguage: 'en', inLanguage: 'en',
mainEntityOfPage: `https://www.qrmaster.net${items[items.length - 1]?.url}`, mainEntityOfPage: `https://www.qrmaster.net${items[items.length - 1]?.url}`,
itemListElement: items.map((item, index) => ({ itemListElement: items.map((item, index) => ({
'@type': 'ListItem', '@type': 'ListItem',
position: index + 1, position: index + 1,
name: item.name, name: item.name,
item: `https://www.qrmaster.net${item.url}`, item: `https://www.qrmaster.net${item.url}`,
})), })),
}; };
} }
export function blogPostingSchema(post: BlogPost) { export function blogPostingSchema(post: BlogPost) {
return { return {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'BlogPosting', '@type': 'BlogPosting',
'@id': `https://www.qrmaster.net/blog/${post.slug}#article`, '@id': `https://www.qrmaster.net/blog/${post.slug}#article`,
headline: post.title, headline: post.title,
description: post.description, description: post.description,
image: post.image, image: post.image,
datePublished: post.datePublished, datePublished: post.datePublished,
dateModified: post.dateModified, dateModified: post.dateModified,
inLanguage: 'en', inLanguage: 'en',
mainEntityOfPage: `https://www.qrmaster.net/blog/${post.slug}`, mainEntityOfPage: `https://www.qrmaster.net/blog/${post.slug}`,
author: { author: {
'@type': 'Person', '@type': 'Person',
name: post.author, name: post.author,
url: post.authorUrl, url: post.authorUrl,
}, },
publisher: { publisher: {
'@type': 'Organization', '@type': 'Organization',
name: 'QR Master', name: 'QR Master',
url: 'https://www.qrmaster.net', url: 'https://www.qrmaster.net',
logo: { logo: {
'@type': 'ImageObject', '@type': 'ImageObject',
url: 'https://www.qrmaster.net/static/og-image.png', url: 'https://www.qrmaster.net/static/og-image.png',
width: 1200, width: 1200,
height: 630, height: 630,
}, },
}, },
isPartOf: { isPartOf: {
'@type': 'Blog', '@type': 'Blog',
'@id': 'https://www.qrmaster.net/blog#blog', '@id': 'https://www.qrmaster.net/blog#blog',
name: 'QR Master Blog', name: 'QR Master Blog',
url: 'https://www.qrmaster.net/blog', url: 'https://www.qrmaster.net/blog',
}, },
}; };
} }
export function faqPageSchema(faqs: FAQItem[]) { export function faqPageSchema(faqs: FAQItem[]) {
return { return {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'FAQPage', '@type': 'FAQPage',
'@id': 'https://www.qrmaster.net/faq#faqpage', '@id': 'https://www.qrmaster.net/faq#faqpage',
inLanguage: 'en', inLanguage: 'en',
mainEntityOfPage: 'https://www.qrmaster.net/faq', mainEntityOfPage: 'https://www.qrmaster.net/faq',
mainEntity: faqs.map((faq) => ({ mainEntity: faqs.map((faq) => ({
'@type': 'Question', '@type': 'Question',
name: faq.question, name: faq.question,
acceptedAnswer: { acceptedAnswer: {
'@type': 'Answer', '@type': 'Answer',
text: faq.answer, text: faq.answer,
}, },
})), })),
}; };
} }
export function productSchema(product: { name: string; description: string; offers: ProductOffer[] }) { export function productSchema(product: { name: string; description: string; offers: ProductOffer[] }) {
return { return {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'Product', '@type': 'Product',
'@id': 'https://www.qrmaster.net/pricing#product', '@id': 'https://www.qrmaster.net/pricing#product',
name: product.name, name: product.name,
description: product.description, description: product.description,
inLanguage: 'en', inLanguage: 'en',
mainEntityOfPage: 'https://www.qrmaster.net/pricing', mainEntityOfPage: 'https://www.qrmaster.net/pricing',
brand: { brand: {
'@type': 'Organization', '@type': 'Organization',
name: 'QR Master', name: 'QR Master',
}, },
offers: product.offers.map((offer) => ({ offers: product.offers.map((offer) => ({
'@type': 'Offer', '@type': 'Offer',
name: offer.name, name: offer.name,
price: offer.price, price: offer.price,
priceCurrency: offer.priceCurrency, priceCurrency: offer.priceCurrency,
availability: offer.availability, availability: offer.availability,
url: offer.url, url: offer.url,
})), })),
}; };
} }
export function howToSchema(task: HowToTask) { export function howToSchema(task: HowToTask) {
return { return {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'HowTo', '@type': 'HowTo',
'@id': `https://www.qrmaster.net/blog/${task.name.toLowerCase().replace(/\s+/g, '-')}#howto`, '@id': `https://www.qrmaster.net/blog/${task.name.toLowerCase().replace(/\s+/g, '-')}#howto`,
name: task.name, name: task.name,
description: task.description, description: task.description,
inLanguage: 'en', inLanguage: 'en',
mainEntityOfPage: `https://www.qrmaster.net/blog/${task.name.toLowerCase().replace(/\s+/g, '-')}`, mainEntityOfPage: `https://www.qrmaster.net/blog/${task.name.toLowerCase().replace(/\s+/g, '-')}`,
totalTime: task.totalTime || 'PT5M', totalTime: task.totalTime || 'PT5M',
step: task.steps.map((step, index) => ({ step: task.steps.map((step, index) => ({
'@type': 'HowToStep', '@type': 'HowToStep',
position: index + 1, position: index + 1,
name: step.name, name: step.name,
text: step.text, text: step.text,
url: step.url, url: step.url,
})), })),
}; };
} }

View File

@ -1,186 +1,186 @@
/** /**
* Zod Validation Schemas for API endpoints * Zod Validation Schemas for API endpoints
* Centralized validation logic for type-safety and security * Centralized validation logic for type-safety and security
*/ */
import { z } from 'zod'; import { z } from 'zod';
// ========================================== // ==========================================
// QR Code Schemas // QR Code Schemas
// ========================================== // ==========================================
export const qrStyleSchema = z.object({ export const qrStyleSchema = z.object({
fgColor: z.string().regex(/^#[0-9A-F]{6}$/i, 'Invalid foreground color format').optional(), 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(), bgColor: z.string().regex(/^#[0-9A-F]{6}$/i, 'Invalid background color format').optional(),
cornerStyle: z.enum(['square', 'rounded']).optional(), cornerStyle: z.enum(['square', 'rounded']).optional(),
size: z.number().min(100).max(1000).optional(), size: z.number().min(100).max(1000).optional(),
}); });
export const createQRSchema = z.object({ export const createQRSchema = z.object({
title: z.string() title: z.string()
.min(1, 'Title is required') .min(1, 'Title is required')
.max(100, 'Title must be less than 100 characters'), .max(100, 'Title must be less than 100 characters'),
content: z.record(z.any()), // Accept any object structure for content content: z.record(z.any()), // Accept any object structure for content
isStatic: z.boolean().optional(), isStatic: z.boolean().optional(),
contentType: z.enum(['URL', 'VCARD', 'GEO', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT', 'PDF', 'APP', 'COUPON', 'FEEDBACK'], { contentType: z.enum(['URL', 'VCARD', 'GEO', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT', 'PDF', 'APP', 'COUPON', 'FEEDBACK'], {
errorMap: () => ({ message: 'Invalid content type' }) errorMap: () => ({ message: 'Invalid content type' })
}), }),
tags: z.array(z.string()).optional(), tags: z.array(z.string()).optional(),
style: z.object({ style: z.object({
foregroundColor: z.string().optional(), foregroundColor: z.string().optional(),
backgroundColor: z.string().optional(), backgroundColor: z.string().optional(),
cornerStyle: z.enum(['square', 'rounded']).optional(), cornerStyle: z.enum(['square', 'rounded']).optional(),
size: z.number().optional(), size: z.number().optional(),
}).optional(), }).optional(),
}); });
export const updateQRSchema = z.object({ export const updateQRSchema = z.object({
title: z.string() title: z.string()
.min(1, 'Title is required') .min(1, 'Title is required')
.max(100, 'Title must be less than 100 characters') .max(100, 'Title must be less than 100 characters')
.optional(), .optional(),
content: z.string() content: z.string()
.min(1, 'Content is required') .min(1, 'Content is required')
.max(5000, 'Content must be less than 5000 characters') .max(5000, 'Content must be less than 5000 characters')
.optional(), .optional(),
style: qrStyleSchema.optional(), style: qrStyleSchema.optional(),
isActive: z.boolean().optional(), isActive: z.boolean().optional(),
}); });
export const bulkQRSchema = z.object({ export const bulkQRSchema = z.object({
qrs: z.array( qrs: z.array(
z.object({ z.object({
title: z.string().min(1).max(100), title: z.string().min(1).max(100),
content: z.string().min(1).max(5000), content: z.string().min(1).max(5000),
contentType: z.enum(['URL', 'VCARD', 'GEO', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT', 'PDF', 'APP', 'COUPON', 'FEEDBACK']), contentType: z.enum(['URL', 'VCARD', 'GEO', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT', 'PDF', 'APP', 'COUPON', 'FEEDBACK']),
}) })
).min(1, 'At least one QR code is required') ).min(1, 'At least one QR code is required')
.max(100, 'Maximum 100 QR codes per bulk creation'), .max(100, 'Maximum 100 QR codes per bulk creation'),
}); });
// ========================================== // ==========================================
// Authentication Schemas // Authentication Schemas
// ========================================== // ==========================================
export const loginSchema = z.object({ export const loginSchema = z.object({
email: z.string() email: z.string()
.email('Invalid email format') .email('Invalid email format')
.toLowerCase(), .toLowerCase(),
password: z.string() password: z.string()
.min(1, 'Password is required'), .min(1, 'Password is required'),
}); });
export const signupSchema = z.object({ export const signupSchema = z.object({
name: z.string() name: z.string()
.min(2, 'Name must be at least 2 characters') .min(2, 'Name must be at least 2 characters')
.max(100, 'Name must be less than 100 characters') .max(100, 'Name must be less than 100 characters')
.trim(), .trim(),
email: z.string() email: z.string()
.email('Invalid email format') .email('Invalid email format')
.toLowerCase() .toLowerCase()
.trim(), .trim(),
password: z.string() password: z.string()
.min(8, 'Password must be at least 8 characters') .min(8, 'Password must be at least 8 characters')
.max(100, 'Password must be less than 100 characters'), .max(100, 'Password must be less than 100 characters'),
// Password complexity rules removed for easier testing // Password complexity rules removed for easier testing
}); });
export const forgotPasswordSchema = z.object({ export const forgotPasswordSchema = z.object({
email: z.string() email: z.string()
.email('Invalid email format') .email('Invalid email format')
.toLowerCase() .toLowerCase()
.trim(), .trim(),
}); });
export const resetPasswordSchema = z.object({ export const resetPasswordSchema = z.object({
token: z.string().min(1, 'Reset token is required'), token: z.string().min(1, 'Reset token is required'),
password: z.string() password: z.string()
.min(8, 'Password must be at least 8 characters') .min(8, 'Password must be at least 8 characters')
.max(100, 'Password must be less than 100 characters'), .max(100, 'Password must be less than 100 characters'),
// Password complexity rules removed for easier testing // Password complexity rules removed for easier testing
}); });
// ========================================== // ==========================================
// Settings Schemas // Settings Schemas
// ========================================== // ==========================================
export const updateProfileSchema = z.object({ export const updateProfileSchema = z.object({
name: z.string() name: z.string()
.min(2, 'Name must be at least 2 characters') .min(2, 'Name must be at least 2 characters')
.max(100, 'Name must be less than 100 characters') .max(100, 'Name must be less than 100 characters')
.trim(), .trim(),
}); });
export const changePasswordSchema = z.object({ export const changePasswordSchema = z.object({
currentPassword: z.string() currentPassword: z.string()
.min(1, 'Current password is required'), .min(1, 'Current password is required'),
newPassword: z.string() newPassword: z.string()
.min(8, 'Password must be at least 8 characters') .min(8, 'Password must be at least 8 characters')
.max(100, 'Password must be less than 100 characters'), .max(100, 'Password must be less than 100 characters'),
// Password complexity rules removed for easier testing // Password complexity rules removed for easier testing
}); });
// ========================================== // ==========================================
// Stripe Schemas // Stripe Schemas
// ========================================== // ==========================================
export const createCheckoutSchema = z.object({ export const createCheckoutSchema = z.object({
priceId: z.string().min(1, 'Price ID is required'), priceId: z.string().min(1, 'Price ID is required'),
}); });
// ========================================== // ==========================================
// Newsletter Schemas // Newsletter Schemas
// ========================================== // ==========================================
export const newsletterSubscribeSchema = z.object({ export const newsletterSubscribeSchema = z.object({
email: z.string() email: z.string()
.email('Invalid email format') .email('Invalid email format')
.toLowerCase() .toLowerCase()
.trim() .trim()
.max(255, 'Email must be less than 255 characters'), .max(255, 'Email must be less than 255 characters'),
}); });
// ========================================== // ==========================================
// Helper: Format Zod Errors // Helper: Format Zod Errors
// ========================================== // ==========================================
export function formatZodError(error: z.ZodError) { export function formatZodError(error: z.ZodError) {
return { return {
error: 'Validation failed', error: 'Validation failed',
details: error.errors.map(err => ({ details: error.errors.map(err => ({
field: err.path.join('.'), field: err.path.join('.'),
message: err.message, message: err.message,
})), })),
}; };
} }
// ========================================== // ==========================================
// Helper: Validate with Zod // Helper: Validate with Zod
// ========================================== // ==========================================
export async function validateRequest<T>( export async function validateRequest<T>(
schema: z.ZodSchema<T>, schema: z.ZodSchema<T>,
data: unknown data: unknown
): Promise<{ success: true; data: T } | { success: false; error: any }> { ): Promise<{ success: true; data: T } | { success: false; error: any }> {
try { try {
const validatedData = schema.parse(data); const validatedData = schema.parse(data);
return { success: true, data: validatedData }; return { success: true, data: validatedData };
} catch (error) { } catch (error) {
if (error instanceof z.ZodError) { if (error instanceof z.ZodError) {
return { success: false, error: formatZodError(error) }; return { success: false, error: formatZodError(error) };
} }
return { success: false, error: { error: 'Invalid request data' } }; return { success: false, error: { error: 'Invalid request data' } };
} }
} }

View File

@ -1,13 +1,13 @@
import { DefaultSession } from 'next-auth'; import { DefaultSession } from 'next-auth';
declare module 'next-auth' { declare module 'next-auth' {
interface Session { interface Session {
user: { user: {
id: string; id: string;
} & DefaultSession['user']; } & DefaultSession['user'];
} }
interface User { interface User {
id: string; id: string;
} }
} }