5 Commits

Author SHA1 Message Date
3682673852 Final 2026-01-22 19:31:05 +01:00
Timo Knuth
1251584b13 fix 2026-01-22 16:33:29 +01:00
Timo Knuth
dd93ca560a fix(marketing): Update hero animation timing and move Free Forever badge 2026-01-22 16:26:23 +01:00
Timo Knuth
efb1654370 fix: Optimize flipping card animation backface and timing 2026-01-22 15:46:31 +01:00
Timo Knuth
05531cda3f 4 neue dynamischen 2026-01-11 01:29:21 +01:00
60 changed files with 13594 additions and 9508 deletions

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

1702
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -112,6 +112,10 @@ enum ContentType {
SMS
TEXT
WHATSAPP
PDF
APP
COUPON
FEEDBACK
}
enum QRStatus {

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

@@ -14,6 +14,20 @@ import { calculateContrast, cn } from '@/lib/utils';
import { useTranslation } from '@/hooks/useTranslation';
import { useCsrf } from '@/hooks/useCsrf';
import { showToast } from '@/components/ui/Toast';
import {
Globe, User, MapPin, Phone, FileText, Smartphone, Ticket, Star, HelpCircle, Upload
} from 'lucide-react';
// Tooltip component for form field help
const Tooltip = ({ text }: { text: string }) => (
<div className="group relative inline-block ml-1">
<HelpCircle className="w-4 h-4 text-gray-400 cursor-help" />
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-gray-900 text-white text-xs rounded-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50 w-48 text-center">
{text}
<div className="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div>
</div>
</div>
);
// Content-type specific frame options
const getFrameOptionsForContentType = (contentType: string) => {
@@ -34,6 +48,14 @@ const getFrameOptionsForContentType = (contentType: string) => {
return [...baseOptions, { id: 'chatme', label: 'Chat Me' }, { id: 'whatsapp', label: 'WhatsApp' }];
case 'TEXT':
return [...baseOptions, { id: 'read', label: 'Read' }, { id: 'info', label: 'Info' }];
case 'PDF':
return [...baseOptions, { id: 'download', label: 'Download' }, { id: 'view', label: 'View PDF' }];
case 'APP':
return [...baseOptions, { id: 'getapp', label: 'Get App' }, { id: 'download', label: 'Download' }];
case 'COUPON':
return [...baseOptions, { id: 'redeem', label: 'Redeem' }, { id: 'save', label: 'Save Offer' }];
case 'FEEDBACK':
return [...baseOptions, { id: 'review', label: 'Review' }, { id: 'feedback', label: 'Feedback' }];
default:
return [...baseOptions, { id: 'website', label: 'Website' }, { id: 'visit', label: 'Visit' }];
}
@@ -44,6 +66,7 @@ export default function CreatePage() {
const { t } = useTranslation();
const { fetchWithCsrf } = useCsrf();
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const [userPlan, setUserPlan] = useState<string>('FREE');
const qrRef = useRef<HTMLDivElement>(null);
@@ -102,10 +125,14 @@ export default function CreatePage() {
const hasGoodContrast = contrast >= 4.5;
const contentTypes = [
{ value: 'URL', label: 'URL / Website' },
{ value: 'VCARD', label: 'Contact Card' },
{ value: 'GEO', label: 'Location/Maps' },
{ value: 'PHONE', label: 'Phone Number' },
{ value: 'URL', label: 'URL / Website', icon: Globe },
{ value: 'VCARD', label: 'Contact Card', icon: User },
{ value: 'GEO', label: 'Location / Maps', icon: MapPin },
{ value: 'PHONE', label: 'Phone Number', icon: Phone },
{ value: 'PDF', label: 'PDF / File', icon: FileText },
{ value: 'APP', label: 'App Download', icon: Smartphone },
{ value: 'COUPON', label: 'Coupon / Discount', icon: Ticket },
{ value: 'FEEDBACK', label: 'Feedback / Review', icon: Star },
];
// Get QR content based on content type
@@ -128,6 +155,14 @@ export default function CreatePage() {
return content.text || 'Sample text';
case 'WHATSAPP':
return `https://wa.me/${content.phone || '+1234567890'}${content.message ? `?text=${encodeURIComponent(content.message)}` : ''}`;
case 'PDF':
return content.fileUrl || 'https://example.com/file.pdf';
case 'APP':
return content.fallbackUrl || content.iosUrl || content.androidUrl || 'https://example.com/app';
case 'COUPON':
return `Coupon: ${content.code || 'SAVE20'} - ${content.discount || '20% OFF'}`;
case 'FEEDBACK':
return content.feedbackUrl || 'https://example.com/feedback';
default:
return 'https://example.com';
}
@@ -398,6 +433,208 @@ export default function CreatePage() {
/>
</div>
);
case 'PDF':
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// 10MB limit
if (file.size > 10 * 1024 * 1024) {
showToast('File size too large (max 10MB)', 'error');
return;
}
setUploading(true);
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
const data = await response.json();
if (response.ok) {
setContent({ ...content, fileUrl: data.url, fileName: data.filename });
showToast('File uploaded successfully!', 'success');
} else {
showToast(data.error || 'Upload failed', 'error');
}
} catch (error) {
console.error('Upload error:', error);
showToast('Error uploading file', 'error');
} finally {
setUploading(false);
}
};
return (
<>
<div>
<div className="flex items-center mb-1">
<label className="block text-sm font-medium text-gray-700">Upload Menu / PDF</label>
<Tooltip text="Upload your menu PDF (Max 10MB). Hosted securely." />
</div>
<div className="mt-2 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-lg hover:bg-gray-50 transition-colors relative">
<div className="space-y-1 text-center">
{uploading ? (
<div className="flex flex-col items-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500 mb-2"></div>
<p className="text-sm text-gray-500">Uploading...</p>
</div>
) : content.fileUrl ? (
<div className="flex flex-col items-center">
<div className="mx-auto h-12 w-12 text-primary-500 bg-primary-50 rounded-full flex items-center justify-center mb-2">
<FileText className="h-6 w-6" />
</div>
<p className="text-sm text-green-600 font-medium mb-1">Upload Complete!</p>
<a href={content.fileUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-primary-500 hover:underline break-all max-w-xs mb-3 block">
{content.fileName || 'View File'}
</a>
<label htmlFor="file-upload" className="cursor-pointer bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
<span>Replace File</span>
<input id="file-upload" name="file-upload" type="file" className="sr-only" accept=".pdf,image/*" onChange={handleFileUpload} />
</label>
</div>
) : (
<>
<Upload className="mx-auto h-12 w-12 text-gray-400" />
<div className="flex text-sm text-gray-600 justify-center">
<label htmlFor="file-upload" className="relative cursor-pointer bg-white rounded-md font-medium text-primary-600 hover:text-primary-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-primary-500">
<span>Upload a file</span>
<input id="file-upload" name="file-upload" type="file" className="sr-only" accept=".pdf,image/*" onChange={handleFileUpload} />
</label>
<p className="pl-1">or drag and drop</p>
</div>
<p className="text-xs text-gray-500">PDF, PNG, JPG up to 10MB</p>
</>
)}
</div>
</div>
</div>
{content.fileUrl && (
<Input
label="File Name / Menu Title"
value={content.fileName || ''}
onChange={(e) => setContent({ ...content, fileName: e.target.value })}
placeholder="Product Catalog 2026"
/>
)}
</>
);
case 'APP':
return (
<>
<div>
<div className="flex items-center mb-1">
<label className="block text-sm font-medium text-gray-700">iOS App Store URL</label>
<Tooltip text="Link to your app in the Apple App Store" />
</div>
<Input
value={content.iosUrl || ''}
onChange={(e) => setContent({ ...content, iosUrl: e.target.value })}
placeholder="https://apps.apple.com/app/..."
/>
</div>
<div>
<div className="flex items-center mb-1">
<label className="block text-sm font-medium text-gray-700">Android Play Store URL</label>
<Tooltip text="Link to your app in the Google Play Store" />
</div>
<Input
value={content.androidUrl || ''}
onChange={(e) => setContent({ ...content, androidUrl: e.target.value })}
placeholder="https://play.google.com/store/apps/..."
/>
</div>
<div>
<div className="flex items-center mb-1">
<label className="block text-sm font-medium text-gray-700">Fallback URL</label>
<Tooltip text="Where desktop users go (e.g., your website). QR detects device automatically!" />
</div>
<Input
value={content.fallbackUrl || ''}
onChange={(e) => setContent({ ...content, fallbackUrl: e.target.value })}
placeholder="https://yourapp.com"
/>
</div>
</>
);
case 'COUPON':
return (
<>
<Input
label="Coupon Code"
value={content.code || ''}
onChange={(e) => setContent({ ...content, code: e.target.value })}
placeholder="SUMMER20"
required
/>
<Input
label="Discount"
value={content.discount || ''}
onChange={(e) => setContent({ ...content, discount: e.target.value })}
placeholder="20% OFF"
required
/>
<Input
label="Title"
value={content.title || ''}
onChange={(e) => setContent({ ...content, title: e.target.value })}
placeholder="Summer Sale 2026"
/>
<Input
label="Description (optional)"
value={content.description || ''}
onChange={(e) => setContent({ ...content, description: e.target.value })}
placeholder="Valid on all products"
/>
<Input
label="Expiry Date (optional)"
type="date"
value={content.expiryDate || ''}
onChange={(e) => setContent({ ...content, expiryDate: e.target.value })}
/>
<Input
label="Redeem URL (optional)"
value={content.redeemUrl || ''}
onChange={(e) => setContent({ ...content, redeemUrl: e.target.value })}
placeholder="https://shop.example.com?coupon=SUMMER20"
/>
</>
);
case 'FEEDBACK':
return (
<>
<Input
label="Business Name"
value={content.businessName || ''}
onChange={(e) => setContent({ ...content, businessName: e.target.value })}
placeholder="Your Restaurant Name"
required
/>
<div>
<div className="flex items-center mb-1">
<label className="block text-sm font-medium text-gray-700">Google Review URL</label>
<Tooltip text="Redirect satisfied customers to leave a Google review." />
</div>
<Input
value={content.googleReviewUrl || ''}
onChange={(e) => setContent({ ...content, googleReviewUrl: e.target.value })}
placeholder="https://search.google.com/local/writereview?placeid=..."
/>
</div>
<Input
label="Thank You Message"
value={content.thankYouMessage || ''}
onChange={(e) => setContent({ ...content, thankYouMessage: e.target.value })}
placeholder="Thanks for your feedback!"
/>
</>
);
default:
return null;
}
@@ -428,12 +665,31 @@ export default function CreatePage() {
required
/>
<Select
label="Content Type"
value={contentType}
onChange={(e) => setContentType(e.target.value)}
options={contentTypes}
/>
{/* Custom Content Type Selector with Icons */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Content Type</label>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{contentTypes.map((type) => {
const Icon = type.icon;
return (
<button
key={type.value}
type="button"
onClick={() => setContentType(type.value)}
className={cn(
"flex flex-col items-center gap-2 p-3 rounded-lg border-2 transition-all text-sm",
contentType === type.value
? "border-primary-500 bg-primary-50 text-primary-700"
: "border-gray-200 hover:border-gray-300 text-gray-600"
)}
>
<Icon className="w-5 h-5" />
<span className="text-xs font-medium text-center">{type.label}</span>
</button>
);
})}
</div>
</div>
{renderContentFields()}
</CardContent>

View File

@@ -7,6 +7,18 @@ import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { showToast } from '@/components/ui/Toast';
import { useCsrf } from '@/hooks/useCsrf';
import { Upload, FileText, HelpCircle } from 'lucide-react';
// Tooltip component for form field help
const Tooltip = ({ text }: { text: string }) => (
<div className="group relative inline-block ml-1">
<HelpCircle className="w-4 h-4 text-gray-400 cursor-help" />
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-gray-900 text-white text-xs rounded-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50 w-48 text-center">
{text}
<div className="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div>
</div>
</div>
);
export default function EditQRPage() {
const router = useRouter();
@@ -16,6 +28,7 @@ export default function EditQRPage() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [qrCode, setQrCode] = useState<any>(null);
const [title, setTitle] = useState('');
const [content, setContent] = useState<any>({});
@@ -45,6 +58,41 @@ export default function EditQRPage() {
fetchQRCode();
}, [qrId, router]);
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// 10MB limit
if (file.size > 10 * 1024 * 1024) {
showToast('File size too large (max 10MB)', 'error');
return;
}
setUploading(true);
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
const data = await response.json();
if (response.ok) {
setContent({ ...content, fileUrl: data.url, fileName: data.filename });
showToast('File uploaded successfully!', 'success');
} else {
showToast(data.error || 'Upload failed', 'error');
}
} catch (error) {
console.error('Upload error:', error);
showToast('Error uploading file', 'error');
} finally {
setUploading(false);
}
};
const handleSave = async () => {
setSaving(true);
@@ -242,6 +290,153 @@ export default function EditQRPage() {
</div>
)}
{qrCode.contentType === 'PDF' && (
<>
<div>
<div className="flex items-center mb-1">
<label className="block text-sm font-medium text-gray-700">Upload Menu / PDF</label>
<Tooltip text="Upload your menu PDF (Max 10MB). Hosted securely." />
</div>
<div className="mt-2 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-lg hover:bg-gray-50 transition-colors relative">
<div className="space-y-1 text-center">
{uploading ? (
<div className="flex flex-col items-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500 mb-2"></div>
<p className="text-sm text-gray-500">Uploading...</p>
</div>
) : content.fileUrl ? (
<div className="flex flex-col items-center">
<div className="mx-auto h-12 w-12 text-primary-500 bg-primary-50 rounded-full flex items-center justify-center mb-2">
<FileText className="h-6 w-6" />
</div>
<p className="text-sm text-green-600 font-medium mb-1">Upload Complete!</p>
<a href={content.fileUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-primary-500 hover:underline break-all max-w-xs mb-3 block">
{content.fileName || 'View File'}
</a>
<label htmlFor="file-upload" className="cursor-pointer bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
<span>Replace File</span>
<input id="file-upload" name="file-upload" type="file" className="sr-only" accept=".pdf,image/*" onChange={handleFileUpload} />
</label>
</div>
) : (
<>
<Upload className="mx-auto h-12 w-12 text-gray-400" />
<div className="flex text-sm text-gray-600 justify-center">
<label htmlFor="file-upload" className="relative cursor-pointer bg-white rounded-md font-medium text-primary-600 hover:text-primary-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-primary-500">
<span>Upload a file</span>
<input id="file-upload" name="file-upload" type="file" className="sr-only" accept=".pdf,image/*" onChange={handleFileUpload} />
</label>
<p className="pl-1">or drag and drop</p>
</div>
<p className="text-xs text-gray-500">PDF, PNG, JPG up to 10MB</p>
</>
)}
</div>
</div>
</div>
{content.fileUrl && (
<Input
label="File Name / Menu Title"
value={content.fileName || ''}
onChange={(e) => setContent({ ...content, fileName: e.target.value })}
placeholder="Product Catalog 2026"
/>
)}
</>
)}
{qrCode.contentType === 'APP' && (
<>
<Input
label="iOS App Store URL"
value={content.iosUrl || ''}
onChange={(e) => setContent({ ...content, iosUrl: e.target.value })}
placeholder="https://apps.apple.com/app/..."
/>
<Input
label="Android Play Store URL"
value={content.androidUrl || ''}
onChange={(e) => setContent({ ...content, androidUrl: e.target.value })}
placeholder="https://play.google.com/store/apps/..."
/>
<Input
label="Fallback URL (Desktop)"
value={content.fallbackUrl || ''}
onChange={(e) => setContent({ ...content, fallbackUrl: e.target.value })}
placeholder="https://yourapp.com"
/>
</>
)}
{qrCode.contentType === 'COUPON' && (
<>
<Input
label="Coupon Code"
value={content.code || ''}
onChange={(e) => setContent({ ...content, code: e.target.value })}
placeholder="SUMMER20"
required
/>
<Input
label="Discount"
value={content.discount || ''}
onChange={(e) => setContent({ ...content, discount: e.target.value })}
placeholder="20% OFF"
required
/>
<Input
label="Title"
value={content.title || ''}
onChange={(e) => setContent({ ...content, title: e.target.value })}
placeholder="Summer Sale 2026"
/>
<Input
label="Description (optional)"
value={content.description || ''}
onChange={(e) => setContent({ ...content, description: e.target.value })}
placeholder="Valid on all products"
/>
<Input
label="Expiry Date (optional)"
type="date"
value={content.expiryDate || ''}
onChange={(e) => setContent({ ...content, expiryDate: e.target.value })}
/>
<Input
label="Redeem URL (optional)"
value={content.redeemUrl || ''}
onChange={(e) => setContent({ ...content, redeemUrl: e.target.value })}
placeholder="https://shop.example.com"
/>
</>
)}
{qrCode.contentType === 'FEEDBACK' && (
<>
<Input
label="Business Name"
value={content.businessName || ''}
onChange={(e) => setContent({ ...content, businessName: e.target.value })}
placeholder="Your Restaurant Name"
required
/>
<Input
label="Google Review URL (optional)"
value={content.googleReviewUrl || ''}
onChange={(e) => setContent({ ...content, googleReviewUrl: e.target.value })}
placeholder="https://search.google.com/local/writereview?placeid=..."
/>
<Input
label="Thank You Message"
value={content.thankYouMessage || ''}
onChange={(e) => setContent({ ...content, thankYouMessage: e.target.value })}
placeholder="Thanks for your feedback!"
/>
</>
)}
<div className="flex justify-end space-x-4 pt-4">
<Button
variant="outline"

View File

@@ -0,0 +1,196 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Star, ArrowLeft, ChevronLeft, ChevronRight, MessageSquare } from 'lucide-react';
interface Feedback {
id: string;
rating: number;
comment: string;
date: string;
}
interface FeedbackStats {
total: number;
avgRating: number;
distribution: { [key: number]: number };
}
interface Pagination {
page: number;
totalPages: number;
hasMore: boolean;
}
export default function FeedbackListPage() {
const params = useParams();
const router = useRouter();
const qrId = params.id as string;
const [feedbacks, setFeedbacks] = useState<Feedback[]>([]);
const [stats, setStats] = useState<FeedbackStats | null>(null);
const [pagination, setPagination] = useState<Pagination>({ page: 1, totalPages: 1, hasMore: false });
const [loading, setLoading] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
useEffect(() => {
fetchFeedback(currentPage);
}, [qrId, currentPage]);
const fetchFeedback = async (page: number) => {
setLoading(true);
try {
const res = await fetch(`/api/qrs/${qrId}/feedback?page=${page}&limit=20`);
if (res.ok) {
const data = await res.json();
setFeedbacks(data.feedbacks);
setStats(data.stats);
setPagination(data.pagination);
}
} catch (error) {
console.error('Error fetching feedback:', error);
} finally {
setLoading(false);
}
};
const renderStars = (rating: number) => (
<div className="flex gap-0.5">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
className={`w-4 h-4 ${star <= rating ? 'text-amber-400 fill-amber-400' : 'text-gray-200'}`}
/>
))}
</div>
);
if (loading && !stats) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
</div>
);
}
return (
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="mb-8">
<Link href={`/qr/${qrId}`} className="inline-flex items-center text-gray-500 hover:text-gray-700 mb-4">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to QR Code
</Link>
<h1 className="text-3xl font-bold text-gray-900">Customer Feedback</h1>
<p className="text-gray-600 mt-1">{stats?.total || 0} total responses</p>
</div>
{/* Stats Overview */}
{stats && (
<Card className="mb-8">
<CardContent className="p-6">
<div className="flex flex-col md:flex-row md:items-center gap-8">
{/* Average Rating */}
<div className="text-center md:text-left">
<div className="text-5xl font-bold text-gray-900 mb-1">{stats.avgRating}</div>
<div className="flex justify-center md:justify-start mb-1">
{renderStars(Math.round(stats.avgRating))}
</div>
<p className="text-sm text-gray-500">{stats.total} reviews</p>
</div>
{/* Distribution */}
<div className="flex-1 space-y-2">
{[5, 4, 3, 2, 1].map((rating) => {
const count = stats.distribution[rating] || 0;
const percentage = stats.total > 0 ? (count / stats.total) * 100 : 0;
return (
<div key={rating} className="flex items-center gap-3">
<span className="text-sm text-gray-600 w-12">{rating} stars</span>
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-amber-400 rounded-full transition-all"
style={{ width: `${percentage}%` }}
/>
</div>
<span className="text-sm text-gray-500 w-12 text-right">{count}</span>
</div>
);
})}
</div>
</div>
</CardContent>
</Card>
)}
{/* Feedback List */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageSquare className="w-5 h-5" />
All Reviews
</CardTitle>
</CardHeader>
<CardContent>
{feedbacks.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<Star className="w-12 h-12 mx-auto mb-4 text-gray-300" />
<p>No feedback received yet</p>
</div>
) : (
<div className="divide-y divide-gray-100">
{feedbacks.map((feedback) => (
<div key={feedback.id} className="py-4">
<div className="flex items-center justify-between mb-2">
{renderStars(feedback.rating)}
<span className="text-sm text-gray-400">
{new Date(feedback.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</span>
</div>
{feedback.comment && (
<p className="text-gray-700">{feedback.comment}</p>
)}
</div>
))}
</div>
)}
{/* Pagination */}
{pagination.totalPages > 1 && (
<div className="flex items-center justify-between mt-6 pt-6 border-t">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
>
<ChevronLeft className="w-4 h-4 mr-1" />
Previous
</Button>
<span className="text-sm text-gray-500">
Page {currentPage} of {pagination.totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => p + 1)}
disabled={!pagination.hasMore}
>
Next
<ChevronRight className="w-4 h-4 ml-1" />
</Button>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,287 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { QRCodeSVG } from 'qrcode.react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import {
ArrowLeft, Edit, ExternalLink, Star, MessageSquare,
BarChart3, Copy, Check, Pause, Play
} from 'lucide-react';
import { showToast } from '@/components/ui/Toast';
import { useCsrf } from '@/hooks/useCsrf';
interface QRCode {
id: string;
title: string;
type: 'STATIC' | 'DYNAMIC';
contentType: string;
content: any;
slug: string;
status: 'ACTIVE' | 'PAUSED';
style: any;
createdAt: string;
_count?: { scans: number };
}
interface FeedbackStats {
total: number;
avgRating: number;
distribution: { [key: number]: number };
}
export default function QRDetailPage() {
const params = useParams();
const router = useRouter();
const qrId = params.id as string;
const { fetchWithCsrf } = useCsrf();
const [qrCode, setQrCode] = useState<QRCode | null>(null);
const [feedbackStats, setFeedbackStats] = useState<FeedbackStats | null>(null);
const [loading, setLoading] = useState(true);
const [copied, setCopied] = useState(false);
useEffect(() => {
fetchQRCode();
}, [qrId]);
const fetchQRCode = async () => {
try {
const res = await fetch(`/api/qrs/${qrId}`);
if (res.ok) {
const data = await res.json();
setQrCode(data);
// Fetch feedback stats if it's a feedback QR
if (data.contentType === 'FEEDBACK') {
const feedbackRes = await fetch(`/api/qrs/${qrId}/feedback?limit=1`);
if (feedbackRes.ok) {
const feedbackData = await feedbackRes.json();
setFeedbackStats(feedbackData.stats);
}
}
} else {
showToast('QR code not found', 'error');
router.push('/dashboard');
}
} catch (error) {
console.error('Error fetching QR code:', error);
} finally {
setLoading(false);
}
};
const copyLink = async () => {
const url = `${window.location.origin}/r/${qrCode?.slug}`;
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
showToast('Link copied!', 'success');
};
const toggleStatus = async () => {
if (!qrCode) return;
const newStatus = qrCode.status === 'ACTIVE' ? 'PAUSED' : 'ACTIVE';
try {
const res = await fetchWithCsrf(`/api/qrs/${qrId}`, {
method: 'PATCH',
body: JSON.stringify({ status: newStatus }),
});
if (res.ok) {
setQrCode({ ...qrCode, status: newStatus });
showToast(`QR code ${newStatus === 'ACTIVE' ? 'activated' : 'paused'}`, 'success');
}
} catch (error) {
showToast('Failed to update status', 'error');
}
};
const renderStars = (rating: number) => (
<div className="flex gap-0.5">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
className={`w-4 h-4 ${star <= rating ? 'text-amber-400 fill-amber-400' : 'text-gray-200'}`}
/>
))}
</div>
);
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
</div>
);
}
if (!qrCode) return null;
const qrUrl = `${typeof window !== 'undefined' ? window.location.origin : ''}/r/${qrCode.slug}`;
return (
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="mb-8">
<Link href="/dashboard" className="inline-flex items-center text-gray-500 hover:text-gray-700 mb-4">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Dashboard
</Link>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900">{qrCode.title}</h1>
<div className="flex items-center gap-2 mt-2">
<Badge variant={qrCode.type === 'DYNAMIC' ? 'info' : 'default'}>
{qrCode.type}
</Badge>
<Badge variant={qrCode.status === 'ACTIVE' ? 'success' : 'warning'}>
{qrCode.status}
</Badge>
<Badge>{qrCode.contentType}</Badge>
</div>
</div>
<div className="flex gap-2">
{qrCode.type === 'DYNAMIC' && (
<>
<Button variant="outline" size="sm" onClick={toggleStatus}>
{qrCode.status === 'ACTIVE' ? <Pause className="w-4 h-4 mr-1" /> : <Play className="w-4 h-4 mr-1" />}
{qrCode.status === 'ACTIVE' ? 'Pause' : 'Activate'}
</Button>
<Link href={`/qr/${qrId}/edit`}>
<Button variant="outline" size="sm">
<Edit className="w-4 h-4 mr-1" /> Edit
</Button>
</Link>
</>
)}
</div>
</div>
</div>
<div className="grid lg:grid-cols-3 gap-8">
{/* Left: QR Code */}
<div>
<Card>
<CardContent className="p-6 flex flex-col items-center">
<div className="bg-white p-4 rounded-xl shadow-sm mb-4">
<QRCodeSVG
value={qrUrl}
size={200}
fgColor={qrCode.style?.foregroundColor || '#000000'}
bgColor={qrCode.style?.backgroundColor || '#FFFFFF'}
/>
</div>
<div className="w-full space-y-2">
<Button variant="outline" className="w-full" onClick={copyLink}>
{copied ? <Check className="w-4 h-4 mr-2" /> : <Copy className="w-4 h-4 mr-2" />}
{copied ? 'Copied!' : 'Copy Link'}
</Button>
<a href={qrUrl} target="_blank" rel="noopener noreferrer" className="block">
<Button variant="outline" className="w-full">
<ExternalLink className="w-4 h-4 mr-2" /> Open Link
</Button>
</a>
</div>
</CardContent>
</Card>
</div>
{/* Right: Stats & Info */}
<div className="lg:col-span-2 space-y-6">
{/* Quick Stats */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
<Card>
<CardContent className="p-4 text-center">
<BarChart3 className="w-6 h-6 mx-auto mb-2 text-indigo-500" />
<p className="text-2xl font-bold text-gray-900">{qrCode._count?.scans || 0}</p>
<p className="text-sm text-gray-500">Total Scans</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<p className="text-2xl font-bold text-gray-900">{qrCode.type}</p>
<p className="text-sm text-gray-500">QR Type</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<p className="text-2xl font-bold text-gray-900">
{new Date(qrCode.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</p>
<p className="text-sm text-gray-500">Created</p>
</CardContent>
</Card>
</div>
{/* Feedback Summary (only for FEEDBACK type) */}
{qrCode.contentType === 'FEEDBACK' && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Star className="w-5 h-5 text-amber-400" />
Customer Feedback
</CardTitle>
</CardHeader>
<CardContent>
{feedbackStats && feedbackStats.total > 0 ? (
<div className="flex flex-col sm:flex-row sm:items-center gap-6 mb-4">
{/* Average */}
<div className="text-center sm:text-left">
<div className="text-4xl font-bold text-gray-900">{feedbackStats.avgRating}</div>
{renderStars(Math.round(feedbackStats.avgRating))}
<p className="text-sm text-gray-500 mt-1">{feedbackStats.total} reviews</p>
</div>
{/* Distribution */}
<div className="flex-1 space-y-1">
{[5, 4, 3, 2, 1].map((rating) => {
const count = feedbackStats.distribution[rating] || 0;
const pct = feedbackStats.total > 0 ? (count / feedbackStats.total) * 100 : 0;
return (
<div key={rating} className="flex items-center gap-2 text-sm">
<span className="w-8 text-gray-500">{rating}</span>
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-amber-400 rounded-full" style={{ width: `${pct}%` }} />
</div>
<span className="w-8 text-gray-400 text-right">{count}</span>
</div>
);
})}
</div>
</div>
) : (
<p className="text-gray-500 mb-4">No feedback received yet. Share your QR code to collect reviews!</p>
)}
<Link href={`/qr/${qrId}/feedback`} className="block">
<Button variant="outline" className="w-full">
<MessageSquare className="w-4 h-4 mr-2" />
View All Feedback
</Button>
</Link>
</CardContent>
</Card>
)}
{/* Content Info */}
<Card>
<CardHeader>
<CardTitle>Content Details</CardTitle>
</CardHeader>
<CardContent>
<pre className="bg-gray-50 p-4 rounded-lg text-sm overflow-auto">
{JSON.stringify(qrCode.content, null, 2)}
</pre>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { slug, rating, comment } = body;
if (!slug || !rating) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
// Find the QR code
const qrCode = await db.qRCode.findUnique({
where: { slug },
select: { id: true },
});
if (!qrCode) {
return NextResponse.json({ error: 'QR Code not found' }, { status: 404 });
}
// Log feedback as a scan with additional data
// In a full implementation, you'd have a Feedback model
// For now, we'll store it in QRScan with special markers
await db.qRScan.create({
data: {
qrId: qrCode.id,
ipHash: 'feedback',
userAgent: `rating:${rating}|comment:${comment?.substring(0, 200) || ''}`,
device: 'feedback',
isUnique: true,
},
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error submitting feedback:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -0,0 +1,122 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { db } from '@/lib/db';
import { cookies } from 'next/headers';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
let userId: string | undefined;
// Try NextAuth session first
const session = await getServerSession(authOptions);
if (session?.user?.id) {
userId = session.user.id;
} else {
// Fallback: Check raw userId cookie (like /api/user does)
const cookieStore = await cookies();
userId = cookieStore.get('userId')?.value;
}
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '20');
const skip = (page - 1) * limit;
// Verify QR ownership and type
const qrCode = await db.qRCode.findUnique({
where: { id, userId: userId },
select: { id: true, contentType: true },
});
if (!qrCode) {
return NextResponse.json({ error: 'QR code not found' }, { status: 404 });
}
// Check if consistent with schema (Prisma enum mismatch fix)
// @ts-ignore - Temporary ignore until client regeneration catches up fully in all envs
if (qrCode.contentType !== 'FEEDBACK') {
return NextResponse.json({ error: 'Not a feedback QR code' }, { status: 400 });
}
// Fetch feedback entries (stored as QRScans with ipHash='feedback')
const [feedbackEntries, totalCount] = await Promise.all([
db.qRScan.findMany({
where: { qrId: id, ipHash: 'feedback' },
orderBy: { ts: 'desc' },
skip,
take: limit,
select: { id: true, userAgent: true, ts: true },
}),
db.qRScan.count({
where: { qrId: id, ipHash: 'feedback' },
}),
]);
// Parse feedback data from userAgent field
const feedbacks = feedbackEntries.map((entry) => {
const parsed = parseFeedback(entry.userAgent || '');
return {
id: entry.id,
rating: parsed.rating,
comment: parsed.comment,
date: entry.ts,
};
});
// Calculate stats
const allRatings = await db.qRScan.findMany({
where: { qrId: id, ipHash: 'feedback' },
select: { userAgent: true },
});
const ratings = allRatings.map((e) => parseFeedback(e.userAgent || '').rating).filter((r) => r > 0);
const avgRating = ratings.length > 0 ? ratings.reduce((a, b) => a + b, 0) / ratings.length : 0;
// Rating distribution
const distribution = {
5: ratings.filter((r) => r === 5).length,
4: ratings.filter((r) => r === 4).length,
3: ratings.filter((r) => r === 3).length,
2: ratings.filter((r) => r === 2).length,
1: ratings.filter((r) => r === 1).length,
};
return NextResponse.json({
feedbacks,
stats: {
total: totalCount,
avgRating: Math.round(avgRating * 10) / 10,
distribution,
},
pagination: {
page,
limit,
totalPages: Math.ceil(totalCount / limit),
hasMore: skip + limit < totalCount,
},
});
} catch (error) {
console.error('Error fetching feedback:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
function parseFeedback(userAgent: string): { rating: number; comment: string } {
// Format: "rating:4|comment:Great service!"
const ratingMatch = userAgent.match(/rating:(\d)/);
const commentMatch = userAgent.match(/comment:(.+)/);
return {
rating: ratingMatch ? parseInt(ratingMatch[1]) : 0,
comment: commentMatch ? commentMatch[1] : '',
};
}

View File

@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
try {
const { slug } = await params;
const qrCode = await db.qRCode.findUnique({
where: { slug },
select: {
id: true,
content: true,
contentType: true,
status: true,
},
});
if (!qrCode) {
return NextResponse.json({ error: 'QR Code not found' }, { status: 404 });
}
if (qrCode.status === 'PAUSED') {
return NextResponse.json({ error: 'QR Code is paused' }, { status: 403 });
}
return NextResponse.json({
contentType: qrCode.contentType,
content: qrCode.content,
});
} catch (error) {
console.error('Error fetching public QR:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -63,7 +63,6 @@ export async function POST(request: NextRequest) {
}
const userId = cookies().get('userId')?.value;
console.log('POST /api/qrs - userId from cookie:', userId);
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);
@@ -90,20 +89,16 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Unauthorized - no userId cookie' }, { status: 401 });
}
// Check if user exists and get their plan
const user = await db.user.findUnique({
where: { id: userId },
select: { plan: true },
});
console.log('User exists:', !!user);
if (!user) {
return NextResponse.json({ error: `User not found: ${userId}` }, { status: 404 });
}
const body = await request.json();
console.log('Request body:', body);
// Validate request body with Zod (only for non-static QRs or simplified validation)
// Note: Static QRs have complex nested content structure, so we do basic validation
@@ -182,6 +177,18 @@ END:VCARD`;
case 'WHATSAPP':
qrContent = `https://wa.me/${body.content.phone}${body.content.message ? `?text=${encodeURIComponent(body.content.message)}` : ''}`;
break;
case 'PDF':
qrContent = body.content.fileUrl || 'https://example.com/file.pdf';
break;
case 'APP':
qrContent = body.content.fallbackUrl || body.content.iosUrl || body.content.androidUrl || 'https://example.com';
break;
case 'COUPON':
qrContent = `Coupon: ${body.content.code || 'CODE'} - ${body.content.discount || 'Discount'}`;
break;
case 'FEEDBACK':
qrContent = body.content.feedbackUrl || 'https://example.com/feedback';
break;
default:
qrContent = body.content.url || 'https://example.com';
}

View File

@@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { uploadFileToR2 } from '@/lib/r2';
import { env } from '@/lib/env';
import { db } from '@/lib/db';
export async function POST(request: NextRequest) {
try {
// 1. Authentication Check
const session = await getServerSession(authOptions);
let userId = session?.user?.id;
// Fallback: Check for simple-login cookie if no NextAuth session
if (!userId) {
const cookieUserId = request.cookies.get('userId')?.value;
if (cookieUserId) {
// Verify user exists
const user = await db.user.findUnique({
where: { id: cookieUserId },
select: { id: true }
});
if (user) {
userId = user.id;
}
}
}
if (!userId) {
return new NextResponse('Unauthorized', { status: 401 });
}
// 2. Parse Form Data
const formData = await request.formData();
const file = formData.get('file') as File | null;
if (!file) {
return NextResponse.json(
{ error: 'No file provided' },
{ status: 400 }
);
}
// 3. Validation
// Check file size (default 10MB)
const MAX_SIZE = parseInt(env.MAX_UPLOAD_SIZE || '10485760');
if (file.size > MAX_SIZE) {
return NextResponse.json(
{ error: `File too large. Maximum size: ${MAX_SIZE / 1024 / 1024}MB` },
{ status: 400 }
);
}
// Check file type (allow images and PDFs)
const allowedTypes = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
return NextResponse.json(
{ error: 'Invalid file type. Only PDF and Images are allowed.' },
{ status: 400 }
);
}
// 4. Upload to R2
const buffer = Buffer.from(await file.arrayBuffer());
const publicUrl = await uploadFileToR2(buffer, file.name, file.type);
// 5. Success
return NextResponse.json({
success: true,
url: publicUrl,
filename: file.name,
type: file.type
});
} catch (error) {
console.error('Upload error:', error);
return NextResponse.json(
{ error: 'Internal server error during upload' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,167 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { Copy, Check, ExternalLink, Gift } from 'lucide-react';
interface CouponData {
code: string;
discount: string;
title?: string;
description?: string;
expiryDate?: string;
redeemUrl?: string;
}
export default function CouponPage() {
const params = useParams();
const slug = params.slug as string;
const [coupon, setCoupon] = useState<CouponData | null>(null);
const [loading, setLoading] = useState(true);
const [copied, setCopied] = useState(false);
useEffect(() => {
async function fetchCoupon() {
try {
const res = await fetch(`/api/qrs/public/${slug}`);
if (res.ok) {
const data = await res.json();
if (data.contentType === 'COUPON') {
setCoupon(data.content as CouponData);
}
}
} catch (error) {
console.error('Error fetching coupon:', error);
} finally {
setLoading(false);
}
}
fetchCoupon();
}, [slug]);
const copyCode = async () => {
if (coupon?.code) {
await navigator.clipboard.writeText(coupon.code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
// Loading
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-rose-50 to-pink-100">
<div className="w-10 h-10 border-3 border-pink-200 border-t-pink-600 rounded-full animate-spin"></div>
</div>
);
}
// Not found
if (!coupon) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-rose-50 to-pink-100 px-6">
<div className="text-center bg-white rounded-2xl p-8 shadow-lg">
<p className="text-gray-500 text-lg">This coupon is not available.</p>
</div>
</div>
);
}
const isExpired = coupon.expiryDate && new Date(coupon.expiryDate) < new Date();
return (<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#FAF8F5] via-[#C6C0B3] to-[#4C5F4E] px-6 py-12">
<div className="max-w-sm w-full">
{/* Card */}
<div className="bg-white rounded-3xl shadow-xl overflow-hidden">
{/* Colorful Header */}
<div className="bg-gradient-to-br from-[#DB5375] to-[#B3FFB3] text-gray-900 p-8 text-center relative overflow-hidden">
{/* Decorative circles */}
<div className="absolute top-0 right-0 w-32 h-32 bg-white/10 rounded-full -translate-y-1/2 translate-x-1/2"></div>
<div className="absolute bottom-0 left-0 w-24 h-24 bg-white/10 rounded-full translate-y-1/2 -translate-x-1/2"></div>
<div className="relative">
<div className="w-14 h-14 bg-[#DB5375]/10 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Gift className="w-7 h-7 text-[#DB5375]" />
</div>
<p className="text-gray-700 text-sm mb-1">{coupon.title || 'Special Offer'}</p>
<p className="text-4xl font-bold tracking-tight text-gray-900">{coupon.discount}</p>
</div>
</div>
{/* Dotted line with circles */}
<div className="relative py-4">
<div className="relative py-4">
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-5 h-10 bg-gradient-to-br from-[#FAF8F5] via-[#C6C0B3] to-[#4C5F4E] rounded-r-full"></div>
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-5 h-10 bg-gradient-to-br from-[#FAF8F5] via-[#C6C0B3] to-[#4C5F4E] rounded-l-full"></div>
<div className="border-t-2 border-dashed border-gray-200 mx-8"></div>
</div>
{/* Content */}
<div className="px-8 pb-8">
{coupon.description && (
<p className="text-gray-500 text-sm text-center mb-6">{coupon.description}</p>
)}
{/* Code Box */}
<div className="bg-gray-50 rounded-2xl p-5 mb-4 border border-emerald-100">
<p className="text-xs text-emerald-600 text-center mb-2 font-medium uppercase tracking-wider">Your Code</p>
<div className="flex items-center justify-center gap-3">
<code className="text-2xl font-mono font-bold text-gray-900 tracking-wider">
{coupon.code}
</code>
<button
onClick={copyCode}
className={`p-2.5 rounded-xl transition-all ${copied
? 'bg-emerald-100 text-emerald-600'
: 'bg-white text-gray-500 hover:text-rose-500 shadow-sm hover:shadow'
}`}
aria-label="Copy code"
>
{copied ? <Check className="w-5 h-5" /> : <Copy className="w-5 h-5" />}
</button>
</div>
{copied && (
<p className="text-emerald-600 text-xs text-center mt-2 font-medium">Copied!</p>
)}
</div>
{/* Expiry */}
{coupon.expiryDate && (
<p className={`text-sm text-center mb-6 font-medium ${isExpired ? 'text-red-500' : 'text-gray-400'}`}>
{isExpired
? '⚠️ This coupon has expired'
: `Valid until ${new Date(coupon.expiryDate).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric'
})}`
}
</p>
)}
{/* Redeem Button */}
{coupon.redeemUrl && !isExpired && (
<a
href={coupon.redeemUrl}
target="_blank"
rel="noopener noreferrer"
className="block w-full py-4 rounded-xl font-semibold text-center bg-gradient-to-r from-[#076653] to-[#0C342C] text-white hover:from-[#087861] hover:to-[#0E4036] transition-all shadow-lg shadow-emerald-200"
>
<span className="flex items-center justify-center gap-2">
Redeem Now
<ExternalLink className="w-4 h-4" />
</span>
</a>
)}
</div>
</div>
{/* Footer */}
<p className="text-center text-sm text-white/60 mt-6">
Powered by QR Master
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,195 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { Star, Send, Check } from 'lucide-react';
interface FeedbackData {
businessName: string;
googleReviewUrl?: string;
thankYouMessage?: string;
}
export default function FeedbackPage() {
const params = useParams();
const slug = params.slug as string;
const [feedback, setFeedback] = useState<FeedbackData | null>(null);
const [loading, setLoading] = useState(true);
const [rating, setRating] = useState(0);
const [hoverRating, setHoverRating] = useState(0);
const [comment, setComment] = useState('');
const [submitted, setSubmitted] = useState(false);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
async function fetchFeedback() {
try {
const res = await fetch(`/api/qrs/public/${slug}`);
if (res.ok) {
const data = await res.json();
if (data.contentType === 'FEEDBACK') {
setFeedback(data.content as FeedbackData);
}
}
} catch (error) {
console.error('Error fetching feedback data:', error);
} finally {
setLoading(false);
}
}
fetchFeedback();
}, [slug]);
const handleSubmit = async () => {
if (rating === 0) return;
setSubmitting(true);
try {
await fetch('/api/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, rating, comment }),
});
setSubmitted(true);
if (rating >= 4 && feedback?.googleReviewUrl) {
setTimeout(() => {
window.location.href = feedback.googleReviewUrl!;
}, 2000);
}
} catch (error) {
console.error('Error submitting feedback:', error);
} finally {
setSubmitting(false);
}
};
// Loading
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#FAF8F5] via-[#C6C0B3] to-[#4C5F4E]">
<div className="w-10 h-10 border-3 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
</div>
);
}
// Not found
if (!feedback) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#FAF8F5] via-[#C6C0B3] to-[#4C5F4E] px-6">
<div className="text-center bg-white rounded-2xl p-8 shadow-lg">
<p className="text-gray-500 text-lg">This feedback form is not available.</p>
</div>
</div>
);
}
// Success
if (submitted) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#FAF8F5] via-[#C6C0B3] to-[#4C5F4E] px-6">
<div className="max-w-sm w-full bg-white rounded-3xl shadow-xl p-10 text-center">
<div className="w-20 h-20 bg-gradient-to-br from-[#4C5F4E] to-[#FAF8F5] rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg">
<Check className="w-10 h-10 text-white" strokeWidth={2.5} />
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">
Thank you!
</h1>
<p className="text-gray-500">
{feedback.thankYouMessage || 'Your feedback has been submitted.'}
</p>
{rating >= 4 && feedback.googleReviewUrl && (
<p className="text-sm text-teal-600 mt-6 font-medium">
Redirecting to Google Reviews...
</p>
)}
</div>
</div>
);
}
// Rating Form
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#FAF8F5] via-[#C6C0B3] to-[#4C5F4E] px-6 py-12">
<div className="max-w-md w-full">
{/* Card */}
<div className="bg-white rounded-3xl shadow-xl overflow-hidden">
{/* Colored Header */}
<div className="bg-gradient-to-r from-[#4C5F4E] via-[#C6C0B3] to-[#FAF8F5] p-8 text-center">
<div className="w-14 h-14 bg-white/20 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Star className="w-7 h-7 text-white" />
</div>
<h1 className="text-2xl font-bold mb-1 text-gray-900">How was your experience?</h1>
<p className="text-gray-700">{feedback.businessName}</p>
</div>
{/* Content */}
<div className="p-8">
{/* Stars */}
<div className="flex justify-center gap-2 mb-2">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
onClick={() => setRating(star)}
onMouseEnter={() => setHoverRating(star)}
onMouseLeave={() => setHoverRating(0)}
className="p-1 transition-transform hover:scale-110 focus:outline-none"
aria-label={`Rate ${star} stars`}
>
<Star
className={`w-11 h-11 transition-all ${star <= (hoverRating || rating)
? 'text-amber-400 fill-amber-400 drop-shadow-sm'
: 'text-gray-200'
}`}
/>
</button>
))}
</div>
{/* Rating text */}
<p className="text-center text-sm font-medium h-6 mb-6" style={{ color: rating > 0 ? '#6366f1' : 'transparent' }}>
{rating === 1 && 'Poor'}
{rating === 2 && 'Fair'}
{rating === 3 && 'Good'}
{rating === 4 && 'Great!'}
{rating === 5 && 'Excellent!'}
</p>
{/* Comment */}
<div className="mb-6">
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Share your thoughts (optional)"
rows={3}
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent resize-none"
/>
</div>
{/* Submit */}
<button
onClick={handleSubmit}
disabled={rating === 0 || submitting}
className={`w-full py-4 rounded-xl font-semibold flex items-center justify-center gap-2 transition-all ${rating === 0
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-gradient-to-r from-[#4C5F4E] to-[#0C342C] text-white hover:from-[#5a705c] hover:to-[#0E4036] shadow-lg shadow-emerald-200'
}`}
>
<Send className="w-4 h-4" />
{submitting ? 'Sending...' : 'Submit Feedback'}
</button>
</div>
</div>
{/* Footer */}
<p className="text-center text-sm text-white/60 mt-6">
Powered by QR Master
</p>
</div>
</div>
);
}

View File

@@ -59,6 +59,34 @@ export async function GET(
const baseUrlText = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050';
destination = `${baseUrlText}/display?text=${encodeURIComponent(content.text || '')}`;
break;
case 'PDF':
// Direct link to file
destination = content.fileUrl || 'https://example.com/file.pdf';
break;
case 'APP':
// Smart device detection for app stores
const userAgent = request.headers.get('user-agent') || '';
const isIOS = /iphone|ipad|ipod/i.test(userAgent);
const isAndroid = /android/i.test(userAgent);
if (isIOS && content.iosUrl) {
destination = content.iosUrl;
} else if (isAndroid && content.androidUrl) {
destination = content.androidUrl;
} else {
destination = content.fallbackUrl || content.iosUrl || content.androidUrl || 'https://example.com';
}
break;
case 'COUPON':
// Redirect to coupon display page
const baseUrlCoupon = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050';
destination = `${baseUrlCoupon}/coupon/${slug}`;
break;
case 'FEEDBACK':
// Redirect to feedback form page
const baseUrlFeedback = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050';
destination = `${baseUrlFeedback}/feedback/${slug}`;
break;
default:
destination = 'https://example.com';
}

View File

@@ -168,6 +168,7 @@ END:VCARD`;
</button>
}
>
<DropdownItem onClick={() => window.location.href = `/qr/${qr.id}`}>View Details</DropdownItem>
<DropdownItem onClick={() => downloadQR('png')}>Download PNG</DropdownItem>
<DropdownItem onClick={() => downloadQR('svg')}>Download SVG</DropdownItem>
{qr.type === 'DYNAMIC' && (
@@ -246,6 +247,15 @@ END:VCARD`;
</p>
</div>
)}
{/* Feedback Button - only for FEEDBACK type */}
{qr.contentType === 'FEEDBACK' && (
<button
onClick={() => window.location.href = `/qr/${qr.id}/feedback`}
className="w-full mt-3 py-2 px-3 bg-amber-50 hover:bg-amber-100 text-amber-700 rounded-lg text-sm font-medium flex items-center justify-center gap-2 transition-colors"
>
View Customer Feedback
</button>
)}
</div>
</CardContent>
</Card>

View File

@@ -213,9 +213,18 @@ export function FreeToolsGrid() {
transition={{ duration: 0.5 }}
className="text-center mb-16"
>
<h2 className="text-3xl lg:text-4xl font-bold text-slate-900 mb-4">
More Free QR Code Tools
</h2>
<div className="flex flex-col md:flex-row items-center justify-center gap-3 mb-4">
<h2 className="text-3xl lg:text-4xl font-bold text-slate-900">
More Free QR Code Tools
</h2>
<div className="bg-gradient-to-r from-emerald-500 to-green-500 text-white px-3 py-1 rounded-full text-xs md:text-sm font-semibold shadow-lg shadow-emerald-500/20 flex items-center gap-2">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-white opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-white"></span>
</span>
Free Forever
</div>
</div>
<p className="text-lg text-slate-600 max-w-2xl mx-auto">
Create specialized QR codes for every need. Completely free and no signup required.
</p>

View File

@@ -6,19 +6,77 @@ import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Card } from '@/components/ui/Card';
import { motion } from 'framer-motion';
import { Globe, User, MapPin, Phone, CheckCircle2, ArrowRight } from 'lucide-react';
import { Globe, User, MapPin, Phone, CheckCircle2, ArrowRight, FileText, Ticket, Smartphone, Star } from 'lucide-react';
import { useState, useEffect } from 'react';
// Sub-component for the flipping effect
const FlippingCard = ({ front, back, delay }: { front: any, back: any, delay: number }) => {
const [isFlipped, setIsFlipped] = useState(false);
useEffect(() => {
// Initial delay
const initialTimeout = setTimeout(() => {
setIsFlipped(true); // First flip
// Setup interval for subsequent flips
const interval = setInterval(() => {
setIsFlipped(prev => !prev);
}, 8000); // Toggle every 8 seconds to prevent overlap (4 cards * 2s gap)
return () => clearInterval(interval);
}, delay * 1000);
return () => clearTimeout(initialTimeout);
}, [delay]);
return (
<div className="relative h-32 w-full perspective-[1000px] group cursor-pointer">
<motion.div
animate={{ rotateY: isFlipped ? 180 : 0 }}
transition={{ duration: 0.6, type: "spring", stiffness: 260, damping: 20 }}
className="relative w-full h-full preserve-3d"
style={{ transformStyle: 'preserve-3d' }}
>
{/* Front Face */}
<div
className="absolute inset-0 backface-hidden"
style={{ backfaceVisibility: 'hidden', WebkitBackfaceVisibility: 'hidden' }}
>
<Card className="w-full h-full backdrop-blur-xl bg-white/70 border-white/50 shadow-xl shadow-gray-200/50 p-4 flex flex-col items-center justify-center hover:scale-105 transition-all duration-300">
<div className={`w-10 h-10 mb-3 rounded-xl ${front.color} flex items-center justify-center`}>
<front.icon className="w-5 h-5" />
</div>
<p className="font-semibold text-gray-800 text-sm">{front.title}</p>
</Card>
</div>
{/* Back Face */}
<div
className="absolute inset-0 backface-hidden"
style={{
backfaceVisibility: 'hidden',
WebkitBackfaceVisibility: 'hidden',
transform: 'rotateY(180deg)'
}}
>
<Card className="w-full h-full backdrop-blur-xl bg-white/80 border-white/60 shadow-xl shadow-blue-200/50 p-4 flex flex-col items-center justify-center hover:scale-105 transition-all duration-300">
<div className={`w-10 h-10 mb-3 rounded-xl ${back.color} flex items-center justify-center`}>
<back.icon className="w-5 h-5" />
</div>
<p className="font-semibold text-gray-900 text-sm">{back.title}</p>
</Card>
</div>
</motion.div>
</div>
);
};
interface HeroProps {
t: any; // i18n translation function
}
export const Hero: React.FC<HeroProps> = ({ t }) => {
const templateCards = [
{ title: 'URL/Website', color: 'bg-blue-500/10 text-blue-600', icon: Globe },
{ title: 'Contact Card', color: 'bg-purple-500/10 text-purple-600', icon: User },
{ title: 'Location', color: 'bg-green-500/10 text-green-600', icon: MapPin },
{ title: 'Phone Number', color: 'bg-pink-500/10 text-pink-600', icon: Phone },
];
const containerjs = {
hidden: { opacity: 0 },
@@ -113,37 +171,34 @@ export const Hero: React.FC<HeroProps> = ({ t }) => {
{/* Right Preview Widget */}
<div className="relative">
<motion.div
variants={containerjs}
initial="hidden"
animate="show"
className="grid grid-cols-2 gap-4"
>
{templateCards.map((card, index) => (
<motion.div key={index} variants={itemjs}>
<Card className={`backdrop-blur-xl bg-white/70 border-white/50 shadow-xl shadow-gray-200/50 p-6 text-center hover:scale-105 transition-all duration-300 group cursor-pointer`}>
<div className={`w-12 h-12 mx-auto mb-4 rounded-xl ${card.color} flex items-center justify-center group-hover:scale-110 transition-transform duration-300`}>
<card.icon className="w-6 h-6" />
</div>
<p className="font-semibold text-gray-800 group-hover:text-gray-900">{card.title}</p>
</Card>
</motion.div>
))}
</motion.div>
{/* Floating Badge */}
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.8 }}
className="absolute -top-4 -right-4 bg-gradient-to-r from-success-500 to-emerald-500 text-white px-4 py-2 rounded-full text-sm font-semibold shadow-lg shadow-success-500/30 flex items-center gap-2"
>
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-white opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-white"></span>
</span>
{t.hero.engagement_badge}
</motion.div>
<div 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>
</div>
</div>
</div>

View File

@@ -6,8 +6,6 @@ const globalForPrisma = globalThis as unknown as {
export const db =
globalForPrisma.prisma ??
new PrismaClient({
log: ['query'],
});
new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;

View File

@@ -11,6 +11,14 @@ const envSchema = z.object({
REDIS_URL: z.string().optional(),
IP_SALT: z.string().default('development-salt-change-in-production'),
ENABLE_DEMO: z.string().default('false'),
// Cloudflare R2 (S3 Compatible)
R2_ACCOUNT_ID: z.string().optional(),
R2_ACCESS_KEY_ID: z.string().optional(),
R2_SECRET_ACCESS_KEY: z.string().optional(),
R2_BUCKET_NAME: z.string().default('qrmaster-menus'),
R2_PUBLIC_URL: z.string().optional(),
MAX_UPLOAD_SIZE: z.string().default('10485760'), // 10MB default
});
// During build, we might not have all env vars, so we'll use defaults

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

@@ -25,7 +25,7 @@ export const createQRSchema = z.object({
isStatic: z.boolean().optional(),
contentType: z.enum(['URL', 'VCARD', 'GEO', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT'], {
contentType: z.enum(['URL', 'VCARD', 'GEO', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT', 'PDF', 'APP', 'COUPON', 'FEEDBACK'], {
errorMap: () => ({ message: 'Invalid content type' })
}),
@@ -60,7 +60,7 @@ export const bulkQRSchema = z.object({
z.object({
title: z.string().min(1).max(100),
content: z.string().min(1).max(5000),
contentType: z.enum(['URL', 'VCARD', 'GEO', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT']),
contentType: z.enum(['URL', 'VCARD', 'GEO', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT', 'PDF', 'APP', 'COUPON', 'FEEDBACK']),
})
).min(1, 'At least one QR code is required')
.max(100, 'Maximum 100 QR codes per bulk creation'),
@@ -93,7 +93,7 @@ export const signupSchema = z.object({
password: z.string()
.min(8, 'Password must be at least 8 characters')
.max(100, 'Password must be less than 100 characters'),
// Password complexity rules removed for easier testing
// Password complexity rules removed for easier testing
});
export const forgotPasswordSchema = z.object({
@@ -108,7 +108,7 @@ export const resetPasswordSchema = z.object({
password: z.string()
.min(8, 'Password must be at least 8 characters')
.max(100, 'Password must be less than 100 characters'),
// Password complexity rules removed for easier testing
// Password complexity rules removed for easier testing
});
// ==========================================
@@ -129,7 +129,7 @@ export const changePasswordSchema = z.object({
newPassword: z.string()
.min(8, 'Password must be at least 8 characters')
.max(100, 'Password must be less than 100 characters'),
// Password complexity rules removed for easier testing
// Password complexity rules removed for easier testing
});
// ==========================================