Compare commits
5 Commits
feature/mo
...
dynamisch
| Author | SHA1 | Date | |
|---|---|---|---|
| 3682673852 | |||
|
|
1251584b13 | ||
|
|
dd93ca560a | ||
|
|
efb1654370 | ||
|
|
05531cda3f |
464
claude_plan_restaurant.md
Normal file
464
claude_plan_restaurant.md
Normal file
@@ -0,0 +1,464 @@
|
||||
Here is Claude's plan:
|
||||
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
|
||||
Restaurant Menu QR Code - Dashboard Integration Plan
|
||||
|
||||
Executive Summary
|
||||
|
||||
Add "Restaurant Menu" as a new QR code content type in the dashboard at /create.
|
||||
Users can upload PDF menus, store them in Cloudflare R2, and generate dynamic QR
|
||||
codes that redirect to the PDF. This integrates with the existing QRCode model and
|
||||
tracking system.
|
||||
|
||||
Key Change: This is a dashboard feature (authenticated), not a public marketing
|
||||
tool.
|
||||
|
||||
Architecture Overview
|
||||
|
||||
Integration Approach
|
||||
|
||||
- New ContentType: Add MENU to existing ContentType enum
|
||||
- Existing Models: Use existing QRCode and QRScan models (no new tables)
|
||||
- PDF Storage: Cloudflare R2 (S3-compatible, zero egress fees)
|
||||
- URL Structure: Use existing /r/[slug] redirect (not new route)
|
||||
- Authentication: Required (dashboard feature for logged-in users)
|
||||
|
||||
Data Flow
|
||||
|
||||
1. User logs in → Goes to /create → Selects "Restaurant Menu" type
|
||||
2. Uploads PDF → Validate → Upload to R2 → Get public URL
|
||||
3. Creates QR code with content: { pdfUrl: "...", restaurantName: "...", menuTitle:
|
||||
"..." }
|
||||
4. QR code redirects to: /r/[slug] → Redirect to PDF URL
|
||||
5. Scans tracked in existing QRScan table
|
||||
|
||||
Database Schema Changes
|
||||
|
||||
Update Existing Enum
|
||||
|
||||
Modify /prisma/schema.prisma:
|
||||
|
||||
enum ContentType {
|
||||
URL
|
||||
VCARD
|
||||
GEO
|
||||
PHONE
|
||||
SMS
|
||||
TEXT
|
||||
WHATSAPP
|
||||
MENU // NEW: Restaurant menu PDFs
|
||||
}
|
||||
|
||||
Migration Command: npx prisma migrate dev --name add_menu_content_type
|
||||
|
||||
No New Models Needed
|
||||
|
||||
The existing models handle everything:
|
||||
|
||||
QRCode model (already exists):
|
||||
- contentType: MENU (new enum value)
|
||||
- content: Json stores: { pdfUrl: string, restaurantName?: string, menuTitle?:
|
||||
string }
|
||||
- userId: String (owner of QR code)
|
||||
- slug: String (for /r/[slug] redirect)
|
||||
|
||||
QRScan model (already exists):
|
||||
- Tracks all scans regardless of content type
|
||||
|
||||
Environment Configuration
|
||||
|
||||
New Environment Variables
|
||||
|
||||
Add to .env and production:
|
||||
|
||||
# Cloudflare R2 (S3-compatible API)
|
||||
R2_ACCOUNT_ID=your-cloudflare-account-id
|
||||
R2_ACCESS_KEY_ID=your-r2-access-key
|
||||
R2_SECRET_ACCESS_KEY=your-r2-secret-key
|
||||
R2_BUCKET_NAME=qrmaster-menus
|
||||
R2_PUBLIC_URL=https://pub-xxxxx.r2.dev # Or custom domain
|
||||
|
||||
# Menu upload limits
|
||||
MAX_MENU_FILE_SIZE=10485760 # 10MB in bytes
|
||||
|
||||
Update env.ts
|
||||
|
||||
Add to /src/lib/env.ts schema:
|
||||
|
||||
const envSchema = z.object({
|
||||
// ... existing fields ...
|
||||
R2_ACCOUNT_ID: z.string().optional(),
|
||||
R2_ACCESS_KEY_ID: z.string().optional(),
|
||||
R2_SECRET_ACCESS_KEY: z.string().optional(),
|
||||
R2_BUCKET_NAME: z.string().default('qrmaster-menus'),
|
||||
R2_PUBLIC_URL: z.string().optional(),
|
||||
MAX_MENU_FILE_SIZE: z.string().default('10485760'),
|
||||
});
|
||||
|
||||
Critical Files to Modify/Create
|
||||
|
||||
1. R2 Client Library
|
||||
|
||||
File: /src/lib/r2.ts (NEW)
|
||||
|
||||
Purpose: Handle PDF uploads to Cloudflare R2
|
||||
|
||||
import { S3Client, PutObjectCommand, DeleteObjectCommand } from
|
||||
'@aws-sdk/client-s3';
|
||||
import { env } from './env';
|
||||
|
||||
const r2Client = new S3Client({
|
||||
region: 'auto',
|
||||
endpoint: `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
||||
credentials: {
|
||||
accessKeyId: env.R2_ACCESS_KEY_ID!,
|
||||
secretAccessKey: env.R2_SECRET_ACCESS_KEY!,
|
||||
},
|
||||
});
|
||||
|
||||
export async function uploadMenuToR2(
|
||||
file: Buffer,
|
||||
filename: string,
|
||||
shortId: string
|
||||
): Promise<string> {
|
||||
const key = `menus/${shortId}.pdf`;
|
||||
|
||||
await r2Client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: env.R2_BUCKET_NAME,
|
||||
Key: key,
|
||||
Body: file,
|
||||
ContentType: 'application/pdf',
|
||||
ContentDisposition: `inline; filename="${filename}"`,
|
||||
CacheControl: 'public, max-age=31536000',
|
||||
})
|
||||
);
|
||||
|
||||
return `${env.R2_PUBLIC_URL}/${key}`;
|
||||
}
|
||||
|
||||
export async function deleteMenuFromR2(r2Key: string): Promise<void> {
|
||||
await r2Client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: env.R2_BUCKET_NAME,
|
||||
Key: r2Key,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function generateUniqueFilename(originalFilename: string): string {
|
||||
const timestamp = Date.now();
|
||||
const random = crypto.randomBytes(4).toString('hex');
|
||||
const ext = originalFilename.split('.').pop();
|
||||
return `menu_${timestamp}_${random}.${ext}`;
|
||||
}
|
||||
|
||||
2. Upload API Endpoint
|
||||
|
||||
File: /src/app/api/menu/upload/route.ts (NEW)
|
||||
|
||||
Purpose: Handle PDF uploads from the create page
|
||||
|
||||
Responsibilities:
|
||||
- Accept multipart/form-data PDF upload
|
||||
- Validate file type (PDF magic bytes), size (max 10MB)
|
||||
- Rate limit: 10 uploads per minute per user (authenticated)
|
||||
- Upload to R2 with unique filename
|
||||
- Return R2 public URL
|
||||
|
||||
Request: FormData { file: File }
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"pdfUrl": "https://pub-xxxxx.r2.dev/menus/menu_1234567890_abcd.pdf"
|
||||
}
|
||||
|
||||
Key Implementation Details:
|
||||
- Use request.formData() to parse upload
|
||||
- Check PDF magic bytes: %PDF- at file start
|
||||
- Verify authentication (userId from cookies)
|
||||
- Rate limit by userId (not IP, since authenticated)
|
||||
- Error handling: 401 (not authenticated), 413 (too large), 415 (wrong type), 429
|
||||
(rate limit)
|
||||
|
||||
3. Update Redirect Route
|
||||
|
||||
File: /src/app/r/[slug]/route.ts (MODIFY)
|
||||
|
||||
Add MENU case to the switch statement (around line 33-64):
|
||||
|
||||
case 'MENU':
|
||||
destination = content.pdfUrl || 'https://example.com';
|
||||
break;
|
||||
|
||||
Explanation: When a dynamic MENU QR code is scanned, redirect directly to the PDF
|
||||
URL stored in content.pdfUrl
|
||||
|
||||
4. Update Validation Schema
|
||||
|
||||
File: /src/lib/validationSchemas.ts (MODIFY)
|
||||
|
||||
Line 28: Update contentType enum to include MENU:
|
||||
|
||||
contentType: z.enum(['URL', 'VCARD', 'GEO', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT',
|
||||
'MENU'], {
|
||||
errorMap: () => ({ message: 'Invalid content type' })
|
||||
}),
|
||||
|
||||
Line 63: Update bulk QR schema as well:
|
||||
|
||||
contentType: z.enum(['URL', 'VCARD', 'GEO', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT',
|
||||
'MENU']),
|
||||
|
||||
5. Update Create Page - Add MENU Type
|
||||
|
||||
File: /src/app/(app)/create/page.tsx (MODIFY)
|
||||
|
||||
Multiple changes needed:
|
||||
|
||||
A. Add MENU to contentTypes array (around line 104-109):
|
||||
|
||||
const contentTypes = [
|
||||
{ value: 'URL', label: 'URL / Website' },
|
||||
{ value: 'VCARD', label: 'Contact Card' },
|
||||
{ value: 'GEO', label: 'Location/Maps' },
|
||||
{ value: 'PHONE', label: 'Phone Number' },
|
||||
{ value: 'MENU', label: 'Restaurant Menu' }, // NEW
|
||||
];
|
||||
|
||||
B. Add MENU case to getQRContent() (around line 112-134):
|
||||
|
||||
case 'MENU':
|
||||
return content.pdfUrl || 'https://example.com/menu.pdf';
|
||||
|
||||
C. Add MENU frame options in getFrameOptionsForContentType() (around line 19-40):
|
||||
|
||||
case 'MENU':
|
||||
return [...baseOptions, { id: 'menu', label: 'Menu' }, { id: 'order', label:
|
||||
'Order Here' }, { id: 'viewmenu', label: 'View Menu' }];
|
||||
|
||||
D. Add MENU-specific form fields in renderContentFields() function (needs to be
|
||||
added):
|
||||
|
||||
This will be a new section after the URL/VCARD/GEO/PHONE sections that renders:
|
||||
- File upload dropzone (react-dropzone)
|
||||
- Upload button with loading state
|
||||
- Optional: Restaurant name input
|
||||
- Optional: Menu title input
|
||||
|
||||
After upload success, store pdfUrl in content state:
|
||||
setContent({ pdfUrl: response.pdfUrl, restaurantName: '', menuTitle: '' });
|
||||
|
||||
6. Update Rate Limiting
|
||||
|
||||
File: /src/lib/rateLimit.ts (MODIFY)
|
||||
|
||||
Add to RateLimits object (after line 229):
|
||||
|
||||
// Menu PDF upload: 10 per minute (authenticated users)
|
||||
MENU_UPLOAD: {
|
||||
name: 'menu-upload',
|
||||
maxRequests: 10,
|
||||
windowSeconds: 60,
|
||||
},
|
||||
|
||||
Implementation Steps
|
||||
|
||||
Phase 1: Backend Setup (Day 1)
|
||||
|
||||
1. Install Dependencies
|
||||
npm install @aws-sdk/client-s3 react-dropzone
|
||||
2. Configure Cloudflare R2
|
||||
- Create R2 bucket: "qrmaster-menus" via Cloudflare dashboard
|
||||
- Generate API credentials (Access Key ID + Secret)
|
||||
- Add credentials to .env and production environment
|
||||
- Set bucket to public (for PDF access)
|
||||
3. Database Migration
|
||||
- Add MENU to ContentType enum in prisma/schema.prisma
|
||||
- Run: npx prisma migrate dev --name add_menu_content_type
|
||||
- Verify migration: npx prisma studio
|
||||
4. Environment Configuration
|
||||
- Update src/lib/env.ts with R2 variables
|
||||
- Update src/lib/rateLimit.ts with MENU_UPLOAD config
|
||||
5. Create R2 Client
|
||||
- Create src/lib/r2.ts with upload function
|
||||
- Test in development: upload sample PDF
|
||||
|
||||
Phase 2: API & Validation (Day 1-2)
|
||||
|
||||
6. Update Validation Schema (/src/lib/validationSchemas.ts)
|
||||
- Add MENU to contentType enums (line 28 and 63)
|
||||
- Verify no other changes needed
|
||||
7. Create Upload API (/src/app/api/menu/upload/route.ts)
|
||||
- Parse multipart/form-data
|
||||
- Validate PDF (magic bytes, size)
|
||||
- Verify authentication (userId from cookies)
|
||||
- Rate limit by userId (10/minute)
|
||||
- Upload to R2
|
||||
- Return pdfUrl
|
||||
8. Update Redirect Route (/src/app/r/[slug]/route.ts)
|
||||
- Add MENU case to switch statement (line 33-64)
|
||||
- Redirect to content.pdfUrl
|
||||
|
||||
Phase 3: Dashboard Integration (Day 2-3)
|
||||
|
||||
9. Update Create Page (/src/app/(app)/create/page.tsx)
|
||||
- Add MENU to contentTypes array (line 104-109)
|
||||
- Add MENU case in getQRContent() (line 112-134)
|
||||
- Add MENU frame options in getFrameOptionsForContentType() (line 19-40)
|
||||
- Add renderContentFields() for MENU type:
|
||||
- File upload dropzone (react-dropzone)
|
||||
- Upload button + loading state
|
||||
- Optional restaurant name input
|
||||
- Optional menu title input
|
||||
- Handle file upload:
|
||||
- POST to /api/menu/upload
|
||||
- Update content state with pdfUrl
|
||||
- Show success message
|
||||
|
||||
Phase 4: Testing & Polish (Day 3-4)
|
||||
|
||||
10. Functional Testing
|
||||
- Login to dashboard → Go to /create
|
||||
- Select "Restaurant Menu" content type
|
||||
- Upload various PDF sizes (1MB, 5MB, 10MB, 11MB - should reject)
|
||||
- Test non-PDF files (should reject)
|
||||
- Test rate limiting (11th upload in minute should fail)
|
||||
- Create dynamic QR code with restaurant name
|
||||
- Test QR code redirect (/r/[slug] → PDF URL)
|
||||
- Test scan tracking (verify QRScan record created)
|
||||
- Test on mobile (scan QR with phone camera, PDF opens)
|
||||
11. Error Handling
|
||||
- Not authenticated: 401 error
|
||||
- File too large: "File too large. Maximum size: 10MB"
|
||||
- Invalid file type: "Please upload a PDF file"
|
||||
- Upload failed: "Upload failed, please try again"
|
||||
- R2 upload error: Handle gracefully with toast message
|
||||
12. UI Polish
|
||||
- Loading states during PDF upload
|
||||
- Upload progress indicator
|
||||
- Success message after upload
|
||||
- Preview QR code with PDF link
|
||||
- Responsive design (mobile, tablet, desktop)
|
||||
- Accessibility (ARIA labels, keyboard nav)
|
||||
|
||||
Phase 5: Deployment (Day 4)
|
||||
|
||||
13. Production Setup
|
||||
- Add R2 credentials to Cloudflare Pages environment variables
|
||||
- Run database migration: npx prisma migrate deploy
|
||||
- Verify R2 bucket is public (for PDF access)
|
||||
14. Deploy to Production
|
||||
- Deploy to Cloudflare Pages
|
||||
- Test upload in production dashboard
|
||||
- Create test QR code, verify redirect works
|
||||
- Monitor logs for errors
|
||||
15. Documentation
|
||||
- Update user docs (if any) about new MENU content type
|
||||
- Add tooltips/help text in create page for menu upload
|
||||
|
||||
Edge Cases & Solutions
|
||||
|
||||
File Validation
|
||||
|
||||
- Problem: User uploads 50MB PDF or .exe file
|
||||
- Solution:
|
||||
- Client-side validation (check file.size and file.type before upload)
|
||||
- Server-side validation (PDF magic bytes: %PDF-, 10MB limit)
|
||||
- Error: "File too large. Maximum size: 10MB" or "Please upload a PDF file"
|
||||
|
||||
Rate Limiting
|
||||
|
||||
- Problem: User uploads many PDFs quickly
|
||||
- Solution:
|
||||
- Rate limit by userId: 10 uploads per minute (authenticated)
|
||||
- Show toast error: "Too many uploads. Please wait a moment."
|
||||
- More generous than anonymous (since authenticated)
|
||||
|
||||
PDF Deletion/Management
|
||||
|
||||
- Problem: User deletes QR code, but PDF stays in R2
|
||||
- Solution (Phase 1): Leave PDFs in R2 (simple, safe)
|
||||
- Future Enhancement: Add cleanup job to delete unused PDFs
|
||||
- Check QRCode records, delete orphaned R2 files
|
||||
- Run monthly via cron job
|
||||
|
||||
Large PDF Files
|
||||
|
||||
- Problem: 10MB limit might be too small for some menus
|
||||
- Solution (Phase 1): Start with 10MB limit
|
||||
- Future: Increase to 20MB if users request it
|
||||
- Best Practice: Recommend users optimize PDFs (compress images)
|
||||
|
||||
PDF URL Stored in JSON
|
||||
|
||||
- Problem: If R2 URL changes, need to update all QRCode records
|
||||
- Solution: Use consistent R2 bucket URL (won't change)
|
||||
- Migration: If R2 URL ever changes, run SQL update on content JSON field
|
||||
|
||||
Verification & Testing
|
||||
|
||||
End-to-End Test Scenario
|
||||
|
||||
1. Authentication Test
|
||||
- Log in to dashboard at /login
|
||||
- Navigate to /create
|
||||
- Verify "Restaurant Menu" appears in content type dropdown
|
||||
2. Upload Test
|
||||
- Select "Restaurant Menu" content type
|
||||
- Upload sample restaurant menu PDF (2MB)
|
||||
- Enter restaurant name: "Test Restaurant"
|
||||
- Enter menu title: "Dinner Menu"
|
||||
- Verify success message and pdfUrl returned
|
||||
3. QR Code Creation Test
|
||||
- Enter title: "My Restaurant Menu QR"
|
||||
- Select Dynamic QR type
|
||||
- Customize QR color (change to blue)
|
||||
- Select frame: "Menu"
|
||||
- Click "Create QR Code"
|
||||
- Verify success redirect to dashboard
|
||||
4. Scan Test
|
||||
- From dashboard, copy QR code URL: qrmaster.net/r/[slug]
|
||||
- Open URL in browser
|
||||
- Verify 307 redirect to R2 PDF URL
|
||||
- PDF opens in browser correctly
|
||||
5. Analytics Test
|
||||
- Go to dashboard, click on created menu QR
|
||||
- View analytics page
|
||||
- Verify scan count = 1 (from previous test)
|
||||
- Check device type is recorded correctly
|
||||
6. Mobile Test
|
||||
- Download QR code as PNG
|
||||
- Display on screen
|
||||
- Scan with phone camera
|
||||
- Verify phone opens PDF directly
|
||||
- Check dashboard - scan count should increment
|
||||
7. Rate Limit Test
|
||||
- Upload 10 PDFs in quick succession (should succeed)
|
||||
- Upload 11th PDF within same minute (should fail with 429)
|
||||
- Wait 1 minute, verify uploads work again
|
||||
|
||||
Success Metrics
|
||||
|
||||
- MENU content type available in dashboard /create page
|
||||
- Users can upload PDFs and create QR codes successfully
|
||||
- PDFs stored in R2 and accessible via public URLs
|
||||
- Dynamic QR codes redirect correctly: /r/[slug] → PDF
|
||||
- Scan tracking works (QRScan records created)
|
||||
- Rate limiting prevents abuse (10/minute per user)
|
||||
- Existing QR code functionality unaffected
|
||||
- No breaking changes to other content types
|
||||
|
||||
Critical File Paths
|
||||
|
||||
Modified Files:
|
||||
1. /prisma/schema.prisma - Add MENU to ContentType enum
|
||||
2. /src/lib/validationSchemas.ts - Add MENU to contentType enums (lines 28, 63)
|
||||
3. /src/app/(app)/create/page.tsx - Add MENU UI and logic
|
||||
4. /src/app/r/[slug]/route.ts - Add MENU redirect case
|
||||
5. /src/lib/env.ts - Add R2 environment variables
|
||||
6. /src/lib/rateLimit.ts - Add MENU_UPLOAD rate limit
|
||||
|
||||
New Files:
|
||||
7. /src/lib/r2.ts - R2 client library for PDF uploads
|
||||
8. /src/app/api/menu/upload/route.ts - PDF upload API endpoint
|
||||
1702
package-lock.json
generated
1702
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -112,6 +112,10 @@ enum ContentType {
|
||||
SMS
|
||||
TEXT
|
||||
WHATSAPP
|
||||
PDF
|
||||
APP
|
||||
COUPON
|
||||
FEEDBACK
|
||||
}
|
||||
|
||||
enum QRStatus {
|
||||
|
||||
156
seo_2026_jan.md
Normal file
156
seo_2026_jan.md
Normal file
@@ -0,0 +1,156 @@
|
||||
SEO Opportunity Report & Implementation Plan (Jan 2026)
|
||||
1. Executive Summary
|
||||
An analysis of the provided Google Keyword Planner data (Jan 22, 2026) reveals significant low-competition, high-volume traffic opportunities that were previously untapped. We have immediately capitalized on the Barcode opportunity and have a clear path to capture Custom QR intent.
|
||||
|
||||
2. Key Data Findings ("Hidden Gems")
|
||||
We identified three specific clusters where search volume is high but competition is exceptionally low.
|
||||
|
||||
A. The "QR Barcode" Anomaly (Gold Mine) 🏆
|
||||
Users are confused about the terminology, searching for "qr barcode" or "bar code generator" instead of just "barcode".
|
||||
|
||||
Keywords: qr barcode, bar code generator, scan code generator
|
||||
Volume: 10k – 100k (High)
|
||||
Competition: Low / Medium
|
||||
Opportunity: Most competitors optimize for "Barcode Generator". By targeting the "wrong" terms users actually type, we can win easy traffic.
|
||||
B. The "Free" Intent
|
||||
High volume, but users are specifically looking for "free" and "no signup".
|
||||
|
||||
Keyword: free qr code generator (100k – 1M)
|
||||
Keyword: qr code generator free (100k – 1M)
|
||||
Opportunity: Aggressive targeting of these exact match phrases on the homepage metadata.
|
||||
C. The "Custom" Gap
|
||||
Users want customization but don't always use the term "design".
|
||||
|
||||
Keyword: custom qr code generator
|
||||
Volume: 1k – 10k
|
||||
Competition: Low
|
||||
Current Status: MISSING. We do not have a dedicated landing page for this high-intent cluster.
|
||||
3. Actions Already Implemented ✅
|
||||
We have immediately updated the metadata to capture the traffic identified in findings A and B.
|
||||
|
||||
1. Barcode Generator Optimization
|
||||
File:
|
||||
src/app/(marketing)/tools/barcode-generator/page.tsx
|
||||
|
||||
Action: Updated <title> and meta description.
|
||||
New Target: "QR Barcode" and "Bar Code Generator".
|
||||
Why: To capture the 100k+ users searching for these specific variants.
|
||||
2. Homepage Optimization
|
||||
File:
|
||||
src/app/(marketing)/page.tsx
|
||||
|
||||
Action: Injected high-volume keyword tags.
|
||||
New Target: qr generator, free qr code generator, custom qr code generator.
|
||||
Why: To signal relevance to Google for the broadest "head terms".
|
||||
4. Implementation Plan: "Custom QR Code" Landing Page 🚀
|
||||
To capture the 1k–10k/month users searching for "custom qr code generator" (Finding C), we need a dedicated landing page. This page will focus on design features (colors, logos, frames) rather than just "generating" a code.
|
||||
|
||||
Phase 1: Page Structure (New File)
|
||||
Path: src/app/(marketing)/tools/custom-qr-code-generator/page.tsx
|
||||
|
||||
Content Strategy:
|
||||
|
||||
H1: "Free Custom QR Code Generator with Logo & Colors"
|
||||
Hero: Visual emphasis on beautiful codes, not black-and-white ones.
|
||||
Live Editor Demo: (Reuse existing QRCodeGeneratorClient but pre-opened "Design" tab).
|
||||
Features Section:
|
||||
"Add Your Logo"
|
||||
"Custom Colors & Gradients"
|
||||
"Unique Frames & Shapes"
|
||||
FAQ: Targeting specific "custom" questions (vector formats, scanning reliability).
|
||||
Phase 2: Internal Linking
|
||||
Add link to /tools/custom-qr-code-generator in the Footer and Main Navigation under "Tools".
|
||||
Link from Homepage "Features" section.
|
||||
Phase 3: Metadata Strategy
|
||||
title: 'Custom QR Code Generator – Add Logo, Colors & Frames',
|
||||
description: 'Create unique custom QR codes with your logo, colors, and frames. Free online customizer with high-quality vector download (SVG/PNG).',
|
||||
keywords: ['custom qr code generator', 'qr code with logo', 'qr code design', 'creative qr code', 'branded qr code']
|
||||
5. Next Steps
|
||||
Approval: Confirm this plan to build the custom-qr-code-generator page.
|
||||
Execution: I will create the directory and page file.
|
||||
Verify: Check that the new page renders and is linked correctly.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
new findings
|
||||
|
||||
Das ist super, dass du die Dateien hochgeladen hast! Denn: Du hast die Daten bereits.
|
||||
|
||||
Ich habe deine Excel-Listen analysiert und das Problem gefunden: In deiner Ansicht im Tool hieß die Spalte vielleicht anders oder war versteckt, aber in der Datei heißt sie Avg. monthly searches. Das ist exakt das Suchvolumen, das du gesucht hast.
|
||||
|
||||
Hier sind die echten "Schätze" direkt aus deiner Datei, gefiltert nach meiner Strategie:
|
||||
|
||||
1. Die "Low Hanging Fruits" (Viel Traffic, fast 0 Konkurrenz)
|
||||
Das sind die Keywords, bei denen du SOFORT ranken kannst, weil die großen Player schlafen.
|
||||
|
||||
call qr code
|
||||
|
||||
Suchvolumen: 50.000 (!)
|
||||
|
||||
Wettbewerb-Index: 0 (Das ist extrem selten! Absolut gar keine Konkurrenz auf Google Ads).
|
||||
|
||||
Idee: Mach eine Seite "Telefon-QR-Code erstellen". Leute wollen, dass beim Scannen direkt eine Nummer gewählt wird. Das ist eine riesige Nische.
|
||||
|
||||
generate website qr code
|
||||
|
||||
Suchvolumen: 50.000
|
||||
|
||||
Wettbewerb-Index: 17 (Sehr gering).
|
||||
|
||||
CPC: 6,16 € (Das ist der Jackpot: Viel Traffic, wenig Konkurrenz UND hoher Wert).
|
||||
|
||||
bar code generator
|
||||
|
||||
Suchvolumen: 500.000
|
||||
|
||||
Wettbewerb-Index: 3 (Fast nichts).
|
||||
|
||||
Bestätigt meine frühere Analyse: Das ist dein Traffic-Bringer.
|
||||
|
||||
2. Die "Goldminen" (Hoher Klickpreis / High Ticket)
|
||||
Hier zahlen Firmen richtig Geld. Wenn du hier rankst, sind die Nutzer sehr wertvoll.
|
||||
|
||||
manage qr codes
|
||||
|
||||
CPC (Gebot): 18,77 €
|
||||
|
||||
Das suchen Firmen, die viele Codes verwalten müssen (Agenturen, Großkunden). Perfekt für dein "Pro"-Abo.
|
||||
|
||||
dynamic code generator
|
||||
|
||||
CPC: 15,62 €
|
||||
|
||||
Das Kern-Keyword für dein Business-Modell.
|
||||
|
||||
qr tracking
|
||||
|
||||
CPC: 12,51 €
|
||||
|
||||
Wettbewerb ist hier höher, aber der Umsatz pro Kunde auch.
|
||||
|
||||
3. Der Trend-Tipp (+900% Wachstum)
|
||||
free bulk qr code generator (Massen-Erstellung)
|
||||
|
||||
Trend: +900%
|
||||
|
||||
Viele Leute suchen nach einer Möglichkeit, 100 Codes auf einmal zu erstellen (z.B. für Mitarbeiterausweise oder Inventar). Wenn du so ein Feature ("Bulk Upload") hast oder baust, bist du Marktführer.
|
||||
|
||||
Was du jetzt tun musst (in Excel oder Google Sheets):
|
||||
Du brauchst das Tool gar nicht mehr zwingend. Du kannst einfach in deiner Datei filtern:
|
||||
|
||||
Such die Spalte Avg. monthly searches -> Das ist dein Suchvolumen.
|
||||
|
||||
Such die Spalte Competition (indexed value) -> Das ist die Härte (0-100).
|
||||
|
||||
Filter: Avg. monthly searches > 1000 UND Competition (indexed value) < 20.
|
||||
|
||||
Damit findest du genau die oben genannten Chancen. Soll ich dir für eines dieser Keywords (z.B. den "Call QR Code" mit Index 0) eine Gliederung schreiben?
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
196
src/app/(app)/qr/[id]/feedback/page.tsx
Normal file
196
src/app/(app)/qr/[id]/feedback/page.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Star, ArrowLeft, ChevronLeft, ChevronRight, MessageSquare } from 'lucide-react';
|
||||
|
||||
interface Feedback {
|
||||
id: string;
|
||||
rating: number;
|
||||
comment: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
interface FeedbackStats {
|
||||
total: number;
|
||||
avgRating: number;
|
||||
distribution: { [key: number]: number };
|
||||
}
|
||||
|
||||
interface Pagination {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export default function FeedbackListPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const qrId = params.id as string;
|
||||
|
||||
const [feedbacks, setFeedbacks] = useState<Feedback[]>([]);
|
||||
const [stats, setStats] = useState<FeedbackStats | null>(null);
|
||||
const [pagination, setPagination] = useState<Pagination>({ page: 1, totalPages: 1, hasMore: false });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFeedback(currentPage);
|
||||
}, [qrId, currentPage]);
|
||||
|
||||
const fetchFeedback = async (page: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/qrs/${qrId}/feedback?page=${page}&limit=20`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setFeedbacks(data.feedbacks);
|
||||
setStats(data.stats);
|
||||
setPagination(data.pagination);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching feedback:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderStars = (rating: number) => (
|
||||
<div className="flex gap-0.5">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Star
|
||||
key={star}
|
||||
className={`w-4 h-4 ${star <= rating ? 'text-amber-400 fill-amber-400' : 'text-gray-200'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading && !stats) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Link href={`/qr/${qrId}`} className="inline-flex items-center text-gray-500 hover:text-gray-700 mb-4">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to QR Code
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Customer Feedback</h1>
|
||||
<p className="text-gray-600 mt-1">{stats?.total || 0} total responses</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Overview */}
|
||||
{stats && (
|
||||
<Card className="mb-8">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-8">
|
||||
{/* Average Rating */}
|
||||
<div className="text-center md:text-left">
|
||||
<div className="text-5xl font-bold text-gray-900 mb-1">{stats.avgRating}</div>
|
||||
<div className="flex justify-center md:justify-start mb-1">
|
||||
{renderStars(Math.round(stats.avgRating))}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">{stats.total} reviews</p>
|
||||
</div>
|
||||
|
||||
{/* Distribution */}
|
||||
<div className="flex-1 space-y-2">
|
||||
{[5, 4, 3, 2, 1].map((rating) => {
|
||||
const count = stats.distribution[rating] || 0;
|
||||
const percentage = stats.total > 0 ? (count / stats.total) * 100 : 0;
|
||||
return (
|
||||
<div key={rating} className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-600 w-12">{rating} stars</span>
|
||||
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-amber-400 rounded-full transition-all"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 w-12 text-right">{count}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Feedback List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="w-5 h-5" />
|
||||
All Reviews
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{feedbacks.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<Star className="w-12 h-12 mx-auto mb-4 text-gray-300" />
|
||||
<p>No feedback received yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{feedbacks.map((feedback) => (
|
||||
<div key={feedback.id} className="py-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
{renderStars(feedback.rating)}
|
||||
<span className="text-sm text-gray-400">
|
||||
{new Date(feedback.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{feedback.comment && (
|
||||
<p className="text-gray-700">{feedback.comment}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-6 pt-6 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-gray-500">
|
||||
Page {currentPage} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => p + 1)}
|
||||
disabled={!pagination.hasMore}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
287
src/app/(app)/qr/[id]/page.tsx
Normal file
287
src/app/(app)/qr/[id]/page.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import {
|
||||
ArrowLeft, Edit, ExternalLink, Star, MessageSquare,
|
||||
BarChart3, Copy, Check, Pause, Play
|
||||
} from 'lucide-react';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
import { useCsrf } from '@/hooks/useCsrf';
|
||||
|
||||
interface QRCode {
|
||||
id: string;
|
||||
title: string;
|
||||
type: 'STATIC' | 'DYNAMIC';
|
||||
contentType: string;
|
||||
content: any;
|
||||
slug: string;
|
||||
status: 'ACTIVE' | 'PAUSED';
|
||||
style: any;
|
||||
createdAt: string;
|
||||
_count?: { scans: number };
|
||||
}
|
||||
|
||||
interface FeedbackStats {
|
||||
total: number;
|
||||
avgRating: number;
|
||||
distribution: { [key: number]: number };
|
||||
}
|
||||
|
||||
export default function QRDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const qrId = params.id as string;
|
||||
const { fetchWithCsrf } = useCsrf();
|
||||
|
||||
const [qrCode, setQrCode] = useState<QRCode | null>(null);
|
||||
const [feedbackStats, setFeedbackStats] = useState<FeedbackStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchQRCode();
|
||||
}, [qrId]);
|
||||
|
||||
const fetchQRCode = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/qrs/${qrId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setQrCode(data);
|
||||
|
||||
// Fetch feedback stats if it's a feedback QR
|
||||
if (data.contentType === 'FEEDBACK') {
|
||||
const feedbackRes = await fetch(`/api/qrs/${qrId}/feedback?limit=1`);
|
||||
if (feedbackRes.ok) {
|
||||
const feedbackData = await feedbackRes.json();
|
||||
setFeedbackStats(feedbackData.stats);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
showToast('QR code not found', 'error');
|
||||
router.push('/dashboard');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching QR code:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyLink = async () => {
|
||||
const url = `${window.location.origin}/r/${qrCode?.slug}`;
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
showToast('Link copied!', 'success');
|
||||
};
|
||||
|
||||
const toggleStatus = async () => {
|
||||
if (!qrCode) return;
|
||||
const newStatus = qrCode.status === 'ACTIVE' ? 'PAUSED' : 'ACTIVE';
|
||||
|
||||
try {
|
||||
const res = await fetchWithCsrf(`/api/qrs/${qrId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setQrCode({ ...qrCode, status: newStatus });
|
||||
showToast(`QR code ${newStatus === 'ACTIVE' ? 'activated' : 'paused'}`, 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to update status', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const renderStars = (rating: number) => (
|
||||
<div className="flex gap-0.5">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Star
|
||||
key={star}
|
||||
className={`w-4 h-4 ${star <= rating ? 'text-amber-400 fill-amber-400' : 'text-gray-200'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!qrCode) return null;
|
||||
|
||||
const qrUrl = `${typeof window !== 'undefined' ? window.location.origin : ''}/r/${qrCode.slug}`;
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Link href="/dashboard" className="inline-flex items-center text-gray-500 hover:text-gray-700 mb-4">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">{qrCode.title}</h1>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Badge variant={qrCode.type === 'DYNAMIC' ? 'info' : 'default'}>
|
||||
{qrCode.type}
|
||||
</Badge>
|
||||
<Badge variant={qrCode.status === 'ACTIVE' ? 'success' : 'warning'}>
|
||||
{qrCode.status}
|
||||
</Badge>
|
||||
<Badge>{qrCode.contentType}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{qrCode.type === 'DYNAMIC' && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={toggleStatus}>
|
||||
{qrCode.status === 'ACTIVE' ? <Pause className="w-4 h-4 mr-1" /> : <Play className="w-4 h-4 mr-1" />}
|
||||
{qrCode.status === 'ACTIVE' ? 'Pause' : 'Activate'}
|
||||
</Button>
|
||||
<Link href={`/qr/${qrId}/edit`}>
|
||||
<Button variant="outline" size="sm">
|
||||
<Edit className="w-4 h-4 mr-1" /> Edit
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-8">
|
||||
{/* Left: QR Code */}
|
||||
<div>
|
||||
<Card>
|
||||
<CardContent className="p-6 flex flex-col items-center">
|
||||
<div className="bg-white p-4 rounded-xl shadow-sm mb-4">
|
||||
<QRCodeSVG
|
||||
value={qrUrl}
|
||||
size={200}
|
||||
fgColor={qrCode.style?.foregroundColor || '#000000'}
|
||||
bgColor={qrCode.style?.backgroundColor || '#FFFFFF'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-2">
|
||||
<Button variant="outline" className="w-full" onClick={copyLink}>
|
||||
{copied ? <Check className="w-4 h-4 mr-2" /> : <Copy className="w-4 h-4 mr-2" />}
|
||||
{copied ? 'Copied!' : 'Copy Link'}
|
||||
</Button>
|
||||
<a href={qrUrl} target="_blank" rel="noopener noreferrer" className="block">
|
||||
<Button variant="outline" className="w-full">
|
||||
<ExternalLink className="w-4 h-4 mr-2" /> Open Link
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right: Stats & Info */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<BarChart3 className="w-6 h-6 mx-auto mb-2 text-indigo-500" />
|
||||
<p className="text-2xl font-bold text-gray-900">{qrCode._count?.scans || 0}</p>
|
||||
<p className="text-sm text-gray-500">Total Scans</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<p className="text-2xl font-bold text-gray-900">{qrCode.type}</p>
|
||||
<p className="text-sm text-gray-500">QR Type</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{new Date(qrCode.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">Created</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Feedback Summary (only for FEEDBACK type) */}
|
||||
{qrCode.contentType === 'FEEDBACK' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Star className="w-5 h-5 text-amber-400" />
|
||||
Customer Feedback
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{feedbackStats && feedbackStats.total > 0 ? (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-6 mb-4">
|
||||
{/* Average */}
|
||||
<div className="text-center sm:text-left">
|
||||
<div className="text-4xl font-bold text-gray-900">{feedbackStats.avgRating}</div>
|
||||
{renderStars(Math.round(feedbackStats.avgRating))}
|
||||
<p className="text-sm text-gray-500 mt-1">{feedbackStats.total} reviews</p>
|
||||
</div>
|
||||
|
||||
{/* Distribution */}
|
||||
<div className="flex-1 space-y-1">
|
||||
{[5, 4, 3, 2, 1].map((rating) => {
|
||||
const count = feedbackStats.distribution[rating] || 0;
|
||||
const pct = feedbackStats.total > 0 ? (count / feedbackStats.total) * 100 : 0;
|
||||
return (
|
||||
<div key={rating} className="flex items-center gap-2 text-sm">
|
||||
<span className="w-8 text-gray-500">{rating}★</span>
|
||||
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-amber-400 rounded-full" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="w-8 text-gray-400 text-right">{count}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 mb-4">No feedback received yet. Share your QR code to collect reviews!</p>
|
||||
)}
|
||||
|
||||
<Link href={`/qr/${qrId}/feedback`} className="block">
|
||||
<Button variant="outline" className="w-full">
|
||||
<MessageSquare className="w-4 h-4 mr-2" />
|
||||
View All Feedback
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Content Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Content Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="bg-gray-50 p-4 rounded-lg text-sm overflow-auto">
|
||||
{JSON.stringify(qrCode.content, null, 2)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
src/app/api/feedback/route.ts
Normal file
41
src/app/api/feedback/route.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { slug, rating, comment } = body;
|
||||
|
||||
if (!slug || !rating) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Find the QR code
|
||||
const qrCode = await db.qRCode.findUnique({
|
||||
where: { slug },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!qrCode) {
|
||||
return NextResponse.json({ error: 'QR Code not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Log feedback as a scan with additional data
|
||||
// In a full implementation, you'd have a Feedback model
|
||||
// For now, we'll store it in QRScan with special markers
|
||||
await db.qRScan.create({
|
||||
data: {
|
||||
qrId: qrCode.id,
|
||||
ipHash: 'feedback',
|
||||
userAgent: `rating:${rating}|comment:${comment?.substring(0, 200) || ''}`,
|
||||
device: 'feedback',
|
||||
isUnique: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error submitting feedback:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
122
src/app/api/qrs/[id]/feedback/route.ts
Normal file
122
src/app/api/qrs/[id]/feedback/route.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
let userId: string | undefined;
|
||||
|
||||
// Try NextAuth session first
|
||||
const session = await getServerSession(authOptions);
|
||||
if (session?.user?.id) {
|
||||
userId = session.user.id;
|
||||
} else {
|
||||
// Fallback: Check raw userId cookie (like /api/user does)
|
||||
const cookieStore = await cookies();
|
||||
userId = cookieStore.get('userId')?.value;
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const limit = parseInt(searchParams.get('limit') || '20');
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// Verify QR ownership and type
|
||||
const qrCode = await db.qRCode.findUnique({
|
||||
where: { id, userId: userId },
|
||||
select: { id: true, contentType: true },
|
||||
});
|
||||
|
||||
if (!qrCode) {
|
||||
return NextResponse.json({ error: 'QR code not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if consistent with schema (Prisma enum mismatch fix)
|
||||
// @ts-ignore - Temporary ignore until client regeneration catches up fully in all envs
|
||||
if (qrCode.contentType !== 'FEEDBACK') {
|
||||
return NextResponse.json({ error: 'Not a feedback QR code' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Fetch feedback entries (stored as QRScans with ipHash='feedback')
|
||||
const [feedbackEntries, totalCount] = await Promise.all([
|
||||
db.qRScan.findMany({
|
||||
where: { qrId: id, ipHash: 'feedback' },
|
||||
orderBy: { ts: 'desc' },
|
||||
skip,
|
||||
take: limit,
|
||||
select: { id: true, userAgent: true, ts: true },
|
||||
}),
|
||||
db.qRScan.count({
|
||||
where: { qrId: id, ipHash: 'feedback' },
|
||||
}),
|
||||
]);
|
||||
|
||||
// Parse feedback data from userAgent field
|
||||
const feedbacks = feedbackEntries.map((entry) => {
|
||||
const parsed = parseFeedback(entry.userAgent || '');
|
||||
return {
|
||||
id: entry.id,
|
||||
rating: parsed.rating,
|
||||
comment: parsed.comment,
|
||||
date: entry.ts,
|
||||
};
|
||||
});
|
||||
|
||||
// Calculate stats
|
||||
const allRatings = await db.qRScan.findMany({
|
||||
where: { qrId: id, ipHash: 'feedback' },
|
||||
select: { userAgent: true },
|
||||
});
|
||||
|
||||
const ratings = allRatings.map((e) => parseFeedback(e.userAgent || '').rating).filter((r) => r > 0);
|
||||
const avgRating = ratings.length > 0 ? ratings.reduce((a, b) => a + b, 0) / ratings.length : 0;
|
||||
|
||||
// Rating distribution
|
||||
const distribution = {
|
||||
5: ratings.filter((r) => r === 5).length,
|
||||
4: ratings.filter((r) => r === 4).length,
|
||||
3: ratings.filter((r) => r === 3).length,
|
||||
2: ratings.filter((r) => r === 2).length,
|
||||
1: ratings.filter((r) => r === 1).length,
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
feedbacks,
|
||||
stats: {
|
||||
total: totalCount,
|
||||
avgRating: Math.round(avgRating * 10) / 10,
|
||||
distribution,
|
||||
},
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(totalCount / limit),
|
||||
hasMore: skip + limit < totalCount,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching feedback:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
function parseFeedback(userAgent: string): { rating: number; comment: string } {
|
||||
// Format: "rating:4|comment:Great service!"
|
||||
const ratingMatch = userAgent.match(/rating:(\d)/);
|
||||
const commentMatch = userAgent.match(/comment:(.+)/);
|
||||
|
||||
return {
|
||||
rating: ratingMatch ? parseInt(ratingMatch[1]) : 0,
|
||||
comment: commentMatch ? commentMatch[1] : '',
|
||||
};
|
||||
}
|
||||
37
src/app/api/qrs/public/[slug]/route.ts
Normal file
37
src/app/api/qrs/public/[slug]/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string }> }
|
||||
) {
|
||||
try {
|
||||
const { slug } = await params;
|
||||
|
||||
const qrCode = await db.qRCode.findUnique({
|
||||
where: { slug },
|
||||
select: {
|
||||
id: true,
|
||||
content: true,
|
||||
contentType: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!qrCode) {
|
||||
return NextResponse.json({ error: 'QR Code not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (qrCode.status === 'PAUSED') {
|
||||
return NextResponse.json({ error: 'QR Code is paused' }, { status: 403 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
contentType: qrCode.contentType,
|
||||
content: qrCode.content,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching public QR:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
82
src/app/api/upload/route.ts
Normal file
82
src/app/api/upload/route.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
import { uploadFileToR2 } from '@/lib/r2';
|
||||
import { env } from '@/lib/env';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// 1. Authentication Check
|
||||
const session = await getServerSession(authOptions);
|
||||
let userId = session?.user?.id;
|
||||
|
||||
// Fallback: Check for simple-login cookie if no NextAuth session
|
||||
if (!userId) {
|
||||
const cookieUserId = request.cookies.get('userId')?.value;
|
||||
if (cookieUserId) {
|
||||
// Verify user exists
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: cookieUserId },
|
||||
select: { id: true }
|
||||
});
|
||||
if (user) {
|
||||
userId = user.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return new NextResponse('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
// 2. Parse Form Data
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No file provided' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Validation
|
||||
// Check file size (default 10MB)
|
||||
const MAX_SIZE = parseInt(env.MAX_UPLOAD_SIZE || '10485760');
|
||||
if (file.size > MAX_SIZE) {
|
||||
return NextResponse.json(
|
||||
{ error: `File too large. Maximum size: ${MAX_SIZE / 1024 / 1024}MB` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check file type (allow images and PDFs)
|
||||
const allowedTypes = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid file type. Only PDF and Images are allowed.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Upload to R2
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const publicUrl = await uploadFileToR2(buffer, file.name, file.type);
|
||||
|
||||
// 5. Success
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
url: publicUrl,
|
||||
filename: file.name,
|
||||
type: file.type
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error during upload' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
167
src/app/coupon/[slug]/page.tsx
Normal file
167
src/app/coupon/[slug]/page.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Copy, Check, ExternalLink, Gift } from 'lucide-react';
|
||||
|
||||
interface CouponData {
|
||||
code: string;
|
||||
discount: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
expiryDate?: string;
|
||||
redeemUrl?: string;
|
||||
}
|
||||
|
||||
export default function CouponPage() {
|
||||
const params = useParams();
|
||||
const slug = params.slug as string;
|
||||
const [coupon, setCoupon] = useState<CouponData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchCoupon() {
|
||||
try {
|
||||
const res = await fetch(`/api/qrs/public/${slug}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.contentType === 'COUPON') {
|
||||
setCoupon(data.content as CouponData);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching coupon:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchCoupon();
|
||||
}, [slug]);
|
||||
|
||||
const copyCode = async () => {
|
||||
if (coupon?.code) {
|
||||
await navigator.clipboard.writeText(coupon.code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
// Loading
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-rose-50 to-pink-100">
|
||||
<div className="w-10 h-10 border-3 border-pink-200 border-t-pink-600 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Not found
|
||||
if (!coupon) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-rose-50 to-pink-100 px-6">
|
||||
<div className="text-center bg-white rounded-2xl p-8 shadow-lg">
|
||||
<p className="text-gray-500 text-lg">This coupon is not available.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isExpired = coupon.expiryDate && new Date(coupon.expiryDate) < new Date();
|
||||
|
||||
return (<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#FAF8F5] via-[#C6C0B3] to-[#4C5F4E] px-6 py-12">
|
||||
<div className="max-w-sm w-full">
|
||||
{/* Card */}
|
||||
<div className="bg-white rounded-3xl shadow-xl overflow-hidden">
|
||||
{/* Colorful Header */}
|
||||
<div className="bg-gradient-to-br from-[#DB5375] to-[#B3FFB3] text-gray-900 p-8 text-center relative overflow-hidden">
|
||||
{/* Decorative circles */}
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-white/10 rounded-full -translate-y-1/2 translate-x-1/2"></div>
|
||||
<div className="absolute bottom-0 left-0 w-24 h-24 bg-white/10 rounded-full translate-y-1/2 -translate-x-1/2"></div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="w-14 h-14 bg-[#DB5375]/10 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<Gift className="w-7 h-7 text-[#DB5375]" />
|
||||
</div>
|
||||
<p className="text-gray-700 text-sm mb-1">{coupon.title || 'Special Offer'}</p>
|
||||
<p className="text-4xl font-bold tracking-tight text-gray-900">{coupon.discount}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dotted line with circles */}
|
||||
<div className="relative py-4">
|
||||
<div className="relative py-4">
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-5 h-10 bg-gradient-to-br from-[#FAF8F5] via-[#C6C0B3] to-[#4C5F4E] rounded-r-full"></div>
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-5 h-10 bg-gradient-to-br from-[#FAF8F5] via-[#C6C0B3] to-[#4C5F4E] rounded-l-full"></div>
|
||||
<div className="border-t-2 border-dashed border-gray-200 mx-8"></div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-8 pb-8">
|
||||
{coupon.description && (
|
||||
<p className="text-gray-500 text-sm text-center mb-6">{coupon.description}</p>
|
||||
)}
|
||||
|
||||
{/* Code Box */}
|
||||
<div className="bg-gray-50 rounded-2xl p-5 mb-4 border border-emerald-100">
|
||||
<p className="text-xs text-emerald-600 text-center mb-2 font-medium uppercase tracking-wider">Your Code</p>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<code className="text-2xl font-mono font-bold text-gray-900 tracking-wider">
|
||||
{coupon.code}
|
||||
</code>
|
||||
<button
|
||||
onClick={copyCode}
|
||||
className={`p-2.5 rounded-xl transition-all ${copied
|
||||
? 'bg-emerald-100 text-emerald-600'
|
||||
: 'bg-white text-gray-500 hover:text-rose-500 shadow-sm hover:shadow'
|
||||
}`}
|
||||
aria-label="Copy code"
|
||||
>
|
||||
{copied ? <Check className="w-5 h-5" /> : <Copy className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
{copied && (
|
||||
<p className="text-emerald-600 text-xs text-center mt-2 font-medium">Copied!</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expiry */}
|
||||
{coupon.expiryDate && (
|
||||
<p className={`text-sm text-center mb-6 font-medium ${isExpired ? 'text-red-500' : 'text-gray-400'}`}>
|
||||
{isExpired
|
||||
? '⚠️ This coupon has expired'
|
||||
: `Valid until ${new Date(coupon.expiryDate).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})}`
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Redeem Button */}
|
||||
{coupon.redeemUrl && !isExpired && (
|
||||
<a
|
||||
href={coupon.redeemUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full py-4 rounded-xl font-semibold text-center bg-gradient-to-r from-[#076653] to-[#0C342C] text-white hover:from-[#087861] hover:to-[#0E4036] transition-all shadow-lg shadow-emerald-200"
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
Redeem Now
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-sm text-white/60 mt-6">
|
||||
Powered by QR Master
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
195
src/app/feedback/[slug]/page.tsx
Normal file
195
src/app/feedback/[slug]/page.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Star, Send, Check } from 'lucide-react';
|
||||
|
||||
interface FeedbackData {
|
||||
businessName: string;
|
||||
googleReviewUrl?: string;
|
||||
thankYouMessage?: string;
|
||||
}
|
||||
|
||||
export default function FeedbackPage() {
|
||||
const params = useParams();
|
||||
const slug = params.slug as string;
|
||||
const [feedback, setFeedback] = useState<FeedbackData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [rating, setRating] = useState(0);
|
||||
const [hoverRating, setHoverRating] = useState(0);
|
||||
const [comment, setComment] = useState('');
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchFeedback() {
|
||||
try {
|
||||
const res = await fetch(`/api/qrs/public/${slug}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.contentType === 'FEEDBACK') {
|
||||
setFeedback(data.content as FeedbackData);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching feedback data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchFeedback();
|
||||
}, [slug]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (rating === 0) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await fetch('/api/feedback', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug, rating, comment }),
|
||||
});
|
||||
|
||||
setSubmitted(true);
|
||||
|
||||
if (rating >= 4 && feedback?.googleReviewUrl) {
|
||||
setTimeout(() => {
|
||||
window.location.href = feedback.googleReviewUrl!;
|
||||
}, 2000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error submitting feedback:', error);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Loading
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#FAF8F5] via-[#C6C0B3] to-[#4C5F4E]">
|
||||
<div className="w-10 h-10 border-3 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Not found
|
||||
if (!feedback) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#FAF8F5] via-[#C6C0B3] to-[#4C5F4E] px-6">
|
||||
<div className="text-center bg-white rounded-2xl p-8 shadow-lg">
|
||||
<p className="text-gray-500 text-lg">This feedback form is not available.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Success
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#FAF8F5] via-[#C6C0B3] to-[#4C5F4E] px-6">
|
||||
<div className="max-w-sm w-full bg-white rounded-3xl shadow-xl p-10 text-center">
|
||||
<div className="w-20 h-20 bg-gradient-to-br from-[#4C5F4E] to-[#FAF8F5] rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg">
|
||||
<Check className="w-10 h-10 text-white" strokeWidth={2.5} />
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
Thank you!
|
||||
</h1>
|
||||
|
||||
<p className="text-gray-500">
|
||||
{feedback.thankYouMessage || 'Your feedback has been submitted.'}
|
||||
</p>
|
||||
|
||||
{rating >= 4 && feedback.googleReviewUrl && (
|
||||
<p className="text-sm text-teal-600 mt-6 font-medium">
|
||||
Redirecting to Google Reviews...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Rating Form
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#FAF8F5] via-[#C6C0B3] to-[#4C5F4E] px-6 py-12">
|
||||
<div className="max-w-md w-full">
|
||||
{/* Card */}
|
||||
<div className="bg-white rounded-3xl shadow-xl overflow-hidden">
|
||||
{/* Colored Header */}
|
||||
<div className="bg-gradient-to-r from-[#4C5F4E] via-[#C6C0B3] to-[#FAF8F5] p-8 text-center">
|
||||
<div className="w-14 h-14 bg-white/20 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<Star className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold mb-1 text-gray-900">How was your experience?</h1>
|
||||
<p className="text-gray-700">{feedback.businessName}</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-8">
|
||||
{/* Stars */}
|
||||
<div className="flex justify-center gap-2 mb-2">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
onClick={() => setRating(star)}
|
||||
onMouseEnter={() => setHoverRating(star)}
|
||||
onMouseLeave={() => setHoverRating(0)}
|
||||
className="p-1 transition-transform hover:scale-110 focus:outline-none"
|
||||
aria-label={`Rate ${star} stars`}
|
||||
>
|
||||
<Star
|
||||
className={`w-11 h-11 transition-all ${star <= (hoverRating || rating)
|
||||
? 'text-amber-400 fill-amber-400 drop-shadow-sm'
|
||||
: 'text-gray-200'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Rating text */}
|
||||
<p className="text-center text-sm font-medium h-6 mb-6" style={{ color: rating > 0 ? '#6366f1' : 'transparent' }}>
|
||||
{rating === 1 && 'Poor'}
|
||||
{rating === 2 && 'Fair'}
|
||||
{rating === 3 && 'Good'}
|
||||
{rating === 4 && 'Great!'}
|
||||
{rating === 5 && 'Excellent!'}
|
||||
</p>
|
||||
|
||||
{/* Comment */}
|
||||
<div className="mb-6">
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="Share your thoughts (optional)"
|
||||
rows={3}
|
||||
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={rating === 0 || submitting}
|
||||
className={`w-full py-4 rounded-xl font-semibold flex items-center justify-center gap-2 transition-all ${rating === 0
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-gradient-to-r from-[#4C5F4E] to-[#0C342C] text-white hover:from-[#5a705c] hover:to-[#0E4036] shadow-lg shadow-emerald-200'
|
||||
}`}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
{submitting ? 'Sending...' : 'Submit Feedback'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-sm text-white/60 mt-6">
|
||||
Powered by QR Master
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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
65
src/lib/r2.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
|
||||
Reference in New Issue
Block a user