Compare commits
11 Commits
feature/mo
...
e44dc1c6bb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e44dc1c6bb | ||
| 3682673852 | |||
|
|
1251584b13 | ||
|
|
dd93ca560a | ||
|
|
efb1654370 | ||
|
|
896c9b1a07 | ||
|
|
cca1374c9e | ||
|
|
c1471830f3 | ||
| 373e19a515 | |||
|
|
99acb37c83 | ||
|
|
05531cda3f |
100
.gitignore
vendored
@@ -1,51 +1,51 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
/node_modules
|
/node_modules
|
||||||
/.pnp
|
/.pnp
|
||||||
.pnp.js
|
.pnp.js
|
||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
/out/
|
/out/
|
||||||
|
|
||||||
# production
|
# production
|
||||||
/build
|
/build
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
|
|
||||||
# debug
|
# debug
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
# local env files
|
# local env files
|
||||||
.env*.local
|
.env*.local
|
||||||
.env
|
.env
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
# prisma
|
# prisma
|
||||||
|
/prisma/migrations/
|
||||||
|
|
||||||
# docker
|
# docker
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
*.sql
|
*.sql
|
||||||
/backups/
|
/backups/
|
||||||
|
|
||||||
# logs
|
# logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# local dev script
|
# local dev script
|
||||||
dev-server.js
|
dev-server.js
|
||||||
87
INDEXING_GUIDE.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Indexing Setup & Usage Guide
|
||||||
|
|
||||||
|
This guide explains how to fast-track your content indexing on **Google** and **Bing/Yandex** using the provided scripts.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **WAIT UNTIL LIVE:** Do not run these scripts until your new URLs are live and returning a `200 OK` status. If you submit a `404` URL, it may negatively impact your crawling budget or cause errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Google Indexing API
|
||||||
|
|
||||||
|
The Google Indexing API allows you to notify Google when pages are added or removed. It is faster than waiting for the Googlebot to crawl your sitemap.
|
||||||
|
|
||||||
|
### Prerequisites: `service_account.json`
|
||||||
|
|
||||||
|
To use the script `scripts/trigger-indexing.js`, you need a **Service Account Key** from Google Cloud.
|
||||||
|
|
||||||
|
1. **Go to Google Cloud Console:** [https://console.cloud.google.com/](https://console.cloud.google.com/)
|
||||||
|
2. **Create a Project:** (e.g., "QR Master Indexing").
|
||||||
|
3. **Enable API:** Search for "Web Search Indexing API" and enable it.
|
||||||
|
4. **Create Service Account:**
|
||||||
|
* Go to "IAM & Admin" > "Service Accounts".
|
||||||
|
* Click "Create Service Account".
|
||||||
|
* Name it (e.g., "indexer").
|
||||||
|
* Grant it the "Owner" role (simplest for this) or a custom role with Indexing permissions.
|
||||||
|
5. **Create Key:**
|
||||||
|
* Click on the newly created service account email.
|
||||||
|
* Go to "Keys" tab -> "Add Key" -> "Create new key" -> **JSON**.
|
||||||
|
* This will download a JSON file.
|
||||||
|
6. **Save Key:**
|
||||||
|
* Rename the file to `service_account.json`.
|
||||||
|
* Place it in the **root** of your project (same folder as `package.json`).
|
||||||
|
* **NOTE:** This file is ignored by git for security (`.gitignore`), so you must copy it manually if you switch laptops.
|
||||||
|
7. **Authorize in Search Console:**
|
||||||
|
* Open the JSON file and copy the `client_email` address.
|
||||||
|
* Go to **Google Search Console** property for `qrmaster.net`.
|
||||||
|
* Go to "Settings" > "Users and permissions".
|
||||||
|
* **Add User:** Paste the service account email and give it **"Owner"** permission. (This is required for the API to work).
|
||||||
|
|
||||||
|
### How to Run
|
||||||
|
|
||||||
|
1. **Run the script:**
|
||||||
|
```bash
|
||||||
|
npm run trigger:indexing
|
||||||
|
```
|
||||||
|
*(Or manually: `npx tsx scripts/trigger-indexing.ts`)*
|
||||||
|
|
||||||
|
2. The script will automatically fetch ALL active URLs from the project (including tools and blog posts) and submit them to Google. You should see a "Success" message for each URL.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. IndexNow (Bing, Yandex, etc.)
|
||||||
|
|
||||||
|
IndexNow is a protocol used by Bing and others. It's much simpler than Google's API.
|
||||||
|
|
||||||
|
### Prerequisites: API Key
|
||||||
|
|
||||||
|
1. **Get Key:** Go to [Bing Webmaster Tools](https://www.bing.com/webmasters) or generate one at [indexnow.org](https://www.indexnow.org/).
|
||||||
|
2. **Verify Setup:**
|
||||||
|
* The key is typically a long random string (e.g., `abc123...`).
|
||||||
|
* Ensure you have a text file named after the key (e.g., `abc123....txt`) containing the key itself inside your `public/` folder so it's accessible at `https://www.qrmaster.net/abc123....txt`.
|
||||||
|
* Alternatively, set the environment variable in your `.env` file:
|
||||||
|
```
|
||||||
|
INDEXNOW_KEY=your_key_here
|
||||||
|
```
|
||||||
|
|
||||||
|
### How to Run
|
||||||
|
|
||||||
|
This script (`scripts/submit-indexnow.ts`) automatically gathers all meaningful URLs from your project (tools, blog posts, main pages) and submits them.
|
||||||
|
|
||||||
|
1. Run the script:
|
||||||
|
```bash
|
||||||
|
npm run submit:indexnow
|
||||||
|
```
|
||||||
|
*(Or manually: `npx tsx scripts/submit-indexnow.ts`)*
|
||||||
|
|
||||||
|
2. It will output which URLs were submitted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary Checklist
|
||||||
|
|
||||||
|
- [ ] New page is published and live.
|
||||||
|
- [ ] `service_account.json` is in the project root.
|
||||||
|
- [ ] Service Account email is added as Owner in Google Search Console.
|
||||||
|
- [ ] Run `npm run trigger:indexing` (for Google).
|
||||||
|
- [ ] Run `npm run submit:indexnow` (for Bing/Yandex).
|
||||||
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
|
||||||
@@ -1,641 +0,0 @@
|
|||||||
Issues
|
|
||||||
/
|
|
||||||
Open Graph tags incomplete
|
|
||||||
|
|
||||||
Why and how to fix
|
|
||||||
|
|
||||||
Submit to IndexNow
|
|
||||||
|
|
||||||
Create new issue
|
|
||||||
|
|
||||||
All URLs
|
|
||||||
|
|
||||||
Pages
|
|
||||||
|
|
||||||
Resources
|
|
||||||
|
|
||||||
Content
|
|
||||||
|
|
||||||
Links
|
|
||||||
|
|
||||||
Redirects
|
|
||||||
|
|
||||||
Indexability
|
|
||||||
|
|
||||||
Sitemaps
|
|
||||||
|
|
||||||
Ahrefs metrics
|
|
||||||
Word or phrase
|
|
||||||
|
|
||||||
URL
|
|
||||||
|
|
||||||
Advanced filter
|
|
||||||
Crawl history
|
|
||||||
Hide chart
|
|
||||||
12 Jan
|
|
||||||
13 Jan
|
|
||||||
13 Jan
|
|
||||||
14 Jan
|
|
||||||
14 Jan
|
|
||||||
15 Jan
|
|
||||||
0
|
|
||||||
2
|
|
||||||
4
|
|
||||||
6
|
|
||||||
8
|
|
||||||
All filter results
|
|
||||||
|
|
||||||
All filter results
|
|
||||||
8
|
|
||||||
|
|
||||||
Lost from filter results
|
|
||||||
0
|
|
||||||
|
|
||||||
Lost
|
|
||||||
0
|
|
||||||
|
|
||||||
Patches
|
|
||||||
|
|
||||||
Changes: Don't show
|
|
||||||
|
|
||||||
Columns
|
|
||||||
|
|
||||||
Export
|
|
||||||
PR
|
|
||||||
URL
|
|
||||||
Organic traffic
|
|
||||||
Is valid Open graph
|
|
||||||
Open graph attributes
|
|
||||||
Open graph values
|
|
||||||
Depth
|
|
||||||
Is indexable page
|
|
||||||
No. of all inlinks
|
|
||||||
24
|
|
||||||
html
|
|
||||||
Free vCard QR Generator: Digital Cards | QR Master
|
|
||||||
https://www.qrmaster.net/blog/vcard-qr-code-generator
|
|
||||||
0
|
|
||||||
No
|
|
||||||
og:type
|
|
||||||
og:image:alt
|
|
||||||
og:image
|
|
||||||
og:description
|
|
||||||
og:title
|
|
||||||
article
|
|
||||||
Professional business card with vCard QR code being scanned by smartphone
|
|
||||||
https://www.qrmaster.net/blog/vcard-qr-code.png
|
|
||||||
Create professional vCard QR codes for digital business cards. Share contact info instantly with a scan—includes templates and best practices.
|
|
||||||
Free vCard QR Generator: Digital Cards
|
|
||||||
0
|
|
||||||
Yes
|
|
||||||
8
|
|
||||||
24
|
|
||||||
html
|
|
||||||
Restaurant Menu QR Codes: 2025 Guide | QR Master
|
|
||||||
https://www.qrmaster.net/blog/qr-code-restaurant-menu
|
|
||||||
0
|
|
||||||
No
|
|
||||||
og:type
|
|
||||||
og:image:alt
|
|
||||||
og:image
|
|
||||||
og:description
|
|
||||||
og:title
|
|
||||||
article
|
|
||||||
Restaurant table with QR code menu card and smartphone scanning
|
|
||||||
https://www.qrmaster.net/blog/restaurant-qr-menu.png
|
|
||||||
Step-by-step guide to creating digital menu QR codes for your restaurant. Learn best practices for touchless menus, placement tips, and tracking.
|
|
||||||
Restaurant Menu QR Codes: 2025 Guide
|
|
||||||
0
|
|
||||||
Yes
|
|
||||||
8
|
|
||||||
24
|
|
||||||
html
|
|
||||||
QR Code Analytics: The Complete Guide | QR Master
|
|
||||||
https://www.qrmaster.net/blog/qr-code-analytics
|
|
||||||
0
|
|
||||||
No
|
|
||||||
og:type
|
|
||||||
og:image:alt
|
|
||||||
og:image
|
|
||||||
og:description
|
|
||||||
og:title
|
|
||||||
article
|
|
||||||
QR Code Analytics dashboard displaying scan metrics and user data
|
|
||||||
https://www.qrmaster.net/blog/qr-code-analytics-hero.webp
|
|
||||||
Master QR Code Analytics with our complete guide. Learn how to track scans, measure ROI, and optimize your marketing campaigns using real-time data.
|
|
||||||
QR Code Analytics: The Complete Guide
|
|
||||||
0
|
|
||||||
Yes
|
|
||||||
8
|
|
||||||
24
|
|
||||||
html
|
|
||||||
Dynamic vs Static QR Codes: The Ultimate Comparison | QR Master
|
|
||||||
https://www.qrmaster.net/blog/dynamic-vs-static-qr-codes
|
|
||||||
0
|
|
||||||
No
|
|
||||||
og:type
|
|
||||||
og:image:alt
|
|
||||||
og:image
|
|
||||||
og:description
|
|
||||||
og:title
|
|
||||||
article
|
|
||||||
Comparison graphic showing features of static versus dynamic QR codes
|
|
||||||
https://www.qrmaster.net/blog/static-vs-dynamic-qr-codes-hero.png
|
|
||||||
Static vs Dynamic QR Codes: Which should you choose? Learn the key differences, pros and cons, and why dynamic codes are better for business.
|
|
||||||
Dynamic vs Static QR Codes: The Ultimate Comparison
|
|
||||||
0
|
|
||||||
Yes
|
|
||||||
8
|
|
||||||
24
|
|
||||||
html
|
|
||||||
How to Generate Bulk QR Codes from Excel | QR Master
|
|
||||||
https://www.qrmaster.net/blog/bulk-qr-code-generator-excel
|
|
||||||
0
|
|
||||||
No
|
|
||||||
og:type
|
|
||||||
og:image:alt
|
|
||||||
og:image
|
|
||||||
og:description
|
|
||||||
og:title
|
|
||||||
article
|
|
||||||
Excel spreadsheet being converted into multiple QR codes
|
|
||||||
https://www.qrmaster.net/blog/building-qr-generator.png
|
|
||||||
Generate hundreds of QR codes from Excel or CSV files in minutes. Step-by-step guide with templates, best practices, and free tools.
|
|
||||||
How to Generate Bulk QR Codes from Excel
|
|
||||||
0
|
|
||||||
Yes
|
|
||||||
8
|
|
||||||
24
|
|
||||||
html
|
|
||||||
QR Code Print Size Guide: Minimum Sizes for Every Use Case | QR Master
|
|
||||||
https://www.qrmaster.net/blog/qr-code-print-size-guide
|
|
||||||
0
|
|
||||||
No
|
|
||||||
og:type
|
|
||||||
og:image:alt
|
|
||||||
og:image
|
|
||||||
og:description
|
|
||||||
og:title
|
|
||||||
article
|
|
||||||
Various print materials showing different QR code sizes
|
|
||||||
https://www.qrmaster.net/blog/qr-print-sizes.png
|
|
||||||
Complete guide to QR code print sizes. Learn minimum dimensions for business cards, posters, banners, and more to ensure reliable scanning.
|
|
||||||
QR Code Print Size Guide: Minimum Sizes for Every Use Case
|
|
||||||
0
|
|
||||||
Yes
|
|
||||||
8
|
|
||||||
24
|
|
||||||
html
|
|
||||||
Best QR Code Generator for Small Business 2025 | QR Master
|
|
||||||
https://www.qrmaster.net/blog/qr-code-small-business
|
|
||||||
0
|
|
||||||
No
|
|
||||||
og:type
|
|
||||||
og:image:alt
|
|
||||||
og:image
|
|
||||||
og:description
|
|
||||||
og:title
|
|
||||||
article
|
|
||||||
Small business owner using QR codes for customer engagement
|
|
||||||
https://www.qrmaster.net/blog/small-business-qr.png
|
|
||||||
Find the best QR code solution for your small business. Compare features, pricing, and use cases for marketing, payments, and operations.
|
|
||||||
Best QR Code Generator for Small Business 2025
|
|
||||||
0
|
|
||||||
Yes
|
|
||||||
8
|
|
||||||
24
|
|
||||||
html
|
|
||||||
QR Code Tracking: Complete Guide 2025 | QR Master
|
|
||||||
https://www.qrmaster.net/blog/qr-code-tracking-guide-2025
|
|
||||||
0
|
|
||||||
No
|
|
||||||
og:type
|
|
||||||
og:image:alt
|
|
||||||
og:image
|
|
||||||
og:description
|
|
||||||
og:title
|
|
||||||
article
|
|
||||||
QR Code Tracking and analytics dashboard visualization
|
|
||||||
https://www.qrmaster.net/blog/qr-code-tracking-guide-hero.webp
|
|
||||||
The complete guide to QR Code Tracking in 2025. Learn how to track scans, measure ROI, and optimize your marketing campaigns.
|
|
||||||
QR Code Tracking: Complete Guide 2025
|
|
||||||
0
|
|
||||||
Yes
|
|
||||||
8
|
|
||||||
Showing 8 of 8
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Issues
|
|
||||||
/
|
|
||||||
Pages to submit to IndexNow
|
|
||||||
|
|
||||||
Why and how to fix
|
|
||||||
|
|
||||||
Submit to IndexNow
|
|
||||||
|
|
||||||
Create new issue
|
|
||||||
|
|
||||||
All URLs
|
|
||||||
|
|
||||||
Pages
|
|
||||||
|
|
||||||
Resources
|
|
||||||
|
|
||||||
Content
|
|
||||||
|
|
||||||
Links
|
|
||||||
|
|
||||||
Redirects
|
|
||||||
|
|
||||||
Indexability
|
|
||||||
|
|
||||||
Sitemaps
|
|
||||||
|
|
||||||
Ahrefs metrics
|
|
||||||
Word or phrase
|
|
||||||
|
|
||||||
URL
|
|
||||||
|
|
||||||
Advanced filter
|
|
||||||
Crawl history
|
|
||||||
Hide chart
|
|
||||||
12 Jan
|
|
||||||
13 Jan
|
|
||||||
13 Jan
|
|
||||||
14 Jan
|
|
||||||
14 Jan
|
|
||||||
15 Jan
|
|
||||||
0
|
|
||||||
9
|
|
||||||
18
|
|
||||||
27
|
|
||||||
36
|
|
||||||
All filter results
|
|
||||||
|
|
||||||
All filter results
|
|
||||||
12
|
|
||||||
|
|
||||||
Lost from filter results
|
|
||||||
|
|
||||||
Lost
|
|
||||||
|
|
||||||
Patches: Show all
|
|
||||||
|
|
||||||
Changes: Absolute
|
|
||||||
|
|
||||||
Columns
|
|
||||||
|
|
||||||
Export
|
|
||||||
PR
|
|
||||||
URL
|
|
||||||
Organic traffic
|
|
||||||
Changes
|
|
||||||
HTTP status code
|
|
||||||
Content type
|
|
||||||
Is indexable page
|
|
||||||
Title
|
|
||||||
Patch it
|
|
||||||
|
|
||||||
Batch AI
|
|
||||||
Meta description
|
|
||||||
Patch it
|
|
||||||
|
|
||||||
Batch AI
|
|
||||||
H1
|
|
||||||
H2
|
|
||||||
No. of content words
|
|
||||||
Changes
|
|
||||||
No. of internal outlinks
|
|
||||||
Changes
|
|
||||||
No. of external outlinks
|
|
||||||
Changes
|
|
||||||
Page text
|
|
||||||
First found at
|
|
||||||
40
|
|
||||||
html
|
|
||||||
QR Master: Dynamic QR Generator
|
|
||||||
https://www.qrmaster.net/
|
|
||||||
0
|
|
||||||
200
|
|
||||||
text/html; charset=utf-8
|
|
||||||
Yes
|
|
||||||
QR Master: Dynamic QR Generator
|
|
||||||
Enter new title
|
|
||||||
Create professional QR codes with QR Master. Dynamic QR with tracking, bulk generation, custom branding, and real-time analytics for all your campaigns.
|
|
||||||
Enter new meta description
|
|
||||||
QR Master: Dynamic QR Code Generator with Analytics
|
|
||||||
Create QR Codes That Work Everywhere
|
|
||||||
Create QR Codes That Work Everywhere
|
|
||||||
Instant QR Code Generator
|
|
||||||
The Future of QR Codes is AI-Powered
|
|
||||||
More Free QR Code Tools
|
|
||||||
Why Dynamic QR Codes Save You Money
|
|
||||||
All 8
|
|
||||||
777
|
|
||||||
29
|
|
||||||
0
|
|
||||||
View text
|
|
||||||
5 KB
|
|
||||||
38
|
|
||||||
html
|
|
||||||
QR Insights: Latest QR Strategies | QR Master
|
|
||||||
https://www.qrmaster.net/blog
|
|
||||||
0
|
|
||||||
200
|
|
||||||
text/html; charset=utf-8
|
|
||||||
Yes
|
|
||||||
QR Insights: Latest QR Strategies | QR Master
|
|
||||||
Enter new title
|
|
||||||
Expert guides on QR code analytics, dynamic vs static codes, bulk generation, and smart marketing use cases. Learn how to maximize your QR campaign ROI.
|
|
||||||
Enter new meta description
|
|
||||||
QR Code Insights
|
|
||||||
481
|
|
||||||
495
|
|
||||||
−14
|
|
||||||
37
|
|
||||||
0
|
|
||||||
View changes
|
|
||||||
3 KB
|
|
||||||
3 KB
|
|
||||||
38
|
|
||||||
html
|
|
||||||
Pricing Plans | QR Master
|
|
||||||
https://www.qrmaster.net/pricing
|
|
||||||
0
|
|
||||||
200
|
|
||||||
text/html; charset=utf-8
|
|
||||||
Yes
|
|
||||||
Pricing Plans | QR Master
|
|
||||||
Enter new title
|
|
||||||
Choose the perfect QR code plan for your needs. Free, Pro, and Business plans with dynamic QR codes, analytics, bulk generation, and custom branding.
|
|
||||||
Enter new meta description
|
|
||||||
QR Master Pricing – Choose Your QR Code Plan
|
|
||||||
Choose Your Plan
|
|
||||||
Compare our plans
|
|
||||||
Choose Your Plan
|
|
||||||
271
|
|
||||||
29
|
|
||||||
30
|
|
||||||
−1
|
|
||||||
0
|
|
||||||
View text
|
|
||||||
2 KB
|
|
||||||
38
|
|
||||||
html
|
|
||||||
QR Code Erstellen – Kostenlos | QR Master
|
|
||||||
https://www.qrmaster.net/qr-code-erstellen
|
|
||||||
0
|
|
||||||
200
|
|
||||||
text/html; charset=utf-8
|
|
||||||
Yes
|
|
||||||
QR Code Erstellen – Kostenlos | QR Master
|
|
||||||
Enter new title
|
|
||||||
Erstellen Sie QR Codes kostenlos in Sekunden. Dynamische QR-Codes mit Tracking, Branding und Massen-Erstellung. Für immer kostenlos.
|
|
||||||
Enter new meta description
|
|
||||||
QR Code Erstellen – Kostenloser QR Code Generator mit Tracking
|
|
||||||
Erstellen Sie QR-Codes, die überall funktionieren
|
|
||||||
Erstellen Sie QR-Codes, die überall funktionieren
|
|
||||||
Sofortiger QR-Code-Generator
|
|
||||||
Warum dynamische QR-Codes Geld sparen
|
|
||||||
Alles was Sie brauchen, um professionelle QR-Codes zu erstellen
|
|
||||||
Wählen Sie Ihren Plan
|
|
||||||
All 6
|
|
||||||
554
|
|
||||||
29
|
|
||||||
0
|
|
||||||
View text
|
|
||||||
4 KB
|
|
||||||
24
|
|
||||||
html
|
|
||||||
Free vCard QR Generator: Digital Cards | QR Master
|
|
||||||
https://www.qrmaster.net/blog/vcard-qr-code-generator
|
|
||||||
0
|
|
||||||
200
|
|
||||||
text/html; charset=utf-8
|
|
||||||
Yes
|
|
||||||
Free vCard QR Generator: Digital Cards | QR Master
|
|
||||||
Enter new title
|
|
||||||
Create professional vCard QR codes for digital business cards. Share contact info instantly with a scan—includes templates and best practices.
|
|
||||||
Enter new meta description
|
|
||||||
Free vCard QR Generator: Digital Cards
|
|
||||||
Quick Answer
|
|
||||||
What is a vCard QR Code?
|
|
||||||
Why Use a Digital Business Card QR Code?
|
|
||||||
Information You Can Include in a vCard
|
|
||||||
Static vs Dynamic vCard QR Codes
|
|
||||||
All 13
|
|
||||||
1,135
|
|
||||||
1,149
|
|
||||||
−14
|
|
||||||
37
|
|
||||||
0
|
|
||||||
View changes
|
|
||||||
7 KB
|
|
||||||
7 KB
|
|
||||||
24
|
|
||||||
html
|
|
||||||
Restaurant Menu QR Codes: 2025 Guide | QR Master
|
|
||||||
https://www.qrmaster.net/blog/qr-code-restaurant-menu
|
|
||||||
0
|
|
||||||
200
|
|
||||||
text/html; charset=utf-8
|
|
||||||
Yes
|
|
||||||
Restaurant Menu QR Codes: 2025 Guide | QR Master
|
|
||||||
Enter new title
|
|
||||||
Step-by-step guide to creating digital menu QR codes for your restaurant. Learn best practices for touchless menus, placement tips, and tracking.
|
|
||||||
Enter new meta description
|
|
||||||
Restaurant Menu QR Codes: 2025 Guide
|
|
||||||
Quick Answer
|
|
||||||
Why Restaurants Need QR Code Menus in 2025
|
|
||||||
Step 1: Prepare Your Digital Menu
|
|
||||||
Step 2: Create Your QR Code with QR Master
|
|
||||||
Step 3: Customize Your Restaurant QR Code
|
|
||||||
All 13
|
|
||||||
1,242
|
|
||||||
1,256
|
|
||||||
−14
|
|
||||||
38
|
|
||||||
0
|
|
||||||
View changes
|
|
||||||
8 KB
|
|
||||||
8 KB
|
|
||||||
24
|
|
||||||
html
|
|
||||||
QR Code Analytics: The Complete Guide | QR Master
|
|
||||||
https://www.qrmaster.net/blog/qr-code-analytics
|
|
||||||
0
|
|
||||||
200
|
|
||||||
text/html; charset=utf-8
|
|
||||||
Yes
|
|
||||||
QR Code Analytics: The Complete Guide | QR Master
|
|
||||||
Enter new title
|
|
||||||
Master QR Code Analytics with our complete guide. Learn how to track scans, measure ROI, and optimize your marketing campaigns using real-time data.
|
|
||||||
Master QR Code Analytics with our complete guide. Learn how to track scans, measure ROI, and optimize your marketing campaigns using real-time data and insights.
|
|
||||||
Enter new meta description
|
|
||||||
QR Code Analytics: The Complete Guide
|
|
||||||
Quick Answer
|
|
||||||
What Are Scan Analytics?
|
|
||||||
How to Set Up QR Code Analytics
|
|
||||||
Key Metrics in QR Code Analytics
|
|
||||||
Advanced Campaign Tracking Strategies
|
|
||||||
All 12
|
|
||||||
1,526
|
|
||||||
1,538
|
|
||||||
−12
|
|
||||||
37
|
|
||||||
0
|
|
||||||
View changes
|
|
||||||
10 KB
|
|
||||||
10 KB
|
|
||||||
24
|
|
||||||
html
|
|
||||||
Dynamic vs Static QR Codes: The Ultimate Comparison | QR Master
|
|
||||||
https://www.qrmaster.net/blog/dynamic-vs-static-qr-codes
|
|
||||||
0
|
|
||||||
200
|
|
||||||
text/html; charset=utf-8
|
|
||||||
Yes
|
|
||||||
Dynamic vs Static QR Codes: The Ultimate Comparison | QR Master
|
|
||||||
Enter new title
|
|
||||||
Static vs Dynamic QR Codes: Which should you choose? Learn the key differences, pros and cons, and why dynamic codes are better for business.
|
|
||||||
Static vs Dynamic QR Codes: Which one should you choose? Learn the key differences, pros and cons, and why dynamic QR codes are the better choice for business and marketing.
|
|
||||||
Enter new meta description
|
|
||||||
Dynamic vs Static QR Codes: The Ultimate Comparison
|
|
||||||
Quick Answer
|
|
||||||
What is a Static QR Code?
|
|
||||||
What is a Dynamic QR Code?
|
|
||||||
Direct Comparison: Static vs Dynamic
|
|
||||||
Why Dynamic QR Codes Are Better for Business
|
|
||||||
All 10
|
|
||||||
1,074
|
|
||||||
1,082
|
|
||||||
−8
|
|
||||||
37
|
|
||||||
0
|
|
||||||
View changes
|
|
||||||
7 KB
|
|
||||||
7 KB
|
|
||||||
24
|
|
||||||
html
|
|
||||||
How to Generate Bulk QR Codes from Excel | QR Master
|
|
||||||
https://www.qrmaster.net/blog/bulk-qr-code-generator-excel
|
|
||||||
0
|
|
||||||
200
|
|
||||||
text/html; charset=utf-8
|
|
||||||
Yes
|
|
||||||
How to Generate Bulk QR Codes from Excel | QR Master
|
|
||||||
Enter new title
|
|
||||||
Generate hundreds of QR codes from Excel or CSV files in minutes. Step-by-step guide with templates, best practices, and free tools.
|
|
||||||
Enter new meta description
|
|
||||||
How to Generate Bulk QR Codes from Excel
|
|
||||||
Quick Answer
|
|
||||||
How Bulk QR Code Generation Works
|
|
||||||
Step-by-Step Guide: Excel to QR Codes
|
|
||||||
Use Cases for Bulk QR Codes
|
|
||||||
Free vs Paid Bulk QR Tools
|
|
||||||
All 12
|
|
||||||
1,882
|
|
||||||
1,896
|
|
||||||
−14
|
|
||||||
37
|
|
||||||
1
|
|
||||||
View changes
|
|
||||||
12 KB
|
|
||||||
13 KB
|
|
||||||
24
|
|
||||||
html
|
|
||||||
QR Code Print Size Guide: Minimum Sizes for Every Use Case | QR Master
|
|
||||||
https://www.qrmaster.net/blog/qr-code-print-size-guide
|
|
||||||
0
|
|
||||||
200
|
|
||||||
text/html; charset=utf-8
|
|
||||||
Yes
|
|
||||||
QR Code Print Size Guide: Minimum Sizes for Every Use Case | QR Master
|
|
||||||
Enter new title
|
|
||||||
Complete guide to QR code print sizes. Learn minimum dimensions for business cards, posters, banners, and more to ensure reliable scanning.
|
|
||||||
Enter new meta description
|
|
||||||
QR Code Print Size Guide: Minimum Sizes for Every Use Case
|
|
||||||
Quick Answer
|
|
||||||
Why QR Code Size Matters
|
|
||||||
The Scanning Distance Formula
|
|
||||||
QR Code Sizes by Application
|
|
||||||
Factors Affecting Scanability
|
|
||||||
All 12
|
|
||||||
948
|
|
||||||
962
|
|
||||||
−14
|
|
||||||
37
|
|
||||||
0
|
|
||||||
View changes
|
|
||||||
6 KB
|
|
||||||
6 KB
|
|
||||||
24
|
|
||||||
html
|
|
||||||
Best QR Code Generator for Small Business 2025 | QR Master
|
|
||||||
https://www.qrmaster.net/blog/qr-code-small-business
|
|
||||||
0
|
|
||||||
200
|
|
||||||
text/html; charset=utf-8
|
|
||||||
Yes
|
|
||||||
Best QR Code Generator for Small Business 2025 | QR Master
|
|
||||||
Enter new title
|
|
||||||
Find the best QR code solution for your small business. Compare features, pricing, and use cases for marketing, payments, and operations.
|
|
||||||
Enter new meta description
|
|
||||||
Best QR Code Generator for Small Business 2025
|
|
||||||
Quick Answer
|
|
||||||
Why Small Businesses Need QR Codes
|
|
||||||
Top 10 QR Code Use Cases for Small Business
|
|
||||||
What to Look for in a Small Business QR Solution
|
|
||||||
QR Master for Small Business
|
|
||||||
All 11
|
|
||||||
1,034
|
|
||||||
1,048
|
|
||||||
−14
|
|
||||||
37
|
|
||||||
0
|
|
||||||
View changes
|
|
||||||
7 KB
|
|
||||||
7 KB
|
|
||||||
24
|
|
||||||
html
|
|
||||||
QR Code Tracking: Complete Guide 2025 | QR Master
|
|
||||||
https://www.qrmaster.net/blog/qr-code-tracking-guide-2025
|
|
||||||
0
|
|
||||||
200
|
|
||||||
text/html; charset=utf-8
|
|
||||||
Yes
|
|
||||||
QR Code Tracking: Complete Guide 2025 | QR Master
|
|
||||||
Enter new title
|
|
||||||
The complete guide to QR Code Tracking in 2025. Learn how to track scans, measure ROI, and optimize your marketing campaigns.
|
|
||||||
The complete guide to QR Code Tracking in 2025. Learn how to track scans, measure ROI with analytics tools, and optimize your marketing campaigns for maximum engagement.
|
|
||||||
Enter new meta description
|
|
||||||
QR Code Tracking: Complete Guide 2025
|
|
||||||
Quick Answer
|
|
||||||
What is QR Code Tracking?
|
|
||||||
Why Track QR Codes? Key Benefits
|
|
||||||
How to Track QR Code Scans: 4 Methods
|
|
||||||
QR Code Tracking Tools Comparison
|
|
||||||
All 15
|
|
||||||
2,959
|
|
||||||
2,967
|
|
||||||
−8
|
|
||||||
38
|
|
||||||
1
|
|
||||||
View changes
|
|
||||||
19 KB
|
|
||||||
19 KB
|
|
||||||
Showing 12 of 12
|
|
||||||
42
next-sitemap.config.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/** @type {import('next-sitemap').IConfig} */
|
||||||
|
module.exports = {
|
||||||
|
siteUrl: 'https://www.qrmaster.net',
|
||||||
|
generateRobotsTxt: true,
|
||||||
|
robotsTxtOptions: {
|
||||||
|
policies: [
|
||||||
|
{
|
||||||
|
userAgent: '*',
|
||||||
|
allow: '/',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
transform: async (config, path) => {
|
||||||
|
// Custom priority and changefreq based on path
|
||||||
|
let priority = 0.7;
|
||||||
|
let changefreq = 'weekly';
|
||||||
|
|
||||||
|
if (path === '/') {
|
||||||
|
priority = 0.9;
|
||||||
|
changefreq = 'daily';
|
||||||
|
} else if (path === '/blog') {
|
||||||
|
priority = 0.7;
|
||||||
|
changefreq = 'daily';
|
||||||
|
} else if (path === '/pricing') {
|
||||||
|
priority = 0.8;
|
||||||
|
changefreq = 'weekly';
|
||||||
|
} else if (path === '/faq') {
|
||||||
|
priority = 0.6;
|
||||||
|
changefreq = 'weekly';
|
||||||
|
} else if (path.startsWith('/blog/')) {
|
||||||
|
priority = 0.6;
|
||||||
|
changefreq = 'weekly';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
loc: path,
|
||||||
|
changefreq,
|
||||||
|
priority,
|
||||||
|
lastmod: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,35 +1,25 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
skipTrailingSlashRedirect: true,
|
skipTrailingSlashRedirect: true,
|
||||||
images: {
|
images: {
|
||||||
unoptimized: false,
|
unoptimized: false,
|
||||||
domains: ['www.qrmaster.net', 'qrmaster.net', 'images.qrmaster.net'],
|
domains: ['www.qrmaster.net', 'qrmaster.net', 'images.qrmaster.net'],
|
||||||
formats: ['image/webp', 'image/avif'],
|
formats: ['image/webp', 'image/avif'],
|
||||||
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
||||||
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
|
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
|
||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
serverComponentsExternalPackages: ['@prisma/client', 'bcryptjs'],
|
serverComponentsExternalPackages: ['@prisma/client', 'bcryptjs'],
|
||||||
},
|
},
|
||||||
// Allow build to succeed even with prerender errors
|
// Allow build to succeed even with prerender errors
|
||||||
// Pages with useSearchParams() will be rendered dynamically at runtime
|
// Pages with useSearchParams() will be rendered dynamically at runtime
|
||||||
staticPageGenerationTimeout: 120,
|
staticPageGenerationTimeout: 120,
|
||||||
onDemandEntries: {
|
onDemandEntries: {
|
||||||
maxInactiveAge: 25 * 1000,
|
maxInactiveAge: 25 * 1000,
|
||||||
pagesBufferLength: 2,
|
pagesBufferLength: 2,
|
||||||
},
|
},
|
||||||
poweredByHeader: false,
|
poweredByHeader: false,
|
||||||
async redirects() {
|
};
|
||||||
return [
|
|
||||||
{
|
export default nextConfig;
|
||||||
source: '/blog/bulk-qr-codes-excel',
|
|
||||||
destination: '/blog/bulk-qr-code-generator-excel',
|
|
||||||
permanent: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
];
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default nextConfig;
|
|
||||||
|
|||||||
2446
package-lock.json
generated
@@ -6,6 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3050",
|
"dev": "next dev -p 3050",
|
||||||
"build": "prisma generate && next build",
|
"build": "prisma generate && next build",
|
||||||
|
"trigger:indexing": "tsx scripts/trigger-indexing.ts",
|
||||||
"submit:indexnow": "tsx scripts/submit-indexnow.ts",
|
"submit:indexnow": "tsx scripts/submit-indexnow.ts",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
@@ -28,6 +29,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^2.11.1",
|
"@auth/prisma-adapter": "^2.11.1",
|
||||||
|
"@aws-sdk/client-s3": "^3.972.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.972.0",
|
||||||
"@edge-runtime/cookies": "^6.0.0",
|
"@edge-runtime/cookies": "^6.0.0",
|
||||||
"@prisma/client": "^5.7.0",
|
"@prisma/client": "^5.7.0",
|
||||||
"@stripe/stripe-js": "^8.0.0",
|
"@stripe/stripe-js": "^8.0.0",
|
||||||
@@ -36,12 +39,14 @@
|
|||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"chart.js": "^4.4.0",
|
"chart.js": "^4.4.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
|
"copy-image-clipboard": "^2.1.2",
|
||||||
"d3-scale": "^4.0.2",
|
"d3-scale": "^4.0.2",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"framer-motion": "^12.24.10",
|
"framer-motion": "^12.24.10",
|
||||||
|
"googleapis": "^170.1.0",
|
||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
"i18next": "^23.7.6",
|
"i18next": "^23.7.6",
|
||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
@@ -51,11 +56,12 @@
|
|||||||
"next": "^14.2.35",
|
"next": "^14.2.35",
|
||||||
"next-auth": "^4.24.5",
|
"next-auth": "^4.24.5",
|
||||||
"papaparse": "^5.4.1",
|
"papaparse": "^5.4.1",
|
||||||
"posthog-js": "^1.276.0",
|
"posthog-js": "^1.332.0",
|
||||||
"qr-code-styling": "^1.9.2",
|
"qr-code-styling": "^1.9.2",
|
||||||
"qrcode": "^1.5.3",
|
"qrcode": "^1.5.3",
|
||||||
"qrcode.react": "^3.1.0",
|
"qrcode.react": "^3.1.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
"react-barcode": "^1.6.1",
|
||||||
"react-chartjs-2": "^5.2.0",
|
"react-chartjs-2": "^5.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-dropzone": "^14.2.3",
|
"react-dropzone": "^14.2.3",
|
||||||
|
|||||||
@@ -1,178 +1,168 @@
|
|||||||
// This is your Prisma schema file,
|
// This is your Prisma schema file,
|
||||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
binaryTargets = ["native", "debian-openssl-3.0.x"]
|
binaryTargets = ["native", "debian-openssl-3.0.x"]
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
name String?
|
name String?
|
||||||
password String?
|
password String?
|
||||||
image String?
|
image String?
|
||||||
emailVerified DateTime?
|
emailVerified DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// Stripe subscription fields
|
// Stripe subscription fields
|
||||||
stripeCustomerId String? @unique
|
stripeCustomerId String? @unique
|
||||||
stripeSubscriptionId String? @unique
|
stripeSubscriptionId String? @unique
|
||||||
stripePriceId String?
|
stripePriceId String?
|
||||||
stripeCurrentPeriodEnd DateTime?
|
stripeCurrentPeriodEnd DateTime?
|
||||||
plan Plan @default(FREE)
|
plan Plan @default(FREE)
|
||||||
|
|
||||||
// Password reset fields
|
// Password reset fields
|
||||||
resetPasswordToken String? @unique
|
resetPasswordToken String? @unique
|
||||||
resetPasswordExpires DateTime?
|
resetPasswordExpires DateTime?
|
||||||
|
|
||||||
qrCodes QRCode[]
|
qrCodes QRCode[]
|
||||||
integrations Integration[]
|
integrations Integration[]
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Plan {
|
enum Plan {
|
||||||
FREE
|
FREE
|
||||||
PRO
|
PRO
|
||||||
BUSINESS
|
BUSINESS
|
||||||
}
|
}
|
||||||
|
|
||||||
model Account {
|
model Account {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
type String
|
type String
|
||||||
provider String
|
provider String
|
||||||
providerAccountId String
|
providerAccountId String
|
||||||
refresh_token String? @db.Text
|
refresh_token String? @db.Text
|
||||||
access_token String? @db.Text
|
access_token String? @db.Text
|
||||||
expires_at Int?
|
expires_at Int?
|
||||||
token_type String?
|
token_type String?
|
||||||
scope String?
|
scope String?
|
||||||
id_token String? @db.Text
|
id_token String? @db.Text
|
||||||
session_state String?
|
session_state String?
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([provider, providerAccountId])
|
@@unique([provider, providerAccountId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Session {
|
model Session {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
sessionToken String @unique
|
sessionToken String @unique
|
||||||
userId String
|
userId String
|
||||||
expires DateTime
|
expires DateTime
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
model VerificationToken {
|
model VerificationToken {
|
||||||
identifier String
|
identifier String
|
||||||
token String @unique
|
token String @unique
|
||||||
expires DateTime
|
expires DateTime
|
||||||
|
|
||||||
@@unique([identifier, token])
|
@@unique([identifier, token])
|
||||||
}
|
}
|
||||||
|
|
||||||
model QRCode {
|
model QRCode {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
title String
|
title String
|
||||||
type QRType @default(DYNAMIC)
|
type QRType @default(DYNAMIC)
|
||||||
contentType ContentType @default(URL)
|
contentType ContentType @default(URL)
|
||||||
content Json
|
content Json
|
||||||
tags String[]
|
tags String[]
|
||||||
status QRStatus @default(ACTIVE)
|
status QRStatus @default(ACTIVE)
|
||||||
style Json
|
style Json
|
||||||
slug String @unique
|
slug String @unique
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
scans QRScan[]
|
scans QRScan[]
|
||||||
|
|
||||||
@@index([userId, createdAt])
|
@@index([userId, createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
enum QRType {
|
enum QRType {
|
||||||
STATIC
|
STATIC
|
||||||
DYNAMIC
|
DYNAMIC
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ContentType {
|
enum ContentType {
|
||||||
URL
|
URL
|
||||||
VCARD
|
VCARD
|
||||||
GEO
|
GEO
|
||||||
PHONE
|
PHONE
|
||||||
SMS
|
SMS
|
||||||
TEXT
|
TEXT
|
||||||
WHATSAPP
|
WHATSAPP
|
||||||
}
|
PDF
|
||||||
|
APP
|
||||||
enum QRStatus {
|
COUPON
|
||||||
ACTIVE
|
FEEDBACK
|
||||||
PAUSED
|
}
|
||||||
}
|
|
||||||
|
enum QRStatus {
|
||||||
model QRScan {
|
ACTIVE
|
||||||
id String @id @default(cuid())
|
PAUSED
|
||||||
qrId String
|
}
|
||||||
ts DateTime @default(now())
|
|
||||||
ipHash String
|
model QRScan {
|
||||||
userAgent String?
|
id String @id @default(cuid())
|
||||||
device String?
|
qrId String
|
||||||
os String?
|
ts DateTime @default(now())
|
||||||
country String?
|
ipHash String
|
||||||
referrer String?
|
userAgent String?
|
||||||
utmSource String?
|
device String?
|
||||||
utmMedium String?
|
os String?
|
||||||
utmCampaign String?
|
country String?
|
||||||
isUnique Boolean @default(false)
|
referrer String?
|
||||||
|
utmSource String?
|
||||||
qr QRCode @relation(fields: [qrId], references: [id], onDelete: Cascade)
|
utmMedium String?
|
||||||
|
utmCampaign String?
|
||||||
@@index([qrId, ts])
|
isUnique Boolean @default(false)
|
||||||
}
|
|
||||||
|
qr QRCode @relation(fields: [qrId], references: [id], onDelete: Cascade)
|
||||||
model Integration {
|
|
||||||
id String @id @default(cuid())
|
@@index([qrId, ts])
|
||||||
userId String
|
}
|
||||||
provider String
|
|
||||||
status String @default("inactive")
|
model Integration {
|
||||||
config Json
|
id String @id @default(cuid())
|
||||||
createdAt DateTime @default(now())
|
userId String
|
||||||
updatedAt DateTime @updatedAt
|
provider String
|
||||||
|
status String @default("inactive")
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
config Json
|
||||||
}
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
model NewsletterSubscription {
|
|
||||||
id String @id @default(cuid())
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
email String @unique
|
}
|
||||||
source String @default("ai-coming-soon")
|
|
||||||
status String @default("subscribed")
|
model NewsletterSubscription {
|
||||||
createdAt DateTime @default(now())
|
id String @id @default(cuid())
|
||||||
updatedAt DateTime @updatedAt
|
email String @unique
|
||||||
|
source String @default("ai-coming-soon")
|
||||||
@@index([email])
|
status String @default("subscribed")
|
||||||
@@index([createdAt])
|
createdAt DateTime @default(now())
|
||||||
}
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
model Lead {
|
@@index([email])
|
||||||
id String @id @default(cuid())
|
@@index([createdAt])
|
||||||
email String
|
|
||||||
source String @default("reprint-calculator")
|
|
||||||
reprintCost Float?
|
|
||||||
updatesPerYear Int?
|
|
||||||
annualSavings Float?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
@@index([email])
|
|
||||||
@@index([createdAt])
|
|
||||||
@@index([source])
|
|
||||||
}
|
}
|
||||||
BIN
public/barcode-generator-preview.png
Normal file
|
After Width: | Height: | Size: 398 KiB |
BIN
public/blog/bulk-qr-events-hero.png
Normal file
|
After Width: | Height: | Size: 804 KiB |
BIN
public/blog/dynamic-vs-static-hero-v2.png
Normal file
|
After Width: | Height: | Size: 860 KiB |
BIN
public/blog/qr-analytics-guide-hero.png
Normal file
|
After Width: | Height: | Size: 630 KiB |
BIN
public/blog/qr-code-analytics-hero-v2.png
Normal file
|
After Width: | Height: | Size: 863 KiB |
BIN
public/blog/qr-code-generator-guide-hero.png
Normal file
|
After Width: | Height: | Size: 454 KiB |
BIN
public/blog/qr-code-tracking-hero-v2.png
Normal file
|
After Width: | Height: | Size: 646 KiB |
BIN
public/blog/qr-marketing-best-practices-hero.png
Normal file
|
After Width: | Height: | Size: 699 KiB |
33
public/sitemap.xml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>https://www.qrmaster.net/</loc>
|
||||||
|
<lastmod>2025-10-16T00:00:00Z</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://www.qrmaster.net/blog</loc>
|
||||||
|
<lastmod>2025-10-16T00:00:00Z</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://www.qrmaster.net/pricing</loc>
|
||||||
|
<lastmod>2025-10-16T00:00:00Z</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://www.qrmaster.net/faq</loc>
|
||||||
|
<lastmod>2025-10-16T00:00:00Z</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://www.qrmaster.net/blog/qr-code-analytics</loc>
|
||||||
|
<lastmod>2025-10-16T00:00:00Z</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
@@ -1,21 +1,23 @@
|
|||||||
|
|
||||||
// Helper script to run IndexNow submission
|
// Helper script to run IndexNow submission
|
||||||
// Run with: npx tsx scripts/submit-indexnow.ts
|
// Run with: npm run submit:indexnow
|
||||||
|
|
||||||
import { getAllIndexableUrls, submitToIndexNow } from '../src/lib/indexnow';
|
import { getAllIndexableUrls, submitToIndexNow } from '../src/lib/indexnow';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log('Gathering URLs for IndexNow submission...');
|
console.log('🚀 Starting IndexNow Submission Script...');
|
||||||
|
|
||||||
|
console.log(' Gathering URLs for IndexNow submission...');
|
||||||
const urls = getAllIndexableUrls();
|
const urls = getAllIndexableUrls();
|
||||||
console.log(`Found ${urls.length} indexable URLs.`);
|
console.log(` Found ${urls.length} indexable URLs.`);
|
||||||
|
|
||||||
// Basic validation of key presence (logic can be improved)
|
// Basic validation of key presence (logic can be improved)
|
||||||
if (!process.env.INDEXNOW_KEY) {
|
if (!process.env.INDEXNOW_KEY) {
|
||||||
console.warn('⚠️ WARNING: INDEXNOW_KEY environment variable is not set. Using placeholder.');
|
console.warn('⚠️ WARNING: INDEXNOW_KEY environment variable is not set.');
|
||||||
// In production, you'd fail here. For dev/demo, we proceed but expect failure from API.
|
console.warn(' The submission might fail if the key is not hardcoded in src/lib/indexnow.ts');
|
||||||
}
|
}
|
||||||
|
|
||||||
await submitToIndexNow(urls);
|
await submitToIndexNow(urls);
|
||||||
|
console.log('\n✨ IndexNow submission process completed.');
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch(console.error);
|
main().catch(console.error);
|
||||||
|
|||||||
81
scripts/trigger-indexing.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
|
||||||
|
import { google } from 'googleapis';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { getAllIndexableUrls } from '../src/lib/indexnow';
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// CONFIGURATION
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// Path to your Service Account Key (JSON file)
|
||||||
|
const KEY_FILE = path.join(__dirname, '../service_account.json');
|
||||||
|
|
||||||
|
// Urls are now fetched dynamically from src/lib/indexnow.ts
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
async function runUsingServiceAccount() {
|
||||||
|
console.log('🚀 Starting Google Indexing Script (All Pages)...');
|
||||||
|
|
||||||
|
if (!fs.existsSync(KEY_FILE)) {
|
||||||
|
console.error('\n❌ ERROR: Service Account Key not found!');
|
||||||
|
console.error(` Expected path: ${KEY_FILE}`);
|
||||||
|
console.error(' Please follow the instructions in INDEXING_GUIDE.md to create and save the key.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔑 Authenticating with key file: ${path.basename(KEY_FILE)}...`);
|
||||||
|
|
||||||
|
const auth = new google.auth.GoogleAuth({
|
||||||
|
keyFile: KEY_FILE,
|
||||||
|
scopes: ['https://www.googleapis.com/auth/indexing'],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = await auth.getClient();
|
||||||
|
console.log('✅ Authentication successful.');
|
||||||
|
|
||||||
|
console.log(' Gathering URLs to index...');
|
||||||
|
const allUrls = getAllIndexableUrls();
|
||||||
|
console.log(` Found ${allUrls.length} URLs to index.`);
|
||||||
|
|
||||||
|
for (const url of allUrls) {
|
||||||
|
console.log(`\n📄 Processing: ${url}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await google.indexing('v3').urlNotifications.publish({
|
||||||
|
auth: client,
|
||||||
|
requestBody: {
|
||||||
|
url: url,
|
||||||
|
type: 'URL_UPDATED'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` 👉 Status: ${result.status} ${result.statusText}`);
|
||||||
|
// Optional: Log more details from result.data if needed
|
||||||
|
|
||||||
|
} catch (innerError: any) {
|
||||||
|
console.error(` ❌ Failed to index ${url}`);
|
||||||
|
if (innerError.response) {
|
||||||
|
console.error(` Reason: ${innerError.response.status} - ${JSON.stringify(innerError.response.data)}`);
|
||||||
|
// 429 = Quota exceeded
|
||||||
|
// 403 = Permission denied (check service account owner status)
|
||||||
|
} else {
|
||||||
|
console.error(` Reason: ${innerError.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: Add a small delay to avoid hitting rate limits too fast if you have hundreds of URLs
|
||||||
|
// await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n✨ Done! All requests processed.');
|
||||||
|
console.log(' Note: Check Google Search Console for actual indexing status over time.');
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('\n❌ Fatal error occurred:');
|
||||||
|
console.error(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runUsingServiceAccount();
|
||||||
742
searchvolume.md
Normal file
@@ -0,0 +1,742 @@
|
|||||||
|
Overview: qr code generator
|
||||||
|
View Cached Page
|
||||||
|
|
||||||
|
Create Report
|
||||||
|
370,000
|
||||||
|
Monthly Volume
|
||||||
|
337,000
|
||||||
|
Estimated Clicks
|
||||||
|
Clicked any result
|
||||||
|
Low
|
||||||
|
91%
|
||||||
|
High
|
||||||
|
Mobile vs Desktop
|
||||||
|
Mobile
|
||||||
|
Desktop
|
||||||
|
Not Enough Data
|
||||||
|
Paid clicks
|
||||||
|
Low
|
||||||
|
0‑3%
|
||||||
|
12%
|
||||||
|
High
|
||||||
|
13%+
|
||||||
|
Difficulty
|
||||||
|
73
|
||||||
|
Google Provided Data
|
||||||
|
|
||||||
|
Expand
|
||||||
|
Cost Per Click
|
||||||
|
$0.51
|
||||||
|
Monthly Cost
|
||||||
|
$3,072
|
||||||
|
Search Volume
|
||||||
|
90,500
|
||||||
|
Advertisers
|
||||||
|
15
|
||||||
|
Homepages
|
||||||
|
6
|
||||||
|
Fresh SV
|
||||||
|
918,000
|
||||||
|
Universal search in SERP
|
||||||
|
8,191
|
||||||
|
Similar keywords
|
||||||
|
qr code generator
|
||||||
|
370,000
|
||||||
|
qr code generator free
|
||||||
|
43,300
|
||||||
|
free qr code generator
|
||||||
|
34,400
|
||||||
|
generate qr code
|
||||||
|
10,800
|
||||||
|
google qr code generator
|
||||||
|
8,000
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
View All
|
||||||
|
655
|
||||||
|
Questions
|
||||||
|
How to generate a qr code
|
||||||
|
1,700
|
||||||
|
How to generate qr code
|
||||||
|
630
|
||||||
|
How to generate qr code for url
|
||||||
|
270
|
||||||
|
What is the best qr code generator?
|
||||||
|
220
|
||||||
|
How to generate bank qr code without edge
|
||||||
|
200
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
View All
|
||||||
|
19,120,108
|
||||||
|
Also ranks for
|
||||||
|
qr code generator free
|
||||||
|
43,300
|
||||||
|
qr code maker
|
||||||
|
52,000
|
||||||
|
free qr code generator
|
||||||
|
34,400
|
||||||
|
qr generator
|
||||||
|
25,300
|
||||||
|
create qr code
|
||||||
|
29,500
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Overview: barcode generator
|
||||||
|
View Cached Page
|
||||||
|
|
||||||
|
Create Report
|
||||||
|
58,300
|
||||||
|
Monthly Volume
|
||||||
|
51,000
|
||||||
|
Estimated Clicks
|
||||||
|
Clicked any result
|
||||||
|
Low
|
||||||
|
87%
|
||||||
|
High
|
||||||
|
Mobile vs Desktop
|
||||||
|
Mobile
|
||||||
|
Desktop
|
||||||
|
Low Mobile
|
||||||
|
Paid clicks
|
||||||
|
Low
|
||||||
|
0‑3%
|
||||||
|
1%
|
||||||
|
High
|
||||||
|
13%+
|
||||||
|
Difficulty
|
||||||
|
22
|
||||||
|
Google Provided Data
|
||||||
|
|
||||||
|
Expand
|
||||||
|
Cost Per Click
|
||||||
|
$1.68
|
||||||
|
Monthly Cost
|
||||||
|
$5,316
|
||||||
|
Search Volume
|
||||||
|
110,000
|
||||||
|
Advertisers
|
||||||
|
5
|
||||||
|
Homepages
|
||||||
|
21
|
||||||
|
Fresh SV
|
||||||
|
72,800
|
||||||
|
Universal search in SERP
|
||||||
|
5,381
|
||||||
|
Similar keywords
|
||||||
|
barcode generator
|
||||||
|
58,300
|
||||||
|
free barcode generator
|
||||||
|
4,000
|
||||||
|
upc barcode generator
|
||||||
|
3,200
|
||||||
|
2d barcode generator
|
||||||
|
1,300
|
||||||
|
generate barcode
|
||||||
|
1,300
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
View All
|
||||||
|
326
|
||||||
|
Questions
|
||||||
|
How to store barcodes generated into a folder in linux python
|
||||||
|
250
|
||||||
|
How to generate barcodes
|
||||||
|
180
|
||||||
|
How to generate barcodes in excel
|
||||||
|
180
|
||||||
|
How to generate barcodes for products
|
||||||
|
135
|
||||||
|
How to generate a third party barcode for j1 waiver
|
||||||
|
110
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
View All
|
||||||
|
27,933,196
|
||||||
|
Also ranks for
|
||||||
|
free barcode generator
|
||||||
|
4,000
|
||||||
|
barcode maker
|
||||||
|
6,100
|
||||||
|
upc code generator
|
||||||
|
3,600
|
||||||
|
upc generator
|
||||||
|
4,500
|
||||||
|
2d barcode generator
|
||||||
|
1,300
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Overview: qr code maker
|
||||||
|
View Cached Page
|
||||||
|
|
||||||
|
Create Report
|
||||||
|
52,000
|
||||||
|
Monthly Volume
|
||||||
|
48,200
|
||||||
|
Estimated Clicks
|
||||||
|
Clicked any result
|
||||||
|
Low
|
||||||
|
93%
|
||||||
|
High
|
||||||
|
Mobile vs Desktop
|
||||||
|
Mobile
|
||||||
|
Desktop
|
||||||
|
Not Enough Data
|
||||||
|
Paid clicks
|
||||||
|
Low
|
||||||
|
0‑3%
|
||||||
|
12%
|
||||||
|
High
|
||||||
|
13%+
|
||||||
|
Difficulty
|
||||||
|
47
|
||||||
|
Google Provided Data
|
||||||
|
|
||||||
|
Expand
|
||||||
|
Cost Per Click
|
||||||
|
$0.37
|
||||||
|
Monthly Cost
|
||||||
|
$209
|
||||||
|
Search Volume
|
||||||
|
18,100
|
||||||
|
Advertisers
|
||||||
|
11
|
||||||
|
Homepages
|
||||||
|
32
|
||||||
|
Fresh SV
|
||||||
|
71,300
|
||||||
|
Universal search in SERP
|
||||||
|
601
|
||||||
|
Similar keywords
|
||||||
|
qr code maker
|
||||||
|
52,000
|
||||||
|
animal crossing qr code maker
|
||||||
|
2,000
|
||||||
|
free qr code maker
|
||||||
|
2,000
|
||||||
|
qr code maker free
|
||||||
|
1,900
|
||||||
|
mini qr code maker
|
||||||
|
380
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
View All
|
||||||
|
13
|
||||||
|
Questions
|
||||||
|
How to maker qr code for cia
|
||||||
|
70
|
||||||
|
How to make qr codes with brother label maker
|
||||||
|
70
|
||||||
|
How to make a qr code qr code maker
|
||||||
|
50
|
||||||
|
How to post qr codes online mii maker
|
||||||
|
40
|
||||||
|
How to get qr code watch maker
|
||||||
|
28
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
View All
|
||||||
|
84,638,180
|
||||||
|
Also ranks for
|
||||||
|
qr code generator free
|
||||||
|
43,300
|
||||||
|
free qr code generator
|
||||||
|
34,400
|
||||||
|
create a qr code
|
||||||
|
17,100
|
||||||
|
create qr code
|
||||||
|
29,500
|
||||||
|
qr generator
|
||||||
|
25,300
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Overview: google qr code generator
|
||||||
|
View Cached Page
|
||||||
|
|
||||||
|
Create Report
|
||||||
|
8,000
|
||||||
|
Monthly Volume
|
||||||
|
5,100
|
||||||
|
Estimated Clicks
|
||||||
|
Clicked any result
|
||||||
|
Low
|
||||||
|
64%
|
||||||
|
High
|
||||||
|
Mobile vs Desktop
|
||||||
|
Mobile
|
||||||
|
Desktop
|
||||||
|
Low Mobile
|
||||||
|
Paid clicks
|
||||||
|
Low
|
||||||
|
0‑3%
|
||||||
|
2%
|
||||||
|
High
|
||||||
|
13%+
|
||||||
|
Difficulty
|
||||||
|
52
|
||||||
|
Google Provided Data
|
||||||
|
|
||||||
|
Expand
|
||||||
|
Cost Per Click
|
||||||
|
$3.53
|
||||||
|
Monthly Cost
|
||||||
|
$0.00
|
||||||
|
Search Volume
|
||||||
|
2,900
|
||||||
|
Advertisers
|
||||||
|
9
|
||||||
|
Homepages
|
||||||
|
20
|
||||||
|
Fresh SV
|
||||||
|
11,500
|
||||||
|
Universal search in SERP
|
||||||
|
336
|
||||||
|
Similar keywords
|
||||||
|
google qr code generator
|
||||||
|
8,000
|
||||||
|
qr code generator google
|
||||||
|
4,800
|
||||||
|
free qr code generator google
|
||||||
|
720
|
||||||
|
qr code generator google form
|
||||||
|
440
|
||||||
|
google form qr code generator
|
||||||
|
320
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
View All
|
||||||
|
59
|
||||||
|
Questions
|
||||||
|
Does google have a qr code generator?
|
||||||
|
135
|
||||||
|
How to generate qr code for google authenticator
|
||||||
|
100
|
||||||
|
Does google have a qr code generator for contact info?
|
||||||
|
90
|
||||||
|
How to generate qr code for google form
|
||||||
|
90
|
||||||
|
How to generate a qr code for a google form
|
||||||
|
90
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
View All
|
||||||
|
27,918,916
|
||||||
|
Also ranks for
|
||||||
|
qr code generator free
|
||||||
|
43,300
|
||||||
|
qr code maker
|
||||||
|
52,000
|
||||||
|
create qr code
|
||||||
|
29,500
|
||||||
|
qr generator
|
||||||
|
25,300
|
||||||
|
free qr code generator
|
||||||
|
34,400
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Overview: create qr code
|
||||||
|
View Cached Page
|
||||||
|
|
||||||
|
Create Report
|
||||||
|
29,500
|
||||||
|
Monthly Volume
|
||||||
|
26,400
|
||||||
|
Estimated Clicks
|
||||||
|
Clicked any result
|
||||||
|
Low
|
||||||
|
89%
|
||||||
|
High
|
||||||
|
Mobile vs Desktop
|
||||||
|
Mobile
|
||||||
|
Desktop
|
||||||
|
Not Enough Data
|
||||||
|
Paid clicks
|
||||||
|
Low
|
||||||
|
0‑3%
|
||||||
|
16%
|
||||||
|
High
|
||||||
|
13%+
|
||||||
|
Difficulty
|
||||||
|
52
|
||||||
|
Google Provided Data
|
||||||
|
|
||||||
|
Expand
|
||||||
|
Cost Per Click
|
||||||
|
$3.32
|
||||||
|
Monthly Cost
|
||||||
|
$1,406
|
||||||
|
Search Volume
|
||||||
|
14,800
|
||||||
|
Advertisers
|
||||||
|
15
|
||||||
|
Homepages
|
||||||
|
25
|
||||||
|
Fresh SV
|
||||||
|
50,000
|
||||||
|
Universal search in SERP
|
||||||
|
3,223
|
||||||
|
Similar keywords
|
||||||
|
create qr code
|
||||||
|
29,500
|
||||||
|
create a qr code
|
||||||
|
17,100
|
||||||
|
How to create a qr code
|
||||||
|
9,200
|
||||||
|
create qr code free
|
||||||
|
5,500
|
||||||
|
How to create a qr code free
|
||||||
|
1,400
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
View All
|
||||||
|
1,110
|
||||||
|
Questions
|
||||||
|
How to create a qr code
|
||||||
|
9,200
|
||||||
|
How to create a qr code free
|
||||||
|
1,400
|
||||||
|
How to create qr codes
|
||||||
|
1,300
|
||||||
|
How to create qr code
|
||||||
|
1,300
|
||||||
|
How to create a qr code for a google form
|
||||||
|
1,100
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
View All
|
||||||
|
18,733,729
|
||||||
|
Also ranks for
|
||||||
|
qr code generator free
|
||||||
|
43,300
|
||||||
|
qr code maker
|
||||||
|
52,000
|
||||||
|
create a qr code
|
||||||
|
17,100
|
||||||
|
free qr code generator
|
||||||
|
34,400
|
||||||
|
qr generator
|
||||||
|
25,300
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Overview: qr code with logo
|
||||||
|
View Cached Page
|
||||||
|
|
||||||
|
Create Report
|
||||||
|
1,600
|
||||||
|
Monthly Volume
|
||||||
|
1,300
|
||||||
|
Estimated Clicks
|
||||||
|
Clicked any result
|
||||||
|
Low
|
||||||
|
81%
|
||||||
|
High
|
||||||
|
Mobile vs Desktop
|
||||||
|
Mobile
|
||||||
|
Desktop
|
||||||
|
Low Mobile
|
||||||
|
Paid clicks
|
||||||
|
Low
|
||||||
|
0‑3%
|
||||||
|
8%
|
||||||
|
High
|
||||||
|
13%+
|
||||||
|
Difficulty
|
||||||
|
48
|
||||||
|
Google Provided Data
|
||||||
|
|
||||||
|
Expand
|
||||||
|
Cost Per Click
|
||||||
|
$0.00
|
||||||
|
Monthly Cost
|
||||||
|
$0.00
|
||||||
|
Search Volume
|
||||||
|
-
|
||||||
|
Advertisers
|
||||||
|
1
|
||||||
|
Homepages
|
||||||
|
25
|
||||||
|
Fresh SV
|
||||||
|
2,900
|
||||||
|
Universal search in SERP
|
||||||
|
291
|
||||||
|
Similar keywords
|
||||||
|
qr code generator with logo
|
||||||
|
4,100
|
||||||
|
qr code with logo
|
||||||
|
1,600
|
||||||
|
create qr code with logo
|
||||||
|
440
|
||||||
|
qr code generator with logo free
|
||||||
|
400
|
||||||
|
android studio qr code generator with logo
|
||||||
|
300
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
View All
|
||||||
|
17
|
||||||
|
Questions
|
||||||
|
How to make qr code with logo
|
||||||
|
40
|
||||||
|
How to design qr code with logo
|
||||||
|
40
|
||||||
|
How to create qr code with logo
|
||||||
|
28
|
||||||
|
How to make own qr code with logo
|
||||||
|
24
|
||||||
|
How to create your own qr code with logo
|
||||||
|
24
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
View All
|
||||||
|
37,452,720
|
||||||
|
Also ranks for
|
||||||
|
qr code maker
|
||||||
|
52,000
|
||||||
|
qr code generator free
|
||||||
|
43,300
|
||||||
|
create qr code
|
||||||
|
29,500
|
||||||
|
free qr code generator
|
||||||
|
34,400
|
||||||
|
create a qr code
|
||||||
|
17,100
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Overview: spotify code generator
|
||||||
|
View Cached Page
|
||||||
|
|
||||||
|
Create Report
|
||||||
|
840
|
||||||
|
Monthly Volume
|
||||||
|
630
|
||||||
|
Estimated Clicks
|
||||||
|
Clicked any result
|
||||||
|
Low
|
||||||
|
76%
|
||||||
|
High
|
||||||
|
Mobile vs Desktop
|
||||||
|
Mobile
|
||||||
|
Desktop
|
||||||
|
Not Enough Data
|
||||||
|
Paid clicks
|
||||||
|
Not Enough Data
|
||||||
|
Difficulty
|
||||||
|
21
|
||||||
|
Google Provided Data
|
||||||
|
|
||||||
|
Expand
|
||||||
|
Cost Per Click
|
||||||
|
$0.00
|
||||||
|
Monthly Cost
|
||||||
|
$0.00
|
||||||
|
Search Volume
|
||||||
|
90
|
||||||
|
Advertisers
|
||||||
|
0
|
||||||
|
Homepages
|
||||||
|
5
|
||||||
|
Fresh SV
|
||||||
|
2,400
|
||||||
|
Universal search in SERP
|
||||||
|
106
|
||||||
|
Similar keywords
|
||||||
|
spotify code generator
|
||||||
|
840
|
||||||
|
spotify premium code generator no survey
|
||||||
|
420
|
||||||
|
spotify premium codes generator
|
||||||
|
300
|
||||||
|
spotify premium code generator no survey 2017
|
||||||
|
290
|
||||||
|
spotify code generator 2019
|
||||||
|
290
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
View All
|
||||||
|
5
|
||||||
|
Questions
|
||||||
|
How to generate spotify code
|
||||||
|
90
|
||||||
|
How to get spotify premium code free generator 2018
|
||||||
|
70
|
||||||
|
How to get code for spotify premium spotify premium free code generator
|
||||||
|
24
|
||||||
|
Where is spotify pin code generator?
|
||||||
|
12
|
||||||
|
How to generate a spotify code
|
||||||
|
-
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
Log in to see all results
|
||||||
|
--
|
||||||
|
View All
|
||||||
|
82,799,750
|
||||||
|
Also ranks for
|
||||||
|
qr code maker
|
||||||
|
52,000
|
||||||
|
spotify codes
|
||||||
|
7,100
|
||||||
|
spotify code
|
||||||
|
5,700
|
||||||
|
qrcode
|
||||||
|
11,900
|
||||||
|
create qr code
|
||||||
|
29,500
|
||||||
506
seo-strategy.md
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
## A) Executive summary (max 12 bullets)
|
||||||
|
|
||||||
|
* **Win fast (0–60 days)** by launching a *“wedge” set* of low-KD, high-intent tool pages (WhatsApp / Instagram / vCard / Bulk / PDF) + one differentiated feature hub (**QR Code Analytics + Tracking**) that every tool page upsells into.
|
||||||
|
* **Build an intent ladder**: *Free generator → Dynamic QR → Tracking/Analytics → Bulk/API/Teams → Custom domains + integrations* (this mirrors how category leaders gate value). ([qr-code-generator.com][1])
|
||||||
|
* **Exploit SERP splits**: head terms (“qr code generator”) are crowded with generalist tools (Canva/Adobe) + legacy generators, while **dynamic/tracking** queries skew toward SaaS platforms—your product sweet spot. ([qr-code-generator.com][1])
|
||||||
|
* **Turn “Google QR Code Generator” into a capture page**: Google/Chrome already generates a basic QR for a URL; your angle is *“Chrome is static-only → here’s dynamic + analytics + UTM + campaign dashboards.”* ([Google Hilfe][2])
|
||||||
|
* **Programmatic SEO (pSEO) is mandatory** in this space: competitors scale with templated “solutions” pages by QR type (vCard, WiFi, Spotify, Instagram, etc.). ([qr-code-generator.com][3])
|
||||||
|
* **Avoid pSEO index bloat** with strict canonical + noindex rules and *minimum content thresholds* per template (examples below).
|
||||||
|
* **Differentiate on trust**: QR scams (“quishing”) are rising; bake “safe redirect + link preview + scan security” into product messaging and content. ([Der Guardian][4])
|
||||||
|
* **Make “Barcode Generator” a top-of-funnel traffic engine** (58k SV / KD 22 in your data) but route conversions toward QR analytics + dynamic capabilities; barcode SERPs are full of embed-only utilities and hardware vendors. ([Free Online Barcode Generator by TEC-IT][5])
|
||||||
|
* **Ship IA early**: a scalable sitemap with `/tools/`, `/features/`, `/integrations/`, `/compare/`, `/learn/`, and `/templates/` prevents cannibalization and makes internal linking deterministic.
|
||||||
|
* **Measure leading indicators**: indexation coverage, impressions, tool-page CVR to signup, activation (QR created), and upgrades (dynamic/tracking enabled).
|
||||||
|
* **Link acquisition**: win with embed widgets, UTM/GA4 tracking guides, open-source SDKs, and directory placements (10 angles below).
|
||||||
|
* **Assumptions used** (adjustable): **EN**, **Global/US focus**, **Freemium SaaS → subscription**, primary conversion **signup → generate → enable tracking**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B) Competitor landscape (top competitors + what they do best + weaknesses)
|
||||||
|
|
||||||
|
Below is a **SERP-driven** view of recurring domains across “QR code generator”, “dynamic QR”, “tracking/analytics”, and “type” queries (vCard/Instagram/Spotify/etc.):
|
||||||
|
|
||||||
|
### 1) QR Code Generator (Bitly) — `qr-code-generator.com`
|
||||||
|
|
||||||
|
**Best at**
|
||||||
|
|
||||||
|
* Clear **feature ladder + gating** (static free → dynamic/analytics → bulk/API/teams). ([qr-code-generator.com][1])
|
||||||
|
* Massive **“solutions” library** (SEO scale by QR type). ([qr-code-generator.com][3])
|
||||||
|
**Weaknesses to exploit**
|
||||||
|
* Heavy gating/upsell can frustrate “free” intent.
|
||||||
|
* Many “solution” pages trend toward **marketing copy**—opportunity for deeper “how-to + templates + examples + tracking instrumentation”.
|
||||||
|
|
||||||
|
### 2) QRCode Monkey — `qrcode-monkey.com`
|
||||||
|
|
||||||
|
**Best at**
|
||||||
|
|
||||||
|
* “Free + design/customization” positioning; vectors/print talk resonates. ([QRCode Monkey][6])
|
||||||
|
* Has an **API pitch** (some scaling). ([QRCode Monkey][7])
|
||||||
|
**Weaknesses**
|
||||||
|
* Less credible on analytics-first workflows; your advantage is *campaign measurement + dashboards*.
|
||||||
|
|
||||||
|
### 3) The QR Code Generator (TQRCG) — `the-qrcode-generator.com`
|
||||||
|
|
||||||
|
**Best at**
|
||||||
|
|
||||||
|
* Trust messaging: “free means free” + warns about expiring codes. ([the-qrcode-generator.com][8])
|
||||||
|
**Weaknesses**
|
||||||
|
* Content often “how-to guide” oriented; you can outrank with **better tools + richer templates + integrations**.
|
||||||
|
|
||||||
|
### 4) Hovercode — `hovercode.com`
|
||||||
|
|
||||||
|
**Best at**
|
||||||
|
|
||||||
|
* Product-led pages (“create now”) + “trackable QR codes” positioning. ([Hovercode][9])
|
||||||
|
* pSEO via many generator variants (logo, circle, etc.). ([Hovercode][10])
|
||||||
|
**Weaknesses**
|
||||||
|
* Opportunity to beat them with **comparison pages + GA4 instrumentation + bulk workflows**.
|
||||||
|
|
||||||
|
### 5) Scanova — `scanova.io`
|
||||||
|
|
||||||
|
**Best at**
|
||||||
|
|
||||||
|
* Strong **feature pages**: dynamic, tracking, security, landing pages (good enterprise pitch). ([Scanova][11])
|
||||||
|
**Weaknesses**
|
||||||
|
* Many blogs are long; you can win snippets with **structured templates + FAQs + exact steps + schema**.
|
||||||
|
|
||||||
|
### 6) Flowcode — `flowcode.com`
|
||||||
|
|
||||||
|
**Best at**
|
||||||
|
|
||||||
|
* Owns “offline conversions + analytics” narrative (enterprise). ([flowcode.com][12])
|
||||||
|
**Weaknesses**
|
||||||
|
* Often skewed to demos; you can capture SMB/free intent and upgrade later.
|
||||||
|
|
||||||
|
### 7) QRCodeChimp — `qrcodechimp.com`
|
||||||
|
|
||||||
|
**Best at**
|
||||||
|
|
||||||
|
* Huge template catalog (menus, forms, cards, etc.) + GA integration content. ([QR Code Chimp][13])
|
||||||
|
**Weaknesses**
|
||||||
|
* Template sprawl risks thin pages—beat them on **quality thresholds + tighter topical clusters**.
|
||||||
|
|
||||||
|
### 8) ME-QR — `me-qr.com`
|
||||||
|
|
||||||
|
**Best at**
|
||||||
|
|
||||||
|
* Aggressive pSEO for types (PDF/Instagram/WhatsApp/Spotify). ([me-qr.com][14])
|
||||||
|
**Weaknesses**
|
||||||
|
* Many pages feel commodity; you can differentiate with **better UX + security + analytics clarity**.
|
||||||
|
|
||||||
|
### 9) Canva / Adobe Express (generalists)
|
||||||
|
|
||||||
|
* Canva and Adobe rank on “free QR code generator” intent via ecosystem pull. ([Canva][15])
|
||||||
|
**Your play**: don’t “out-brand” them—**out-specialize** on dynamic/tracking/bulk/API and win long-tail + mid-tail.
|
||||||
|
|
||||||
|
### 10) Barcode generators (for your “Barcode Generator” gold mine)
|
||||||
|
|
||||||
|
* TEC-IT (embed + backlink requirement) and Barcodes Inc (hardware upsell). ([Free Online Barcode Generator by TEC-IT][5])
|
||||||
|
**Your play**: best-in-class UX + formats + bulk + API docs + “barcode vs QR” education to route users into QR analytics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C) Keyword clusters + priority order (explain why)
|
||||||
|
|
||||||
|
### Intent model (how to cluster)
|
||||||
|
|
||||||
|
* **Do / Generate (tool intent)**: “X QR code generator”, “bulk”, “PDF to QR”, “WiFi QR”, “Instagram QR”, “WhatsApp QR”.
|
||||||
|
* **Decide (commercial investigation)**: “dynamic vs static”, “trackable QR codes”, “best QR code generator”, “QR code analytics”.
|
||||||
|
* **Implement (technical)**: “QR code API”, “track QR codes in GA4”, “UTM QR code”, “bulk QR from CSV / Sheets”.
|
||||||
|
* **Navigate (platform-native)**: “Google QR code generator”, “Spotify code generator”, “Instagram QR code”.
|
||||||
|
|
||||||
|
### Priority ladder (P0 → P2)
|
||||||
|
|
||||||
|
**P0 (launch first; fastest to rank + high upsell value)**
|
||||||
|
|
||||||
|
1. **WhatsApp QR Code Generator** (SV 180 / KD 17 in your list) → high intent + low KD + SMB conversion path.
|
||||||
|
2. **Instagram QR Code Generator** (SV 440 / KD 23) → same logic + add “IG has native QR; here’s branded + tracked campaigns”. ([Instagram Hilfe][16])
|
||||||
|
3. **vCard QR Code Generator** (SV 180 / KD 24) → business use case; great signup driver.
|
||||||
|
4. **QR Code Analytics** (SV 135 / KD 24) → *your core differentiator*; becomes the internal-link destination from every tool page.
|
||||||
|
5. **Trackable QR Codes** (SV 135 / KD 0) → perfect wedge term; map to a commercial page that demonstrates tracking dashboard and “dynamic”.
|
||||||
|
6. **Barcode Generator** (58k / KD 22) → big traffic engine; route to QR features + analytics.
|
||||||
|
|
||||||
|
**P1 (build authority + revenue features)**
|
||||||
|
|
||||||
|
* **Bulk QR Code Generator** (SV 360 / KD 33)
|
||||||
|
* **QR Code Tracking** (SV 320 / KD 37) (map carefully vs “analytics”)
|
||||||
|
* **WiFi QR Code Generator** (SV 1,400 / KD 34)
|
||||||
|
* **PDF to QR Code Generator** (SV ~260 / KD 36, CPC high)
|
||||||
|
* **Google QR Code Generator** (SV 8k) (capture via “Chrome static QR” + upsell). ([Google Hilfe][2])
|
||||||
|
|
||||||
|
**P2 (long-term mid/high competition)**
|
||||||
|
|
||||||
|
* **Dynamic QR Code Generator** (SV 1,200 / KD 43)
|
||||||
|
* **Free QR Code Generator** (SV 34,400 / KD 34)
|
||||||
|
* **QR code maker** (SV 52k / KD 47)
|
||||||
|
* **QR Code Generator** (SV 370k) — pillar target supported by everything above.
|
||||||
|
|
||||||
|
### Cannibalization rule (critical)
|
||||||
|
|
||||||
|
* **One primary intent per page.** Example mapping:
|
||||||
|
|
||||||
|
* `/features/qr-code-analytics/` = “qr code analytics” (feature/commercial)
|
||||||
|
* `/learn/qr-code-tracking/` = “qr code tracking” (educational/how it works + GA4)
|
||||||
|
* `/tools/trackable-qr-code-generator/` = “trackable qr codes” (tool + demo dashboard)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D) Recommended sitemap / IA (with URL examples)
|
||||||
|
|
||||||
|
### Core structure (scalable + pSEO-safe)
|
||||||
|
|
||||||
|
**1) Tools (transactional)**
|
||||||
|
|
||||||
|
* `/qr-code-generator/` (core tool hub, not a blog post)
|
||||||
|
* `/tools/vcard-qr-code-generator/`
|
||||||
|
* `/tools/whatsapp-qr-code-generator/`
|
||||||
|
* `/tools/instagram-qr-code-generator/`
|
||||||
|
* `/tools/wifi-qr-code-generator/`
|
||||||
|
* `/tools/pdf-to-qr-code-generator/`
|
||||||
|
* `/tools/bulk-qr-code-generator/`
|
||||||
|
* `/barcode-generator/` (separate category; include QR/2D + 1D)
|
||||||
|
|
||||||
|
**2) Features (commercial)**
|
||||||
|
|
||||||
|
* `/features/dynamic-qr-codes/`
|
||||||
|
* `/features/qr-code-analytics/`
|
||||||
|
* `/features/qr-code-campaigns/` (folders, tags, exports)
|
||||||
|
* `/features/custom-domain/`
|
||||||
|
* `/features/teams-roles/`
|
||||||
|
* `/features/security-anti-phishing/` (trust wedge; see “quishing”). ([Der Guardian][4])
|
||||||
|
|
||||||
|
**3) Integrations (high-intent + linkable)**
|
||||||
|
|
||||||
|
* `/integrations/google-analytics-4/`
|
||||||
|
* `/integrations/hubspot/`
|
||||||
|
* `/integrations/zapier/`
|
||||||
|
* `/integrations/shopify/`
|
||||||
|
(Ship GA4 first; it supports your “tracking” narrative.)
|
||||||
|
|
||||||
|
**4) Learn Hub (educational; supports rankings + conversions)**
|
||||||
|
|
||||||
|
* `/learn/dynamic-vs-static-qr-codes/`
|
||||||
|
* `/learn/how-to-track-qr-codes-in-ga4/`
|
||||||
|
* `/learn/qr-code-size-guide/`
|
||||||
|
* `/learn/qr-code-error-correction/`
|
||||||
|
* `/learn/google-qr-code-generator/` (Chrome’s built-in QR + limitations). ([Google Hilfe][2])
|
||||||
|
* `/learn/spotify-code-generator/` (Spotify Codes explainer + CTA to your tool). ([SpotifyCodes][17])
|
||||||
|
|
||||||
|
**5) Templates / Use cases (pSEO with guardrails)**
|
||||||
|
|
||||||
|
* `/templates/restaurant-menu-qr/`
|
||||||
|
* `/templates/business-card-qr/`
|
||||||
|
* `/templates/event-check-in-qr/`
|
||||||
|
Each template must include: examples, copy/paste CTAs, recommended QR type, tracking setup, and links to the tool.
|
||||||
|
|
||||||
|
### Breadcrumb + internal linking rules (hub-and-spoke)
|
||||||
|
|
||||||
|
* **Tool pages** link up to:
|
||||||
|
|
||||||
|
* `/features/qr-code-analytics/`
|
||||||
|
* `/features/dynamic-qr-codes/`
|
||||||
|
* `/learn/dynamic-vs-static-qr-codes/`
|
||||||
|
* the **closest** templates + GA4 integration (where relevant)
|
||||||
|
* **Learn pages** link down to:
|
||||||
|
|
||||||
|
* the *single best-matching tool page* (primary CTA)
|
||||||
|
* 2–4 related learn pages (cluster reinforcement)
|
||||||
|
* **Integrations** link to:
|
||||||
|
|
||||||
|
* analytics feature + tracking learn guide + relevant tool pages
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E) “Wedge” plan: what to launch first to rank within 30–60 days
|
||||||
|
|
||||||
|
### Launch set (minimum viable topical authority)
|
||||||
|
|
||||||
|
**Week 1–3 shipping goal: 8 pages that create a ranking flywheel**
|
||||||
|
|
||||||
|
**Tool pages (P0)**
|
||||||
|
|
||||||
|
1. `/tools/whatsapp-qr-code-generator/` (KD 17)
|
||||||
|
2. `/tools/instagram-qr-code-generator/` (KD 23)
|
||||||
|
3. `/tools/vcard-qr-code-generator/` (KD 24)
|
||||||
|
4. `/tools/trackable-qr-code-generator/` (KD 0 term → commercial wedge)
|
||||||
|
5. `/barcode-generator/` (traffic engine)
|
||||||
|
|
||||||
|
**Feature + Learn pages (conversion + trust)**
|
||||||
|
6) `/features/qr-code-analytics/` (your core differentiator)
|
||||||
|
7) `/learn/dynamic-vs-static-qr-codes/` (decision content)
|
||||||
|
8) `/learn/google-qr-code-generator/` (steal “Google/Chrome” demand; Chrome is static URL sharing). ([Google Hilfe][2])
|
||||||
|
|
||||||
|
### Why this ranks fast on a new domain
|
||||||
|
|
||||||
|
* Low-KD type terms are less “brand dominated” than head terms.
|
||||||
|
* Every tool page naturally links to analytics + dynamic, so **internal PageRank concentrates** on your money features.
|
||||||
|
* “Google QR code generator” content can win featured snippets because it’s step-based and grounded in official Chrome documentation. ([Google Hilfe][2])
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## F) 90-day execution roadmap (week-by-week)
|
||||||
|
|
||||||
|
### Weeks 1–2: Foundations (technical + tracking + SEO hygiene)
|
||||||
|
|
||||||
|
* **Tech SEO**
|
||||||
|
|
||||||
|
* Set up GSC + GA4 (or PostHog) + server-side event pipeline for “QR created / downloaded / scan events”.
|
||||||
|
* Define **indexation policy**: which templates get indexed, which are noindex.
|
||||||
|
* Implement: XML sitemaps by type (`/sitemap-tools.xml`, `/sitemap-learn.xml`), robots, canonicals, hreflang plan (even if EN-only now).
|
||||||
|
* **Schema baseline**
|
||||||
|
|
||||||
|
* Organization, WebSite, BreadcrumbList sitewide.
|
||||||
|
* SoftwareApplication/WebApplication on core tool hub.
|
||||||
|
* **Information architecture**
|
||||||
|
|
||||||
|
* Ship nav for Tools / Features / Learn / Pricing / API.
|
||||||
|
|
||||||
|
### Week 3: Ship the wedge tool pages (P0)
|
||||||
|
|
||||||
|
* Publish WhatsApp / Instagram / vCard / Trackable tool pages.
|
||||||
|
* Each ships with: FAQ, examples, “Static vs Dynamic” block, “Enable analytics” CTA, and internal links to `/features/qr-code-analytics/`.
|
||||||
|
|
||||||
|
### Week 4: Ship the analytics feature hub + dynamic feature hub
|
||||||
|
|
||||||
|
* `/features/qr-code-analytics/` + `/features/dynamic-qr-codes/`
|
||||||
|
* Add product screenshots/GIFs and a simple “How tracking works” diagram (dynamic redirect → logging → dashboard).
|
||||||
|
|
||||||
|
### Week 5: Learn cluster for decision + “Google QR”
|
||||||
|
|
||||||
|
* `/learn/dynamic-vs-static-qr-codes/`
|
||||||
|
* `/learn/google-qr-code-generator/` (include “Chrome creates QR for a page” and limitations). ([Google Hilfe][2])
|
||||||
|
|
||||||
|
### Week 6: Barcode Generator tool + “Barcode vs QR” guide
|
||||||
|
|
||||||
|
* Launch `/barcode-generator/` + `/learn/barcode-vs-qr-code/` to route barcode traffic into QR use cases.
|
||||||
|
* Add bulk export formats and “print quality” section to compete with incumbents. ([Free Online Barcode Generator by TEC-IT][5])
|
||||||
|
|
||||||
|
### Week 7: Bulk + PDF tools (P1)
|
||||||
|
|
||||||
|
* `/tools/bulk-qr-code-generator/` (CSV upload; align with SERP expectations like “download ZIP”). ([quickchart.io][18])
|
||||||
|
* `/tools/pdf-to-qr-code-generator/` (CPC-heavy query → strong conversion)
|
||||||
|
|
||||||
|
### Week 8: GA4 integration page (linkable asset)
|
||||||
|
|
||||||
|
* `/integrations/google-analytics-4/`
|
||||||
|
* Companion guide: `/learn/how-to-track-qr-codes-in-ga4/` (UTMs, events, attribution).
|
||||||
|
|
||||||
|
### Week 9: Authority pieces (start the pillar support)
|
||||||
|
|
||||||
|
Publish 2 of these 5 (see section below):
|
||||||
|
|
||||||
|
* “QR Code Size Guide”
|
||||||
|
* “QR Code Error Correction Explained”
|
||||||
|
* “UTM Builder for QR Campaigns”
|
||||||
|
* “QR Code Security / Quishing Prevention”
|
||||||
|
* “QR Code Analytics Benchmarks”
|
||||||
|
|
||||||
|
### Week 10: pSEO expansion (controlled)
|
||||||
|
|
||||||
|
* Add 10–20 additional `/tools/{type}/` pages (WiFi, email, SMS, etc.) only if they meet your thin-content threshold.
|
||||||
|
* Add 10–20 `/templates/` pages tied to real use cases.
|
||||||
|
|
||||||
|
### Week 11: Comparisons (conversion-focused)
|
||||||
|
|
||||||
|
* `/compare/qr-code-generator-vs-canva/`
|
||||||
|
* `/compare/qr-code-generator-vs-qrcode-monkey/`
|
||||||
|
* `/compare/dynamic-qr-code-generators/` (listicle with your wedge terms)
|
||||||
|
|
||||||
|
### Week 12–13: Iterate based on GSC data
|
||||||
|
|
||||||
|
* Optimize pages with impressions but low CTR (titles/meta).
|
||||||
|
* Expand FAQs to match PAA.
|
||||||
|
* Strengthen internal links from high-impression pages to money pages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## G) Page briefs for the top 5 money pages (H1, sections, schema, CTA, internal links)
|
||||||
|
|
||||||
|
### 1) Dynamic QR Code Generator
|
||||||
|
|
||||||
|
**URL:** `/features/dynamic-qr-codes/` (feature) + optional `/tools/dynamic-qr-code-generator/` (tool demo)
|
||||||
|
**Primary keyword:** dynamic qr code generator
|
||||||
|
**H1:** Dynamic QR Code Generator (Editable + Trackable)
|
||||||
|
**Sections (order matters)**
|
||||||
|
|
||||||
|
* What is a dynamic QR code? (vs static)
|
||||||
|
* Edit destination after printing (URL, file, page)
|
||||||
|
* Tracking/analytics overview (scans, time, location, device)
|
||||||
|
* Use cases (menus, flyers, events, packaging)
|
||||||
|
* How it works (redirect + logging)
|
||||||
|
* Pricing preview + free tier
|
||||||
|
* FAQ (Do they expire? Can I change the URL? Can I export data?)
|
||||||
|
**Schema**
|
||||||
|
* FAQPage
|
||||||
|
* SoftwareApplication (or WebApplication)
|
||||||
|
* BreadcrumbList
|
||||||
|
**Primary CTA**
|
||||||
|
* “Create a dynamic QR code” (signup)
|
||||||
|
**Internal links**
|
||||||
|
* To `/features/qr-code-analytics/`, `/learn/dynamic-vs-static-qr-codes/`, `/integrations/google-analytics-4/`
|
||||||
|
|
||||||
|
> Competitor pattern to beat: strong gating + feature ladder is common. ([qr-code-generator.com][1])
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2) QR Code Analytics
|
||||||
|
|
||||||
|
**URL:** `/features/qr-code-analytics/`
|
||||||
|
**Primary keyword:** qr code analytics
|
||||||
|
**H1:** QR Code Analytics: Track Scans, Measure Campaign ROI
|
||||||
|
**Sections**
|
||||||
|
|
||||||
|
* What you can measure (total/unique scans, geo, device, time)
|
||||||
|
* Campaign organization (folders/tags, UTM conventions)
|
||||||
|
* Export + integrations (GA4 first)
|
||||||
|
* Dashboards (examples: restaurant menu, event check-in, retail)
|
||||||
|
* Data accuracy & privacy notes
|
||||||
|
* FAQ (“Can I track a static QR?” → explain dynamic requirement)
|
||||||
|
**Schema**
|
||||||
|
* FAQPage
|
||||||
|
* SoftwareApplication
|
||||||
|
* BreadcrumbList
|
||||||
|
**CTA**
|
||||||
|
* “Enable analytics on your QR code” (upgrade nudges)
|
||||||
|
**Internal links**
|
||||||
|
* From **every tool page** (sticky sidebar “Track scans with Analytics”)
|
||||||
|
* To `/learn/how-to-track-qr-codes-in-ga4/`
|
||||||
|
|
||||||
|
> This is exactly what SaaS competitors highlight for upsell. ([flowcode.com][12])
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3) Bulk QR Code Generator
|
||||||
|
|
||||||
|
**URL:** `/tools/bulk-qr-code-generator/`
|
||||||
|
**Primary keyword:** bulk qr code generator
|
||||||
|
**H1:** Bulk QR Code Generator (CSV Upload → Download ZIP)
|
||||||
|
**Sections**
|
||||||
|
|
||||||
|
* Upload CSV / paste data / Google Sheets import (later)
|
||||||
|
* Output formats (PNG/SVG/PDF), naming conventions
|
||||||
|
* Dynamic vs static toggle per row (upsell!)
|
||||||
|
* Common workflows: inventory labels, invites, coupons
|
||||||
|
* QA: scan testing, error correction, print sizing
|
||||||
|
* FAQ
|
||||||
|
**Schema**
|
||||||
|
* FAQPage
|
||||||
|
* HowTo (only if you include step-by-step with images)
|
||||||
|
**CTA**
|
||||||
|
* “Generate bulk QR codes” + secondary “Enable tracking for all”
|
||||||
|
**Internal links**
|
||||||
|
* To `/features/qr-code-analytics/` + `/features/dynamic-qr-codes/`
|
||||||
|
|
||||||
|
> SERPs often expect “free bulk + zip”; match that intent. ([QR Explore][19])
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4) vCard QR Code Generator
|
||||||
|
|
||||||
|
**URL:** `/tools/vcard-qr-code-generator/`
|
||||||
|
**Primary keyword:** vCard qr code generator
|
||||||
|
**H1:** vCard QR Code Generator (Digital Business Card)
|
||||||
|
**Sections**
|
||||||
|
|
||||||
|
* vCard fields + preview (VCF standard)
|
||||||
|
* iOS/Android compatibility + best practices
|
||||||
|
* Static vs dynamic vCard (edit contact later)
|
||||||
|
* Examples: sales reps, events, storefront QR
|
||||||
|
* CTA: “Add scan tracking to your business cards”
|
||||||
|
* FAQ (works on Android/iOS; does it expire; can I add photo; etc.)
|
||||||
|
**Schema**
|
||||||
|
* FAQPage
|
||||||
|
* SoftwareApplication
|
||||||
|
**CTA**
|
||||||
|
* “Create vCard QR” + upsell “Track scans / update later”
|
||||||
|
**Internal links**
|
||||||
|
* To `/learn/dynamic-vs-static-qr-codes/` + analytics feature
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5) QR Code API (developer money page)
|
||||||
|
|
||||||
|
**URL:** `/features/qr-code-api/` + `/docs/api/`
|
||||||
|
**Primary keyword:** qr code api, qr code generator api
|
||||||
|
**H1:** QR Code API (Generate QR Codes at Scale)
|
||||||
|
**Sections**
|
||||||
|
|
||||||
|
* Authentication, endpoints, rate limits
|
||||||
|
* Generate static/dynamic, bulk endpoints, webhooks (scan events)
|
||||||
|
* Code samples (JS/Python/cURL)
|
||||||
|
* Compliance + uptime
|
||||||
|
* Pricing tiers
|
||||||
|
**Schema**
|
||||||
|
* SoftwareApplication (feature page)
|
||||||
|
* TechArticle (docs pages)
|
||||||
|
**CTA**
|
||||||
|
* “Get API key” / “Start trial”
|
||||||
|
**Internal links**
|
||||||
|
* From bulk generator + analytics pages
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## H) Risks + mitigation (cannibalization, programmatic pitfalls, E-E-A-T, index bloat)
|
||||||
|
|
||||||
|
### 1) Keyword cannibalization (very likely in this niche)
|
||||||
|
|
||||||
|
**Risk:** “qr code tracking”, “trackable qr codes”, “qr code analytics” collapse into the same intent.
|
||||||
|
**Mitigation:** hard-map intents:
|
||||||
|
|
||||||
|
* Analytics = feature/commercial
|
||||||
|
* Tracking = learn/how-to + GA4
|
||||||
|
* Trackable QR = tool landing with demo dashboard
|
||||||
|
|
||||||
|
### 2) Programmatic SEO thin pages / index bloat
|
||||||
|
|
||||||
|
**Risk:** hundreds of near-identical “{type} QR generator” pages get ignored/deindexed.
|
||||||
|
**Mitigation (hard rules)**
|
||||||
|
|
||||||
|
* Index only pages that include **unique elements**:
|
||||||
|
|
||||||
|
* type-specific fields + validation (real tool)
|
||||||
|
* 2–3 examples
|
||||||
|
* type-specific FAQs
|
||||||
|
* type-specific tracking use case
|
||||||
|
* **Noindex**: parameter pages, empty states, duplicate locale stubs, search/filter pages.
|
||||||
|
|
||||||
|
### 3) Trust & QR scam concerns (reputation risk, but also opportunity)
|
||||||
|
|
||||||
|
**Risk:** Users fear scanning QR codes; Google may reward safety content.
|
||||||
|
**Mitigation:** ship “Security” feature page + learn content about safe scanning and link previews, referencing real-world scam patterns. ([Der Guardian][4])
|
||||||
|
|
||||||
|
### 4) Over-reliance on “Google QR Code Generator” traffic
|
||||||
|
|
||||||
|
**Risk:** users only want Chrome’s built-in static QR and bounce.
|
||||||
|
**Mitigation:** page structure: “How to do it in Chrome” (satisfy intent) → “When you need dynamic + analytics” (convert). ([Google Hilfe][2])
|
||||||
|
|
||||||
|
### 5) E-E-A-T gap vs incumbents
|
||||||
|
|
||||||
|
**Risk:** new domain lacks credibility.
|
||||||
|
**Mitigation**
|
||||||
|
|
||||||
|
* Publish 2–3 “benchmarks / research” assets with original data (even small): scan-rate benchmarks, print-size testing, or campaign case studies.
|
||||||
|
* Add transparent pricing, uptime, privacy policy, and author/editor pages for Learn content.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
If you tell me your **target market (US vs DACH vs global), language (EN/DE), and monetization (freemium vs trials)**, I can *tighten the sitemap + 90-day calendar* so it perfectly matches your rollout (especially internationalization + URL strategy).
|
||||||
|
|
||||||
|
[1]: https://www.qr-code-generator.com/?utm_source=chatgpt.com "QR Code Generator | Create Your Free QR Codes"
|
||||||
|
[2]: https://support.google.com/chrome/answer/10051760?co=GENIE.Platform%3DDesktop&hl=en&utm_source=chatgpt.com "Share pages in Chrome - Computer"
|
||||||
|
[3]: https://www.qr-code-generator.com/solutions/?utm_source=chatgpt.com "QR Code Solution for Every Purpose"
|
||||||
|
[4]: https://www.theguardian.com/money/2025/may/25/qr-code-scam-what-is-quishing-drivers-app-phone-parking-payment?utm_source=chatgpt.com "'Pay here': the QR code 'quishing' scam targeting drivers"
|
||||||
|
[5]: https://barcode.tec-it.com/en?utm_source=chatgpt.com "Free Online Barcode Generator: Create Barcodes for Free!"
|
||||||
|
[6]: https://www.qrcode-monkey.com/?utm_source=chatgpt.com "QRCode Monkey - The free QR Code Generator to create ..."
|
||||||
|
[7]: https://www.qrcode-monkey.com/de/qr-code-service/?utm_source=chatgpt.com "QR Code API for Static Codes"
|
||||||
|
[8]: https://www.the-qrcode-generator.com/?utm_source=chatgpt.com "The QR Code Generator (TQRCG): Create Free QR Codes"
|
||||||
|
[9]: https://hovercode.com/?utm_source=chatgpt.com "QR Code Generator | Create Free Dynamic QR Codes"
|
||||||
|
[10]: https://hovercode.com/circle-qr-code-generator/?utm_source=chatgpt.com "Generate circle QR codes (no sign up required)"
|
||||||
|
[11]: https://scanova.io/features/?utm_source=chatgpt.com "Powerful features for all QR Code use cases"
|
||||||
|
[12]: https://www.flowcode.com/product/analytics?utm_source=chatgpt.com "Gain insight into your offline marketing with in-depth Analytics"
|
||||||
|
[13]: https://www.qrcodechimp.com/qr-code-analytics-guide/?utm_source=chatgpt.com "QR Code Analytics: Track, Analyze & Optimize Your ..."
|
||||||
|
[14]: https://me-qr.com/qr-code-generator/pdf?srsltid=AfmBOooK1o7kkjaSizlEOWcEcYcDWfKhZuuM3XvrJGQlm2xdiTbw1exS&utm_source=chatgpt.com "Create QR Code For PDF FREE"
|
||||||
|
[15]: https://www.canva.com/qr-code-generator/?utm_source=chatgpt.com "Free QR Code Generator - Create QR codes with ease"
|
||||||
|
[16]: https://help.instagram.com/925529167647849/?utm_source=chatgpt.com "Find and customize the QR code of your Instagram profile"
|
||||||
|
[17]: https://www.spotifycodes.com/?utm_source=chatgpt.com "Spotify Codes"
|
||||||
|
[18]: https://quickchart.io/bulk-qr-code-generator/?utm_source=chatgpt.com "Bulk QR Code Generator | Custom colors and logo, free"
|
||||||
|
[19]: https://qrexplore.com/generate/?utm_source=chatgpt.com "Bulk QR Code Generator"
|
||||||
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?
|
||||||
13
service_account.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"type": "service_account",
|
||||||
|
"project_id": "gen-lang-client-0595806638",
|
||||||
|
"private_key_id": "e44bc1717f1cf413521149de272bf13bfa89a336",
|
||||||
|
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC0xJkozHODpcpD\nu3dTHPfprZk6eKiOT05h+uG8Clm8i8LLaS/eHT+B02qxFYMBX0VH9O2GvPp/VnfC\nB/Clc7bofN5VDpQMjVUiPDqMbUVEAiQHNOTp9pkfJltaHAl/J5Cc/DccCaOn89xT\nFD5b7dTn29suuBZHTqsaFDlydnU2xJAwcrWBm7/A0JZM85d76yhY0Jxcg9w8XlpE\n+TWN8OxSUIfubaac0mfI40RH2EfugmA7M45t7B3sEbmHk5tVQSItvncz2ls9fUE4\neB6u4foMFp4Z9k5Ejs7y4N3Yft0JWS+RjI0bcvvvQ/wcnDfcwCdDFFn2Y+hflKMm\nS9+ZRnmBAgMBAAECggEAAztAeo3JifZD3nzEUcDte9cHgN7AMtlJ3Wvc7va5Sw50\nizkCmSlwPoc4/0MvoMo0+701JVxbenXveMpEb3fZMoszkdU9U9iPZCfzB4wQErOa\nppuprbbOXtO9JzZVinWzflPSIUVK16lUVvYVrmfpHYou1G/dIMIXQkVsD7NR9t/B\nafD0w/q1nwwyPB08BjSemKXDQo6NF0cE/TIvaMj8vtxuouAL+fea0n/XxMQNoIoJ\nF+pJtPQ1hkQrpayzuj3smQ11PFpYuvsZHuS3dG9j4gPjGClezK3Sflt7vwNywIRc\ntJ0Qx58on0dy0YnppMWrHh/nykraVLusvMI04joqwQKBgQDlE1Mbi8dpeKn7zkV9\nLS/O6S5Ql2k2G6KxI8GHn3qxB5yfU8G2xqk64r04YB6SMCXscIQu1Tmro8kDMTZk\n5b/issH3+7uqGcJMYhZczWsjax3S1ugepXt29dF26VnbyfvD7h9qleKLhIq32z9P\nxzZGhptTCa0swypi7prNE0MhZwKBgQDKA75g8UhVULA6q3hFEG+24ICd3Gekdz1y\nmaDrPjSJmeMSUlDl4QhGRbZBSJcAfcFKk4+Nme3sTYvjMMz6per4a5TC/+IlSufm\nOSL+CSVijvVYwCMyLyiAcm5Pqcjw16S6enHIidnOYP8e8OM0H2aNKfFTKq30B3ww\nAF8ipa+01wKBgQC24JaYhx7LtOj/fc08AbcJGF9BN59m8ukPQdxeyZLJgaooCFW9\n9RtlR16IgzPkwUuFVs4wFUnVHQx83+zs3/4wnUT9FJrdUXMsR6JStCu0Ou+0Qp1M\n2g+XCOgQZnq2XKoB4ThzfvU9LLMR1JbWudM6unuF71OxSJ2uHY636YjOQQKBgBs6\n+fSTUY6+e6LM7j9RAd4C0RN2XDodIJlMABb1oZtStPsJQYJbHQRr7S9Lm58jVGS7\nE0ShFSMfKNYNA/RdXRjzV3AZkeA5Ap1T4lWf4fwxDP1TmOrw1GLMCfaPClj8mGXS\nj3farRNWm80N53JlMSuiFbeCL0SPpbvKsQg4kUCtAoGAUORyhW70nhZJ1BbmvyRf\n17fcwenK/3GmWgqsrzN7/ucPwjqIzLGVoAXd2euxpE49/VW2xYpJjyHJHuoXDc66\n+AUog0bsxcKpM5tL3VelQl3SkUlCG7jYe20rMm01y35uM2REvQv3/r9F7Bbaq/9n\nSCwu/45QobgLCUx0B7wDqWA=\n-----END PRIVATE KEY-----\n",
|
||||||
|
"client_email": "indexer@gen-lang-client-0595806638.iam.gserviceaccount.com",
|
||||||
|
"client_id": "111279247752160222047",
|
||||||
|
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||||
|
"token_uri": "https://oauth2.googleapis.com/token",
|
||||||
|
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||||
|
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/indexer%40gen-lang-client-0595806638.iam.gserviceaccount.com",
|
||||||
|
"universe_domain": "googleapis.com"
|
||||||
|
}
|
||||||
@@ -14,6 +14,20 @@ import { calculateContrast, cn } from '@/lib/utils';
|
|||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { useCsrf } from '@/hooks/useCsrf';
|
import { useCsrf } from '@/hooks/useCsrf';
|
||||||
import { showToast } from '@/components/ui/Toast';
|
import { showToast } from '@/components/ui/Toast';
|
||||||
|
import {
|
||||||
|
Globe, User, MapPin, Phone, FileText, Smartphone, Ticket, Star, HelpCircle, Upload
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
// Tooltip component for form field help
|
||||||
|
const Tooltip = ({ text }: { text: string }) => (
|
||||||
|
<div className="group relative inline-block ml-1">
|
||||||
|
<HelpCircle className="w-4 h-4 text-gray-400 cursor-help" />
|
||||||
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-gray-900 text-white text-xs rounded-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50 w-48 text-center">
|
||||||
|
{text}
|
||||||
|
<div className="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
// Content-type specific frame options
|
// Content-type specific frame options
|
||||||
const getFrameOptionsForContentType = (contentType: string) => {
|
const getFrameOptionsForContentType = (contentType: string) => {
|
||||||
@@ -34,6 +48,14 @@ const getFrameOptionsForContentType = (contentType: string) => {
|
|||||||
return [...baseOptions, { id: 'chatme', label: 'Chat Me' }, { id: 'whatsapp', label: 'WhatsApp' }];
|
return [...baseOptions, { id: 'chatme', label: 'Chat Me' }, { id: 'whatsapp', label: 'WhatsApp' }];
|
||||||
case 'TEXT':
|
case 'TEXT':
|
||||||
return [...baseOptions, { id: 'read', label: 'Read' }, { id: 'info', label: 'Info' }];
|
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:
|
default:
|
||||||
return [...baseOptions, { id: 'website', label: 'Website' }, { id: 'visit', label: 'Visit' }];
|
return [...baseOptions, { id: 'website', label: 'Website' }, { id: 'visit', label: 'Visit' }];
|
||||||
}
|
}
|
||||||
@@ -44,6 +66,7 @@ export default function CreatePage() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { fetchWithCsrf } = useCsrf();
|
const { fetchWithCsrf } = useCsrf();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
const [userPlan, setUserPlan] = useState<string>('FREE');
|
const [userPlan, setUserPlan] = useState<string>('FREE');
|
||||||
const qrRef = useRef<HTMLDivElement>(null);
|
const qrRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -102,10 +125,14 @@ export default function CreatePage() {
|
|||||||
const hasGoodContrast = contrast >= 4.5;
|
const hasGoodContrast = contrast >= 4.5;
|
||||||
|
|
||||||
const contentTypes = [
|
const contentTypes = [
|
||||||
{ value: 'URL', label: 'URL / Website' },
|
{ value: 'URL', label: 'URL / Website', icon: Globe },
|
||||||
{ value: 'VCARD', label: 'Contact Card' },
|
{ value: 'VCARD', label: 'Contact Card', icon: User },
|
||||||
{ value: 'GEO', label: 'Location/Maps' },
|
{ value: 'GEO', label: 'Location / Maps', icon: MapPin },
|
||||||
{ value: 'PHONE', label: 'Phone Number' },
|
{ value: 'PHONE', label: 'Phone Number', icon: Phone },
|
||||||
|
{ value: 'PDF', label: 'PDF / File', icon: FileText },
|
||||||
|
{ value: 'APP', label: 'App Download', icon: Smartphone },
|
||||||
|
{ value: 'COUPON', label: 'Coupon / Discount', icon: Ticket },
|
||||||
|
{ value: 'FEEDBACK', label: 'Feedback / Review', icon: Star },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Get QR content based on content type
|
// Get QR content based on content type
|
||||||
@@ -128,6 +155,14 @@ export default function CreatePage() {
|
|||||||
return content.text || 'Sample text';
|
return content.text || 'Sample text';
|
||||||
case 'WHATSAPP':
|
case 'WHATSAPP':
|
||||||
return `https://wa.me/${content.phone || '+1234567890'}${content.message ? `?text=${encodeURIComponent(content.message)}` : ''}`;
|
return `https://wa.me/${content.phone || '+1234567890'}${content.message ? `?text=${encodeURIComponent(content.message)}` : ''}`;
|
||||||
|
case 'PDF':
|
||||||
|
return content.fileUrl || 'https://example.com/file.pdf';
|
||||||
|
case 'APP':
|
||||||
|
return content.fallbackUrl || content.iosUrl || content.androidUrl || 'https://example.com/app';
|
||||||
|
case 'COUPON':
|
||||||
|
return `Coupon: ${content.code || 'SAVE20'} - ${content.discount || '20% OFF'}`;
|
||||||
|
case 'FEEDBACK':
|
||||||
|
return content.feedbackUrl || 'https://example.com/feedback';
|
||||||
default:
|
default:
|
||||||
return 'https://example.com';
|
return 'https://example.com';
|
||||||
}
|
}
|
||||||
@@ -398,6 +433,208 @@ export default function CreatePage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
case 'PDF':
|
||||||
|
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// 10MB limit
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
showToast('File size too large (max 10MB)', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setContent({ ...content, fileUrl: data.url, fileName: data.filename });
|
||||||
|
showToast('File uploaded successfully!', 'success');
|
||||||
|
} else {
|
||||||
|
showToast(data.error || 'Upload failed', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error);
|
||||||
|
showToast('Error uploading file', 'error');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center mb-1">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Upload Menu / PDF</label>
|
||||||
|
<Tooltip text="Upload your menu PDF (Max 10MB). Hosted securely." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-lg hover:bg-gray-50 transition-colors relative">
|
||||||
|
<div className="space-y-1 text-center">
|
||||||
|
{uploading ? (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500 mb-2"></div>
|
||||||
|
<p className="text-sm text-gray-500">Uploading...</p>
|
||||||
|
</div>
|
||||||
|
) : content.fileUrl ? (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="mx-auto h-12 w-12 text-primary-500 bg-primary-50 rounded-full flex items-center justify-center mb-2">
|
||||||
|
<FileText className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-green-600 font-medium mb-1">Upload Complete!</p>
|
||||||
|
<a href={content.fileUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-primary-500 hover:underline break-all max-w-xs mb-3 block">
|
||||||
|
{content.fileName || 'View File'}
|
||||||
|
</a>
|
||||||
|
<label htmlFor="file-upload" className="cursor-pointer bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
|
||||||
|
<span>Replace File</span>
|
||||||
|
<input id="file-upload" name="file-upload" type="file" className="sr-only" accept=".pdf,image/*" onChange={handleFileUpload} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload className="mx-auto h-12 w-12 text-gray-400" />
|
||||||
|
<div className="flex text-sm text-gray-600 justify-center">
|
||||||
|
<label htmlFor="file-upload" className="relative cursor-pointer bg-white rounded-md font-medium text-primary-600 hover:text-primary-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-primary-500">
|
||||||
|
<span>Upload a file</span>
|
||||||
|
<input id="file-upload" name="file-upload" type="file" className="sr-only" accept=".pdf,image/*" onChange={handleFileUpload} />
|
||||||
|
</label>
|
||||||
|
<p className="pl-1">or drag and drop</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">PDF, PNG, JPG up to 10MB</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{content.fileUrl && (
|
||||||
|
<Input
|
||||||
|
label="File Name / Menu Title"
|
||||||
|
value={content.fileName || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, fileName: e.target.value })}
|
||||||
|
placeholder="Product Catalog 2026"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case 'APP':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center mb-1">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">iOS App Store URL</label>
|
||||||
|
<Tooltip text="Link to your app in the Apple App Store" />
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={content.iosUrl || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, iosUrl: e.target.value })}
|
||||||
|
placeholder="https://apps.apple.com/app/..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center mb-1">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Android Play Store URL</label>
|
||||||
|
<Tooltip text="Link to your app in the Google Play Store" />
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={content.androidUrl || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, androidUrl: e.target.value })}
|
||||||
|
placeholder="https://play.google.com/store/apps/..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center mb-1">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Fallback URL</label>
|
||||||
|
<Tooltip text="Where desktop users go (e.g., your website). QR detects device automatically!" />
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={content.fallbackUrl || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, fallbackUrl: e.target.value })}
|
||||||
|
placeholder="https://yourapp.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case 'COUPON':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
label="Coupon Code"
|
||||||
|
value={content.code || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, code: e.target.value })}
|
||||||
|
placeholder="SUMMER20"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Discount"
|
||||||
|
value={content.discount || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, discount: e.target.value })}
|
||||||
|
placeholder="20% OFF"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Title"
|
||||||
|
value={content.title || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, title: e.target.value })}
|
||||||
|
placeholder="Summer Sale 2026"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Description (optional)"
|
||||||
|
value={content.description || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, description: e.target.value })}
|
||||||
|
placeholder="Valid on all products"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Expiry Date (optional)"
|
||||||
|
type="date"
|
||||||
|
value={content.expiryDate || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, expiryDate: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Redeem URL (optional)"
|
||||||
|
value={content.redeemUrl || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, redeemUrl: e.target.value })}
|
||||||
|
placeholder="https://shop.example.com?coupon=SUMMER20"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case 'FEEDBACK':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
label="Business Name"
|
||||||
|
value={content.businessName || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, businessName: e.target.value })}
|
||||||
|
placeholder="Your Restaurant Name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center mb-1">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Google Review URL</label>
|
||||||
|
<Tooltip text="Redirect satisfied customers to leave a Google review." />
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={content.googleReviewUrl || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, googleReviewUrl: e.target.value })}
|
||||||
|
placeholder="https://search.google.com/local/writereview?placeid=..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="Thank You Message"
|
||||||
|
value={content.thankYouMessage || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, thankYouMessage: e.target.value })}
|
||||||
|
placeholder="Thanks for your feedback!"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -428,12 +665,31 @@ export default function CreatePage() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Select
|
{/* Custom Content Type Selector with Icons */}
|
||||||
label="Content Type"
|
<div>
|
||||||
value={contentType}
|
<label className="block text-sm font-medium text-gray-700 mb-2">Content Type</label>
|
||||||
onChange={(e) => setContentType(e.target.value)}
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||||
options={contentTypes}
|
{contentTypes.map((type) => {
|
||||||
/>
|
const Icon = type.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={type.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setContentType(type.value)}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center gap-2 p-3 rounded-lg border-2 transition-all text-sm",
|
||||||
|
contentType === type.value
|
||||||
|
? "border-primary-500 bg-primary-50 text-primary-700"
|
||||||
|
: "border-gray-200 hover:border-gray-300 text-gray-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
<span className="text-xs font-medium text-center">{type.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{renderContentFields()}
|
{renderContentFields()}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,264 +1,459 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useRouter, useParams } from 'next/navigation';
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { showToast } from '@/components/ui/Toast';
|
import { showToast } from '@/components/ui/Toast';
|
||||||
import { useCsrf } from '@/hooks/useCsrf';
|
import { useCsrf } from '@/hooks/useCsrf';
|
||||||
|
import { Upload, FileText, HelpCircle } from 'lucide-react';
|
||||||
export default function EditQRPage() {
|
|
||||||
const router = useRouter();
|
// Tooltip component for form field help
|
||||||
const params = useParams();
|
const Tooltip = ({ text }: { text: string }) => (
|
||||||
const qrId = params.id as string;
|
<div className="group relative inline-block ml-1">
|
||||||
const { fetchWithCsrf, loading: csrfLoading } = useCsrf();
|
<HelpCircle className="w-4 h-4 text-gray-400 cursor-help" />
|
||||||
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-gray-900 text-white text-xs rounded-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50 w-48 text-center">
|
||||||
const [loading, setLoading] = useState(true);
|
{text}
|
||||||
const [saving, setSaving] = useState(false);
|
<div className="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div>
|
||||||
const [qrCode, setQrCode] = useState<any>(null);
|
</div>
|
||||||
const [title, setTitle] = useState('');
|
</div>
|
||||||
const [content, setContent] = useState<any>({});
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
export default function EditQRPage() {
|
||||||
const fetchQRCode = async () => {
|
const router = useRouter();
|
||||||
try {
|
const params = useParams();
|
||||||
const response = await fetch(`/api/qrs/${qrId}`);
|
const qrId = params.id as string;
|
||||||
if (response.ok) {
|
const { fetchWithCsrf, loading: csrfLoading } = useCsrf();
|
||||||
const data = await response.json();
|
|
||||||
setQrCode(data);
|
const [loading, setLoading] = useState(true);
|
||||||
setTitle(data.title);
|
const [saving, setSaving] = useState(false);
|
||||||
setContent(data.content || {});
|
const [uploading, setUploading] = useState(false);
|
||||||
} else {
|
const [qrCode, setQrCode] = useState<any>(null);
|
||||||
showToast('Failed to load QR code', 'error');
|
const [title, setTitle] = useState('');
|
||||||
router.push('/dashboard');
|
const [content, setContent] = useState<any>({});
|
||||||
}
|
|
||||||
} catch (error) {
|
useEffect(() => {
|
||||||
console.error('Error fetching QR code:', error);
|
const fetchQRCode = async () => {
|
||||||
showToast('Failed to load QR code', 'error');
|
try {
|
||||||
router.push('/dashboard');
|
const response = await fetch(`/api/qrs/${qrId}`);
|
||||||
} finally {
|
if (response.ok) {
|
||||||
setLoading(false);
|
const data = await response.json();
|
||||||
}
|
setQrCode(data);
|
||||||
};
|
setTitle(data.title);
|
||||||
|
setContent(data.content || {});
|
||||||
fetchQRCode();
|
} else {
|
||||||
}, [qrId, router]);
|
showToast('Failed to load QR code', 'error');
|
||||||
|
router.push('/dashboard');
|
||||||
const handleSave = async () => {
|
}
|
||||||
setSaving(true);
|
} catch (error) {
|
||||||
|
console.error('Error fetching QR code:', error);
|
||||||
try {
|
showToast('Failed to load QR code', 'error');
|
||||||
const response = await fetchWithCsrf(`/api/qrs/${qrId}`, {
|
router.push('/dashboard');
|
||||||
method: 'PATCH',
|
} finally {
|
||||||
body: JSON.stringify({
|
setLoading(false);
|
||||||
title,
|
}
|
||||||
content,
|
};
|
||||||
}),
|
|
||||||
});
|
fetchQRCode();
|
||||||
|
}, [qrId, router]);
|
||||||
if (response.ok) {
|
|
||||||
showToast('QR code updated successfully!', 'success');
|
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
router.push('/dashboard');
|
const file = e.target.files?.[0];
|
||||||
} else {
|
if (!file) return;
|
||||||
const error = await response.json();
|
|
||||||
showToast(error.error || 'Failed to update QR code', 'error');
|
// 10MB limit
|
||||||
}
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
} catch (error) {
|
showToast('File size too large (max 10MB)', 'error');
|
||||||
console.error('Error updating QR code:', error);
|
return;
|
||||||
showToast('Failed to update QR code', 'error');
|
}
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
setUploading(true);
|
||||||
}
|
const formData = new FormData();
|
||||||
};
|
formData.append('file', file);
|
||||||
|
|
||||||
if (loading) {
|
try {
|
||||||
return (
|
const response = await fetch('/api/upload', {
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
method: 'POST',
|
||||||
<div className="text-center">
|
body: formData,
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
|
});
|
||||||
<p className="text-gray-600">Loading QR code...</p>
|
const data = await response.json();
|
||||||
</div>
|
|
||||||
</div>
|
if (response.ok) {
|
||||||
);
|
setContent({ ...content, fileUrl: data.url, fileName: data.filename });
|
||||||
}
|
showToast('File uploaded successfully!', 'success');
|
||||||
|
} else {
|
||||||
if (!qrCode) {
|
showToast(data.error || 'Upload failed', 'error');
|
||||||
return null;
|
}
|
||||||
}
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error);
|
||||||
// Static QR codes cannot be edited
|
showToast('Error uploading file', 'error');
|
||||||
if (qrCode.type === 'STATIC') {
|
} finally {
|
||||||
return (
|
setUploading(false);
|
||||||
<div className="max-w-2xl mx-auto mt-12">
|
}
|
||||||
<Card>
|
};
|
||||||
<CardContent className="p-12 text-center">
|
|
||||||
<div className="w-20 h-20 bg-warning-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
const handleSave = async () => {
|
||||||
<svg className="w-10 h-10 text-warning-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
setSaving(true);
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
||||||
</svg>
|
try {
|
||||||
</div>
|
const response = await fetchWithCsrf(`/api/qrs/${qrId}`, {
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Static QR Code</h2>
|
method: 'PATCH',
|
||||||
<p className="text-gray-600 mb-8">
|
body: JSON.stringify({
|
||||||
Static QR codes cannot be edited because their content is embedded directly in the QR code image.
|
title,
|
||||||
</p>
|
content,
|
||||||
<Button onClick={() => router.push('/dashboard')}>
|
}),
|
||||||
Back to Dashboard
|
});
|
||||||
</Button>
|
|
||||||
</CardContent>
|
if (response.ok) {
|
||||||
</Card>
|
showToast('QR code updated successfully!', 'success');
|
||||||
</div>
|
router.push('/dashboard');
|
||||||
);
|
} else {
|
||||||
}
|
const error = await response.json();
|
||||||
|
showToast(error.error || 'Failed to update QR code', 'error');
|
||||||
return (
|
}
|
||||||
<div className="max-w-3xl mx-auto">
|
} catch (error) {
|
||||||
<div className="mb-8">
|
console.error('Error updating QR code:', error);
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Edit QR Code</h1>
|
showToast('Failed to update QR code', 'error');
|
||||||
<p className="text-gray-600 mt-2">Update your dynamic QR code content</p>
|
} finally {
|
||||||
</div>
|
setSaving(false);
|
||||||
|
}
|
||||||
<Card>
|
};
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>QR Code Details</CardTitle>
|
if (loading) {
|
||||||
</CardHeader>
|
return (
|
||||||
<CardContent className="space-y-6">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
<Input
|
<div className="text-center">
|
||||||
label="Title"
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
|
||||||
value={title}
|
<p className="text-gray-600">Loading QR code...</p>
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
</div>
|
||||||
placeholder="Enter QR code title"
|
</div>
|
||||||
required
|
);
|
||||||
/>
|
}
|
||||||
|
|
||||||
{qrCode.contentType === 'URL' && (
|
if (!qrCode) {
|
||||||
<Input
|
return null;
|
||||||
label="URL"
|
}
|
||||||
type="url"
|
|
||||||
value={content.url || ''}
|
// Static QR codes cannot be edited
|
||||||
onChange={(e) => setContent({ ...content, url: e.target.value })}
|
if (qrCode.type === 'STATIC') {
|
||||||
placeholder="https://example.com"
|
return (
|
||||||
required
|
<div className="max-w-2xl mx-auto mt-12">
|
||||||
/>
|
<Card>
|
||||||
)}
|
<CardContent className="p-12 text-center">
|
||||||
|
<div className="w-20 h-20 bg-warning-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
{qrCode.contentType === 'PHONE' && (
|
<svg className="w-10 h-10 text-warning-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<Input
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
label="Phone Number"
|
</svg>
|
||||||
type="tel"
|
</div>
|
||||||
value={content.phone || ''}
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">Static QR Code</h2>
|
||||||
onChange={(e) => setContent({ ...content, phone: e.target.value })}
|
<p className="text-gray-600 mb-8">
|
||||||
placeholder="+1234567890"
|
Static QR codes cannot be edited because their content is embedded directly in the QR code image.
|
||||||
required
|
</p>
|
||||||
/>
|
<Button onClick={() => router.push('/dashboard')}>
|
||||||
)}
|
Back to Dashboard
|
||||||
|
</Button>
|
||||||
{qrCode.contentType === 'VCARD' && (
|
</CardContent>
|
||||||
<>
|
</Card>
|
||||||
<Input
|
</div>
|
||||||
label="First Name"
|
);
|
||||||
value={content.firstName || ''}
|
}
|
||||||
onChange={(e) => setContent({ ...content, firstName: e.target.value })}
|
|
||||||
placeholder="John"
|
return (
|
||||||
required
|
<div className="max-w-3xl mx-auto">
|
||||||
/>
|
<div className="mb-8">
|
||||||
<Input
|
<h1 className="text-3xl font-bold text-gray-900">Edit QR Code</h1>
|
||||||
label="Last Name"
|
<p className="text-gray-600 mt-2">Update your dynamic QR code content</p>
|
||||||
value={content.lastName || ''}
|
</div>
|
||||||
onChange={(e) => setContent({ ...content, lastName: e.target.value })}
|
|
||||||
placeholder="Doe"
|
<Card>
|
||||||
required
|
<CardHeader>
|
||||||
/>
|
<CardTitle>QR Code Details</CardTitle>
|
||||||
<Input
|
</CardHeader>
|
||||||
label="Email"
|
<CardContent className="space-y-6">
|
||||||
type="email"
|
<Input
|
||||||
value={content.email || ''}
|
label="Title"
|
||||||
onChange={(e) => setContent({ ...content, email: e.target.value })}
|
value={title}
|
||||||
placeholder="john@example.com"
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
/>
|
placeholder="Enter QR code title"
|
||||||
<Input
|
required
|
||||||
label="Phone"
|
/>
|
||||||
value={content.phone || ''}
|
|
||||||
onChange={(e) => setContent({ ...content, phone: e.target.value })}
|
{qrCode.contentType === 'URL' && (
|
||||||
placeholder="+1234567890"
|
<Input
|
||||||
/>
|
label="URL"
|
||||||
<Input
|
type="url"
|
||||||
label="Organization"
|
value={content.url || ''}
|
||||||
value={content.organization || ''}
|
onChange={(e) => setContent({ ...content, url: e.target.value })}
|
||||||
onChange={(e) => setContent({ ...content, organization: e.target.value })}
|
placeholder="https://example.com"
|
||||||
placeholder="Company Name"
|
required
|
||||||
/>
|
/>
|
||||||
<Input
|
)}
|
||||||
label="Job Title"
|
|
||||||
value={content.title || ''}
|
{qrCode.contentType === 'PHONE' && (
|
||||||
onChange={(e) => setContent({ ...content, title: e.target.value })}
|
<Input
|
||||||
placeholder="CEO"
|
label="Phone Number"
|
||||||
/>
|
type="tel"
|
||||||
</>
|
value={content.phone || ''}
|
||||||
)}
|
onChange={(e) => setContent({ ...content, phone: e.target.value })}
|
||||||
|
placeholder="+1234567890"
|
||||||
{qrCode.contentType === 'GEO' && (
|
required
|
||||||
<>
|
/>
|
||||||
<Input
|
)}
|
||||||
label="Latitude"
|
|
||||||
type="number"
|
{qrCode.contentType === 'VCARD' && (
|
||||||
step="any"
|
<>
|
||||||
value={content.latitude || ''}
|
<Input
|
||||||
onChange={(e) => setContent({ ...content, latitude: parseFloat(e.target.value) || 0 })}
|
label="First Name"
|
||||||
placeholder="37.7749"
|
value={content.firstName || ''}
|
||||||
required
|
onChange={(e) => setContent({ ...content, firstName: e.target.value })}
|
||||||
/>
|
placeholder="John"
|
||||||
<Input
|
required
|
||||||
label="Longitude"
|
/>
|
||||||
type="number"
|
<Input
|
||||||
step="any"
|
label="Last Name"
|
||||||
value={content.longitude || ''}
|
value={content.lastName || ''}
|
||||||
onChange={(e) => setContent({ ...content, longitude: parseFloat(e.target.value) || 0 })}
|
onChange={(e) => setContent({ ...content, lastName: e.target.value })}
|
||||||
placeholder="-122.4194"
|
placeholder="Doe"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Location Label (Optional)"
|
label="Email"
|
||||||
value={content.label || ''}
|
type="email"
|
||||||
onChange={(e) => setContent({ ...content, label: e.target.value })}
|
value={content.email || ''}
|
||||||
placeholder="Golden Gate Bridge"
|
onChange={(e) => setContent({ ...content, email: e.target.value })}
|
||||||
/>
|
placeholder="john@example.com"
|
||||||
</>
|
/>
|
||||||
)}
|
<Input
|
||||||
|
label="Phone"
|
||||||
{qrCode.contentType === 'TEXT' && (
|
value={content.phone || ''}
|
||||||
<div>
|
onChange={(e) => setContent({ ...content, phone: e.target.value })}
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
placeholder="+1234567890"
|
||||||
Text Content
|
/>
|
||||||
</label>
|
<Input
|
||||||
<textarea
|
label="Organization"
|
||||||
value={content.text || ''}
|
value={content.organization || ''}
|
||||||
onChange={(e) => setContent({ ...content, text: e.target.value })}
|
onChange={(e) => setContent({ ...content, organization: e.target.value })}
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
placeholder="Company Name"
|
||||||
rows={4}
|
/>
|
||||||
placeholder="Enter your text content"
|
<Input
|
||||||
required
|
label="Job Title"
|
||||||
/>
|
value={content.title || ''}
|
||||||
</div>
|
onChange={(e) => setContent({ ...content, title: e.target.value })}
|
||||||
)}
|
placeholder="CEO"
|
||||||
|
/>
|
||||||
<div className="flex justify-end space-x-4 pt-4">
|
</>
|
||||||
<Button
|
)}
|
||||||
variant="outline"
|
|
||||||
onClick={() => router.push('/dashboard')}
|
{qrCode.contentType === 'GEO' && (
|
||||||
>
|
<>
|
||||||
Cancel
|
<Input
|
||||||
</Button>
|
label="Latitude"
|
||||||
<Button
|
type="number"
|
||||||
onClick={handleSave}
|
step="any"
|
||||||
loading={saving}
|
value={content.latitude || ''}
|
||||||
disabled={csrfLoading || saving}
|
onChange={(e) => setContent({ ...content, latitude: parseFloat(e.target.value) || 0 })}
|
||||||
>
|
placeholder="37.7749"
|
||||||
{csrfLoading ? 'Loading...' : 'Save Changes'}
|
required
|
||||||
</Button>
|
/>
|
||||||
</div>
|
<Input
|
||||||
</CardContent>
|
label="Longitude"
|
||||||
</Card>
|
type="number"
|
||||||
</div>
|
step="any"
|
||||||
);
|
value={content.longitude || ''}
|
||||||
}
|
onChange={(e) => setContent({ ...content, longitude: parseFloat(e.target.value) || 0 })}
|
||||||
|
placeholder="-122.4194"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Location Label (Optional)"
|
||||||
|
value={content.label || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, label: e.target.value })}
|
||||||
|
placeholder="Golden Gate Bridge"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{qrCode.contentType === 'TEXT' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Text Content
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={content.text || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, text: e.target.value })}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
rows={4}
|
||||||
|
placeholder="Enter your text content"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{qrCode.contentType === 'PDF' && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center mb-1">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Upload Menu / PDF</label>
|
||||||
|
<Tooltip text="Upload your menu PDF (Max 10MB). Hosted securely." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-lg hover:bg-gray-50 transition-colors relative">
|
||||||
|
<div className="space-y-1 text-center">
|
||||||
|
{uploading ? (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500 mb-2"></div>
|
||||||
|
<p className="text-sm text-gray-500">Uploading...</p>
|
||||||
|
</div>
|
||||||
|
) : content.fileUrl ? (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="mx-auto h-12 w-12 text-primary-500 bg-primary-50 rounded-full flex items-center justify-center mb-2">
|
||||||
|
<FileText className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-green-600 font-medium mb-1">Upload Complete!</p>
|
||||||
|
<a href={content.fileUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-primary-500 hover:underline break-all max-w-xs mb-3 block">
|
||||||
|
{content.fileName || 'View File'}
|
||||||
|
</a>
|
||||||
|
<label htmlFor="file-upload" className="cursor-pointer bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
|
||||||
|
<span>Replace File</span>
|
||||||
|
<input id="file-upload" name="file-upload" type="file" className="sr-only" accept=".pdf,image/*" onChange={handleFileUpload} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload className="mx-auto h-12 w-12 text-gray-400" />
|
||||||
|
<div className="flex text-sm text-gray-600 justify-center">
|
||||||
|
<label htmlFor="file-upload" className="relative cursor-pointer bg-white rounded-md font-medium text-primary-600 hover:text-primary-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-primary-500">
|
||||||
|
<span>Upload a file</span>
|
||||||
|
<input id="file-upload" name="file-upload" type="file" className="sr-only" accept=".pdf,image/*" onChange={handleFileUpload} />
|
||||||
|
</label>
|
||||||
|
<p className="pl-1">or drag and drop</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">PDF, PNG, JPG up to 10MB</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{content.fileUrl && (
|
||||||
|
<Input
|
||||||
|
label="File Name / Menu Title"
|
||||||
|
value={content.fileName || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, fileName: e.target.value })}
|
||||||
|
placeholder="Product Catalog 2026"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{qrCode.contentType === 'APP' && (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
label="iOS App Store URL"
|
||||||
|
value={content.iosUrl || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, iosUrl: e.target.value })}
|
||||||
|
placeholder="https://apps.apple.com/app/..."
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Android Play Store URL"
|
||||||
|
value={content.androidUrl || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, androidUrl: e.target.value })}
|
||||||
|
placeholder="https://play.google.com/store/apps/..."
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Fallback URL (Desktop)"
|
||||||
|
value={content.fallbackUrl || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, fallbackUrl: e.target.value })}
|
||||||
|
placeholder="https://yourapp.com"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{qrCode.contentType === 'COUPON' && (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
label="Coupon Code"
|
||||||
|
value={content.code || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, code: e.target.value })}
|
||||||
|
placeholder="SUMMER20"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Discount"
|
||||||
|
value={content.discount || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, discount: e.target.value })}
|
||||||
|
placeholder="20% OFF"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Title"
|
||||||
|
value={content.title || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, title: e.target.value })}
|
||||||
|
placeholder="Summer Sale 2026"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Description (optional)"
|
||||||
|
value={content.description || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, description: e.target.value })}
|
||||||
|
placeholder="Valid on all products"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Expiry Date (optional)"
|
||||||
|
type="date"
|
||||||
|
value={content.expiryDate || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, expiryDate: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Redeem URL (optional)"
|
||||||
|
value={content.redeemUrl || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, redeemUrl: e.target.value })}
|
||||||
|
placeholder="https://shop.example.com"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{qrCode.contentType === 'FEEDBACK' && (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
label="Business Name"
|
||||||
|
value={content.businessName || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, businessName: e.target.value })}
|
||||||
|
placeholder="Your Restaurant Name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Google Review URL (optional)"
|
||||||
|
value={content.googleReviewUrl || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, googleReviewUrl: e.target.value })}
|
||||||
|
placeholder="https://search.google.com/local/writereview?placeid=..."
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Thank You Message"
|
||||||
|
value={content.thankYouMessage || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, thankYouMessage: e.target.value })}
|
||||||
|
placeholder="Thanks for your feedback!"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-4 pt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push('/dashboard')}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
loading={saving}
|
||||||
|
disabled={csrfLoading || saving}
|
||||||
|
>
|
||||||
|
{csrfLoading ? 'Loading...' : 'Save Changes'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
196
src/app/(app)/qr/[id]/feedback/page.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Star, ArrowLeft, ChevronLeft, ChevronRight, MessageSquare } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Feedback {
|
||||||
|
id: string;
|
||||||
|
rating: number;
|
||||||
|
comment: string;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FeedbackStats {
|
||||||
|
total: number;
|
||||||
|
avgRating: number;
|
||||||
|
distribution: { [key: number]: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Pagination {
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FeedbackListPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const qrId = params.id as string;
|
||||||
|
|
||||||
|
const [feedbacks, setFeedbacks] = useState<Feedback[]>([]);
|
||||||
|
const [stats, setStats] = useState<FeedbackStats | null>(null);
|
||||||
|
const [pagination, setPagination] = useState<Pagination>({ page: 1, totalPages: 1, hasMore: false });
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchFeedback(currentPage);
|
||||||
|
}, [qrId, currentPage]);
|
||||||
|
|
||||||
|
const fetchFeedback = async (page: number) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/qrs/${qrId}/feedback?page=${page}&limit=20`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setFeedbacks(data.feedbacks);
|
||||||
|
setStats(data.stats);
|
||||||
|
setPagination(data.pagination);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching feedback:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStars = (rating: number) => (
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<Star
|
||||||
|
key={star}
|
||||||
|
className={`w-4 h-4 ${star <= rating ? 'text-amber-400 fill-amber-400' : 'text-gray-200'}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading && !stats) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<Link href={`/qr/${qrId}`} className="inline-flex items-center text-gray-500 hover:text-gray-700 mb-4">
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Back to QR Code
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Customer Feedback</h1>
|
||||||
|
<p className="text-gray-600 mt-1">{stats?.total || 0} total responses</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Overview */}
|
||||||
|
{stats && (
|
||||||
|
<Card className="mb-8">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center gap-8">
|
||||||
|
{/* Average Rating */}
|
||||||
|
<div className="text-center md:text-left">
|
||||||
|
<div className="text-5xl font-bold text-gray-900 mb-1">{stats.avgRating}</div>
|
||||||
|
<div className="flex justify-center md:justify-start mb-1">
|
||||||
|
{renderStars(Math.round(stats.avgRating))}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500">{stats.total} reviews</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Distribution */}
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
{[5, 4, 3, 2, 1].map((rating) => {
|
||||||
|
const count = stats.distribution[rating] || 0;
|
||||||
|
const percentage = stats.total > 0 ? (count / stats.total) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div key={rating} className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-gray-600 w-12">{rating} stars</span>
|
||||||
|
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-amber-400 rounded-full transition-all"
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-500 w-12 text-right">{count}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Feedback List */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<MessageSquare className="w-5 h-5" />
|
||||||
|
All Reviews
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{feedbacks.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-gray-500">
|
||||||
|
<Star className="w-12 h-12 mx-auto mb-4 text-gray-300" />
|
||||||
|
<p>No feedback received yet</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{feedbacks.map((feedback) => (
|
||||||
|
<div key={feedback.id} className="py-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
{renderStars(feedback.rating)}
|
||||||
|
<span className="text-sm text-gray-400">
|
||||||
|
{new Date(feedback.date).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{feedback.comment && (
|
||||||
|
<p className="text-gray-700">{feedback.comment}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{pagination.totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between mt-6 pt-6 border-t">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
Page {currentPage} of {pagination.totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentPage((p) => p + 1)}
|
||||||
|
disabled={!pagination.hasMore}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="w-4 h-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
287
src/app/(app)/qr/[id]/page.tsx
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import {
|
||||||
|
ArrowLeft, Edit, ExternalLink, Star, MessageSquare,
|
||||||
|
BarChart3, Copy, Check, Pause, Play
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { showToast } from '@/components/ui/Toast';
|
||||||
|
import { useCsrf } from '@/hooks/useCsrf';
|
||||||
|
|
||||||
|
interface QRCode {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
type: 'STATIC' | 'DYNAMIC';
|
||||||
|
contentType: string;
|
||||||
|
content: any;
|
||||||
|
slug: string;
|
||||||
|
status: 'ACTIVE' | 'PAUSED';
|
||||||
|
style: any;
|
||||||
|
createdAt: string;
|
||||||
|
_count?: { scans: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FeedbackStats {
|
||||||
|
total: number;
|
||||||
|
avgRating: number;
|
||||||
|
distribution: { [key: number]: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QRDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const qrId = params.id as string;
|
||||||
|
const { fetchWithCsrf } = useCsrf();
|
||||||
|
|
||||||
|
const [qrCode, setQrCode] = useState<QRCode | null>(null);
|
||||||
|
const [feedbackStats, setFeedbackStats] = useState<FeedbackStats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchQRCode();
|
||||||
|
}, [qrId]);
|
||||||
|
|
||||||
|
const fetchQRCode = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/qrs/${qrId}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setQrCode(data);
|
||||||
|
|
||||||
|
// Fetch feedback stats if it's a feedback QR
|
||||||
|
if (data.contentType === 'FEEDBACK') {
|
||||||
|
const feedbackRes = await fetch(`/api/qrs/${qrId}/feedback?limit=1`);
|
||||||
|
if (feedbackRes.ok) {
|
||||||
|
const feedbackData = await feedbackRes.json();
|
||||||
|
setFeedbackStats(feedbackData.stats);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast('QR code not found', 'error');
|
||||||
|
router.push('/dashboard');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching QR code:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyLink = async () => {
|
||||||
|
const url = `${window.location.origin}/r/${qrCode?.slug}`;
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
showToast('Link copied!', 'success');
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleStatus = async () => {
|
||||||
|
if (!qrCode) return;
|
||||||
|
const newStatus = qrCode.status === 'ACTIVE' ? 'PAUSED' : 'ACTIVE';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetchWithCsrf(`/api/qrs/${qrId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ status: newStatus }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setQrCode({ ...qrCode, status: newStatus });
|
||||||
|
showToast(`QR code ${newStatus === 'ACTIVE' ? 'activated' : 'paused'}`, 'success');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Failed to update status', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStars = (rating: number) => (
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<Star
|
||||||
|
key={star}
|
||||||
|
className={`w-4 h-4 ${star <= rating ? 'text-amber-400 fill-amber-400' : 'text-gray-200'}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!qrCode) return null;
|
||||||
|
|
||||||
|
const qrUrl = `${typeof window !== 'undefined' ? window.location.origin : ''}/r/${qrCode.slug}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<Link href="/dashboard" className="inline-flex items-center text-gray-500 hover:text-gray-700 mb-4">
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Back to Dashboard
|
||||||
|
</Link>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">{qrCode.title}</h1>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<Badge variant={qrCode.type === 'DYNAMIC' ? 'info' : 'default'}>
|
||||||
|
{qrCode.type}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant={qrCode.status === 'ACTIVE' ? 'success' : 'warning'}>
|
||||||
|
{qrCode.status}
|
||||||
|
</Badge>
|
||||||
|
<Badge>{qrCode.contentType}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{qrCode.type === 'DYNAMIC' && (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" size="sm" onClick={toggleStatus}>
|
||||||
|
{qrCode.status === 'ACTIVE' ? <Pause className="w-4 h-4 mr-1" /> : <Play className="w-4 h-4 mr-1" />}
|
||||||
|
{qrCode.status === 'ACTIVE' ? 'Pause' : 'Activate'}
|
||||||
|
</Button>
|
||||||
|
<Link href={`/qr/${qrId}/edit`}>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Edit className="w-4 h-4 mr-1" /> Edit
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid lg:grid-cols-3 gap-8">
|
||||||
|
{/* Left: QR Code */}
|
||||||
|
<div>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6 flex flex-col items-center">
|
||||||
|
<div className="bg-white p-4 rounded-xl shadow-sm mb-4">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={qrUrl}
|
||||||
|
size={200}
|
||||||
|
fgColor={qrCode.style?.foregroundColor || '#000000'}
|
||||||
|
bgColor={qrCode.style?.backgroundColor || '#FFFFFF'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full space-y-2">
|
||||||
|
<Button variant="outline" className="w-full" onClick={copyLink}>
|
||||||
|
{copied ? <Check className="w-4 h-4 mr-2" /> : <Copy className="w-4 h-4 mr-2" />}
|
||||||
|
{copied ? 'Copied!' : 'Copy Link'}
|
||||||
|
</Button>
|
||||||
|
<a href={qrUrl} target="_blank" rel="noopener noreferrer" className="block">
|
||||||
|
<Button variant="outline" className="w-full">
|
||||||
|
<ExternalLink className="w-4 h-4 mr-2" /> Open Link
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Stats & Info */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Quick Stats */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4 text-center">
|
||||||
|
<BarChart3 className="w-6 h-6 mx-auto mb-2 text-indigo-500" />
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{qrCode._count?.scans || 0}</p>
|
||||||
|
<p className="text-sm text-gray-500">Total Scans</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4 text-center">
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{qrCode.type}</p>
|
||||||
|
<p className="text-sm text-gray-500">QR Type</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4 text-center">
|
||||||
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
|
{new Date(qrCode.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">Created</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feedback Summary (only for FEEDBACK type) */}
|
||||||
|
{qrCode.contentType === 'FEEDBACK' && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Star className="w-5 h-5 text-amber-400" />
|
||||||
|
Customer Feedback
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{feedbackStats && feedbackStats.total > 0 ? (
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-6 mb-4">
|
||||||
|
{/* Average */}
|
||||||
|
<div className="text-center sm:text-left">
|
||||||
|
<div className="text-4xl font-bold text-gray-900">{feedbackStats.avgRating}</div>
|
||||||
|
{renderStars(Math.round(feedbackStats.avgRating))}
|
||||||
|
<p className="text-sm text-gray-500 mt-1">{feedbackStats.total} reviews</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Distribution */}
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
{[5, 4, 3, 2, 1].map((rating) => {
|
||||||
|
const count = feedbackStats.distribution[rating] || 0;
|
||||||
|
const pct = feedbackStats.total > 0 ? (count / feedbackStats.total) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div key={rating} className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="w-8 text-gray-500">{rating}★</span>
|
||||||
|
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-amber-400 rounded-full" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="w-8 text-gray-400 text-right">{count}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500 mb-4">No feedback received yet. Share your QR code to collect reviews!</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link href={`/qr/${qrId}/feedback`} className="block">
|
||||||
|
<Button variant="outline" className="w-full">
|
||||||
|
<MessageSquare className="w-4 h-4 mr-2" />
|
||||||
|
View All Feedback
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content Info */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Content Details</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<pre className="bg-gray-50 p-4 rounded-lg text-sm overflow-auto">
|
||||||
|
{JSON.stringify(qrCode.content, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,38 +1,11 @@
|
|||||||
import '@/styles/globals.css';
|
export default function AuthLayout({
|
||||||
import { Providers } from '@/components/Providers';
|
children,
|
||||||
import type { Metadata } from 'next';
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
export const metadata: Metadata = {
|
}) {
|
||||||
title: 'Authentication | QR Master',
|
return (
|
||||||
description: 'Securely login or sign up to QR Master to manage your dynamic QR codes, track analytics, and access premium features. Your gateway to professional QR management.',
|
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white">
|
||||||
icons: {
|
{children}
|
||||||
icon: [
|
</div>
|
||||||
{ url: '/favicon.svg', type: 'image/svg+xml' },
|
);
|
||||||
{ url: '/logo.svg', type: 'image/svg+xml' },
|
|
||||||
],
|
|
||||||
apple: '/logo.svg',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AuthRootLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<html lang="en">
|
|
||||||
<body className="font-sans">
|
|
||||||
<Providers>
|
|
||||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white">
|
|
||||||
{children}
|
|
||||||
<div className="py-6 text-center text-sm text-slate-500 space-x-4">
|
|
||||||
<a href="/" className="hover:text-primary-600 transition-colors">Home</a>
|
|
||||||
<a href="/privacy" className="hover:text-primary-600 transition-colors">Privacy</a>
|
|
||||||
<a href="/faq" className="hover:text-primary-600 transition-colors">FAQ</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Providers>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -1,68 +1,187 @@
|
|||||||
import React, { Suspense } from 'react';
|
'use client';
|
||||||
import type { Metadata } from 'next';
|
|
||||||
import Link from 'next/link';
|
import React, { useState, useEffect } from 'react';
|
||||||
import LoginClientPage from './ClientPage';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
export const metadata: Metadata = {
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
title: {
|
import { Input } from '@/components/ui/Input';
|
||||||
absolute: 'Login to QR Master | Access Your Dashboard'
|
import { Button } from '@/components/ui/Button';
|
||||||
},
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
description: 'Sign in to QR Master to create, manage, and track your QR codes. Access your dashboard and view analytics.',
|
import { useCsrf } from '@/hooks/useCsrf';
|
||||||
alternates: {
|
|
||||||
canonical: 'https://www.qrmaster.net/login',
|
export default function LoginPage() {
|
||||||
},
|
const router = useRouter();
|
||||||
openGraph: {
|
const searchParams = useSearchParams();
|
||||||
title: 'Login to QR Master | Access Your Dashboard',
|
const { t } = useTranslation();
|
||||||
description: 'Sign in to QR Master to create, manage, and track your QR codes.',
|
const { fetchWithCsrf, loading: csrfLoading } = useCsrf();
|
||||||
url: 'https://www.qrmaster.net/login',
|
const [email, setEmail] = useState('');
|
||||||
type: 'website',
|
const [password, setPassword] = useState('');
|
||||||
images: [{
|
const [loading, setLoading] = useState(false);
|
||||||
url: 'https://www.qrmaster.net/og-image.png',
|
const [error, setError] = useState('');
|
||||||
width: 1200,
|
|
||||||
height: 630,
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
alt: 'QR Master Login',
|
e.preventDefault();
|
||||||
}],
|
setLoading(true);
|
||||||
},
|
setError('');
|
||||||
twitter: {
|
|
||||||
card: 'summary_large_image',
|
try {
|
||||||
title: 'Login to QR Master | Access Your Dashboard',
|
const response = await fetchWithCsrf('/api/auth/simple-login', {
|
||||||
description: 'Sign in to QR Master to create, manage, and track your QR codes.',
|
method: 'POST',
|
||||||
images: ['https://www.qrmaster.net/og-image.png'],
|
body: JSON.stringify({ email, password }),
|
||||||
},
|
});
|
||||||
};
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
export default function LoginPage() {
|
if (response.ok && data.success) {
|
||||||
return (
|
// Store user in localStorage for client-side
|
||||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
localStorage.setItem('user', JSON.stringify(data.user));
|
||||||
<div className="w-full max-w-md">
|
|
||||||
<div className="text-center mb-8">
|
// Track successful login with PostHog
|
||||||
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
|
try {
|
||||||
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
|
const { identifyUser, trackEvent } = await import('@/components/PostHogProvider');
|
||||||
<span className="text-2xl font-bold text-gray-900">QR Master</span>
|
identifyUser(data.user.id, {
|
||||||
</Link>
|
email: data.user.email,
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Welcome Back</h1>
|
name: data.user.name,
|
||||||
<p className="text-gray-600 mt-2">Sign in to your account</p>
|
plan: data.user.plan || 'FREE',
|
||||||
<Link href="/" className="text-sm text-primary-600 hover:text-primary-700 font-medium mt-2 inline-block border border-primary-600 hover:border-primary-700 px-4 py-2 rounded-lg transition-colors">
|
});
|
||||||
← Back to Home
|
trackEvent('user_login', {
|
||||||
</Link>
|
method: 'email',
|
||||||
</div>
|
email: data.user.email,
|
||||||
|
});
|
||||||
<Suspense fallback={
|
} catch (error) {
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-8 flex items-center justify-center min-h-[400px]">
|
console.error('PostHog tracking error:', error);
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
}
|
||||||
</div>
|
|
||||||
}>
|
// Check for redirect parameter
|
||||||
<LoginClientPage />
|
const redirectUrl = searchParams.get('redirect') || '/dashboard';
|
||||||
</Suspense>
|
router.push(redirectUrl);
|
||||||
|
router.refresh();
|
||||||
<p className="text-center text-sm text-gray-500 mt-6">
|
} else {
|
||||||
By signing in, you agree to our{' '}
|
setError(data.error || 'Invalid email or password');
|
||||||
<Link href="/privacy" className="text-primary-600 hover:text-primary-700">
|
}
|
||||||
Privacy Policy
|
} catch (err) {
|
||||||
</Link>
|
setError('An error occurred. Please try again.');
|
||||||
</p>
|
} finally {
|
||||||
</div>
|
setLoading(false);
|
||||||
</div>
|
}
|
||||||
);
|
};
|
||||||
|
|
||||||
|
const handleGoogleSignIn = () => {
|
||||||
|
// Redirect to Google OAuth API route
|
||||||
|
window.location.href = '/api/auth/google';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
|
||||||
|
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
|
||||||
|
<span className="text-2xl font-bold text-gray-900">QR Master</span>
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Welcome Back</h1>
|
||||||
|
<p className="text-gray-600 mt-2">Sign in to your account</p>
|
||||||
|
<Link href="/" className="text-sm text-primary-600 hover:text-primary-700 font-medium mt-2 inline-block border border-primary-600 hover:border-primary-700 px-4 py-2 rounded-lg transition-colors">
|
||||||
|
← Back to Home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input type="checkbox" className="mr-2" />
|
||||||
|
<span className="text-sm text-gray-600">Remember me</span>
|
||||||
|
</label>
|
||||||
|
<Link href="/forgot-password" className="text-sm text-primary-600 hover:text-primary-700">
|
||||||
|
Forgot password?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" loading={loading} disabled={csrfLoading || loading}>
|
||||||
|
{csrfLoading ? 'Loading...' : 'Sign In'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="relative my-6">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-gray-300"></div>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-sm">
|
||||||
|
<span className="px-2 bg-white text-gray-500">Or continue with</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleGoogleSignIn}
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
fill="#4285F4"
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#34A853"
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#FBBC05"
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#EA4335"
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Sign in with Google
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<Link href="/signup" className="text-primary-600 hover:text-primary-700 font-medium">
|
||||||
|
Sign up
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-gray-500 mt-6">
|
||||||
|
By signing in, you agree to our{' '}
|
||||||
|
<Link href="/privacy" className="text-primary-600 hover:text-primary-700">
|
||||||
|
Privacy Policy
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -1,218 +1,208 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useSearchParams, useRouter } from 'next/navigation';
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
import { Card, CardContent } from '@/components/ui/Card';
|
import { Card, CardContent } from '@/components/ui/Card';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { useCsrf } from '@/hooks/useCsrf';
|
import { useCsrf } from '@/hooks/useCsrf';
|
||||||
|
|
||||||
import { Suspense } from 'react';
|
export default function ResetPasswordPage() {
|
||||||
|
const { fetchWithCsrf, loading: csrfLoading } = useCsrf();
|
||||||
function ResetPasswordContent() {
|
const searchParams = useSearchParams();
|
||||||
const { fetchWithCsrf, loading: csrfLoading } = useCsrf();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const router = useRouter();
|
const [token, setToken] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
const [token, setToken] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [loading, setLoading] = useState(false);
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [success, setSuccess] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [error, setError] = useState('');
|
||||||
const [success, setSuccess] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
useEffect(() => {
|
||||||
|
const tokenParam = searchParams.get('token');
|
||||||
useEffect(() => {
|
if (!tokenParam) {
|
||||||
const tokenParam = searchParams.get('token');
|
setError('Invalid or missing reset token. Please request a new password reset link.');
|
||||||
if (!tokenParam) {
|
} else {
|
||||||
setError('Invalid or missing reset token. Please request a new password reset link.');
|
setToken(tokenParam);
|
||||||
} else {
|
}
|
||||||
setToken(tokenParam);
|
}, [searchParams]);
|
||||||
}
|
|
||||||
}, [searchParams]);
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
setLoading(true);
|
||||||
e.preventDefault();
|
setError('');
|
||||||
setLoading(true);
|
|
||||||
setError('');
|
// Validate passwords match
|
||||||
|
if (password !== confirmPassword) {
|
||||||
// Validate passwords match
|
setError('Passwords do not match');
|
||||||
if (password !== confirmPassword) {
|
setLoading(false);
|
||||||
setError('Passwords do not match');
|
return;
|
||||||
setLoading(false);
|
}
|
||||||
return;
|
|
||||||
}
|
// Validate password length
|
||||||
|
if (password.length < 8) {
|
||||||
// Validate password length
|
setError('Password must be at least 8 characters long');
|
||||||
if (password.length < 8) {
|
setLoading(false);
|
||||||
setError('Password must be at least 8 characters long');
|
return;
|
||||||
setLoading(false);
|
}
|
||||||
return;
|
|
||||||
}
|
try {
|
||||||
|
const response = await fetchWithCsrf('/api/auth/reset-password', {
|
||||||
try {
|
method: 'POST',
|
||||||
const response = await fetchWithCsrf('/api/auth/reset-password', {
|
body: JSON.stringify({ token, password }),
|
||||||
method: 'POST',
|
});
|
||||||
body: JSON.stringify({ token, password }),
|
|
||||||
});
|
const data = await response.json();
|
||||||
|
|
||||||
const data = await response.json();
|
if (response.ok) {
|
||||||
|
setSuccess(true);
|
||||||
if (response.ok) {
|
// Redirect to login after 3 seconds
|
||||||
setSuccess(true);
|
setTimeout(() => {
|
||||||
// Redirect to login after 3 seconds
|
router.push('/login');
|
||||||
setTimeout(() => {
|
}, 3000);
|
||||||
router.push('/login');
|
} else {
|
||||||
}, 3000);
|
setError(data.error || 'Failed to reset password');
|
||||||
} else {
|
}
|
||||||
setError(data.error || 'Failed to reset password');
|
} catch (err) {
|
||||||
}
|
setError('An error occurred. Please try again.');
|
||||||
} catch (err) {
|
} finally {
|
||||||
setError('An error occurred. Please try again.');
|
setLoading(false);
|
||||||
} finally {
|
}
|
||||||
setLoading(false);
|
};
|
||||||
}
|
|
||||||
};
|
if (success) {
|
||||||
|
return (
|
||||||
if (success) {
|
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
||||||
return (
|
<div className="w-full max-w-md">
|
||||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
<div className="text-center mb-8">
|
||||||
<div className="w-full max-w-md">
|
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
|
||||||
<div className="text-center mb-8">
|
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
|
||||||
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
|
<span className="text-2xl font-bold text-gray-900">QR Master</span>
|
||||||
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
|
</Link>
|
||||||
<span className="text-2xl font-bold text-gray-900">QR Master</span>
|
<h1 className="text-3xl font-bold text-gray-900">Password Reset Successful</h1>
|
||||||
</Link>
|
<p className="text-gray-600 mt-2">Your password has been updated</p>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Password Reset Successful</h1>
|
</div>
|
||||||
<p className="text-gray-600 mt-2">Your password has been updated</p>
|
|
||||||
</div>
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
<Card>
|
<div className="text-center">
|
||||||
<CardContent className="p-6">
|
<div className="inline-flex items-center justify-center w-16 h-16 bg-green-100 rounded-full mb-4">
|
||||||
<div className="text-center">
|
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-green-100 rounded-full mb-4">
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
</svg>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
</div>
|
||||||
</svg>
|
|
||||||
</div>
|
<p className="text-gray-700 mb-4">
|
||||||
|
Your password has been successfully reset!
|
||||||
<p className="text-gray-700 mb-4">
|
</p>
|
||||||
Your password has been successfully reset!
|
|
||||||
</p>
|
<p className="text-sm text-gray-600 mb-6">
|
||||||
|
Redirecting you to the login page in 3 seconds...
|
||||||
<p className="text-sm text-gray-600 mb-6">
|
</p>
|
||||||
Redirecting you to the login page in 3 seconds...
|
|
||||||
</p>
|
<Link href="/login" className="block">
|
||||||
|
<Button variant="primary" className="w-full">
|
||||||
<Link href="/login" className="block">
|
Go to Login
|
||||||
<Button variant="primary" className="w-full">
|
</Button>
|
||||||
Go to Login
|
</Link>
|
||||||
</Button>
|
</div>
|
||||||
</Link>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
);
|
||||||
</div>
|
}
|
||||||
);
|
|
||||||
}
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
||||||
return (
|
<div className="w-full max-w-md">
|
||||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
<div className="text-center mb-8">
|
||||||
<div className="w-full max-w-md">
|
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
|
||||||
<div className="text-center mb-8">
|
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
|
||||||
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
|
<span className="text-2xl font-bold text-gray-900">QR Master</span>
|
||||||
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
|
</Link>
|
||||||
<span className="text-2xl font-bold text-gray-900">QR Master</span>
|
<h1 className="text-3xl font-bold text-gray-900">Reset Your Password</h1>
|
||||||
</Link>
|
<p className="text-gray-600 mt-2">Enter your new password below</p>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Reset Your Password</h1>
|
</div>
|
||||||
<p className="text-gray-600 mt-2">Enter your new password below</p>
|
|
||||||
</div>
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
<Card>
|
{!token ? (
|
||||||
<CardContent className="p-6">
|
<div className="text-center">
|
||||||
{!token ? (
|
<div className="inline-flex items-center justify-center w-16 h-16 bg-red-100 rounded-full mb-4">
|
||||||
<div className="text-center">
|
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-red-100 rounded-full mb-4">
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
</svg>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
</div>
|
||||||
</svg>
|
<p className="text-red-600 mb-4">{error}</p>
|
||||||
</div>
|
<Link href="/forgot-password" className="block">
|
||||||
<p className="text-red-600 mb-4">{error}</p>
|
<Button variant="primary" className="w-full">
|
||||||
<Link href="/forgot-password" className="block">
|
Request New Reset Link
|
||||||
<Button variant="primary" className="w-full">
|
</Button>
|
||||||
Request New Reset Link
|
</Link>
|
||||||
</Button>
|
</div>
|
||||||
</Link>
|
) : (
|
||||||
</div>
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
) : (
|
{error && (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
|
||||||
{error && (
|
{error}
|
||||||
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
|
</div>
|
||||||
{error}
|
)}
|
||||||
</div>
|
|
||||||
)}
|
<Input
|
||||||
|
label="New Password"
|
||||||
<Input
|
type="password"
|
||||||
label="New Password"
|
value={password}
|
||||||
type="password"
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
value={password}
|
placeholder="Enter new password"
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
required
|
||||||
placeholder="Enter new password"
|
disabled={loading || csrfLoading}
|
||||||
required
|
minLength={8}
|
||||||
disabled={loading || csrfLoading}
|
/>
|
||||||
minLength={8}
|
|
||||||
/>
|
<Input
|
||||||
|
label="Confirm Password"
|
||||||
<Input
|
type="password"
|
||||||
label="Confirm Password"
|
value={confirmPassword}
|
||||||
type="password"
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
value={confirmPassword}
|
placeholder="Confirm new password"
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
required
|
||||||
placeholder="Confirm new password"
|
disabled={loading || csrfLoading}
|
||||||
required
|
minLength={8}
|
||||||
disabled={loading || csrfLoading}
|
/>
|
||||||
minLength={8}
|
|
||||||
/>
|
<div className="text-xs text-gray-500">
|
||||||
|
Password must be at least 8 characters long
|
||||||
<div className="text-xs text-gray-500">
|
</div>
|
||||||
Password must be at least 8 characters long
|
|
||||||
</div>
|
<Button
|
||||||
|
type="submit"
|
||||||
<Button
|
className="w-full"
|
||||||
type="submit"
|
loading={loading}
|
||||||
className="w-full"
|
disabled={csrfLoading || loading}
|
||||||
loading={loading}
|
>
|
||||||
disabled={csrfLoading || loading}
|
{csrfLoading ? 'Loading...' : 'Reset Password'}
|
||||||
>
|
</Button>
|
||||||
{csrfLoading ? 'Loading...' : 'Reset Password'}
|
|
||||||
</Button>
|
<div className="text-center">
|
||||||
|
<Link href="/login" className="text-sm text-primary-600 hover:text-primary-700 font-medium">
|
||||||
<div className="text-center">
|
← Back to Login
|
||||||
<Link href="/login" className="text-sm text-primary-600 hover:text-primary-700 font-medium">
|
</Link>
|
||||||
← Back to Login
|
</div>
|
||||||
</Link>
|
</form>
|
||||||
</div>
|
)}
|
||||||
</form>
|
</CardContent>
|
||||||
)}
|
</Card>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
<p className="text-center text-sm text-gray-500 mt-6">
|
||||||
|
Remember your password?{' '}
|
||||||
<p className="text-center text-sm text-gray-500 mt-6">
|
<Link href="/login" className="text-primary-600 hover:text-primary-700 font-medium">
|
||||||
Remember your password?{' '}
|
Sign in
|
||||||
<Link href="/login" className="text-primary-600 hover:text-primary-700 font-medium">
|
</Link>
|
||||||
Sign in
|
</p>
|
||||||
</Link>
|
</div>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
);
|
||||||
</div>
|
}
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ResetPasswordPage() {
|
|
||||||
return (
|
|
||||||
<Suspense fallback={<div className="min-h-screen flex items-center justify-center">Loading...</div>}>
|
|
||||||
<ResetPasswordContent />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,69 +1,208 @@
|
|||||||
import React, { Suspense } from 'react';
|
'use client';
|
||||||
import type { Metadata } from 'next';
|
|
||||||
import Link from 'next/link';
|
import React, { useState } from 'react';
|
||||||
import SignupClientPage from './ClientPage';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
export const metadata: Metadata = {
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
title: {
|
import { Input } from '@/components/ui/Input';
|
||||||
absolute: 'Create Free Account | QR Master'
|
import { Button } from '@/components/ui/Button';
|
||||||
},
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
description: 'Sign up for QR Master to create free QR codes. Start with tracking, customization, and bulk generation features.',
|
import { useCsrf } from '@/hooks/useCsrf';
|
||||||
alternates: {
|
|
||||||
canonical: 'https://www.qrmaster.net/signup',
|
export default function SignupPage() {
|
||||||
},
|
const router = useRouter();
|
||||||
openGraph: {
|
const { t } = useTranslation();
|
||||||
title: 'Create Free Account | QR Master',
|
const { fetchWithCsrf } = useCsrf();
|
||||||
description: 'Sign up for QR Master to create free QR codes with tracking and customization.',
|
const [name, setName] = useState('');
|
||||||
url: 'https://www.qrmaster.net/signup',
|
const [email, setEmail] = useState('');
|
||||||
type: 'website',
|
const [password, setPassword] = useState('');
|
||||||
images: [{
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
url: 'https://www.qrmaster.net/og-image.png',
|
const [loading, setLoading] = useState(false);
|
||||||
width: 1200,
|
const [error, setError] = useState('');
|
||||||
height: 630,
|
|
||||||
alt: 'QR Master Sign Up',
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
}],
|
e.preventDefault();
|
||||||
},
|
setLoading(true);
|
||||||
twitter: {
|
setError('');
|
||||||
card: 'summary_large_image',
|
|
||||||
title: 'Create Free Account | QR Master',
|
if (password !== confirmPassword) {
|
||||||
description: 'Sign up for QR Master to create free QR codes with tracking and customization.',
|
setError('Passwords do not match');
|
||||||
images: ['https://www.qrmaster.net/og-image.png'],
|
setLoading(false);
|
||||||
},
|
return;
|
||||||
};
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError('Password must be at least 8 characters');
|
||||||
export default function SignupPage() {
|
setLoading(false);
|
||||||
return (
|
return;
|
||||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
}
|
||||||
<div className="w-full max-w-md">
|
|
||||||
<div className="text-center mb-8">
|
try {
|
||||||
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
|
const response = await fetchWithCsrf('/api/auth/signup', {
|
||||||
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
|
method: 'POST',
|
||||||
<span className="text-2xl font-bold text-gray-900">QR Master</span>
|
body: JSON.stringify({ name, email, password }),
|
||||||
</Link>
|
});
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Create Account</h1>
|
|
||||||
<p className="text-gray-600 mt-2">Start creating QR codes in seconds</p>
|
const data = await response.json();
|
||||||
<Link href="/" className="text-sm text-primary-600 hover:text-primary-700 font-medium mt-2 inline-block border border-primary-600 hover:border-primary-700 px-4 py-2 rounded-lg transition-colors">
|
|
||||||
← Back to Home
|
if (response.ok && data.success) {
|
||||||
</Link>
|
// Store user in localStorage for client-side
|
||||||
</div>
|
localStorage.setItem('user', JSON.stringify(data.user));
|
||||||
|
|
||||||
<Suspense fallback={
|
// Track successful signup with PostHog
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-8 flex items-center justify-center min-h-[500px]">
|
try {
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
const { identifyUser, trackEvent } = await import('@/components/PostHogProvider');
|
||||||
</div>
|
identifyUser(data.user.id, {
|
||||||
}>
|
email: data.user.email,
|
||||||
<SignupClientPage />
|
name: data.user.name,
|
||||||
</Suspense>
|
plan: data.user.plan || 'FREE',
|
||||||
|
signupMethod: 'email',
|
||||||
<p className="text-center text-sm text-gray-500 mt-6">
|
});
|
||||||
By signing up, you agree to our{' '}
|
trackEvent('user_signup', {
|
||||||
<Link href="/privacy" className="text-primary-600 hover:text-primary-700">
|
method: 'email',
|
||||||
Privacy Policy
|
email: data.user.email,
|
||||||
</Link>
|
});
|
||||||
</p>
|
} catch (error) {
|
||||||
</div>
|
console.error('PostHog tracking error:', error);
|
||||||
</div>
|
}
|
||||||
);
|
|
||||||
|
// Redirect to dashboard
|
||||||
|
router.push('/dashboard');
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
setError(data.error || 'Failed to create account');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('An error occurred. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoogleSignIn = () => {
|
||||||
|
// Redirect to Google OAuth API route
|
||||||
|
window.location.href = '/api/auth/google';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
|
||||||
|
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
|
||||||
|
<span className="text-2xl font-bold text-gray-900">QR Master</span>
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Create Account</h1>
|
||||||
|
<p className="text-gray-600 mt-2">Start creating QR codes in seconds</p>
|
||||||
|
<Link href="/" className="text-sm text-primary-600 hover:text-primary-700 font-medium mt-2 inline-block border border-primary-600 hover:border-primary-700 px-4 py-2 rounded-lg transition-colors">
|
||||||
|
← Back to Home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Full Name"
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="John Doe"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Confirm Password"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" loading={loading}>
|
||||||
|
Create Account
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="relative my-6">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-gray-300"></div>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-sm">
|
||||||
|
<span className="px-2 bg-white text-gray-500">Or continue with</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleGoogleSignIn}
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
fill="#4285F4"
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#34A853"
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#FBBC05"
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#EA4335"
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Sign up with Google
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Already have an account?{' '}
|
||||||
|
<Link href="/login" className="text-primary-600 hover:text-primary-700 font-medium">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-gray-500 mt-6">
|
||||||
|
By signing up, you agree to our{' '}
|
||||||
|
<Link href="/privacy" className="text-primary-600 hover:text-primary-700">
|
||||||
|
Privacy Policy
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,7 @@ import { usePathname } from 'next/navigation';
|
|||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Footer } from '@/components/ui/Footer';
|
import { Footer } from '@/components/ui/Footer';
|
||||||
import en from '@/i18n/en.json';
|
import en from '@/i18n/en.json';
|
||||||
import { ChevronDown, Wifi, Contact, MessageCircle, QrCode, Link2, Type, Mail, MessageSquare, Phone, Calendar, MapPin, Facebook, Instagram, Twitter, Youtube, Music, Bitcoin, CreditCard, Video, Users } from 'lucide-react';
|
import { ChevronDown, Wifi, Contact, MessageCircle, QrCode, Link2, Type, Mail, MessageSquare, Phone, Calendar, MapPin, Facebook, Instagram, Twitter, Youtube, Music, Bitcoin, CreditCard, Video, Users, Barcode as BarcodeIcon } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
|
||||||
@@ -62,6 +62,7 @@ export default function MarketingLayout({
|
|||||||
{ name: 'PayPal', description: 'Receive payments', href: '/tools/paypal-qr-code', icon: CreditCard, color: 'text-blue-700', bgColor: 'bg-blue-50' },
|
{ name: 'PayPal', description: 'Receive payments', href: '/tools/paypal-qr-code', icon: CreditCard, color: 'text-blue-700', bgColor: 'bg-blue-50' },
|
||||||
{ name: 'Zoom', description: 'Join Zoom meeting', href: '/tools/zoom-qr-code', icon: Video, color: 'text-sky-500', bgColor: 'bg-sky-50' },
|
{ name: 'Zoom', description: 'Join Zoom meeting', href: '/tools/zoom-qr-code', icon: Video, color: 'text-sky-500', bgColor: 'bg-sky-50' },
|
||||||
{ name: 'Teams', description: 'Join Teams meeting', href: '/tools/teams-qr-code', icon: Users, color: 'text-violet-500', bgColor: 'bg-violet-50' },
|
{ name: 'Teams', description: 'Join Teams meeting', href: '/tools/teams-qr-code', icon: Users, color: 'text-violet-500', bgColor: 'bg-violet-50' },
|
||||||
|
{ name: 'Barcode', description: 'Generate barcodes', href: '/tools/barcode-generator', icon: BarcodeIcon, color: 'text-slate-800', bgColor: 'bg-slate-100' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -96,6 +97,7 @@ export default function MarketingLayout({
|
|||||||
<li><a href="/tools/paypal-qr-code">PayPal QR Code</a></li>
|
<li><a href="/tools/paypal-qr-code">PayPal QR Code</a></li>
|
||||||
<li><a href="/tools/zoom-qr-code">Zoom QR Code</a></li>
|
<li><a href="/tools/zoom-qr-code">Zoom QR Code</a></li>
|
||||||
<li><a href="/tools/teams-qr-code">Teams QR Code</a></li>
|
<li><a href="/tools/teams-qr-code">Teams QR Code</a></li>
|
||||||
|
<li><a href="/tools/barcode-generator">Barcode Generator</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,118 +1,182 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import SeoJsonLd from '@/components/SeoJsonLd';
|
import SeoJsonLd from '@/components/SeoJsonLd';
|
||||||
import { websiteSchema, breadcrumbSchema } from '@/lib/schema';
|
import { websiteSchema, breadcrumbSchema } from '@/lib/schema';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
import Breadcrumbs, { BreadcrumbItem } from '@/components/Breadcrumbs';
|
import Breadcrumbs, { BreadcrumbItem } from '@/components/Breadcrumbs';
|
||||||
import { blogPostList } from '@/lib/blog-data';
|
|
||||||
|
function truncateAtWord(text: string, maxLength: number): string {
|
||||||
function truncateAtWord(text: string, maxLength: number): string {
|
if (text.length <= maxLength) return text;
|
||||||
if (text.length <= maxLength) return text;
|
const truncated = text.slice(0, maxLength);
|
||||||
const truncated = text.slice(0, maxLength);
|
const lastSpace = truncated.lastIndexOf(' ');
|
||||||
const lastSpace = truncated.lastIndexOf(' ');
|
return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated;
|
||||||
return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated;
|
}
|
||||||
}
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
const title = truncateAtWord('QR Insights: Latest QR Strategies', 60);
|
||||||
const title = truncateAtWord('QR Insights: Latest QR Strategies', 60);
|
const description = truncateAtWord(
|
||||||
const description = truncateAtWord(
|
'Expert guides on QR analytics, dynamic codes & smart marketing uses.',
|
||||||
'Expert guides on QR code analytics, dynamic vs static codes, bulk generation, and smart marketing use cases. Learn how to maximize your QR campaign ROI.',
|
160
|
||||||
160
|
);
|
||||||
);
|
|
||||||
|
return {
|
||||||
return {
|
title,
|
||||||
title,
|
description,
|
||||||
description,
|
alternates: {
|
||||||
alternates: {
|
canonical: 'https://www.qrmaster.net/blog',
|
||||||
canonical: 'https://www.qrmaster.net/blog',
|
languages: {
|
||||||
languages: {
|
'x-default': 'https://www.qrmaster.net/blog',
|
||||||
'x-default': 'https://www.qrmaster.net/blog',
|
en: 'https://www.qrmaster.net/blog',
|
||||||
en: 'https://www.qrmaster.net/blog',
|
},
|
||||||
},
|
},
|
||||||
},
|
openGraph: {
|
||||||
openGraph: {
|
title,
|
||||||
title,
|
description,
|
||||||
description,
|
url: 'https://www.qrmaster.net/blog',
|
||||||
url: 'https://www.qrmaster.net/blog',
|
type: 'website',
|
||||||
type: 'website',
|
},
|
||||||
images: [
|
twitter: {
|
||||||
{
|
title,
|
||||||
url: 'https://www.qrmaster.net/og-image.png',
|
description,
|
||||||
width: 1200,
|
},
|
||||||
height: 630,
|
};
|
||||||
alt: 'QR Insights - QR Code Marketing & Analytics Blog',
|
}
|
||||||
},
|
|
||||||
],
|
const blogPosts = [
|
||||||
},
|
// NEW POSTS (January 2026)
|
||||||
twitter: {
|
{
|
||||||
title,
|
slug: 'qr-code-restaurant-menu',
|
||||||
description,
|
title: 'How to Create a QR Code for Restaurant Menu',
|
||||||
},
|
excerpt: 'Step-by-step guide to creating digital menu QR codes for your restaurant. Learn best practices for touchless menus, placement tips, and tracking.',
|
||||||
};
|
date: 'January 5, 2026',
|
||||||
}
|
readTime: '12 Min',
|
||||||
|
category: 'Restaurant',
|
||||||
|
image: '/blog/restaurant-qr-menu.png',
|
||||||
|
},
|
||||||
export default function BlogPage() {
|
{
|
||||||
const breadcrumbItems: BreadcrumbItem[] = [
|
slug: 'vcard-qr-code-generator',
|
||||||
{ name: 'Home', url: '/' },
|
title: 'Free vCard QR Code Generator: Digital Business Cards',
|
||||||
{ name: 'Blog', url: '/blog' },
|
excerpt: 'Create professional vCard QR codes for digital business cards. Share contact info instantly with a scan—includes templates and best practices.',
|
||||||
];
|
date: 'January 5, 2026',
|
||||||
|
readTime: '10 Min',
|
||||||
return (
|
category: 'Business Cards',
|
||||||
<>
|
image: '/blog/vcard-qr-code.png',
|
||||||
<SeoJsonLd data={[websiteSchema(), breadcrumbSchema(breadcrumbItems)]} />
|
},
|
||||||
<div className="py-20 bg-gradient-to-b from-gray-50 to-white">
|
{
|
||||||
<div className="container mx-auto px-4">
|
slug: 'qr-code-small-business',
|
||||||
<Breadcrumbs items={breadcrumbItems} />
|
title: 'Best QR Code Generator for Small Business: 2025 Guide',
|
||||||
<div className="text-center mb-16">
|
excerpt: 'Find the best QR code solution for your small business. Compare features, pricing, and use cases for marketing, payments, and operations.',
|
||||||
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-6">
|
date: 'January 5, 2026',
|
||||||
QR Code Insights
|
readTime: '14 Min',
|
||||||
</h1>
|
category: 'Business',
|
||||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
image: '/blog/small-business-qr.png',
|
||||||
Expert guides on dynamic QR codes, campaign tracking, UTM analytics, and smart marketing use cases.
|
},
|
||||||
Discover how-to tutorials and best practices for QR code analytics.
|
{
|
||||||
</p>
|
slug: 'qr-code-print-size-guide',
|
||||||
</div>
|
title: 'QR Code Print Size Guide: Minimum Sizes for Every Use Case',
|
||||||
|
excerpt: 'Complete guide to QR code print sizes. Learn minimum dimensions for business cards, posters, banners, and more to ensure reliable scanning.',
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8 max-w-6xl mx-auto">
|
date: 'January 5, 2026',
|
||||||
{blogPostList.map((post: any) => (
|
readTime: '8 Min',
|
||||||
<Link key={post.slug} href={post.link || `/blog/${post.slug}`}>
|
category: 'Printing',
|
||||||
<Card hover className="h-full overflow-hidden shadow-md hover:shadow-xl transition-all duration-300">
|
image: '/blog/qr-print-sizes.png',
|
||||||
<div className="relative h-56 overflow-hidden">
|
},
|
||||||
<Image
|
// EXISTING POSTS
|
||||||
src={post.image}
|
{
|
||||||
alt={`${post.title} - QR code guide showing ${post.category.toLowerCase()} strategies`}
|
slug: 'qr-code-tracking-guide-2025',
|
||||||
width={800}
|
title: 'QR Code Tracking: Complete Guide 2025',
|
||||||
height={600}
|
excerpt: 'Learn how to track QR code scans with real-time analytics. Compare free vs paid tracking tools, setup Google Analytics, and measure ROI.',
|
||||||
className="w-full h-full object-cover transition-transform duration-500 hover:scale-110"
|
date: 'October 18, 2025',
|
||||||
/>
|
readTime: '12 Min',
|
||||||
</div>
|
category: 'Tracking & Analytics',
|
||||||
<CardHeader className="pb-3">
|
image: '/blog/1-hero.png',
|
||||||
<div className="flex items-center justify-between mb-3">
|
},
|
||||||
<Badge variant="info">{post.category}</Badge>
|
{
|
||||||
<span className="text-sm text-gray-500 font-medium">{post.readTime} read</span>
|
slug: 'dynamic-vs-static-qr-codes',
|
||||||
</div>
|
title: 'Dynamic vs Static QR Codes: Which Should You Use?',
|
||||||
<CardTitle className="text-xl leading-tight mb-3">{post.title}</CardTitle>
|
excerpt: 'Understand the difference between static and dynamic QR codes. Learn when to use each type, pros/cons, and how dynamic QR codes save money.',
|
||||||
</CardHeader>
|
date: 'October 17, 2025',
|
||||||
<CardContent className="pt-0">
|
readTime: '10 Min',
|
||||||
<p className="text-gray-600 mb-4 leading-relaxed">{post.excerpt}</p>
|
category: 'QR Code Basics',
|
||||||
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
|
image: '/blog/2-hero.png',
|
||||||
<p className="text-sm text-gray-500">{post.date}</p>
|
},
|
||||||
<span className="text-primary-600 text-sm font-medium">
|
{
|
||||||
{post.link ? 'Try Now →' : 'Read Article →'}
|
slug: 'bulk-qr-code-generator-excel',
|
||||||
</span>
|
title: 'How to Generate Bulk QR Codes from Excel',
|
||||||
</div>
|
excerpt: 'Generate hundreds of QR codes from Excel or CSV files in minutes. Step-by-step guide with templates, best practices, and free tools.',
|
||||||
</CardContent>
|
date: 'October 16, 2025',
|
||||||
</Card>
|
readTime: '13 Min',
|
||||||
</Link>
|
category: 'Bulk Generation',
|
||||||
))}
|
image: '/blog/3-hero.png',
|
||||||
</div>
|
},
|
||||||
</div>
|
{
|
||||||
</div>
|
slug: 'qr-code-analytics',
|
||||||
</>
|
title: 'QR Code Analytics: Track, Measure & Optimize Campaigns',
|
||||||
);
|
excerpt: 'Learn how to leverage scan analytics, campaign tracking, and dashboard insights to maximize QR code ROI.',
|
||||||
}
|
date: 'October 16, 2025',
|
||||||
|
readTime: '15 Min',
|
||||||
|
category: 'Analytics',
|
||||||
|
image: '/blog/4-hero.png',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function BlogPage() {
|
||||||
|
const breadcrumbItems: BreadcrumbItem[] = [
|
||||||
|
{ name: 'Home', url: '/' },
|
||||||
|
{ name: 'Blog', url: '/blog' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SeoJsonLd data={[websiteSchema(), breadcrumbSchema(breadcrumbItems)]} />
|
||||||
|
<div className="py-20 bg-gradient-to-b from-gray-50 to-white">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<Breadcrumbs items={breadcrumbItems} />
|
||||||
|
<div className="text-center mb-16">
|
||||||
|
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-6">
|
||||||
|
QR Code Insights
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||||
|
Expert guides on dynamic QR codes, campaign tracking, UTM analytics, and smart marketing use cases.
|
||||||
|
Discover how-to tutorials and best practices for QR code analytics.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8 max-w-6xl mx-auto">
|
||||||
|
{blogPosts.map((post) => (
|
||||||
|
<Link key={post.slug} href={`/blog/${post.slug}`}>
|
||||||
|
<Card hover className="h-full overflow-hidden shadow-md hover:shadow-xl transition-all duration-300">
|
||||||
|
<div className="relative h-56 overflow-hidden">
|
||||||
|
<Image
|
||||||
|
src={post.image}
|
||||||
|
alt={`${post.title} - QR code guide showing ${post.category.toLowerCase()} strategies`}
|
||||||
|
width={800}
|
||||||
|
height={600}
|
||||||
|
className="w-full h-full object-cover transition-transform duration-500 hover:scale-110"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<Badge variant="info">{post.category}</Badge>
|
||||||
|
<span className="text-sm text-gray-500 font-medium">{post.readTime} read</span>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-xl leading-tight mb-3">{post.title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<p className="text-gray-600 mb-4 leading-relaxed">{post.excerpt}</p>
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
|
||||||
|
<p className="text-sm text-gray-500">{post.date}</p>
|
||||||
|
<span className="text-primary-600 text-sm font-medium">Read more →</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,119 +1,119 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
|
||||||
export default function Error({
|
export default function Error({
|
||||||
error,
|
error,
|
||||||
reset,
|
reset,
|
||||||
}: {
|
}: {
|
||||||
error: Error & { digest?: string };
|
error: Error & { digest?: string };
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}) {
|
}) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Log the error to an error reporting service
|
// Log the error to an error reporting service
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white flex items-center justify-center px-4">
|
<div className="min-h-screen bg-white flex items-center justify-center px-4">
|
||||||
<div className="max-w-2xl w-full text-center">
|
<div className="max-w-2xl w-full text-center">
|
||||||
{/* Error Icon */}
|
{/* Error Icon */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="inline-flex items-center justify-center w-24 h-24 bg-red-100 rounded-full mb-6">
|
<div className="inline-flex items-center justify-center w-24 h-24 bg-red-100 rounded-full mb-6">
|
||||||
<svg
|
<svg
|
||||||
className="w-12 h-12 text-red-600"
|
className="w-12 h-12 text-red-600"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error Text */}
|
{/* Error Text */}
|
||||||
<h1 className="text-6xl md:text-8xl font-bold text-gray-900 mb-4">500</h1>
|
<h1 className="text-6xl md:text-8xl font-bold text-gray-900 mb-4">500</h1>
|
||||||
<h2 className="text-2xl md:text-3xl font-semibold text-gray-700 mb-4">
|
<h2 className="text-2xl md:text-3xl font-semibold text-gray-700 mb-4">
|
||||||
Something Went Wrong
|
Something Went Wrong
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-lg text-gray-600 mb-8 max-w-md mx-auto">
|
<p className="text-lg text-gray-600 mb-8 max-w-md mx-auto">
|
||||||
We're sorry, but something unexpected happened. Our team has been notified and is working on a fix.
|
We're sorry, but something unexpected happened. Our team has been notified and is working on a fix.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Error Details (only in development) */}
|
{/* Error Details (only in development) */}
|
||||||
{process.env.NODE_ENV === 'development' && error.message && (
|
{process.env.NODE_ENV === 'development' && error.message && (
|
||||||
<div className="mb-8 p-4 bg-red-50 border border-red-200 rounded-lg text-left">
|
<div className="mb-8 p-4 bg-red-50 border border-red-200 rounded-lg text-left">
|
||||||
<p className="text-sm font-mono text-red-800 break-all">
|
<p className="text-sm font-mono text-red-800 break-all">
|
||||||
<strong>Error:</strong> {error.message}
|
<strong>Error:</strong> {error.message}
|
||||||
</p>
|
</p>
|
||||||
{error.digest && (
|
{error.digest && (
|
||||||
<p className="text-sm font-mono text-red-600 mt-2">
|
<p className="text-sm font-mono text-red-600 mt-2">
|
||||||
<strong>Digest:</strong> {error.digest}
|
<strong>Digest:</strong> {error.digest}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||||
<Button size="lg" onClick={reset}>
|
<Button size="lg" onClick={reset}>
|
||||||
<svg
|
<svg
|
||||||
className="w-5 h-5 mr-2"
|
className="w-5 h-5 mr-2"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Try Again
|
Try Again
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<Button variant="outline" size="lg">
|
<Button variant="outline" size="lg">
|
||||||
<svg
|
<svg
|
||||||
className="w-5 h-5 mr-2"
|
className="w-5 h-5 mr-2"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
|
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Go Home
|
Go Home
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Help Text */}
|
{/* Help Text */}
|
||||||
<div className="mt-12 pt-8 border-t border-gray-200">
|
<div className="mt-12 pt-8 border-t border-gray-200">
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
If this problem persists, please{' '}
|
If this problem persists, please{' '}
|
||||||
<Link href="/#faq" className="text-primary-600 hover:text-primary-700 font-medium">
|
<Link href="/#faq" className="text-primary-600 hover:text-primary-700 font-medium">
|
||||||
check our FAQ
|
check our FAQ
|
||||||
</Link>
|
</Link>
|
||||||
{' '}or contact support.
|
{' '}or contact support.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,141 +1,143 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import SeoJsonLd from '@/components/SeoJsonLd';
|
import SeoJsonLd from '@/components/SeoJsonLd';
|
||||||
import { faqPageSchema } from '@/lib/schema';
|
import { faqPageSchema } from '@/lib/schema';
|
||||||
import { Card, CardContent } from '@/components/ui/Card';
|
import { Card, CardContent } from '@/components/ui/Card';
|
||||||
import { ContactSupport } from './ContactSupport';
|
|
||||||
|
function truncateAtWord(text: string, maxLength: number): string {
|
||||||
function truncateAtWord(text: string, maxLength: number): string {
|
if (text.length <= maxLength) return text;
|
||||||
if (text.length <= maxLength) return text;
|
const truncated = text.slice(0, maxLength);
|
||||||
const truncated = text.slice(0, maxLength);
|
const lastSpace = truncated.lastIndexOf(' ');
|
||||||
const lastSpace = truncated.lastIndexOf(' ');
|
return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated;
|
||||||
return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated;
|
}
|
||||||
}
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
const title = truncateAtWord('QR Master FAQ: Dynamic & Bulk QR', 60);
|
||||||
const title = truncateAtWord('QR Master FAQ: Dynamic & Bulk QR', 60);
|
const description = truncateAtWord(
|
||||||
const description = truncateAtWord(
|
'All answers: dynamic QR, security, analytics, bulk, events & print.',
|
||||||
'Find answers about dynamic QR codes, scan tracking, security, bulk generation, and event QR codes. Everything you need to know about QR Master features.',
|
160
|
||||||
160
|
);
|
||||||
);
|
|
||||||
|
return {
|
||||||
return {
|
title,
|
||||||
title,
|
description,
|
||||||
description,
|
alternates: {
|
||||||
alternates: {
|
canonical: 'https://www.qrmaster.net/faq',
|
||||||
canonical: 'https://www.qrmaster.net/faq',
|
languages: {
|
||||||
languages: {
|
'x-default': 'https://www.qrmaster.net/faq',
|
||||||
'x-default': 'https://www.qrmaster.net/faq',
|
en: 'https://www.qrmaster.net/faq',
|
||||||
en: 'https://www.qrmaster.net/faq',
|
},
|
||||||
},
|
},
|
||||||
},
|
openGraph: {
|
||||||
openGraph: {
|
title,
|
||||||
title,
|
description,
|
||||||
description,
|
url: 'https://www.qrmaster.net/faq',
|
||||||
url: 'https://www.qrmaster.net/faq',
|
type: 'website',
|
||||||
type: 'website',
|
},
|
||||||
images: [
|
twitter: {
|
||||||
{
|
title,
|
||||||
url: 'https://www.qrmaster.net/og-image.png',
|
description,
|
||||||
width: 1200,
|
},
|
||||||
height: 630,
|
};
|
||||||
alt: 'QR Master FAQ',
|
}
|
||||||
},
|
|
||||||
],
|
const faqs = [
|
||||||
},
|
{
|
||||||
twitter: {
|
question: 'What is a dynamic QR code?',
|
||||||
title,
|
answer: 'A dynamic QR code allows you to change the destination URL after the code has been created and printed. Unlike static QR codes, dynamic codes redirect through a short URL that you control, enabling real-time updates, scan analytics, and campaign tracking without reprinting the code.',
|
||||||
description,
|
},
|
||||||
},
|
{
|
||||||
};
|
question: 'How do I track QR scans?',
|
||||||
}
|
answer: 'QR Master provides a comprehensive analytics dashboard that tracks every scan in real-time. You can monitor scan rates, geographic locations, device types, timestamps, and user behavior. Enable UTM parameters to integrate with Google Analytics for advanced campaign tracking and conversion attribution.',
|
||||||
|
},
|
||||||
const faqs = [
|
{
|
||||||
{
|
question: 'What security features does QR Master offer?',
|
||||||
question: 'What is a dynamic QR code?',
|
answer: 'QR Master employs enterprise-grade security including SSL encryption, link validation to prevent malicious redirects, fraud detection, and GDPR-compliant data handling. All scan analytics are stored securely and access is protected with multi-factor authentication for business accounts.',
|
||||||
answer: 'A dynamic QR code allows you to change the destination URL after the code has been created and printed. Unlike static QR codes, dynamic codes redirect through a short URL that you control, enabling real-time updates, scan analytics, and campaign tracking without reprinting the code.',
|
},
|
||||||
},
|
{
|
||||||
{
|
question: 'Can I generate bulk QR codes for print?',
|
||||||
question: 'How do I track QR scans?',
|
answer: 'Yes. Our bulk QR generation tool allows you to create thousands of QR codes at once by uploading a CSV file. Each code can be customized with unique URLs, UTM parameters, and branding. Download print-ready files in SVG, PNG, or PDF formats optimized for high-resolution printing.',
|
||||||
answer: 'QR Master provides a comprehensive analytics dashboard that tracks every scan in real-time. You can monitor scan rates, geographic locations, device types, timestamps, and user behavior. Enable UTM parameters to integrate with Google Analytics for advanced campaign tracking and conversion attribution.',
|
},
|
||||||
},
|
{
|
||||||
{
|
question: 'How do I brand my QR codes?',
|
||||||
question: 'What security features does QR Master offer?',
|
answer: 'QR Master offers customization options including custom colors, corner styles, and pattern designs. Branded QR codes maintain scannability while matching your brand identity. Choose your color palette and preview designs before downloading.',
|
||||||
answer: 'QR Master employs enterprise-grade security including SSL encryption, link validation to prevent malicious redirects, fraud detection, and GDPR-compliant data handling. All scan analytics are stored securely and access is protected with multi-factor authentication for business accounts.',
|
},
|
||||||
},
|
{
|
||||||
{
|
question: 'Is scan analytics GDPR compliant?',
|
||||||
question: 'Can I generate bulk QR codes for print?',
|
answer: 'Yes. All QR Master analytics are fully GDPR compliant. We collect only necessary data, provide transparent privacy policies, allow users to opt out, and store data securely in EU-compliant data centers. You maintain full control over data retention and deletion.',
|
||||||
answer: 'Yes. Our bulk QR generation tool allows you to create thousands of QR codes at once by uploading a CSV file. Each code can be customized with unique URLs, UTM parameters, and branding. Download print-ready files in SVG, PNG, or PDF formats optimized for high-resolution printing.',
|
},
|
||||||
},
|
{
|
||||||
{
|
question: 'Can QR Master track campaigns with UTM?',
|
||||||
question: 'How do I brand my QR codes?',
|
answer: 'Absolutely. QR Master supports UTM parameter integration for all dynamic QR codes. Automatically append source, medium, campaign, term, and content parameters to track QR performance in Google Analytics, Adobe Analytics, and other marketing platforms. UTM tracking enables multi-channel attribution and ROI measurement.',
|
||||||
answer: 'QR Master offers customization options including custom colors, corner styles, and pattern designs. Branded QR codes maintain scannability while matching your brand identity. Choose your color palette and preview designs before downloading.',
|
},
|
||||||
},
|
{
|
||||||
{
|
question: 'Difference between static and dynamic QR codes?',
|
||||||
question: 'Is scan analytics GDPR compliant?',
|
answer: 'Static QR codes encode the destination URL directly in the code pattern and cannot be changed after creation. Dynamic QR codes use a short redirect URL, allowing you to update destinations, track scans, enable/disable codes, and gather analytics—all without reprinting. Dynamic codes are essential for professional marketing campaigns.',
|
||||||
answer: 'Yes. All QR Master analytics are fully GDPR compliant. We collect only necessary data, provide transparent privacy policies, allow users to opt out, and store data securely in EU-compliant data centers. You maintain full control over data retention and deletion.',
|
},
|
||||||
},
|
{
|
||||||
{
|
question: 'How are QR codes used for events?',
|
||||||
question: 'Can QR Master track campaigns with UTM?',
|
answer: 'QR codes streamline event check-ins, ticket validation, attendee tracking, and engagement measurement. Generate unique codes for each ticket, track scan times and locations, enable contactless entry, and analyze attendee behavior. Event organizers use QR analytics to measure session popularity and optimize future events.',
|
||||||
answer: 'Absolutely. QR Master supports UTM parameter integration for all dynamic QR codes. Automatically append source, medium, campaign, term, and content parameters to track QR performance in Google Analytics, Adobe Analytics, and other marketing platforms. UTM tracking enables multi-channel attribution and ROI measurement.',
|
},
|
||||||
},
|
{
|
||||||
{
|
question: 'Can I make QR codes for business cards?',
|
||||||
question: 'Difference between static and dynamic QR codes?',
|
answer: 'Yes. QR codes on business cards provide instant contact sharing via vCard format, link to your portfolio or LinkedIn profile, and track networking effectiveness. Use branded QR codes that match your card design, and leverage scan analytics to see how many contacts engage and when they follow up.',
|
||||||
answer: 'Static QR codes encode the destination URL directly in the code pattern and cannot be changed after creation. Dynamic QR codes use a short redirect URL, allowing you to update destinations, track scans, enable/disable codes, and gather analytics—all without reprinting. Dynamic codes are essential for professional marketing campaigns.',
|
},
|
||||||
},
|
{
|
||||||
{
|
question: 'How do I use QR codes for bulk marketing?',
|
||||||
question: 'How are QR codes used for events?',
|
answer: 'Bulk QR codes enable scalable campaigns across print ads, packaging, direct mail, and retail displays. Generate thousands of codes with unique tracking URLs, distribute them across channels, and use analytics to measure which placements drive the highest engagement. Bulk generation supports CSV upload, API integration, and automated workflows.',
|
||||||
answer: 'QR codes streamline event check-ins, ticket validation, attendee tracking, and engagement measurement. Generate unique codes for each ticket, track scan times and locations, enable contactless entry, and analyze attendee behavior. Event organizers use QR analytics to measure session popularity and optimize future events.',
|
},
|
||||||
},
|
{
|
||||||
{
|
question: 'Is API access available for bulk QR generation?',
|
||||||
question: 'Can I make QR codes for business cards?',
|
answer: 'Yes. QR Master offers a developer-friendly REST API for programmatic QR code generation, URL management, and analytics retrieval. Integrate QR creation into your CRM, marketing automation platform, or e-commerce system. API access is included in Business plans and supports bulk operations, webhooks, and real-time updates.',
|
||||||
answer: 'Yes. QR codes on business cards provide instant contact sharing via vCard format, link to your portfolio or LinkedIn profile, and track networking effectiveness. Use branded QR codes that match your card design, and leverage scan analytics to see how many contacts engage and when they follow up.',
|
},
|
||||||
},
|
];
|
||||||
{
|
|
||||||
question: 'How do I use QR codes for bulk marketing?',
|
export default function FAQPage() {
|
||||||
answer: 'Bulk QR codes enable scalable campaigns across print ads, packaging, direct mail, and retail displays. Generate thousands of codes with unique tracking URLs, distribute them across channels, and use analytics to measure which placements drive the highest engagement. Bulk generation supports CSV upload, API integration, and automated workflows.',
|
return (
|
||||||
},
|
<>
|
||||||
{
|
<SeoJsonLd data={faqPageSchema(faqs)} />
|
||||||
question: 'Is API access available for bulk QR generation?',
|
<div className="py-20 bg-gradient-to-b from-gray-50 to-white">
|
||||||
answer: 'Yes. QR Master offers a developer-friendly REST API for programmatic QR code generation, URL management, and analytics retrieval. Integrate QR creation into your CRM, marketing automation platform, or e-commerce system. API access is included in Business plans and supports bulk operations, webhooks, and real-time updates.',
|
<div className="container mx-auto px-4">
|
||||||
},
|
<div className="max-w-4xl mx-auto">
|
||||||
];
|
<div className="text-center mb-16">
|
||||||
|
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-6">
|
||||||
export default function FAQPage() {
|
Frequently Asked Questions
|
||||||
return (
|
</h1>
|
||||||
<>
|
<p className="text-xl text-gray-600">
|
||||||
<SeoJsonLd data={faqPageSchema(faqs)} />
|
Everything you need to know about dynamic QR codes, security, analytics, bulk generation, events, and print quality.
|
||||||
<div className="py-20 bg-gradient-to-b from-gray-50 to-white">
|
</p>
|
||||||
<div className="container mx-auto px-4">
|
</div>
|
||||||
<div className="max-w-4xl mx-auto">
|
|
||||||
<div className="text-center mb-16">
|
<div className="space-y-6">
|
||||||
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-6">
|
{faqs.map((faq, index) => (
|
||||||
Frequently Asked Questions
|
<Card key={index} className="border-l-4 border-blue-500">
|
||||||
</h1>
|
<CardContent className="p-8">
|
||||||
<p className="text-xl text-gray-600">
|
<h2 className="text-2xl font-semibold mb-4 text-gray-900">
|
||||||
Everything you need to know about dynamic QR codes, security, analytics, bulk generation, events, and print quality.
|
{faq.question}
|
||||||
</p>
|
</h2>
|
||||||
</div>
|
<p className="text-lg text-gray-700 leading-relaxed">
|
||||||
|
{faq.answer}
|
||||||
<div className="space-y-6">
|
</p>
|
||||||
{faqs.map((faq, index) => (
|
</CardContent>
|
||||||
<Card key={index} className="border-l-4 border-blue-500">
|
</Card>
|
||||||
<CardContent className="p-8">
|
))}
|
||||||
<h2 className="text-2xl font-semibold mb-4 text-gray-900">
|
</div>
|
||||||
{faq.question}
|
|
||||||
</h2>
|
<div className="mt-16 bg-blue-50 border-l-4 border-blue-500 p-8 rounded-r-lg">
|
||||||
<p className="text-lg text-gray-700 leading-relaxed">
|
<h2 className="text-2xl font-bold mb-4 text-gray-900">
|
||||||
{faq.answer}
|
Still have questions?
|
||||||
</p>
|
</h2>
|
||||||
</CardContent>
|
<p className="text-lg text-gray-700 mb-6 leading-relaxed">
|
||||||
</Card>
|
Our support team is here to help. Contact us at{' '}
|
||||||
))}
|
<a href="mailto:support@qrmaster.net" className="text-blue-600 hover:text-blue-700 font-semibold">
|
||||||
</div>
|
support@qrmaster.net
|
||||||
|
</a>{' '}
|
||||||
<ContactSupport />
|
or reach out through our live chat.
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
</div>
|
||||||
}
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,63 +1,63 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
|
||||||
import '@/styles/globals.css';
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
export default function NotFound() {
|
<div className="min-h-screen bg-white flex items-center justify-center px-4">
|
||||||
return (
|
<div className="max-w-2xl w-full text-center">
|
||||||
<div className="min-h-screen bg-white flex items-center justify-center px-4">
|
{/* 404 Icon */}
|
||||||
<div className="max-w-2xl w-full text-center">
|
<div className="mb-8">
|
||||||
{/* 404 Icon */}
|
<div className="inline-flex items-center justify-center w-24 h-24 bg-primary-100 rounded-full mb-6">
|
||||||
<div className="mb-8">
|
<svg
|
||||||
<div className="inline-flex items-center justify-center w-24 h-24 bg-primary-100 rounded-full mb-6">
|
className="w-12 h-12 text-primary-600"
|
||||||
<svg
|
fill="none"
|
||||||
className="w-12 h-12 text-primary-600"
|
stroke="currentColor"
|
||||||
fill="none"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 24 24"
|
>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<path
|
||||||
>
|
strokeLinecap="round"
|
||||||
<path
|
strokeLinejoin="round"
|
||||||
strokeLinecap="round"
|
strokeWidth={2}
|
||||||
strokeLinejoin="round"
|
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
strokeWidth={2}
|
/>
|
||||||
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
</svg>
|
||||||
/>
|
</div>
|
||||||
</svg>
|
|
||||||
</div>
|
{/* 404 Text */}
|
||||||
|
<h1 className="text-6xl md:text-8xl font-bold text-gray-900 mb-4">404</h1>
|
||||||
{/* 404 Text */}
|
<h2 className="text-2xl md:text-3xl font-semibold text-gray-700 mb-4">
|
||||||
<h1 className="text-6xl md:text-8xl font-bold text-gray-900 mb-4">404</h1>
|
Page Not Found
|
||||||
<h2 className="text-2xl md:text-3xl font-semibold text-gray-700 mb-4">
|
</h2>
|
||||||
Page Not Found
|
<p className="text-lg text-gray-600 mb-8 max-w-md mx-auto">
|
||||||
</h2>
|
Sorry, we couldn't find the page you're looking for. It might have been moved or deleted.
|
||||||
<p className="text-lg text-gray-600 mb-8 max-w-md mx-auto">
|
</p>
|
||||||
Sorry, we couldn't find the page you're looking for. It might have been moved or deleted.
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
{/* Action Button */}
|
||||||
|
<div className="flex justify-center">
|
||||||
{/* Action Button */}
|
<Link href="/">
|
||||||
<div className="flex justify-center">
|
<Button size="lg">
|
||||||
<Link href="/" className="inline-flex items-center justify-center px-4 py-2 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
|
<svg
|
||||||
<svg
|
className="w-5 h-5 mr-2"
|
||||||
className="w-5 h-5 mr-2"
|
fill="none"
|
||||||
fill="none"
|
stroke="currentColor"
|
||||||
stroke="currentColor"
|
viewBox="0 0 24 24"
|
||||||
viewBox="0 0 24 24"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
>
|
||||||
>
|
<path
|
||||||
<path
|
strokeLinecap="round"
|
||||||
strokeLinecap="round"
|
strokeLinejoin="round"
|
||||||
strokeLinejoin="round"
|
strokeWidth={2}
|
||||||
strokeWidth={2}
|
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
|
||||||
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
|
/>
|
||||||
/>
|
</svg>
|
||||||
</svg>
|
Back to Home
|
||||||
Back to Home
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import type { Metadata } from 'next';
|
|||||||
import SeoJsonLd from '@/components/SeoJsonLd';
|
import SeoJsonLd from '@/components/SeoJsonLd';
|
||||||
import { organizationSchema, websiteSchema } from '@/lib/schema';
|
import { organizationSchema, websiteSchema } from '@/lib/schema';
|
||||||
import HomePageClient from '@/components/marketing/HomePageClient';
|
import HomePageClient from '@/components/marketing/HomePageClient';
|
||||||
import { generateFaqSchema } from '@/lib/schema-utils';
|
|
||||||
import en from '@/i18n/en.json'; // Import English translations for schema generation
|
|
||||||
|
|
||||||
function truncateAtWord(text: string, maxLength: number): string {
|
function truncateAtWord(text: string, maxLength: number): string {
|
||||||
if (text.length <= maxLength) return text;
|
if (text.length <= maxLength) return text;
|
||||||
@@ -16,7 +14,7 @@ function truncateAtWord(text: string, maxLength: number): string {
|
|||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const title = truncateAtWord('QR Master: Dynamic QR Generator', 60);
|
const title = truncateAtWord('QR Master: Dynamic QR Generator', 60);
|
||||||
const description = truncateAtWord(
|
const description = truncateAtWord(
|
||||||
'Create professional QR codes with QR Master. Dynamic QR with tracking, bulk generation, custom branding, and real-time analytics for all your campaigns.',
|
'Dynamic QR, branding, bulk generation & analytics for all campaigns.',
|
||||||
160
|
160
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -28,7 +26,6 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
languages: {
|
languages: {
|
||||||
'x-default': 'https://www.qrmaster.net/',
|
'x-default': 'https://www.qrmaster.net/',
|
||||||
en: 'https://www.qrmaster.net/',
|
en: 'https://www.qrmaster.net/',
|
||||||
de: 'https://www.qrmaster.net/qr-code-erstellen',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
@@ -36,19 +33,10 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
description,
|
description,
|
||||||
url: 'https://www.qrmaster.net/',
|
url: 'https://www.qrmaster.net/',
|
||||||
type: 'website',
|
type: 'website',
|
||||||
images: [
|
|
||||||
{
|
|
||||||
url: 'https://www.qrmaster.net/og-image.png',
|
|
||||||
width: 1200,
|
|
||||||
height: 630,
|
|
||||||
alt: 'QR Master - Dynamic QR Code Generator and Analytics Platform',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
images: ['https://www.qrmaster.net/og-image.png'],
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -56,13 +44,11 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SeoJsonLd data={[organizationSchema(), websiteSchema(), generateFaqSchema(en.faq.questions)]} />
|
<SeoJsonLd data={[organizationSchema(), websiteSchema()]} />
|
||||||
|
|
||||||
{/* Server-rendered H1 for SEO - visually hidden but crawlable */}
|
|
||||||
<h1 className="sr-only">QR Master: Dynamic QR Code Generator with Analytics</h1>
|
|
||||||
|
|
||||||
{/* Server-rendered SEO content for crawlers */}
|
{/* Server-rendered SEO content for crawlers */}
|
||||||
<div className="sr-only" aria-hidden="false">
|
<div className="sr-only" aria-hidden="false">
|
||||||
|
<h1>QR Master: Free Dynamic QR Code Generator with Tracking & Analytics</h1>
|
||||||
<p>
|
<p>
|
||||||
Create professional QR codes for your business with QR Master. Our dynamic QR code generator
|
Create professional QR codes for your business with QR Master. Our dynamic QR code generator
|
||||||
lets you create trackable QR codes, edit destinations anytime, and view detailed analytics.
|
lets you create trackable QR codes, edit destinations anytime, and view detailed analytics.
|
||||||
|
|||||||
@@ -1,269 +1,268 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
import { showToast } from '@/components/ui/Toast';
|
import { showToast } from '@/components/ui/Toast';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { BillingToggle } from '@/components/ui/BillingToggle';
|
import { BillingToggle } from '@/components/ui/BillingToggle';
|
||||||
import { ObfuscatedMailto } from '@/components/ui/ObfuscatedMailto';
|
|
||||||
|
export default function PricingPage() {
|
||||||
export default function PricingClient() {
|
const router = useRouter();
|
||||||
const router = useRouter();
|
const [loading, setLoading] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState<string | null>(null);
|
const [currentPlan, setCurrentPlan] = useState<string>('FREE');
|
||||||
const [currentPlan, setCurrentPlan] = useState<string>('FREE');
|
const [currentInterval, setCurrentInterval] = useState<'month' | 'year' | null>(null);
|
||||||
const [currentInterval, setCurrentInterval] = useState<'month' | 'year' | null>(null);
|
const [billingPeriod, setBillingPeriod] = useState<'month' | 'year'>('month');
|
||||||
const [billingPeriod, setBillingPeriod] = useState<'month' | 'year'>('month');
|
|
||||||
|
useEffect(() => {
|
||||||
useEffect(() => {
|
// Fetch current user plan
|
||||||
// Fetch current user plan
|
const fetchUserPlan = async () => {
|
||||||
const fetchUserPlan = async () => {
|
try {
|
||||||
try {
|
const response = await fetch('/api/user/plan');
|
||||||
const response = await fetch('/api/user/plan');
|
if (response.ok) {
|
||||||
if (response.ok) {
|
const data = await response.json();
|
||||||
const data = await response.json();
|
setCurrentPlan(data.plan || 'FREE');
|
||||||
setCurrentPlan(data.plan || 'FREE');
|
setCurrentInterval(data.interval || null);
|
||||||
setCurrentInterval(data.interval || null);
|
}
|
||||||
}
|
} catch (error) {
|
||||||
} catch (error) {
|
console.error('Error fetching user plan:', error);
|
||||||
console.error('Error fetching user plan:', error);
|
}
|
||||||
}
|
};
|
||||||
};
|
|
||||||
|
fetchUserPlan();
|
||||||
fetchUserPlan();
|
}, []);
|
||||||
}, []);
|
|
||||||
|
const handleUpgrade = async (plan: 'PRO' | 'BUSINESS') => {
|
||||||
const handleUpgrade = async (plan: 'PRO' | 'BUSINESS') => {
|
setLoading(plan);
|
||||||
setLoading(plan);
|
|
||||||
|
try {
|
||||||
try {
|
const response = await fetch('/api/stripe/create-checkout-session', {
|
||||||
const response = await fetch('/api/stripe/create-checkout-session', {
|
method: 'POST',
|
||||||
method: 'POST',
|
headers: {
|
||||||
headers: {
|
'Content-Type': 'application/json',
|
||||||
'Content-Type': 'application/json',
|
},
|
||||||
},
|
body: JSON.stringify({
|
||||||
body: JSON.stringify({
|
plan,
|
||||||
plan,
|
billingInterval: billingPeriod === 'month' ? 'month' : 'year',
|
||||||
billingInterval: billingPeriod === 'month' ? 'month' : 'year',
|
}),
|
||||||
}),
|
});
|
||||||
});
|
|
||||||
|
if (!response.ok) {
|
||||||
if (!response.ok) {
|
throw new Error('Failed to create checkout session');
|
||||||
throw new Error('Failed to create checkout session');
|
}
|
||||||
}
|
|
||||||
|
const { url } = await response.json();
|
||||||
const { url } = await response.json();
|
window.location.href = url;
|
||||||
window.location.href = url;
|
} catch (error) {
|
||||||
} catch (error) {
|
console.error('Error creating checkout session:', error);
|
||||||
console.error('Error creating checkout session:', error);
|
showToast('Failed to start checkout. Please try again.', 'error');
|
||||||
showToast('Failed to start checkout. Please try again.', 'error');
|
setLoading(null);
|
||||||
setLoading(null);
|
}
|
||||||
}
|
};
|
||||||
};
|
|
||||||
|
const handleDowngrade = async () => {
|
||||||
const handleDowngrade = async () => {
|
// Show confirmation dialog
|
||||||
// Show confirmation dialog
|
const confirmed = window.confirm(
|
||||||
const confirmed = window.confirm(
|
'Are you sure you want to downgrade to the Free plan? Your subscription will be canceled immediately and you will lose access to premium features.'
|
||||||
'Are you sure you want to downgrade to the Free plan? Your subscription will be canceled immediately and you will lose access to premium features.'
|
);
|
||||||
);
|
|
||||||
|
if (!confirmed) {
|
||||||
if (!confirmed) {
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
setLoading('FREE');
|
||||||
setLoading('FREE');
|
|
||||||
|
try {
|
||||||
try {
|
const response = await fetch('/api/stripe/cancel-subscription', {
|
||||||
const response = await fetch('/api/stripe/cancel-subscription', {
|
method: 'POST',
|
||||||
method: 'POST',
|
headers: {
|
||||||
headers: {
|
'Content-Type': 'application/json',
|
||||||
'Content-Type': 'application/json',
|
},
|
||||||
},
|
});
|
||||||
});
|
|
||||||
|
if (!response.ok) {
|
||||||
if (!response.ok) {
|
const error = await response.json();
|
||||||
const error = await response.json();
|
throw new Error(error.error || 'Failed to cancel subscription');
|
||||||
throw new Error(error.error || 'Failed to cancel subscription');
|
}
|
||||||
}
|
|
||||||
|
showToast('Successfully downgraded to Free plan', 'success');
|
||||||
showToast('Successfully downgraded to Free plan', 'success');
|
|
||||||
|
// Refresh to update the plan
|
||||||
// Refresh to update the plan
|
setTimeout(() => {
|
||||||
setTimeout(() => {
|
window.location.reload();
|
||||||
window.location.reload();
|
}, 1500);
|
||||||
}, 1500);
|
} catch (error: any) {
|
||||||
} catch (error: any) {
|
console.error('Error canceling subscription:', error);
|
||||||
console.error('Error canceling subscription:', error);
|
showToast(error.message || 'Failed to downgrade. Please try again.', 'error');
|
||||||
showToast(error.message || 'Failed to downgrade. Please try again.', 'error');
|
setLoading(null);
|
||||||
setLoading(null);
|
}
|
||||||
}
|
};
|
||||||
};
|
|
||||||
|
// Helper function to check if this is the user's exact current plan (plan + interval)
|
||||||
// Helper function to check if this is the user's exact current plan (plan + interval)
|
const isCurrentPlanWithInterval = (planType: string, interval: 'month' | 'year') => {
|
||||||
const isCurrentPlanWithInterval = (planType: string, interval: 'month' | 'year') => {
|
return currentPlan === planType && currentInterval === interval;
|
||||||
return currentPlan === planType && currentInterval === interval;
|
};
|
||||||
};
|
|
||||||
|
// Helper function to check if user has this plan but different interval
|
||||||
// Helper function to check if user has this plan but different interval
|
const hasPlanDifferentInterval = (planType: string) => {
|
||||||
const hasPlanDifferentInterval = (planType: string) => {
|
return currentPlan === planType && currentInterval && currentInterval !== billingPeriod;
|
||||||
return currentPlan === planType && currentInterval && currentInterval !== billingPeriod;
|
};
|
||||||
};
|
|
||||||
|
const selectedInterval = billingPeriod === 'month' ? 'month' : 'year';
|
||||||
const selectedInterval = billingPeriod === 'month' ? 'month' : 'year';
|
|
||||||
|
const plans = [
|
||||||
const plans = [
|
{
|
||||||
{
|
key: 'free',
|
||||||
key: 'free',
|
name: 'Free',
|
||||||
name: 'Free',
|
price: '€0',
|
||||||
price: '€0',
|
period: 'forever',
|
||||||
period: 'forever',
|
showDiscount: false,
|
||||||
showDiscount: false,
|
features: [
|
||||||
features: [
|
'3 dynamic QR codes',
|
||||||
'3 dynamic QR codes',
|
'Unlimited static QR codes',
|
||||||
'Unlimited static QR codes',
|
'Basic scan tracking',
|
||||||
'Basic scan tracking',
|
'Standard QR design templates',
|
||||||
'Standard QR design templates',
|
'Download as SVG/PNG',
|
||||||
'Download as SVG/PNG',
|
],
|
||||||
],
|
buttonText: currentPlan === 'FREE' ? 'Current Plan' : 'Downgrade to Free',
|
||||||
buttonText: currentPlan === 'FREE' ? 'Current Plan' : 'Downgrade to Free',
|
buttonVariant: 'outline' as const,
|
||||||
buttonVariant: 'outline' as const,
|
disabled: currentPlan === 'FREE',
|
||||||
disabled: currentPlan === 'FREE',
|
popular: false,
|
||||||
popular: false,
|
onDowngrade: handleDowngrade,
|
||||||
onDowngrade: handleDowngrade,
|
},
|
||||||
},
|
{
|
||||||
{
|
key: 'pro',
|
||||||
key: 'pro',
|
name: 'Pro',
|
||||||
name: 'Pro',
|
price: billingPeriod === 'month' ? '€9' : '€90',
|
||||||
price: billingPeriod === 'month' ? '€9' : '€90',
|
period: billingPeriod === 'month' ? 'per month' : 'per year',
|
||||||
period: billingPeriod === 'month' ? 'per month' : 'per year',
|
showDiscount: billingPeriod === 'year',
|
||||||
showDiscount: billingPeriod === 'year',
|
features: [
|
||||||
features: [
|
'50 dynamic QR codes',
|
||||||
'50 dynamic QR codes',
|
'Unlimited static QR codes',
|
||||||
'Unlimited static QR codes',
|
'Advanced analytics (scans, devices, locations)',
|
||||||
'Advanced analytics (scans, devices, locations)',
|
'Custom branding (colors & logos)',
|
||||||
'Custom branding (colors & logos)',
|
],
|
||||||
],
|
buttonText: isCurrentPlanWithInterval('PRO', selectedInterval)
|
||||||
buttonText: isCurrentPlanWithInterval('PRO', selectedInterval)
|
? 'Current Plan'
|
||||||
? 'Current Plan'
|
: hasPlanDifferentInterval('PRO')
|
||||||
: hasPlanDifferentInterval('PRO')
|
? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}`
|
||||||
? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}`
|
: 'Upgrade to Pro',
|
||||||
: 'Upgrade to Pro',
|
buttonVariant: 'primary' as const,
|
||||||
buttonVariant: 'primary' as const,
|
disabled: isCurrentPlanWithInterval('PRO', selectedInterval),
|
||||||
disabled: isCurrentPlanWithInterval('PRO', selectedInterval),
|
popular: true,
|
||||||
popular: true,
|
onUpgrade: () => handleUpgrade('PRO'),
|
||||||
onUpgrade: () => handleUpgrade('PRO'),
|
},
|
||||||
},
|
{
|
||||||
{
|
key: 'business',
|
||||||
key: 'business',
|
name: 'Business',
|
||||||
name: 'Business',
|
price: billingPeriod === 'month' ? '€29' : '€290',
|
||||||
price: billingPeriod === 'month' ? '€29' : '€290',
|
period: billingPeriod === 'month' ? 'per month' : 'per year',
|
||||||
period: billingPeriod === 'month' ? 'per month' : 'per year',
|
showDiscount: billingPeriod === 'year',
|
||||||
showDiscount: billingPeriod === 'year',
|
features: [
|
||||||
features: [
|
'500 dynamic QR codes',
|
||||||
'500 dynamic QR codes',
|
'Unlimited static QR codes',
|
||||||
'Unlimited static QR codes',
|
'Everything from Pro',
|
||||||
'Everything from Pro',
|
'Bulk QR Creation (up to 1,000)',
|
||||||
'Bulk QR Creation (up to 1,000)',
|
'Priority email support',
|
||||||
'Priority email support',
|
'Advanced tracking & insights',
|
||||||
'Advanced tracking & insights',
|
],
|
||||||
],
|
buttonText: isCurrentPlanWithInterval('BUSINESS', selectedInterval)
|
||||||
buttonText: isCurrentPlanWithInterval('BUSINESS', selectedInterval)
|
? 'Current Plan'
|
||||||
? 'Current Plan'
|
: hasPlanDifferentInterval('BUSINESS')
|
||||||
: hasPlanDifferentInterval('BUSINESS')
|
? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}`
|
||||||
? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}`
|
: 'Upgrade to Business',
|
||||||
: 'Upgrade to Business',
|
buttonVariant: 'primary' as const,
|
||||||
buttonVariant: 'primary' as const,
|
disabled: isCurrentPlanWithInterval('BUSINESS', selectedInterval),
|
||||||
disabled: isCurrentPlanWithInterval('BUSINESS', selectedInterval),
|
popular: false,
|
||||||
popular: false,
|
onUpgrade: () => handleUpgrade('BUSINESS'),
|
||||||
onUpgrade: () => handleUpgrade('BUSINESS'),
|
},
|
||||||
},
|
];
|
||||||
];
|
|
||||||
|
return (
|
||||||
return (
|
<div className="container mx-auto px-4 py-12">
|
||||||
<div className="container mx-auto px-4 py-12">
|
<div className="text-center mb-12">
|
||||||
<div className="text-center mb-12">
|
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
||||||
<h2 className="text-4xl font-bold text-gray-900 mb-4">
|
Choose Your Plan
|
||||||
Choose Your Plan
|
</h1>
|
||||||
</h2>
|
<p className="text-xl text-gray-600">
|
||||||
<p className="text-xl text-gray-600">
|
Select the perfect plan for your QR code needs
|
||||||
Select the perfect plan for your QR code needs
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div className="flex justify-center mb-8">
|
||||||
<div className="flex justify-center mb-8">
|
<BillingToggle value={billingPeriod} onChange={setBillingPeriod} />
|
||||||
<BillingToggle value={billingPeriod} onChange={setBillingPeriod} />
|
</div>
|
||||||
</div>
|
|
||||||
|
<div className="grid md:grid-cols-3 gap-8 max-w-6xl mx-auto">
|
||||||
<div className="grid md:grid-cols-3 gap-8 max-w-6xl mx-auto">
|
{plans.map((plan) => (
|
||||||
{plans.map((plan) => (
|
<Card
|
||||||
<Card
|
key={plan.key}
|
||||||
key={plan.key}
|
className={plan.popular ? 'border-primary-500 shadow-xl relative' : ''}
|
||||||
className={plan.popular ? 'border-primary-500 shadow-xl relative' : ''}
|
>
|
||||||
>
|
{plan.popular && (
|
||||||
{plan.popular && (
|
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
|
||||||
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
|
<Badge variant="info" className="px-3 py-1">
|
||||||
<Badge variant="info" className="px-3 py-1">
|
Most Popular
|
||||||
Most Popular
|
</Badge>
|
||||||
</Badge>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
<CardHeader className="text-center pb-8">
|
||||||
<CardHeader className="text-center pb-8">
|
<CardTitle className="text-2xl mb-4">
|
||||||
<CardTitle className="text-2xl mb-4">
|
{plan.name}
|
||||||
{plan.name}
|
</CardTitle>
|
||||||
</CardTitle>
|
<div className="flex flex-col items-center">
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex items-baseline justify-center">
|
||||||
<div className="flex items-baseline justify-center">
|
<span className="text-4xl font-bold">
|
||||||
<span className="text-4xl font-bold">
|
{plan.price}
|
||||||
{plan.price}
|
</span>
|
||||||
</span>
|
<span className="text-gray-600 ml-2">
|
||||||
<span className="text-gray-600 ml-2">
|
{plan.period}
|
||||||
{plan.period}
|
</span>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
{plan.showDiscount && (
|
||||||
{plan.showDiscount && (
|
<Badge variant="success" className="mt-2">
|
||||||
<Badge variant="success" className="mt-2">
|
Save 16%
|
||||||
Save 16%
|
</Badge>
|
||||||
</Badge>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</CardHeader>
|
||||||
</CardHeader>
|
|
||||||
|
<CardContent className="space-y-6">
|
||||||
<CardContent className="space-y-6">
|
<ul className="space-y-3">
|
||||||
<ul className="space-y-3">
|
{plan.features.map((feature: string, index: number) => (
|
||||||
{plan.features.map((feature: string, index: number) => (
|
<li key={index} className="flex items-start space-x-3">
|
||||||
<li key={index} className="flex items-start space-x-3">
|
<svg className="w-5 h-5 text-success-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<svg className="w-5 h-5 text-success-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
</svg>
|
||||||
</svg>
|
<span className="text-gray-700">{feature}</span>
|
||||||
<span className="text-gray-700">{feature}</span>
|
</li>
|
||||||
</li>
|
))}
|
||||||
))}
|
</ul>
|
||||||
</ul>
|
|
||||||
|
<Button
|
||||||
<Button
|
variant={plan.buttonVariant}
|
||||||
variant={plan.buttonVariant}
|
className="w-full"
|
||||||
className="w-full"
|
size="lg"
|
||||||
size="lg"
|
disabled={plan.disabled || loading === plan.key.toUpperCase()}
|
||||||
disabled={plan.disabled || loading === plan.key.toUpperCase()}
|
onClick={plan.key === 'free' ? (plan as any).onDowngrade : (plan as any).onUpgrade}
|
||||||
onClick={plan.key === 'free' ? (plan as any).onDowngrade : (plan as any).onUpgrade}
|
>
|
||||||
>
|
{loading === plan.key.toUpperCase() ? 'Processing...' : plan.buttonText}
|
||||||
{loading === plan.key.toUpperCase() ? 'Processing...' : plan.buttonText}
|
</Button>
|
||||||
</Button>
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
|
||||||
|
<div className="text-center mt-12">
|
||||||
<div className="text-center mt-12">
|
<p className="text-gray-600">
|
||||||
<p className="text-gray-600">
|
All plans include unlimited static QR codes and basic customization.
|
||||||
All plans include unlimited static QR codes and basic customization.
|
</p>
|
||||||
</p>
|
<p className="text-gray-600 mt-2">
|
||||||
<p className="text-gray-600 mt-2">
|
Need help choosing? <a href="mailto:support@qrmaster.net" className="text-primary-600 hover:text-primary-700 underline">Contact our team</a>
|
||||||
Need help choosing? <ObfuscatedMailto email="support@qrmaster.net" className="text-primary-600 hover:text-primary-700 underline">Contact our team</ObfuscatedMailto>
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,147 +1,133 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { PrivacyEmailLink } from './PrivacyEmailLink';
|
|
||||||
|
export const metadata = {
|
||||||
export const metadata = {
|
title: 'Privacy Policy | QR Master',
|
||||||
title: 'Privacy Policy | QR Master',
|
description: 'Privacy Policy and data protection information for QR Master',
|
||||||
description: 'Read our Privacy Policy to understand how QR Master collects, uses, and protects your data. We are committed to GDPR compliance and data security.',
|
};
|
||||||
alternates: {
|
|
||||||
canonical: 'https://www.qrmaster.net/privacy',
|
export default function PrivacyPage() {
|
||||||
},
|
return (
|
||||||
openGraph: {
|
<div className="min-h-screen bg-white py-12">
|
||||||
title: 'Privacy Policy | QR Master',
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-4xl">
|
||||||
description: 'Read our Privacy Policy to understand how QR Master collects, uses, and protects your data.',
|
<div className="mb-8">
|
||||||
url: 'https://www.qrmaster.net/privacy',
|
<Link href="/" className="text-primary-600 hover:text-primary-700 font-medium">
|
||||||
type: 'website',
|
← Back to Home
|
||||||
images: [
|
</Link>
|
||||||
{
|
</div>
|
||||||
url: 'https://www.qrmaster.net/og-image.png',
|
|
||||||
width: 1200,
|
<h1 className="text-4xl font-bold text-gray-900 mb-4">Privacy Policy</h1>
|
||||||
height: 630,
|
<p className="text-gray-600 mb-8">Last updated: January 2025</p>
|
||||||
alt: 'QR Master Privacy Policy',
|
|
||||||
},
|
<div className="prose prose-lg max-w-none">
|
||||||
],
|
<section className="mb-8">
|
||||||
},
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">1. Introduction</h2>
|
||||||
};
|
<p className="text-gray-700 mb-4">
|
||||||
|
Welcome to QR Master ("we," "our," or "us"). We respect your privacy and are committed to protecting your personal data.
|
||||||
export default function PrivacyPage() {
|
This privacy policy explains how we collect, use, and protect your information when you use our services.
|
||||||
return (
|
</p>
|
||||||
<div className="min-h-screen bg-white py-12">
|
<p className="text-gray-700 mb-4">
|
||||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-4xl">
|
We implement appropriate security measures including secure HTTPS transmission, password hashing, database access controls,
|
||||||
<div className="mb-8">
|
and CSRF protection to keep your data safe.
|
||||||
<Link href="/" className="text-primary-600 hover:text-primary-700 font-medium">
|
</p>
|
||||||
← Back to Home
|
</section>
|
||||||
</Link>
|
|
||||||
</div>
|
<section className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">2. Information We Collect</h2>
|
||||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">Privacy Policy</h1>
|
|
||||||
<p className="text-gray-600 mb-8">Last updated: January 2025</p>
|
<h3 className="text-xl font-semibold text-gray-900 mb-3">Information You Provide</h3>
|
||||||
|
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
||||||
<div className="prose prose-lg max-w-none">
|
<li><strong>Account Information:</strong> Name, email address, and password</li>
|
||||||
<section className="mb-8">
|
<li><strong>Payment Information:</strong> Processed securely through Stripe (we do not store credit card information)</li>
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">1. Introduction</h2>
|
<li><strong>QR Code Content:</strong> URLs, text, and customization settings for your QR codes</li>
|
||||||
<p className="text-gray-700 mb-4">
|
</ul>
|
||||||
Welcome to QR Master ("we," "our," or "us"). We respect your privacy and are committed to protecting your personal data.
|
|
||||||
This privacy policy explains how we collect, use, and protect your information when you use our services.
|
<h3 className="text-xl font-semibold text-gray-900 mb-3">Information Collected Automatically</h3>
|
||||||
</p>
|
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
||||||
<p className="text-gray-700 mb-4">
|
<li><strong>Usage Data:</strong> QR code scans and analytics</li>
|
||||||
We implement appropriate security measures including secure HTTPS transmission, password hashing, database access controls,
|
<li><strong>Technical Data:</strong> IP address, browser type, and device information</li>
|
||||||
and CSRF protection to keep your data safe.
|
<li><strong>Cookies:</strong> Essential cookies for authentication and optional analytics cookies (PostHog) with your consent</li>
|
||||||
</p>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">2. Information We Collect</h2>
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">3. How We Use Your Information</h2>
|
||||||
|
<p className="text-gray-700 mb-4">We use your data to:</p>
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">Information You Provide</h3>
|
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
||||||
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
<li>Provide and maintain our QR code services</li>
|
||||||
<li><strong>Account Information:</strong> Name, email address, and password</li>
|
<li>Process payments and manage subscriptions</li>
|
||||||
<li><strong>Payment Information:</strong> Processed securely through Stripe (we do not store credit card information)</li>
|
<li>Provide customer support</li>
|
||||||
<li><strong>QR Code Content:</strong> URLs, text, and customization settings for your QR codes</li>
|
<li>Improve our services and develop new features</li>
|
||||||
</ul>
|
<li>Detect and prevent fraud</li>
|
||||||
|
</ul>
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">Information Collected Automatically</h3>
|
<p className="text-gray-700 mb-4">
|
||||||
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
We retain your data while your account is active. Upon account deletion, most data is removed immediately,
|
||||||
<li><strong>Usage Data:</strong> QR code scans and analytics</li>
|
though some may be retained for legal compliance. Aggregated, anonymized analytics may be kept indefinitely.
|
||||||
<li><strong>Technical Data:</strong> IP address, browser type, and device information</li>
|
</p>
|
||||||
<li><strong>Cookies:</strong> Essential cookies for authentication and optional analytics cookies (PostHog) with your consent</li>
|
</section>
|
||||||
</ul>
|
|
||||||
</section>
|
<section className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">4. Data Sharing</h2>
|
||||||
<section className="mb-8">
|
<p className="text-gray-700 mb-4">We may share your data with:</p>
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">3. How We Use Your Information</h2>
|
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
||||||
<p className="text-gray-700 mb-4">We use your data to:</p>
|
<li><strong>Stripe:</strong> Payment processing</li>
|
||||||
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
<li><strong>PostHog:</strong> Analytics (only with your consent, respects Do Not Track)</li>
|
||||||
<li>Provide and maintain our QR code services</li>
|
<li><strong>Vercel:</strong> Cloud hosting provider</li>
|
||||||
<li>Process payments and manage subscriptions</li>
|
<li><strong>Legal Requirements:</strong> When required by law</li>
|
||||||
<li>Provide customer support</li>
|
</ul>
|
||||||
<li>Improve our services and develop new features</li>
|
<p className="text-gray-700 mb-4">
|
||||||
<li>Detect and prevent fraud</li>
|
We do not sell your personal data. Analytics are only activated if you accept optional cookies.
|
||||||
</ul>
|
</p>
|
||||||
<p className="text-gray-700 mb-4">
|
</section>
|
||||||
We retain your data while your account is active. Upon account deletion, most data is removed immediately,
|
|
||||||
though some may be retained for legal compliance. Aggregated, anonymized analytics may be kept indefinitely.
|
<section className="mb-8">
|
||||||
</p>
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">5. Your Rights (GDPR)</h2>
|
||||||
</section>
|
<p className="text-gray-700 mb-4">You have the right to:</p>
|
||||||
|
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
||||||
<section className="mb-8">
|
<li><strong>Access:</strong> Request a copy of your personal data</li>
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">4. Data Sharing</h2>
|
<li><strong>Rectification:</strong> Correct inaccurate data (update in account settings)</li>
|
||||||
<p className="text-gray-700 mb-4">We may share your data with:</p>
|
<li><strong>Erasure:</strong> Delete your data (account deletion available in settings)</li>
|
||||||
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
<li><strong>Data Portability:</strong> Receive your data in a portable format</li>
|
||||||
<li><strong>Stripe:</strong> Payment processing</li>
|
<li><strong>Object:</strong> Object to processing based on legitimate interests</li>
|
||||||
<li><strong>PostHog:</strong> Analytics (only with your consent, respects Do Not Track)</li>
|
<li><strong>Withdraw Consent:</strong> Withdraw cookie consent at any time</li>
|
||||||
<li><strong>Vercel:</strong> Cloud hosting provider</li>
|
</ul>
|
||||||
<li><strong>Legal Requirements:</strong> When required by law</li>
|
<p className="text-gray-700 mb-4">
|
||||||
</ul>
|
To exercise these rights, contact us at{' '}
|
||||||
<p className="text-gray-700 mb-4">
|
<a href="mailto:support@qrmaster.net" className="text-primary-600 hover:text-primary-700">
|
||||||
We do not sell your personal data. Analytics are only activated if you accept optional cookies.
|
support@qrmaster.net
|
||||||
</p>
|
</a>
|
||||||
</section>
|
</p>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
<section className="mb-8">
|
Our service is for users 16 years and older. If you're in the EEA and have concerns,
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">5. Your Rights (GDPR)</h2>
|
you may lodge a complaint with your local data protection authority.
|
||||||
<p className="text-gray-700 mb-4">You have the right to:</p>
|
</p>
|
||||||
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
</section>
|
||||||
<li><strong>Access:</strong> Request a copy of your personal data</li>
|
|
||||||
<li><strong>Rectification:</strong> Correct inaccurate data (update in account settings)</li>
|
<section className="mb-8">
|
||||||
<li><strong>Erasure:</strong> Delete your data (account deletion available in settings)</li>
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">6. Contact Us</h2>
|
||||||
<li><strong>Data Portability:</strong> Receive your data in a portable format</li>
|
<p className="text-gray-700 mb-4">
|
||||||
<li><strong>Object:</strong> Object to processing based on legitimate interests</li>
|
If you have questions about this privacy policy, please contact us:
|
||||||
<li><strong>Withdraw Consent:</strong> Withdraw cookie consent at any time</li>
|
</p>
|
||||||
</ul>
|
<div className="bg-gray-50 p-6 rounded-lg">
|
||||||
<p className="text-gray-700 mb-4">
|
<p className="text-gray-700 mb-2">
|
||||||
To exercise these rights, contact us at{' '}
|
<strong>Email:</strong>{' '}
|
||||||
<PrivacyEmailLink />
|
<a href="mailto:support@qrmaster.net" className="text-primary-600 hover:text-primary-700">
|
||||||
</p>
|
support@qrmaster.net
|
||||||
<p className="text-gray-700 mb-4">
|
</a>
|
||||||
Our service is for users 16 years and older. If you're in the EEA and have concerns,
|
</p>
|
||||||
you may lodge a complaint with your local data protection authority.
|
<p className="text-gray-700 mb-2"><strong>Website:</strong> <a href="/" className="text-primary-600 hover:text-primary-700">qrmaster.net</a></p>
|
||||||
</p>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
<section className="mb-8">
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">6. Contact Us</h2>
|
<div className="mt-12 pt-8 border-t border-gray-200">
|
||||||
<p className="text-gray-700 mb-4">
|
<p className="text-gray-600 text-center">
|
||||||
If you have questions about this privacy policy, please contact us:
|
<Link href="/" className="text-primary-600 hover:text-primary-700">
|
||||||
</p>
|
Back to Home
|
||||||
<div className="bg-gray-50 p-6 rounded-lg">
|
</Link>
|
||||||
<p className="text-gray-700 mb-2">
|
</p>
|
||||||
<strong>Email:</strong>{' '}
|
</div>
|
||||||
<PrivacyEmailLink />
|
</div>
|
||||||
</p>
|
</div>
|
||||||
<p className="text-gray-700 mb-2"><strong>Website:</strong> <a href="/" className="text-primary-600 hover:text-primary-700">qrmaster.net</a></p>
|
);
|
||||||
</div>
|
}
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-12 pt-8 border-t border-gray-200">
|
|
||||||
<p className="text-gray-600 text-center">
|
|
||||||
<Link href="/" className="text-primary-600 hover:text-primary-700">
|
|
||||||
Back to Home
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,415 +1,398 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import SeoJsonLd from '@/components/SeoJsonLd';
|
import SeoJsonLd from '@/components/SeoJsonLd';
|
||||||
import Breadcrumbs, { BreadcrumbItem } from '@/components/Breadcrumbs';
|
import Breadcrumbs, { BreadcrumbItem } from '@/components/Breadcrumbs';
|
||||||
import { breadcrumbSchema } from '@/lib/schema';
|
import { breadcrumbSchema } from '@/lib/schema';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'QR Code Tracking & Analytics - Track Scans | QR Master',
|
title: 'QR Code Tracking & Analytics - Track Every Scan | QR Master',
|
||||||
description: 'Track QR code scans with real-time analytics. Monitor location, device, time, and user behavior. Free QR code tracking software with detailed reports.',
|
description: 'Track QR code scans with real-time analytics. Monitor location, device, time, and user behavior. Free QR code tracking software with detailed reports.',
|
||||||
keywords: 'qr code tracking, qr code analytics, track qr scans, qr code statistics, free qr tracking, qr code monitoring',
|
keywords: 'qr code tracking, qr code analytics, track qr scans, qr code statistics, free qr tracking, qr code monitoring',
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: 'https://www.qrmaster.net/qr-code-tracking',
|
canonical: 'https://www.qrmaster.net/qr-code-tracking',
|
||||||
languages: {
|
languages: {
|
||||||
'x-default': 'https://www.qrmaster.net/qr-code-tracking',
|
'x-default': 'https://www.qrmaster.net/qr-code-tracking',
|
||||||
en: 'https://www.qrmaster.net/qr-code-tracking',
|
en: 'https://www.qrmaster.net/qr-code-tracking',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: 'QR Code Tracking & Analytics - Track Scans | QR Master',
|
title: 'QR Code Tracking & Analytics - Track Every Scan | QR Master',
|
||||||
description: 'Track QR code scans with real-time analytics. Monitor location, device, time, and user behavior.',
|
description: 'Track QR code scans with real-time analytics. Monitor location, device, time, and user behavior.',
|
||||||
url: 'https://www.qrmaster.net/qr-code-tracking',
|
url: 'https://www.qrmaster.net/qr-code-tracking',
|
||||||
type: 'website',
|
type: 'website',
|
||||||
images: [
|
},
|
||||||
{
|
twitter: {
|
||||||
url: 'https://www.qrmaster.net/og-image.png',
|
title: 'QR Code Tracking & Analytics - Track Every Scan | QR Master',
|
||||||
width: 1200,
|
description: 'Track QR code scans with real-time analytics. Monitor location, device, time, and user behavior.',
|
||||||
height: 630,
|
},
|
||||||
alt: 'QR Code Tracking & Analytics - QR Master',
|
};
|
||||||
},
|
|
||||||
],
|
export default function QRCodeTrackingPage() {
|
||||||
},
|
const trackingFeatures = [
|
||||||
twitter: {
|
{
|
||||||
title: 'QR Code Tracking & Analytics - Track Scans | QR Master',
|
icon: '📊',
|
||||||
description: 'Track QR code scans with real-time analytics. Monitor location, device, time, and user behavior.',
|
title: 'Real-Time Analytics',
|
||||||
},
|
description: 'See scan data instantly as it happens. Monitor your QR code performance in real-time with live dashboards.',
|
||||||
};
|
},
|
||||||
|
{
|
||||||
export default function QRCodeTrackingPage() {
|
icon: '🌍',
|
||||||
const trackingFeatures = [
|
title: 'Location Tracking',
|
||||||
{
|
description: 'Know exactly where your QR codes are being scanned. Track by country, city, and region.',
|
||||||
icon: '📊',
|
},
|
||||||
title: 'Real-Time Analytics',
|
{
|
||||||
description: 'See scan data instantly as it happens. Monitor your QR code performance in real-time with live dashboards.',
|
icon: '📱',
|
||||||
},
|
title: 'Device Detection',
|
||||||
{
|
description: 'Identify which devices scan your codes. Track iOS, Android, desktop, and browser types.',
|
||||||
icon: '🌍',
|
},
|
||||||
title: 'Location Tracking',
|
{
|
||||||
description: 'Know exactly where your QR codes are being scanned. Track by country, city, and region.',
|
icon: '🕐',
|
||||||
},
|
title: 'Time-Based Reports',
|
||||||
{
|
description: 'Analyze scan patterns by hour, day, week, or month. Optimize your campaigns with timing insights.',
|
||||||
icon: '📱',
|
},
|
||||||
title: 'Device Detection',
|
{
|
||||||
description: 'Identify which devices scan your codes. Track iOS, Android, desktop, and browser types.',
|
icon: '👥',
|
||||||
},
|
title: 'Unique vs Total Scans',
|
||||||
{
|
description: 'Distinguish between unique users and repeat scans. Measure true reach and engagement.',
|
||||||
icon: '🕐',
|
},
|
||||||
title: 'Time-Based Reports',
|
{
|
||||||
description: 'Analyze scan patterns by hour, day, week, or month. Optimize your campaigns with timing insights.',
|
icon: '📈',
|
||||||
},
|
title: 'Campaign Performance',
|
||||||
{
|
description: 'Track ROI with UTM parameters. Measure conversion rates and campaign effectiveness.',
|
||||||
icon: '👥',
|
},
|
||||||
title: 'Unique vs Total Scans',
|
];
|
||||||
description: 'Distinguish between unique users and repeat scans. Measure true reach and engagement.',
|
|
||||||
},
|
const useCases = [
|
||||||
{
|
{
|
||||||
icon: '📈',
|
title: 'Marketing Campaigns',
|
||||||
title: 'Campaign Performance',
|
description: 'Track print ads, billboards, and product packaging to measure marketing ROI.',
|
||||||
description: 'Track ROI with UTM parameters. Measure conversion rates and campaign effectiveness.',
|
benefits: ['Measure ad performance', 'A/B test campaigns', 'Track conversions'],
|
||||||
},
|
},
|
||||||
];
|
{
|
||||||
|
title: 'Event Management',
|
||||||
const useCases = [
|
description: 'Monitor event check-ins, booth visits, and attendee engagement in real-time.',
|
||||||
{
|
benefits: ['Live attendance tracking', 'Booth analytics', 'Engagement metrics'],
|
||||||
title: 'Marketing Campaigns',
|
},
|
||||||
description: 'Track print ads, billboards, and product packaging to measure marketing ROI.',
|
{
|
||||||
benefits: ['Measure ad performance', 'A/B test campaigns', 'Track conversions'],
|
title: 'Product Labels',
|
||||||
},
|
description: 'Track product authenticity scans, manual downloads, and warranty registrations.',
|
||||||
{
|
benefits: ['Anti-counterfeiting', 'User registration tracking', 'Product analytics'],
|
||||||
title: 'Event Management',
|
},
|
||||||
description: 'Monitor event check-ins, booth visits, and attendee engagement in real-time.',
|
{
|
||||||
benefits: ['Live attendance tracking', 'Booth analytics', 'Engagement metrics'],
|
title: 'Restaurant Menus',
|
||||||
},
|
description: 'See how many customers scan your menu QR codes and when peak times occur.',
|
||||||
{
|
benefits: ['Customer insights', 'Peak time analysis', 'Menu engagement'],
|
||||||
title: 'Product Labels',
|
},
|
||||||
description: 'Track product authenticity scans, manual downloads, and warranty registrations.',
|
];
|
||||||
benefits: ['Anti-counterfeiting', 'User registration tracking', 'Product analytics'],
|
|
||||||
},
|
const comparisonData = [
|
||||||
{
|
{ feature: 'Real-Time Analytics', free: true, qrMaster: true },
|
||||||
title: 'Restaurant Menus',
|
{ feature: 'Location Tracking', free: false, qrMaster: true },
|
||||||
description: 'See how many customers scan your menu QR codes and when peak times occur.',
|
{ feature: 'Device Detection', free: false, qrMaster: true },
|
||||||
benefits: ['Customer insights', 'Peak time analysis', 'Menu engagement'],
|
{ feature: 'Unlimited Scans', free: false, qrMaster: true },
|
||||||
},
|
{ feature: 'Historical Data', free: '7 days', qrMaster: 'Unlimited' },
|
||||||
];
|
{ feature: 'Export Reports', free: false, qrMaster: true },
|
||||||
|
{ feature: 'API Access', free: false, qrMaster: true },
|
||||||
const comparisonData = [
|
];
|
||||||
{ feature: 'Real-Time Analytics', free: true, qrMaster: true },
|
|
||||||
{ feature: 'Location Tracking', free: false, qrMaster: true },
|
const softwareSchema = {
|
||||||
{ feature: 'Device Detection', free: false, qrMaster: true },
|
'@context': 'https://schema.org',
|
||||||
{ feature: 'Unlimited Scans', free: false, qrMaster: true },
|
'@type': 'SoftwareApplication',
|
||||||
{ feature: 'Historical Data', free: '7 days', qrMaster: 'Unlimited' },
|
'@id': 'https://www.qrmaster.net/qr-code-tracking#software',
|
||||||
{ feature: 'Export Reports', free: false, qrMaster: true },
|
name: 'QR Master - QR Code Tracking & Analytics',
|
||||||
{ feature: 'API Access', free: false, qrMaster: true },
|
applicationCategory: 'BusinessApplication',
|
||||||
];
|
operatingSystem: 'Web Browser, iOS, Android',
|
||||||
|
offers: {
|
||||||
const softwareSchema = {
|
'@type': 'Offer',
|
||||||
'@context': 'https://schema.org',
|
price: '0',
|
||||||
'@type': 'SoftwareApplication',
|
priceCurrency: 'USD',
|
||||||
'@id': 'https://www.qrmaster.net/qr-code-tracking#software',
|
availability: 'https://schema.org/InStock',
|
||||||
name: 'QR Master - QR Code Tracking & Analytics',
|
},
|
||||||
applicationCategory: 'BusinessApplication',
|
aggregateRating: {
|
||||||
operatingSystem: 'Web Browser, iOS, Android',
|
'@type': 'AggregateRating',
|
||||||
offers: {
|
ratingValue: '4.8',
|
||||||
'@type': 'Offer',
|
ratingCount: '1250',
|
||||||
price: '0',
|
},
|
||||||
priceCurrency: 'USD',
|
description: 'Track QR code scans with real-time analytics. Monitor location, device, time, and user behavior with our free QR code tracking software.',
|
||||||
availability: 'https://schema.org/InStock',
|
features: [
|
||||||
},
|
'Real-time analytics dashboard',
|
||||||
aggregateRating: {
|
'Location tracking by country and city',
|
||||||
'@type': 'AggregateRating',
|
'Device detection (iOS, Android, Desktop)',
|
||||||
ratingValue: '4.8',
|
'Time-based scan reports',
|
||||||
ratingCount: '1250',
|
'Unique vs total scan tracking',
|
||||||
},
|
'Campaign performance metrics',
|
||||||
description: 'Track QR code scans with real-time analytics. Monitor location, device, time, and user behavior with our free QR code tracking software.',
|
'Unlimited scans',
|
||||||
features: [
|
'Export detailed reports',
|
||||||
'Real-time analytics dashboard',
|
],
|
||||||
'Location tracking by country and city',
|
};
|
||||||
'Device detection (iOS, Android, Desktop)',
|
|
||||||
'Time-based scan reports',
|
const howToSchema = {
|
||||||
'Unique vs total scan tracking',
|
'@context': 'https://schema.org',
|
||||||
'Campaign performance metrics',
|
'@type': 'HowTo',
|
||||||
'Unlimited scans',
|
'@id': 'https://www.qrmaster.net/qr-code-tracking#howto',
|
||||||
'Export detailed reports',
|
name: 'How to Track QR Code Scans',
|
||||||
],
|
description: 'Learn how to track and analyze QR code scans with real-time analytics',
|
||||||
};
|
totalTime: 'PT5M',
|
||||||
|
step: [
|
||||||
const howToSchema = {
|
{
|
||||||
'@context': 'https://schema.org',
|
'@type': 'HowToStep',
|
||||||
'@type': 'HowTo',
|
position: 1,
|
||||||
'@id': 'https://www.qrmaster.net/qr-code-tracking#howto',
|
name: 'Create QR Code',
|
||||||
name: 'How to Track QR Code Scans',
|
text: 'Sign up for free and create a dynamic QR code with tracking enabled',
|
||||||
description: 'Learn how to track and analyze QR code scans with real-time analytics',
|
url: 'https://www.qrmaster.net/signup',
|
||||||
totalTime: 'PT5M',
|
},
|
||||||
step: [
|
{
|
||||||
{
|
'@type': 'HowToStep',
|
||||||
'@type': 'HowToStep',
|
position: 2,
|
||||||
position: 1,
|
name: 'Deploy QR Code',
|
||||||
name: 'Create QR Code',
|
text: 'Download and place your QR code on marketing materials, products, or digital platforms',
|
||||||
text: 'Sign up for free and create a dynamic QR code with tracking enabled',
|
},
|
||||||
url: 'https://www.qrmaster.net/signup',
|
{
|
||||||
},
|
'@type': 'HowToStep',
|
||||||
{
|
position: 3,
|
||||||
'@type': 'HowToStep',
|
name: 'Monitor Analytics',
|
||||||
position: 2,
|
text: 'View real-time scan data including location, device, and time patterns in your dashboard',
|
||||||
name: 'Deploy QR Code',
|
url: 'https://www.qrmaster.net/analytics',
|
||||||
text: 'Download and place your QR code on marketing materials, products, or digital platforms',
|
},
|
||||||
},
|
{
|
||||||
{
|
'@type': 'HowToStep',
|
||||||
'@type': 'HowToStep',
|
position: 4,
|
||||||
position: 3,
|
name: 'Optimize Campaigns',
|
||||||
name: 'Monitor Analytics',
|
text: 'Use insights to optimize placement, timing, and targeting of your QR code campaigns',
|
||||||
text: 'View real-time scan data including location, device, and time patterns in your dashboard',
|
},
|
||||||
url: 'https://www.qrmaster.net/signup',
|
],
|
||||||
},
|
};
|
||||||
{
|
|
||||||
'@type': 'HowToStep',
|
const breadcrumbItems: BreadcrumbItem[] = [
|
||||||
position: 4,
|
{ name: 'Home', url: '/' },
|
||||||
name: 'Optimize Campaigns',
|
{ name: 'QR Code Tracking', url: '/qr-code-tracking' },
|
||||||
text: 'Use insights to optimize placement, timing, and targeting of your QR code campaigns',
|
];
|
||||||
},
|
|
||||||
],
|
return (
|
||||||
};
|
<>
|
||||||
|
<SeoJsonLd data={[softwareSchema, howToSchema, breadcrumbSchema(breadcrumbItems)]} />
|
||||||
const breadcrumbItems: BreadcrumbItem[] = [
|
<div className="min-h-screen bg-white">
|
||||||
{ name: 'Home', url: '/' },
|
{/* Hero Section */}
|
||||||
{ name: 'QR Code Tracking', url: '/qr-code-tracking' },
|
<section className="relative overflow-hidden bg-gradient-to-br from-blue-50 via-white to-purple-50 py-20">
|
||||||
];
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||||
|
<Breadcrumbs items={breadcrumbItems} />
|
||||||
return (
|
<div className="grid lg:grid-cols-2 gap-12 items-center">
|
||||||
<>
|
<div className="space-y-8">
|
||||||
<SeoJsonLd data={[softwareSchema, howToSchema, breadcrumbSchema(breadcrumbItems)]} />
|
<div className="inline-flex items-center space-x-2 bg-blue-100 text-blue-800 px-4 py-2 rounded-full text-sm font-semibold">
|
||||||
<div className="min-h-screen bg-white">
|
<span>📊</span>
|
||||||
{/* Hero Section */}
|
<span>Free QR Code Tracking</span>
|
||||||
<section className="relative overflow-hidden bg-gradient-to-br from-blue-50 via-white to-purple-50 py-20">
|
</div>
|
||||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
|
||||||
<Breadcrumbs items={breadcrumbItems} />
|
<h1 className="text-5xl lg:text-6xl font-bold text-gray-900 leading-tight">
|
||||||
<div className="grid lg:grid-cols-2 gap-12 items-center">
|
Track Every QR Code Scan with Powerful Analytics
|
||||||
<div className="space-y-8">
|
</h1>
|
||||||
<div className="inline-flex items-center space-x-2 bg-blue-100 text-blue-800 px-4 py-2 rounded-full text-sm font-semibold">
|
|
||||||
<span>📊</span>
|
<p className="text-xl text-gray-600 leading-relaxed">
|
||||||
<span>Free QR Code Tracking</span>
|
Monitor your QR code performance in real-time. Get detailed insights on location, device, time, and user behavior. Make data-driven decisions with our free tracking software.
|
||||||
</div>
|
</p>
|
||||||
|
|
||||||
<h1 className="text-5xl lg:text-6xl font-bold text-gray-900 leading-tight">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
Track Every QR Code Scan with Powerful Analytics
|
<Link href="/signup">
|
||||||
</h1>
|
<Button size="lg" className="text-lg px-8 py-4 w-full sm:w-auto">
|
||||||
|
Start Tracking Free
|
||||||
<p className="text-xl text-gray-600 leading-relaxed">
|
</Button>
|
||||||
Monitor your QR code performance in real-time. Get detailed insights on location, device, time, and user behavior. Make data-driven decisions with our free tracking software.
|
</Link>
|
||||||
</p>
|
<Link href="/create">
|
||||||
|
<Button variant="outline" size="lg" className="text-lg px-8 py-4 w-full sm:w-auto">
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
Create Trackable QR Code
|
||||||
<Link href="/signup">
|
</Button>
|
||||||
<Button size="lg" className="text-lg px-8 py-4 w-full sm:w-auto">
|
</Link>
|
||||||
Start Tracking Free
|
</div>
|
||||||
</Button>
|
|
||||||
</Link>
|
<div className="flex items-center space-x-6 text-sm text-gray-600">
|
||||||
<Link href="/signup">
|
<div className="flex items-center space-x-2">
|
||||||
<Button variant="outline" size="lg" className="text-lg px-8 py-4 w-full sm:w-auto">
|
<svg className="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||||
Create Trackable QR Code
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
</Button>
|
</svg>
|
||||||
</Link>
|
<span>No credit card required</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
<div className="flex items-center space-x-6 text-sm text-gray-600">
|
<svg className="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<div className="flex items-center space-x-2">
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
<svg className="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
</svg>
|
||||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
<span>Unlimited scans</span>
|
||||||
</svg>
|
</div>
|
||||||
<span>No credit card required</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<svg className="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
{/* Analytics Preview */}
|
||||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
<div className="relative">
|
||||||
</svg>
|
<Card className="p-6 shadow-2xl">
|
||||||
<span>Unlimited scans</span>
|
<h3 className="font-semibold text-lg mb-4">Live Analytics Dashboard</h3>
|
||||||
</div>
|
<div className="space-y-4">
|
||||||
</div>
|
<div className="flex justify-between items-center pb-3 border-b">
|
||||||
</div>
|
<span className="text-gray-600">Total Scans</span>
|
||||||
|
<span className="text-2xl font-bold text-primary-600">12,547</span>
|
||||||
{/* Analytics Preview */}
|
</div>
|
||||||
<div className="relative">
|
<div className="flex justify-between items-center pb-3 border-b">
|
||||||
<Card className="p-6 shadow-2xl">
|
<span className="text-gray-600">Unique Users</span>
|
||||||
<h3 className="font-semibold text-lg mb-4">Live Analytics Dashboard</h3>
|
<span className="text-2xl font-bold text-primary-600">8,392</span>
|
||||||
<div className="space-y-4">
|
</div>
|
||||||
<div className="flex justify-between items-center pb-3 border-b">
|
<div className="flex justify-between items-center pb-3 border-b">
|
||||||
<span className="text-gray-600">Total Scans</span>
|
<span className="text-gray-600">Top Location</span>
|
||||||
<span className="text-2xl font-bold text-primary-600">12,547</span>
|
<span className="font-semibold">🇩🇪 Germany</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center pb-3 border-b">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-gray-600">Unique Users</span>
|
<span className="text-gray-600">Top Device</span>
|
||||||
<span className="text-2xl font-bold text-primary-600">8,392</span>
|
<span className="font-semibold">📱 iPhone</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center pb-3 border-b">
|
</div>
|
||||||
<span className="text-gray-600">Top Location</span>
|
</Card>
|
||||||
<span className="font-semibold">🇩🇪 Germany</span>
|
<div className="absolute -top-4 -right-4 bg-green-500 text-white px-4 py-2 rounded-full text-sm font-semibold shadow-lg animate-pulse">
|
||||||
</div>
|
Live Updates
|
||||||
<div className="flex justify-between items-center">
|
</div>
|
||||||
<span className="text-gray-600">Top Device</span>
|
</div>
|
||||||
<span className="font-semibold">📱 iPhone</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</Card>
|
|
||||||
<div className="absolute -top-4 -right-4 bg-green-500 text-white px-4 py-2 rounded-full text-sm font-semibold shadow-lg animate-pulse">
|
{/* Tracking Features */}
|
||||||
Live Updates
|
<section className="py-20 bg-gray-50">
|
||||||
</div>
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||||
</div>
|
<div className="text-center mb-16">
|
||||||
</div>
|
<h2 className="text-4xl font-bold text-gray-900 mb-4">
|
||||||
</div>
|
Powerful QR Code Tracking Features
|
||||||
</section>
|
</h2>
|
||||||
|
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||||
{/* Tracking Features */}
|
Get complete visibility into your QR code performance with our comprehensive analytics suite
|
||||||
<section className="py-20 bg-gray-50">
|
</p>
|
||||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
</div>
|
||||||
<div className="text-center mb-16">
|
|
||||||
<h2 className="text-4xl font-bold text-gray-900 mb-4">
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
Powerful QR Code Tracking Features
|
{trackingFeatures.map((feature, index) => (
|
||||||
</h2>
|
<Card key={index} className="p-6 hover:shadow-lg transition-shadow">
|
||||||
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
|
<div className="text-4xl mb-4">{feature.icon}</div>
|
||||||
Get complete visibility into your QR code performance with our comprehensive analytics suite
|
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||||
</p>
|
{feature.title}
|
||||||
</div>
|
</h3>
|
||||||
|
<p className="text-gray-600">
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
{feature.description}
|
||||||
{trackingFeatures.map((feature, index) => (
|
</p>
|
||||||
<Card key={index} className="p-6 hover:shadow-lg transition-shadow">
|
</Card>
|
||||||
<div className="text-4xl mb-4">{feature.icon}</div>
|
))}
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
</div>
|
||||||
{feature.title}
|
</div>
|
||||||
</h3>
|
</section>
|
||||||
<p className="text-gray-600">
|
|
||||||
{feature.description}
|
{/* Use Cases */}
|
||||||
</p>
|
<section className="py-20">
|
||||||
</Card>
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||||
))}
|
<div className="text-center mb-16">
|
||||||
</div>
|
<h2 className="text-4xl font-bold text-gray-900 mb-4">
|
||||||
</div>
|
QR Code Tracking Use Cases
|
||||||
</section>
|
</h2>
|
||||||
|
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||||
{/* Use Cases */}
|
See how businesses use QR code tracking to improve their operations
|
||||||
<section className="py-20">
|
</p>
|
||||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
</div>
|
||||||
<div className="text-center mb-16">
|
|
||||||
<h2 className="text-4xl font-bold text-gray-900 mb-4">
|
<div className="grid md:grid-cols-2 gap-8">
|
||||||
QR Code Tracking Use Cases
|
{useCases.map((useCase, index) => (
|
||||||
</h2>
|
<Card key={index} className="p-8">
|
||||||
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
|
<h3 className="text-2xl font-bold text-gray-900 mb-3">
|
||||||
See how businesses use QR code tracking to improve their operations
|
{useCase.title}
|
||||||
</p>
|
</h3>
|
||||||
</div>
|
<p className="text-gray-600 mb-6">
|
||||||
|
{useCase.description}
|
||||||
<div className="grid md:grid-cols-2 gap-8">
|
</p>
|
||||||
{useCases.map((useCase, index) => (
|
<ul className="space-y-2">
|
||||||
<Card key={index} className="p-8">
|
{useCase.benefits.map((benefit, idx) => (
|
||||||
<h3 className="text-2xl font-bold text-gray-900 mb-3">
|
<li key={idx} className="flex items-center space-x-2">
|
||||||
{useCase.title}
|
<svg className="w-5 h-5 text-green-500 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||||
</h3>
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
<p className="text-gray-600 mb-6">
|
</svg>
|
||||||
{useCase.description}
|
<span className="text-gray-700">{benefit}</span>
|
||||||
</p>
|
</li>
|
||||||
<ul className="space-y-2">
|
))}
|
||||||
{useCase.benefits.map((benefit, idx) => (
|
</ul>
|
||||||
<li key={idx} className="flex items-center space-x-2">
|
</Card>
|
||||||
<svg className="w-5 h-5 text-green-500 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
))}
|
||||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
</div>
|
||||||
</svg>
|
</div>
|
||||||
<span className="text-gray-700">{benefit}</span>
|
</section>
|
||||||
</li>
|
|
||||||
))}
|
{/* Comparison Table */}
|
||||||
</ul>
|
<section className="py-20 bg-gray-50">
|
||||||
</Card>
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-5xl">
|
||||||
))}
|
<div className="text-center mb-16">
|
||||||
</div>
|
<h2 className="text-4xl font-bold text-gray-900 mb-4">
|
||||||
</div>
|
QR Master vs Free Tools
|
||||||
</section>
|
</h2>
|
||||||
|
<p className="text-xl text-gray-600">
|
||||||
{/* Comparison Table */}
|
See why businesses choose QR Master for QR code tracking
|
||||||
<section className="py-20 bg-gray-50">
|
</p>
|
||||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-5xl">
|
</div>
|
||||||
<div className="text-center mb-16">
|
|
||||||
<h2 className="text-4xl font-bold text-gray-900 mb-4">
|
<Card className="overflow-hidden">
|
||||||
QR Master vs Free Tools
|
<table className="w-full">
|
||||||
</h2>
|
<thead className="bg-gray-100">
|
||||||
<p className="text-xl text-gray-600">
|
<tr>
|
||||||
See why businesses choose QR Master for QR code tracking
|
<th className="px-6 py-4 text-left text-gray-900 font-semibold">Feature</th>
|
||||||
</p>
|
<th className="px-6 py-4 text-center text-gray-900 font-semibold">Free Tools</th>
|
||||||
</div>
|
<th className="px-6 py-4 text-center text-primary-600 font-semibold">QR Master</th>
|
||||||
|
</tr>
|
||||||
<Card className="overflow-hidden">
|
</thead>
|
||||||
<table className="w-full">
|
<tbody className="divide-y divide-gray-200">
|
||||||
<thead className="bg-gray-100">
|
{comparisonData.map((row, index) => (
|
||||||
<tr>
|
<tr key={index}>
|
||||||
<th className="px-6 py-4 text-left text-gray-900 font-semibold">Feature</th>
|
<td className="px-6 py-4 text-gray-900 font-medium">{row.feature}</td>
|
||||||
<th className="px-6 py-4 text-center text-gray-900 font-semibold">Free Tools</th>
|
<td className="px-6 py-4 text-center">
|
||||||
<th className="px-6 py-4 text-center text-primary-600 font-semibold">QR Master</th>
|
{typeof row.free === 'boolean' ? (
|
||||||
</tr>
|
row.free ? (
|
||||||
</thead>
|
<span className="text-green-500 text-2xl">✓</span>
|
||||||
<tbody className="divide-y divide-gray-200">
|
) : (
|
||||||
{comparisonData.map((row, index) => (
|
<span className="text-red-500 text-2xl">✗</span>
|
||||||
<tr key={index}>
|
)
|
||||||
<td className="px-6 py-4 text-gray-900 font-medium">{row.feature}</td>
|
) : (
|
||||||
<td className="px-6 py-4 text-center">
|
<span className="text-gray-600">{row.free}</span>
|
||||||
{typeof row.free === 'boolean' ? (
|
)}
|
||||||
row.free ? (
|
</td>
|
||||||
<span className="text-green-500 text-2xl">✓</span>
|
<td className="px-6 py-4 text-center">
|
||||||
) : (
|
{typeof row.qrMaster === 'boolean' ? (
|
||||||
<span className="text-red-500 text-2xl">✗</span>
|
<span className="text-green-500 text-2xl">✓</span>
|
||||||
)
|
) : (
|
||||||
) : (
|
<span className="text-primary-600 font-semibold">{row.qrMaster}</span>
|
||||||
<span className="text-gray-600">{row.free}</span>
|
)}
|
||||||
)}
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
<td className="px-6 py-4 text-center">
|
))}
|
||||||
{typeof row.qrMaster === 'boolean' ? (
|
</tbody>
|
||||||
<span className="text-green-500 text-2xl">✓</span>
|
</table>
|
||||||
) : (
|
</Card>
|
||||||
<span className="text-primary-600 font-semibold">{row.qrMaster}</span>
|
</div>
|
||||||
)}
|
</section>
|
||||||
</td>
|
|
||||||
</tr>
|
{/* CTA Section */}
|
||||||
))}
|
<section className="py-20 bg-gradient-to-r from-primary-600 to-purple-600 text-white">
|
||||||
</tbody>
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-4xl text-center">
|
||||||
</table>
|
<h2 className="text-4xl font-bold mb-6">
|
||||||
</Card>
|
Start Tracking Your QR Codes Today
|
||||||
</div>
|
</h2>
|
||||||
</section>
|
<p className="text-xl mb-8 text-primary-100">
|
||||||
|
Join thousands of businesses using QR Master to track and optimize their QR code campaigns
|
||||||
{/* CTA Section */}
|
</p>
|
||||||
{/* CTA Section */}
|
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
<section className="py-24 bg-slate-900 relative overflow-hidden">
|
<Link href="/signup">
|
||||||
{/* Background Decorations */}
|
<Button size="lg" variant="secondary" className="text-lg px-8 py-4 w-full sm:w-auto bg-white text-primary-600 hover:bg-gray-100">
|
||||||
<div className="absolute top-0 right-0 -mr-20 -mt-20 w-96 h-96 bg-primary-500/20 rounded-full blur-3xl opacity-50" />
|
Create Free Account
|
||||||
<div className="absolute bottom-0 left-0 -ml-20 -mb-20 w-80 h-80 bg-purple-500/20 rounded-full blur-3xl opacity-50" />
|
</Button>
|
||||||
|
</Link>
|
||||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-4xl text-center relative z-10">
|
<Link href="/pricing">
|
||||||
<h2 className="text-4xl lg:text-5xl font-bold mb-6 text-white tracking-tight">
|
<Button size="lg" variant="outline" className="text-lg px-8 py-4 w-full sm:w-auto border-white text-white hover:bg-white/10">
|
||||||
Start Tracking Your <span className="text-transparent bg-clip-text bg-gradient-to-r from-primary-400 to-purple-400">QR Codes Today</span>
|
View Pricing
|
||||||
</h2>
|
</Button>
|
||||||
<p className="text-xl mb-10 text-slate-300 leading-relaxed max-w-2xl mx-auto">
|
</Link>
|
||||||
Join thousands of businesses using QR Master to optimize their campaigns with real-time analytics.
|
</div>
|
||||||
</p>
|
</div>
|
||||||
<div className="flex flex-col sm:flex-row gap-5 justify-center">
|
</section>
|
||||||
<Link href="/signup">
|
</div>
|
||||||
<Button size="lg" className="text-lg px-8 py-6 h-auto w-full sm:w-auto bg-white text-slate-900 hover:bg-slate-50 font-bold shadow-xl shadow-primary-900/20 transition-all hover:-translate-y-1">
|
</>
|
||||||
Create Free Account
|
);
|
||||||
</Button>
|
}
|
||||||
</Link>
|
|
||||||
<Link href="/pricing">
|
|
||||||
<Button size="lg" variant="outline" className="text-lg px-8 py-6 h-auto w-full sm:w-auto border-slate-700 text-white hover:bg-slate-800 hover:border-slate-600 transition-all">
|
|
||||||
View Pricing
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="mt-8 text-sm text-slate-500">
|
|
||||||
Full analytics accessible on free plan.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,435 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import Barcode from 'react-barcode';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Download, Printer, Barcode as BarcodeIcon, Sparkles, Sliders, Check, Info, Copy } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Select } from '@/components/ui/Select';
|
||||||
|
import { showToast } from '@/components/ui/Toast';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { toPng, toSvg, toBlob } from 'html-to-image';
|
||||||
|
|
||||||
|
// Brand Colors
|
||||||
|
const BRAND = {
|
||||||
|
paleGrey: '#EBEBDF',
|
||||||
|
slate900: '#0f172a',
|
||||||
|
};
|
||||||
|
|
||||||
|
const BARCODE_COLORS = [
|
||||||
|
{ name: 'Classic Black', value: '#000000' },
|
||||||
|
{ name: 'Dark Blue', value: '#1A1265' },
|
||||||
|
{ name: 'Rich Indigo', value: '#4338CA' },
|
||||||
|
{ name: 'Deep Emerald', value: '#065F46' },
|
||||||
|
{ name: 'Crimson', value: '#991B1B' },
|
||||||
|
{ name: 'Slate Gray', value: '#334155' },
|
||||||
|
{ name: 'Business Navy', value: '#1E293B' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FRAME_OPTIONS = [
|
||||||
|
{ id: 'none', label: 'No Frame' },
|
||||||
|
{ id: 'scanme', label: 'Scan Me' },
|
||||||
|
{ id: 'product', label: 'Product' },
|
||||||
|
{ id: 'serial', label: 'Serial' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FORMAT_INFO: Record<string, string> = {
|
||||||
|
'CODE128': 'High-density alphanumeric format. Best for general purpose use.',
|
||||||
|
'EAN13': 'International retail standard for products worldwide.',
|
||||||
|
'UPC': 'Standard retail format used primarily in North America.',
|
||||||
|
'CODE39': 'Older industrial standard supporting uppercase letters and numbers.',
|
||||||
|
'ITF14': 'Used on shipping containers and logistics packaging.',
|
||||||
|
'MSI': 'Specialized format for retail shelf labeling and inventory.',
|
||||||
|
'pharmacode': 'Pharmaceutical packaging control standard.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BarcodeGeneratorClient() {
|
||||||
|
const [value, setValue] = useState('123456789');
|
||||||
|
const [format, setFormat] = useState('CODE128');
|
||||||
|
const [width, setWidth] = useState(2);
|
||||||
|
const [height, setHeight] = useState(100);
|
||||||
|
const [displayValue, setDisplayValue] = useState(true);
|
||||||
|
const [lineColor, setLineColor] = useState('#000000');
|
||||||
|
const [frameType, setFrameType] = useState('none');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const barcodeRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Validation Logic
|
||||||
|
React.useEffect(() => {
|
||||||
|
setError(null);
|
||||||
|
if (!value) return;
|
||||||
|
|
||||||
|
if (format === 'EAN13' && !/^\d{12,13}$/.test(value)) {
|
||||||
|
setError('EAN-13 requires 12 or 13 digits.');
|
||||||
|
} else if (format === 'UPC' && !/^\d{11,12}$/.test(value)) {
|
||||||
|
setError('UPC requires 11 or 12 digits.');
|
||||||
|
} else if (format === 'CODE39' && !/^[0-9A-Z\-\.\ \$\/\+\%]+$/.test(value)) {
|
||||||
|
setError('Code 39 only supports numbers, uppercase letters, and - . $ / + % spaces.');
|
||||||
|
} else if ((format === 'ITF14' || format === 'MSI') && !/^\d+$/.test(value)) {
|
||||||
|
setError('This format only supports numbers.');
|
||||||
|
}
|
||||||
|
}, [value, format]);
|
||||||
|
|
||||||
|
const downloadBarcode = async (extension: 'png' | 'svg') => {
|
||||||
|
if (!barcodeRef.current) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let dataUrl;
|
||||||
|
if (extension === 'png') {
|
||||||
|
dataUrl = await toPng(barcodeRef.current, {
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
pixelRatio: 3,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
dataUrl = await toSvg(barcodeRef.current, {
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = dataUrl;
|
||||||
|
link.download = `barcode-${value || 'generator'}.${extension}`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
showToast(`Barcode downloaded as ${extension.toUpperCase()}`, 'success');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Download failed', err);
|
||||||
|
showToast('Download failed', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyBarcode = async () => {
|
||||||
|
if (!barcodeRef.current) return;
|
||||||
|
try {
|
||||||
|
// Use toBlob directly for better performance and compatibility
|
||||||
|
const blob = await toBlob(barcodeRef.current, {
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
pixelRatio: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!blob) {
|
||||||
|
throw new Error('Failed to generate image blob');
|
||||||
|
}
|
||||||
|
|
||||||
|
await navigator.clipboard.write([
|
||||||
|
new ClipboardItem({
|
||||||
|
'image/png': blob,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
showToast('Barcode copied to clipboard', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Copy failed', err);
|
||||||
|
showToast('Failed to copy barcode', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formats = [
|
||||||
|
{ value: 'CODE128', label: 'Code 128 (Standard)' },
|
||||||
|
{ value: 'EAN13', label: 'EAN-13 (Retail)' },
|
||||||
|
{ value: 'UPC', label: 'UPC-A (US Retail)' },
|
||||||
|
{ value: 'CODE39', label: 'Code 39' },
|
||||||
|
{ value: 'ITF14', label: 'ITF-14' },
|
||||||
|
{ value: 'MSI', label: 'MSI' },
|
||||||
|
{ value: 'pharmacode', label: 'Pharmacode' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-5xl mx-auto px-4 md:px-6">
|
||||||
|
|
||||||
|
{/* Main Generator Card */}
|
||||||
|
<div className="bg-white rounded-3xl shadow-2xl shadow-slate-900/10 overflow-hidden border border-slate-100">
|
||||||
|
<div className="grid lg:grid-cols-2">
|
||||||
|
|
||||||
|
{/* LEFT: Input Section */}
|
||||||
|
<div className="p-6 md:p-8 lg:p-10 space-y-8 border-r border-slate-100">
|
||||||
|
|
||||||
|
{/* Configuration */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Sliders className="w-5 h-5 text-slate-900" aria-hidden="true" />
|
||||||
|
Configuration
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="barcode-content" className="block text-sm font-medium text-slate-700 mb-2">Content</label>
|
||||||
|
<Input
|
||||||
|
id="barcode-content"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
placeholder="Enter barcode data (e.g. 12345678)"
|
||||||
|
className="h-12 text-base rounded-xl border-slate-200 focus:border-slate-900 focus:ring-slate-900"
|
||||||
|
aria-label="Barcode content"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="block text-sm font-medium text-slate-700">Format</label>
|
||||||
|
<div className="group relative">
|
||||||
|
<Info className="w-4 h-4 text-slate-400 cursor-help" />
|
||||||
|
<div className="absolute right-0 bottom-full mb-2 w-64 p-3 bg-slate-900 text-white text-[11px] rounded-xl opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none shadow-xl z-50">
|
||||||
|
<p className="font-bold mb-1">Format Guide:</p>
|
||||||
|
<p>{FORMAT_INFO[format]}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={format}
|
||||||
|
onChange={(e) => setFormat(e.target.value)}
|
||||||
|
className="h-12 rounded-xl border-slate-200"
|
||||||
|
options={formats}
|
||||||
|
aria-label="Format"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-slate-500 mt-2 px-1">
|
||||||
|
{FORMAT_INFO[format]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-100"></div>
|
||||||
|
|
||||||
|
{/* Design Options */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Sparkles className="w-5 h-5 text-slate-900" aria-hidden="true" />
|
||||||
|
Design Options
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<label htmlFor="width-range" className="text-sm font-medium text-slate-700">Width</label>
|
||||||
|
<span className="text-xs text-slate-500 bg-slate-100 px-2 py-1 rounded-md font-bold">{width}px</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="width-range"
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="4"
|
||||||
|
step="0.5"
|
||||||
|
value={width}
|
||||||
|
onChange={(e) => setWidth(parseFloat(e.target.value))}
|
||||||
|
className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-slate-900"
|
||||||
|
aria-label="Barcode width"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<label htmlFor="height-range" className="text-sm font-medium text-slate-700">Height</label>
|
||||||
|
<span className="text-xs text-slate-500 bg-slate-100 px-2 py-1 rounded-md font-bold">{height}px</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="height-range"
|
||||||
|
type="range"
|
||||||
|
min="30"
|
||||||
|
max="200"
|
||||||
|
step="5"
|
||||||
|
value={height}
|
||||||
|
onChange={(e) => setHeight(parseInt(e.target.value))}
|
||||||
|
className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-slate-900"
|
||||||
|
aria-label="Barcode height"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">Line Color</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{BARCODE_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.name}
|
||||||
|
onClick={() => setLineColor(c.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-9 h-9 rounded-full border-2 flex items-center justify-center transition-all hover:scale-110",
|
||||||
|
lineColor === c.value ? "border-slate-900 ring-2 ring-offset-2 ring-slate-200" : "border-white shadow-md"
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: c.value }}
|
||||||
|
aria-label={`Select color ${c.name}`}
|
||||||
|
title={c.name}
|
||||||
|
>
|
||||||
|
{lineColor === c.value && <Check className="w-4 h-4 text-white" strokeWidth={3} aria-hidden="true" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">Frame Label</label>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||||
|
{FRAME_OPTIONS.map((frame) => (
|
||||||
|
<button
|
||||||
|
key={frame.id}
|
||||||
|
onClick={() => setFrameType(frame.id)}
|
||||||
|
className={cn(
|
||||||
|
"py-2.5 px-3 rounded-lg text-sm font-medium transition-all border",
|
||||||
|
frameType === frame.id
|
||||||
|
? "bg-slate-900 text-white border-slate-900"
|
||||||
|
: "bg-slate-50 text-slate-600 border-slate-200 hover:border-slate-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{frame.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer group p-3 border border-slate-200 rounded-xl hover:border-slate-900 transition-colors bg-slate-50/50">
|
||||||
|
<div className={cn(
|
||||||
|
"w-5 h-5 rounded border-2 flex items-center justify-center transition-all",
|
||||||
|
displayValue ? "bg-slate-900 border-slate-900" : "border-slate-300 group-hover:border-slate-400"
|
||||||
|
)}>
|
||||||
|
{displayValue && <Check className="w-3.5 h-3.5 text-white" strokeWidth={3} />}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={displayValue}
|
||||||
|
onChange={(e) => setDisplayValue(e.target.checked)}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-slate-700">Show Value Text</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT: Preview Section */}
|
||||||
|
<div className="p-6 md:p-8 lg:p-10 flex flex-col items-center justify-center" style={{ backgroundColor: BRAND.paleGrey }}>
|
||||||
|
|
||||||
|
{/* Barcode Card */}
|
||||||
|
<div
|
||||||
|
className="bg-white rounded-3xl shadow-xl p-8 flex flex-col items-center justify-center min-h-[300px] w-full max-w-[400px] border border-slate-100 relative"
|
||||||
|
>
|
||||||
|
<div className="absolute top-4 right-4">
|
||||||
|
<div className="flex items-center gap-1.5 px-2 py-1 bg-slate-100 rounded-md text-[10px] font-bold text-slate-500 uppercase tracking-wider">
|
||||||
|
Live Preview
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={barcodeRef} className="py-4 bg-white flex flex-col items-center justify-center overflow-hidden w-full">
|
||||||
|
{frameType !== 'none' && !error && (
|
||||||
|
<div
|
||||||
|
className="mb-4 px-6 py-2 rounded-full text-white font-bold text-xs tracking-widest uppercase shadow-md"
|
||||||
|
style={{ backgroundColor: lineColor }}
|
||||||
|
>
|
||||||
|
{FRAME_OPTIONS.find(f => f.id === frameType)?.label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="flex flex-col items-center text-center p-6 animate-in fade-in zoom-in duration-200">
|
||||||
|
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center mb-3">
|
||||||
|
<Info className="w-6 h-6 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-red-500 font-bold text-sm">{error}</p>
|
||||||
|
<p className="text-slate-400 text-xs mt-1">Please correct your input.</p>
|
||||||
|
</div>
|
||||||
|
) : value ? (
|
||||||
|
<Barcode
|
||||||
|
key={`${format}-${lineColor}-${value}-${width}-${height}-${displayValue}`}
|
||||||
|
value={value}
|
||||||
|
format={format as any}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
displayValue={displayValue}
|
||||||
|
background="#ffffff"
|
||||||
|
lineColor={lineColor}
|
||||||
|
margin={10}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-slate-400 p-6">
|
||||||
|
<BarcodeIcon className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||||
|
<p className="text-sm font-medium">Enter data to generate</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Preview */}
|
||||||
|
<div className="mt-6 text-center w-full">
|
||||||
|
<h3 className="font-bold text-slate-900 text-lg flex items-center justify-center gap-2 truncate">
|
||||||
|
<span className="truncate">{formats.find(f => f.value === format)?.label}</span>
|
||||||
|
</h3>
|
||||||
|
<div className="text-xs text-slate-600 mt-1 truncate px-2 font-mono">
|
||||||
|
{value || 'Barcode Value'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Download Buttons */}
|
||||||
|
<div className="flex flex-col gap-4 mt-8 w-full max-w-[450px]">
|
||||||
|
<div className="flex flex-col sm:flex-row items-center gap-3 w-full">
|
||||||
|
<Button
|
||||||
|
onClick={() => downloadBarcode('png')}
|
||||||
|
className="w-full sm:flex-1 bg-slate-900 hover:bg-black text-white shadow-lg h-12 rounded-xl"
|
||||||
|
aria-label="Download barcode as PNG"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" aria-hidden="true" />
|
||||||
|
Download PNG
|
||||||
|
</Button>
|
||||||
|
<div className="relative w-full sm:w-auto">
|
||||||
|
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-indigo-600 text-white text-[9px] font-bold px-2 py-0.5 rounded-full whitespace-nowrap shadow-sm z-10 pointer-events-none">
|
||||||
|
BEST FOR PRINT
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => downloadBarcode('svg')}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full sm:w-auto px-6 border-slate-300 hover:bg-white h-12 rounded-xl font-bold"
|
||||||
|
aria-label="Download barcode as SVG"
|
||||||
|
>
|
||||||
|
SVG
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={copyBarcode}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full sm:w-auto px-4 border-slate-300 hover:bg-white h-12 rounded-xl"
|
||||||
|
title="Copy to Clipboard"
|
||||||
|
aria-label="Copy barcode image to clipboard"
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4 text-slate-600" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => window.print()}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full sm:w-auto px-4 border-slate-300 hover:bg-white h-12 rounded-xl"
|
||||||
|
title="Print"
|
||||||
|
aria-label="Print barcode"
|
||||||
|
>
|
||||||
|
<Printer className="w-4 h-4 text-slate-600" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<Link href="/signup" className="text-xs font-medium text-slate-400 hover:text-indigo-600 transition-colors flex items-center justify-center gap-1 group">
|
||||||
|
Need bulk generation?
|
||||||
|
<span className="underline decoration-slate-300 group-hover:decoration-indigo-300 underline-offset-4">Available in Pro →</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upsell Banner */}
|
||||||
|
<div className="mt-8 bg-gradient-to-r from-slate-900 to-slate-700 rounded-2xl p-6 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="text-white text-center sm:text-left">
|
||||||
|
<h3 className="font-bold text-lg">Need Dynamic QR Codes?</h3>
|
||||||
|
<p className="text-white/80 text-sm mt-1">
|
||||||
|
Switch to QR codes to edit content later and track your scans.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/signup">
|
||||||
|
<Button className="bg-white text-slate-900 hover:bg-slate-100 shrink-0 shadow-lg px-8">
|
||||||
|
Get Started Free
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
321
src/app/(marketing)/tools/barcode-generator/BarcodeGuide.tsx
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
import { BookOpen, CheckCircle, HelpCircle, Layers, Settings, ShoppingCart, Tag, Activity, Factory } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export function BarcodeGuide() {
|
||||||
|
return (
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8 bg-white" id="guide">
|
||||||
|
<div className="max-w-3xl mx-auto prose prose-slate prose-lg">
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mb-8 not-prose">
|
||||||
|
<div className="p-3 bg-blue-100/50 rounded-xl">
|
||||||
|
<BookOpen className="w-8 h-8 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 m-0">
|
||||||
|
Barcode Generator – How Barcodes Work and Why They Matter
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="lead text-xl text-slate-600">
|
||||||
|
Barcodes are an essential part of modern commerce, logistics, and inventory management. A <strong>Barcode Generator</strong> allows businesses and individuals to create scannable barcodes quickly and efficiently for products, packaging, and internal systems. Whether you run an online shop, manage a warehouse, or sell products locally, understanding how barcodes work can save time and reduce errors.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
In this article, you will learn what barcodes are, how they work, and how a <strong>Barcode Generator</strong> helps you create professional barcodes in seconds.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* SEO Image */}
|
||||||
|
<div className="my-8 rounded-2xl overflow-hidden shadow-lg not-prose border border-slate-100">
|
||||||
|
<img
|
||||||
|
src="/barcode-generator-preview.png"
|
||||||
|
alt="Free Online Barcode Generator Preview - Create EAN, UPC, and Code 128 Barcodes"
|
||||||
|
className="w-full h-64 sm:h-80 object-cover"
|
||||||
|
width="800"
|
||||||
|
height="320"
|
||||||
|
/>
|
||||||
|
<div className="bg-slate-50 p-4 text-sm text-slate-500 text-center border-t border-slate-100">
|
||||||
|
Use our <strong>free barcode generator</strong> to create scannable codes.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>What Is a Barcode?</h2>
|
||||||
|
<p>
|
||||||
|
A barcode is a visual representation of data that can be read by machines. It consists of vertical lines with different widths and spacing, which encode numbers or characters. When scanned with a barcode scanner or smartphone, the information is instantly translated into readable data.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Barcodes are commonly used to identify products, track inventory, manage logistics, and speed up checkout processes. They reduce manual input and significantly lower the risk of human error.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>How Does a Barcode Generator Work?</h2>
|
||||||
|
<p>
|
||||||
|
A Barcode Generator converts text or numeric input into a barcode format that scanners can read. The process is simple:
|
||||||
|
</p>
|
||||||
|
<ul className="list-none pl-0 space-y-4 not-prose my-8">
|
||||||
|
<li className="flex gap-4">
|
||||||
|
<div className="w-8 h-8 mt-1 rounded-full bg-slate-100 flex items-center justify-center shrink-0 text-slate-600 font-bold text-sm">1</div>
|
||||||
|
<div>
|
||||||
|
<strong className="text-slate-900 block mb-1">Input Data</strong>
|
||||||
|
<p className="text-slate-600 m-0 text-base">You enter a number or text (for example, a product ID).</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li className="flex gap-4">
|
||||||
|
<div className="w-8 h-8 mt-1 rounded-full bg-slate-100 flex items-center justify-center shrink-0 text-slate-600 font-bold text-sm">2</div>
|
||||||
|
<div>
|
||||||
|
<strong className="text-slate-900 block mb-1">Select Format</strong>
|
||||||
|
<p className="text-slate-600 m-0 text-base">You select a barcode format such as EAN-13 or Code 128.</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li className="flex gap-4">
|
||||||
|
<div className="w-8 h-8 mt-1 rounded-full bg-slate-100 flex items-center justify-center shrink-0 text-slate-600 font-bold text-sm">3</div>
|
||||||
|
<div>
|
||||||
|
<strong className="text-slate-900 block mb-1">Generate</strong>
|
||||||
|
<p className="text-slate-600 m-0 text-base">The generator creates a scannable barcode image instantly.</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li className="flex gap-4">
|
||||||
|
<div className="w-8 h-8 mt-1 rounded-full bg-slate-100 flex items-center justify-center shrink-0 text-slate-600 font-bold text-sm">4</div>
|
||||||
|
<div>
|
||||||
|
<strong className="text-slate-900 block mb-1">Download</strong>
|
||||||
|
<p className="text-slate-600 m-0 text-base">You download or print the barcode for use.</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
A modern <strong>Barcode Generator</strong> works directly in the browser and does not require additional software.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Common Types of Barcodes</h2>
|
||||||
|
<p>
|
||||||
|
Different barcode formats are used for different purposes. Choosing the right one is important for compatibility and scanning accuracy.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-6 not-prose my-8">
|
||||||
|
{/* EAN-13 Card */}
|
||||||
|
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-shadow">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<Tag className="w-5 h-5 text-blue-500" />
|
||||||
|
<h4 className="text-lg font-bold text-slate-900 m-0">EAN-13</h4>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-mono bg-slate-100 inline-block px-2 py-1 rounded text-slate-500 mb-3">Retail • Europe</div>
|
||||||
|
<div className="mb-3 bg-slate-50 rounded border border-slate-100 p-2 flex justify-center">
|
||||||
|
<img src="/barcode-generator-preview.png" alt="EAN-13 Barcode Sample for International Products" className="h-10 object-contain opacity-75 grayscale" width="200" height="40" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-600 m-0">
|
||||||
|
EAN-13 is widely used in retail, especially in Europe. It is designed for consumer products sold in stores and supermarkets.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* UPC-A Card */}
|
||||||
|
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-shadow">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<ShoppingCart className="w-5 h-5 text-indigo-500" />
|
||||||
|
<h4 className="text-lg font-bold text-slate-900 m-0">UPC-A</h4>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-mono bg-slate-100 inline-block px-2 py-1 rounded text-slate-500 mb-3">Retail • USA/Canada</div>
|
||||||
|
<div className="mb-3 bg-slate-50 rounded border border-slate-100 p-2 flex justify-center">
|
||||||
|
<img src="/barcode-generator-preview.png" alt="UPC-A Barcode Example for Retail Products in USA" className="h-10 object-contain opacity-75 grayscale" width="200" height="40" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-600 m-0">
|
||||||
|
UPC-A is similar to EAN-13 but is mainly used in the United States and Canada for retail products.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Code 128 Card */}
|
||||||
|
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-shadow">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<Settings className="w-5 h-5 text-emerald-500" />
|
||||||
|
<h4 className="text-lg font-bold text-slate-900 m-0">Code 128</h4>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-mono bg-slate-100 inline-block px-2 py-1 rounded text-slate-500 mb-3">Logistics • Universal</div>
|
||||||
|
<div className="mb-3 bg-slate-50 rounded border border-slate-100 p-2 flex justify-center">
|
||||||
|
<img src="/barcode-generator-preview.png" alt="Code 128 Barcode for Inventory and Shipping Labels" className="h-10 object-contain opacity-75 grayscale" width="200" height="40" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-600 m-0">
|
||||||
|
Code 128 is a flexible barcode format that supports letters and numbers. It is commonly used in logistics, shipping, and internal tracking systems.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Code 39 Card */}
|
||||||
|
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-shadow">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<Factory className="w-5 h-5 text-orange-500" />
|
||||||
|
<h4 className="text-lg font-bold text-slate-900 m-0">Code 39</h4>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-mono bg-slate-100 inline-block px-2 py-1 rounded text-slate-500 mb-3">Industrial • Military</div>
|
||||||
|
<div className="mb-3 bg-slate-50 rounded border border-slate-100 p-2 flex justify-center">
|
||||||
|
<img src="/barcode-generator-preview.png" alt="Code 39 Barcode for Industrial Use" className="h-10 object-contain opacity-75 grayscale" width="200" height="40" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-600 m-0">
|
||||||
|
The first alphanumeric barcode, Code 39 is still widely used in automotive and defense industries. It supports numbers and uppercase letters.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* MSI Card */}
|
||||||
|
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-shadow">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<Layers className="w-5 h-5 text-purple-500" />
|
||||||
|
<h4 className="text-lg font-bold text-slate-900 m-0">MSI</h4>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-mono bg-slate-100 inline-block px-2 py-1 rounded text-slate-500 mb-3">Inventory • Shelves</div>
|
||||||
|
<div className="mb-3 bg-slate-50 rounded border border-slate-100 p-2 flex justify-center">
|
||||||
|
<img src="/barcode-generator-preview.png" alt="MSI Barcode for Inventory Management" className="h-10 object-contain opacity-75 grayscale" width="200" height="40" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-600 m-0">
|
||||||
|
MSI (Modified Plessey) is often used for inventory control in retail environments, such as labeling shelves in supermarkets and warehouses.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pharmacode Card */}
|
||||||
|
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-shadow">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<Activity className="w-5 h-5 text-red-500" />
|
||||||
|
<h4 className="text-lg font-bold text-slate-900 m-0">Pharmacode</h4>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-mono bg-slate-100 inline-block px-2 py-1 rounded text-slate-500 mb-3">Pharma • Packaging</div>
|
||||||
|
<div className="mb-3 bg-slate-50 rounded border border-slate-100 p-2 flex justify-center">
|
||||||
|
<img src="/barcode-generator-preview.png" alt="Pharmacode for Pharmaceutical Packaging" className="h-10 object-contain opacity-75 grayscale" width="200" height="40" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-600 m-0">
|
||||||
|
Pharmacode is a specialized barcode standard used in the pharmaceutical industry for packaging control to prevent medication errors.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Why Use a Barcode Generator?</h2>
|
||||||
|
<p>Using a Barcode Generator offers several advantages:</p>
|
||||||
|
<div className="not-prose grid gap-4 mb-8">
|
||||||
|
<div className="flex gap-4 items-start">
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-500 mt-1 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<h5 className="font-bold text-slate-900 m-0">Speed</h5>
|
||||||
|
<p className="text-slate-600 text-sm m-0">Create barcodes instantly without technical knowledge.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 items-start">
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-500 mt-1 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<h5 className="font-bold text-slate-900 m-0">Accuracy</h5>
|
||||||
|
<p className="text-slate-600 text-sm m-0">Reduce manual data entry errors.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 items-start">
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-500 mt-1 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<h5 className="font-bold text-slate-900 m-0">Flexibility</h5>
|
||||||
|
<p className="text-slate-600 text-sm m-0">Generate barcodes for different formats and use cases.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 items-start">
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-500 mt-1 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<h5 className="font-bold text-slate-900 m-0">Cost-effective</h5>
|
||||||
|
<p className="text-slate-600 text-sm m-0">No need for expensive software or hardware.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
For small businesses, online shops, and startups, a <strong>free Barcode Generator</strong> is often the easiest way to get started.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Barcode vs QR Code</h2>
|
||||||
|
<p>
|
||||||
|
Although barcodes and QR codes are often confused, they serve different purposes. A barcode stores data horizontally and is mainly used for product identification. A <Link href="/tools/url-qr-code" className="text-blue-600 hover:underline">QR code</Link> stores data both horizontally and vertically and can contain more complex information such as URLs or <Link href="/tools/vcard-qr-code" className="text-blue-600 hover:underline">contact details</Link>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If you only need to identify products or inventory items, a classic barcode is usually the better choice.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Are Barcodes Free to Use?</h2>
|
||||||
|
<p>
|
||||||
|
The barcode image itself can be generated for free using a Barcode Generator. However, for retail products sold internationally, the barcode number may need to be officially registered through organizations such as GS1. This ensures that the barcode is unique and recognized globally.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
For internal use, testing, or small projects, free barcode generation is usually sufficient.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Use Cases for Barcodes</h2>
|
||||||
|
<p>Barcodes are used in many industries, including:</p>
|
||||||
|
<ul className="list-disc pl-6 space-y-2 mb-8">
|
||||||
|
<li>Retail and e-commerce</li>
|
||||||
|
<li>Inventory and warehouse management</li>
|
||||||
|
<li>Shipping and logistics</li>
|
||||||
|
<li>Libraries and document tracking</li>
|
||||||
|
<li>Event tickets and labeling</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
A reliable <strong>Barcode Generator</strong> helps streamline these processes and improves efficiency.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Understanding Check Digits</h2>
|
||||||
|
<p>
|
||||||
|
Most barcodes (like EAN and UPC) include a "Check Digit"—the last number in the sequence. This digit is calculated mathematically from the other numbers to ensure the barcode is scanned correctly. Even if a barcode is slightly damaged or scratched, the scanner uses the check digit to verify the integrity of the data.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Best Practices for Printing Barcodes</h2>
|
||||||
|
<p>
|
||||||
|
To ensure your barcodes scan instantly at the checkout or in the warehouse, follow these printing tips:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-6 space-y-2 mb-8">
|
||||||
|
<li><strong>High Contrast:</strong> Always print black bars on a white background. Reverse colors (white bars on black) often fail to scan.</li>
|
||||||
|
<li><strong>Quiet Zone:</strong> Leave enough white space (margins) on the left and right sides of the barcode.</li>
|
||||||
|
<li><strong>Resolution:</strong> For professional labels, use <strong>SVG format</strong> (vector) or high-resolution PNGs (at least 300 DPI) to avoid blurry edges.</li>
|
||||||
|
<li><strong>Size:</strong> Do not scale the barcode too small. Standard EAN-13 codes should be at least 30mm wide for reliable scanning.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<hr className="my-12 border-slate-200" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mb-6 not-prose">
|
||||||
|
<HelpCircle className="w-6 h-6 text-blue-500" />
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 m-0">Frequently Asked Questions (FAQ)</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="not-prose space-y-8">
|
||||||
|
<div>
|
||||||
|
<h5 className="font-bold text-slate-900 text-lg mb-2">❓ What is a Barcode Generator?</h5>
|
||||||
|
<p className="text-slate-600">A Barcode Generator is an online tool that converts numbers or text into scannable barcode images that can be used for products, labels, and inventory systems.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 className="font-bold text-slate-900 text-lg mb-2">❓ Is this barcode generator free to use?</h5>
|
||||||
|
<p className="text-slate-600">Yes, our online barcode generator is completely free to use with no hidden costs or sign-ups required. You can generate, download, and print barcodes instantly.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 className="font-bold text-slate-900 text-lg mb-2">❓ Which barcode format should I use?</h5>
|
||||||
|
<p className="text-slate-600">
|
||||||
|
<strong>EAN-13:</strong> Standard for retail products in Europe and globally.<br />
|
||||||
|
<strong>UPC-A:</strong> Standard for retail products in USA/Canada.<br />
|
||||||
|
<strong>Code 128:</strong> Best for logistics, shipping, and internal tracking (supports letters & numbers).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 className="font-bold text-slate-900 text-lg mb-2">❓ Can I download barcodes in vector format (SVG)?</h5>
|
||||||
|
<p className="text-slate-600">Yes! We offer <strong>SVG downloads</strong>. SVG files are vector-based, meaning they can be scaled to any size without losing quality—perfect for professional product packaging.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 className="font-bold text-slate-900 text-lg mb-2">❓ How do I generate a barcode online?</h5>
|
||||||
|
<p className="text-slate-600">To generate a barcode online, enter your product number or text, select the desired barcode format (such as EAN-13 or Code 128), and click the generate button. The barcode will be created instantly.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 className="font-bold text-slate-900 text-lg mb-2">❓ Are generated barcodes scannable?</h5>
|
||||||
|
<p className="text-slate-600">Yes, barcodes generated with a proper barcode generator are fully scannable. We generate standard-compliant barcodes readable by any standard optical or laser barcode scanner.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 className="font-bold text-slate-900 text-lg mb-2">❓ Can I use these barcodes for Amazon (EAN/UPC)?</h5>
|
||||||
|
<p className="text-slate-600">You can generate the <em>image</em> for Amazon here if you already have your EAN/UPC number. However, you cannot "create" a valid global EAN number here—you must purchase those official numbers from GS1 to sell on major platforms like Amazon.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 className="font-bold text-slate-900 text-lg mb-2">❓ What is the difference between a barcode and a QR code?</h5>
|
||||||
|
<p className="text-slate-600">A barcode stores data horizontally (1D) and is mainly used for product IDs. A QR code stores data in 2D (matrix) and can hold much more information, such as URLs, vCards, or WiFi credentials.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 p-6 bg-slate-900 rounded-xl text-white not-prose">
|
||||||
|
<h4 className="text-lg font-bold mb-2">Final Thoughts</h4>
|
||||||
|
<p className="text-slate-300 m-0">
|
||||||
|
A Barcode Generator is a simple yet powerful tool that helps businesses save time, reduce errors, and improve operational efficiency. By choosing the right barcode format and using a reliable generator, you can create professional barcodes that work across different systems and industries.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
303
src/app/(marketing)/tools/barcode-generator/page.tsx
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
import BarcodeGeneratorClient from './BarcodeGeneratorClient';
|
||||||
|
import { BarcodeGuide } from './BarcodeGuide';
|
||||||
|
import { Barcode as BarcodeIcon, Shield, Zap, Printer, Download, Share2, Sparkles, Sliders, Check } from 'lucide-react';
|
||||||
|
import { ToolBreadcrumb } from '@/components/seo/BreadcrumbSchema';
|
||||||
|
import { RelatedTools } from '@/components/marketing/RelatedTools';
|
||||||
|
import { generateSoftwareAppSchema, generateFaqSchema } from '@/lib/schema-utils';
|
||||||
|
|
||||||
|
// SEO Optimized Metadata
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: {
|
||||||
|
absolute: 'Barcode Generator – Create Barcodes Online for Free',
|
||||||
|
},
|
||||||
|
description: 'Use a free Barcode Generator to create scannable barcodes online. Supports EAN, UPC and Code 128 for products, labels and inventory.',
|
||||||
|
keywords: ['barcode generator', 'online barcode maker', 'create barcode free', 'ean-13 generator', 'upc-a generator', 'code 128 generator', 'barcode creator', 'printable barcodes'],
|
||||||
|
alternates: {
|
||||||
|
canonical: 'https://www.qrmaster.net/tools/barcode-generator',
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: 'Barcode Generator: Create EAN, UPC & Code 128',
|
||||||
|
description: 'Barcode Generator: Create professional labels instantly. Free & Secured.',
|
||||||
|
url: 'https://www.qrmaster.net/tools/barcode-generator',
|
||||||
|
siteName: 'QR Master',
|
||||||
|
locale: 'en_US',
|
||||||
|
type: 'website',
|
||||||
|
images: [{ url: '/barcode-generator-preview.png', width: 1200, height: 630 }],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: 'Free Barcode Generator',
|
||||||
|
description: 'Create custom barcodes in seconds. Download high-quality PNG/SVG.',
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// JSON-LD Structured Data
|
||||||
|
const jsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@graph': [
|
||||||
|
generateSoftwareAppSchema(
|
||||||
|
'Barcode Generator',
|
||||||
|
'Generate custom printable barcodes instantly for EAN, UPC, Code 128 and more.',
|
||||||
|
'/og-barcode-generator.png',
|
||||||
|
'UtilitiesApplication'
|
||||||
|
),
|
||||||
|
{
|
||||||
|
'@type': 'HowTo',
|
||||||
|
name: 'How to Create a Barcode',
|
||||||
|
description: 'Create custom barcodes for products or inventory.',
|
||||||
|
step: [
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 1,
|
||||||
|
name: 'Enter Content',
|
||||||
|
text: 'Type or paste the numeric or alphanumeric data for your barcode.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 2,
|
||||||
|
name: 'Select Format',
|
||||||
|
text: 'Choose the appropriate barcode type (e.g., Code 128 for general use, EAN-13 for retail).',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 3,
|
||||||
|
name: 'Customize Design',
|
||||||
|
text: 'Adjust the height and width of the barcode to fit your needs.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 4,
|
||||||
|
name: 'Toggle Text',
|
||||||
|
text: 'Decide if you want the human-readable value to appear below the barcode.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 5,
|
||||||
|
name: 'Download & Print',
|
||||||
|
text: 'Save your barcode as PNG or SVG and print it for labels or inventory.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalTime: 'PT20S',
|
||||||
|
},
|
||||||
|
generateFaqSchema({
|
||||||
|
'What is a Barcode Generator?': {
|
||||||
|
question: 'What is a Barcode Generator?',
|
||||||
|
answer: 'A Barcode Generator is an online tool that converts numbers or text into scannable barcode images that can be used for products, labels, and inventory systems.',
|
||||||
|
},
|
||||||
|
'Is this barcode generator free to use?': {
|
||||||
|
question: 'Is this barcode generator free to use?',
|
||||||
|
answer: 'Yes, our online barcode generator is completely free to use with no hidden costs or sign-ups required. You can generate, download, and print barcodes instantly.',
|
||||||
|
},
|
||||||
|
'Which barcode format should I use?': {
|
||||||
|
question: 'Which barcode format should I use?',
|
||||||
|
answer: 'EAN-13 is standard for retail in Europe/Global. UPC-A is standard for retail in USA/Canada. Code 128 is best for logistics and internal tracking as it supports letters and numbers.',
|
||||||
|
},
|
||||||
|
'Can I download barcodes in vector format (SVG)?': {
|
||||||
|
question: 'Can I download barcodes in vector format (SVG)?',
|
||||||
|
answer: 'Yes! We offer SVG downloads. SVG files are vector-based, meaning they can be scaled to any size without losing quality—perfect for professional product packaging.',
|
||||||
|
},
|
||||||
|
'How do I generate a barcode online?': {
|
||||||
|
question: 'How do I generate a barcode online?',
|
||||||
|
answer: 'To generate a barcode online, enter your product number or text, select the desired barcode format (such as EAN-13 or Code 128), and click the generate button. The barcode will be created instantly.',
|
||||||
|
},
|
||||||
|
'Are generated barcodes scannable?': {
|
||||||
|
question: 'Are generated barcodes scannable?',
|
||||||
|
answer: 'Yes, barcodes generated with a proper barcode generator are fully scannable. We generate standard-compliant barcodes readable by any standard optical or laser barcode scanner.',
|
||||||
|
},
|
||||||
|
'Can I use these barcodes for Amazon (EAN/UPC)?': {
|
||||||
|
question: 'Can I use these barcodes for Amazon (EAN/UPC)?',
|
||||||
|
answer: 'You can generate the image for Amazon here if you already have your EAN/UPC number. However, you cannot "create" a valid global EAN number here—you must purchase those official numbers from GS1 to sell on major platforms like Amazon.',
|
||||||
|
},
|
||||||
|
'What is the difference between a barcode and a QR code?': {
|
||||||
|
question: 'What is the difference between a barcode and a QR code?',
|
||||||
|
answer: 'A barcode stores data horizontally (1D) and is mainly used for product IDs. A QR code stores data in 2D (matrix) and can hold much more information, such as URLs, vCards, or WiFi credentials.',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BarcodeGeneratorPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
/>
|
||||||
|
<ToolBreadcrumb toolName="Barcode Generator" toolSlug="barcode-generator" />
|
||||||
|
|
||||||
|
<div className="min-h-screen" style={{ backgroundColor: '#EBEBDF' }}>
|
||||||
|
|
||||||
|
{/* HERO SECTION */}
|
||||||
|
<section className="relative pt-20 pb-20 lg:pt-32 lg:pb-32 px-4 sm:px-6 lg:px-8 overflow-hidden bg-slate-900">
|
||||||
|
<div className="absolute inset-0 opacity-10">
|
||||||
|
{/* Barcode Pattern */}
|
||||||
|
<svg className="w-full h-full" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<pattern id="barcode_pattern" width="60" height="60" patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M5 0 V 60 M15 0 V 60 M20 0 V 60 M35 0 V 60 M40 0 V 60 M55 0 V 60" stroke="white" strokeWidth="2" strokeOpacity="0.5" />
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="url(#barcode_pattern)" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto grid lg:grid-cols-2 gap-12 items-center relative z-10">
|
||||||
|
<div className="text-center lg:text-left">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/10 text-white/90 text-sm font-medium mb-6 backdrop-blur-sm border border-white/10 hover:bg-white/20 transition-colors cursor-default">
|
||||||
|
<span className="flex h-2 w-2 relative">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-400"></span>
|
||||||
|
</span>
|
||||||
|
Free Tool — Professional & Fast
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl md:text-5xl lg:text-6xl font-extrabold text-white tracking-tight leading-tight mb-6">
|
||||||
|
Free Online <span className="text-blue-400">Barcode Generator</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-lg md:text-xl text-slate-400 max-w-2xl mx-auto lg:mx-0 mb-8 leading-relaxed">
|
||||||
|
Our <strong>barcode generator</strong> makes it easy to create and print high-quality labels for products and inventory.
|
||||||
|
<span className="text-white block sm:inline mt-2 sm:mt-0"> Supports EAN, UPC, Code 128.</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center lg:justify-start gap-4 text-sm font-medium text-white/80">
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/10 backdrop-blur-sm">
|
||||||
|
<Check className="w-4 h-4 text-blue-400" />
|
||||||
|
Retail Ready
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/10 backdrop-blur-sm">
|
||||||
|
<Check className="w-4 h-4 text-blue-400" />
|
||||||
|
Vector SVG Export
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-4 py-2.5 rounded-xl border border-white/10 backdrop-blur-sm">
|
||||||
|
<Check className="w-4 h-4 text-blue-400" />
|
||||||
|
No Registration
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visual Abstract */}
|
||||||
|
<div className="hidden lg:flex relative items-center justify-center min-h-[400px]">
|
||||||
|
<div className="absolute w-[500px] h-[500px] bg-blue-500/10 rounded-full blur-[100px] -top-20 -right-20 animate-pulse" />
|
||||||
|
|
||||||
|
<div className="relative w-80 h-96 bg-white/5 backdrop-blur-xl border border-white/20 rounded-3xl shadow-2xl flex flex-col items-center justify-center p-8 transform rotate-2 hover:-rotate-1 transition-all duration-700 group">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-white/5 to-transparent rounded-3xl" />
|
||||||
|
|
||||||
|
<div className="w-full bg-gradient-to-br from-blue-400 to-indigo-600 rounded-xl shadow-lg p-5 mb-6 relative overflow-hidden text-white">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<BarcodeIcon className="w-8 h-8 opacity-80" />
|
||||||
|
<div className="bg-white/20 px-2 py-1 rounded text-xs font-bold uppercase tracking-wider">Label</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold tracking-wider mb-1">PROD-98234</div>
|
||||||
|
<div className="text-xs opacity-70">Inventory ID</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-48 h-32 bg-white rounded-xl p-4 shadow-inner relative overflow-hidden flex flex-col items-center justify-center">
|
||||||
|
<div className="w-full h-16 bg-black flex gap-1 mb-2">
|
||||||
|
{[2, 4, 1, 3, 2, 1, 4, 2, 1, 3].map((w, i) => (
|
||||||
|
<div key={i} className="bg-black flex-1" style={{ flex: w }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-mono font-bold tracking-widest uppercase">98234001A</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Badge */}
|
||||||
|
<div className="absolute -bottom-6 -right-6 bg-slate-900 border border-white/10 py-3 px-5 rounded-xl shadow-xl flex items-center gap-3 transform transition-transform hover:scale-105 duration-300">
|
||||||
|
<div className="bg-blue-500/20 p-2 rounded-full">
|
||||||
|
<Printer className="w-5 h-5 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-[10px] text-slate-400 font-bold uppercase tracking-wider">Ready</div>
|
||||||
|
<div className="text-sm font-bold text-white">Print Instantly</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* GENERATOR SECTION */}
|
||||||
|
<section className="py-12 px-4 sm:px-6 lg:px-8 -mt-8">
|
||||||
|
<BarcodeGeneratorClient />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* HOW IT WORKS */}
|
||||||
|
<section className="py-16 px-4 sm:px-6 lg:px-8 bg-white">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center mb-12">
|
||||||
|
How Our Barcode Generator Works
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 lg:grid-cols-5 gap-8">
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-black flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Sliders className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">1. Configure</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Enter your data and select the format.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-black flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Sparkles className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">2. Customize</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Adjust height, width and text display.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-black flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Zap className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">3. Preview</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
See your barcode update in real-time.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-black flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Download className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">4. Download</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Save as professional PNG or SVG.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-black flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Printer className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900 mb-2">5. Print</h3>
|
||||||
|
<p className="text-slate-600 text-xs leading-relaxed">
|
||||||
|
Print labels directly from your browser.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* RELATED TOOLS */}
|
||||||
|
<RelatedTools />
|
||||||
|
|
||||||
|
{/* SEO GUIDE */}
|
||||||
|
<BarcodeGuide />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import React, { useState, useRef } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { QRCodeSVG } from 'qrcode.react';
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import { toPng } from 'html-to-image';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Link as LinkIcon,
|
Link as LinkIcon,
|
||||||
@@ -52,7 +53,6 @@ export default function URLGenerator() {
|
|||||||
if (!qrRef.current) return;
|
if (!qrRef.current) return;
|
||||||
try {
|
try {
|
||||||
if (format === 'png') {
|
if (format === 'png') {
|
||||||
const { toPng } = await import('html-to-image');
|
|
||||||
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3 });
|
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3 });
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.download = `url-qr-code.png`;
|
link.download = `url-qr-code.png`;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { usePathname } from 'next/navigation';
|
|||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Footer } from '@/components/ui/Footer';
|
import { Footer } from '@/components/ui/Footer';
|
||||||
import de from '@/i18n/de.json';
|
import de from '@/i18n/de.json';
|
||||||
import { ChevronDown, Wifi, Contact, MessageCircle, QrCode, Link2, Type, Mail, MessageSquare, Phone, Calendar, MapPin, Facebook, Instagram, Twitter, Youtube, Music, Bitcoin, CreditCard, Video, Users } from 'lucide-react';
|
import { ChevronDown, Wifi, Contact, MessageCircle, QrCode, Link2, Type, Mail, MessageSquare, Phone, Calendar, MapPin, Facebook, Instagram, Twitter, Youtube, Music, Bitcoin, CreditCard, Video, Users, Barcode as BarcodeIcon } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
|
||||||
@@ -62,6 +62,7 @@ export default function MarketingLayout({
|
|||||||
{ name: 'PayPal', description: 'Receive payments', href: '/tools/paypal-qr-code', icon: CreditCard, color: 'text-blue-700', bgColor: 'bg-blue-50' },
|
{ name: 'PayPal', description: 'Receive payments', href: '/tools/paypal-qr-code', icon: CreditCard, color: 'text-blue-700', bgColor: 'bg-blue-50' },
|
||||||
{ name: 'Zoom', description: 'Join Zoom meeting', href: '/tools/zoom-qr-code', icon: Video, color: 'text-sky-500', bgColor: 'bg-sky-50' },
|
{ name: 'Zoom', description: 'Join Zoom meeting', href: '/tools/zoom-qr-code', icon: Video, color: 'text-sky-500', bgColor: 'bg-sky-50' },
|
||||||
{ name: 'Teams', description: 'Join Teams meeting', href: '/tools/teams-qr-code', icon: Users, color: 'text-violet-500', bgColor: 'bg-violet-50' },
|
{ name: 'Teams', description: 'Join Teams meeting', href: '/tools/teams-qr-code', icon: Users, color: 'text-violet-500', bgColor: 'bg-violet-50' },
|
||||||
|
{ name: 'Barcode', description: 'Barcode erstellen', href: '/tools/barcode-generator', icon: BarcodeIcon, color: 'text-slate-800', bgColor: 'bg-slate-100' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -96,6 +97,7 @@ export default function MarketingLayout({
|
|||||||
<li><a href="/tools/paypal-qr-code">PayPal QR Code</a></li>
|
<li><a href="/tools/paypal-qr-code">PayPal QR Code</a></li>
|
||||||
<li><a href="/tools/zoom-qr-code">Zoom QR Code</a></li>
|
<li><a href="/tools/zoom-qr-code">Zoom QR Code</a></li>
|
||||||
<li><a href="/tools/teams-qr-code">Teams QR Code</a></li>
|
<li><a href="/tools/teams-qr-code">Teams QR Code</a></li>
|
||||||
|
<li><a href="/tools/barcode-generator">Barcode Generator</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Features } from '@/components/marketing/Features';
|
|||||||
import { Pricing } from '@/components/marketing/Pricing';
|
import { Pricing } from '@/components/marketing/Pricing';
|
||||||
import { FAQ } from '@/components/marketing/FAQ';
|
import { FAQ } from '@/components/marketing/FAQ';
|
||||||
import { ScrollToTop } from '@/components/ui/ScrollToTop';
|
import { ScrollToTop } from '@/components/ui/ScrollToTop';
|
||||||
|
import { FreeToolsGrid } from '@/components/marketing/FreeToolsGrid';
|
||||||
import de from '@/i18n/de.json';
|
import de from '@/i18n/de.json';
|
||||||
|
|
||||||
function truncateAtWord(text: string, maxLength: number): string {
|
function truncateAtWord(text: string, maxLength: number): string {
|
||||||
@@ -102,6 +103,9 @@ export default function QRCodeErstellenPage() {
|
|||||||
{/* Main Interaction: Generator */}
|
{/* Main Interaction: Generator */}
|
||||||
<InstantGenerator t={t} />
|
<InstantGenerator t={t} />
|
||||||
|
|
||||||
|
{/* Free Tools Grid */}
|
||||||
|
<FreeToolsGrid />
|
||||||
|
|
||||||
<StaticVsDynamic t={t} />
|
<StaticVsDynamic t={t} />
|
||||||
<Features t={t} />
|
<Features t={t} />
|
||||||
|
|
||||||
|
|||||||
41
src/app/api/feedback/route.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { slug, rating, comment } = body;
|
||||||
|
|
||||||
|
if (!slug || !rating) {
|
||||||
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the QR code
|
||||||
|
const qrCode = await db.qRCode.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!qrCode) {
|
||||||
|
return NextResponse.json({ error: 'QR Code not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log feedback as a scan with additional data
|
||||||
|
// In a full implementation, you'd have a Feedback model
|
||||||
|
// For now, we'll store it in QRScan with special markers
|
||||||
|
await db.qRScan.create({
|
||||||
|
data: {
|
||||||
|
qrId: qrCode.id,
|
||||||
|
ipHash: 'feedback',
|
||||||
|
userAgent: `rating:${rating}|comment:${comment?.substring(0, 200) || ''}`,
|
||||||
|
device: 'feedback',
|
||||||
|
isUnique: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error submitting feedback:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
122
src/app/api/qrs/[id]/feedback/route.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
let userId: string | undefined;
|
||||||
|
|
||||||
|
// Try NextAuth session first
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (session?.user?.id) {
|
||||||
|
userId = session.user.id;
|
||||||
|
} else {
|
||||||
|
// Fallback: Check raw userId cookie (like /api/user does)
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
userId = cookieStore.get('userId')?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const page = parseInt(searchParams.get('page') || '1');
|
||||||
|
const limit = parseInt(searchParams.get('limit') || '20');
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
// Verify QR ownership and type
|
||||||
|
const qrCode = await db.qRCode.findUnique({
|
||||||
|
where: { id, userId: userId },
|
||||||
|
select: { id: true, contentType: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!qrCode) {
|
||||||
|
return NextResponse.json({ error: 'QR code not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if consistent with schema (Prisma enum mismatch fix)
|
||||||
|
// @ts-ignore - Temporary ignore until client regeneration catches up fully in all envs
|
||||||
|
if (qrCode.contentType !== 'FEEDBACK') {
|
||||||
|
return NextResponse.json({ error: 'Not a feedback QR code' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch feedback entries (stored as QRScans with ipHash='feedback')
|
||||||
|
const [feedbackEntries, totalCount] = await Promise.all([
|
||||||
|
db.qRScan.findMany({
|
||||||
|
where: { qrId: id, ipHash: 'feedback' },
|
||||||
|
orderBy: { ts: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
select: { id: true, userAgent: true, ts: true },
|
||||||
|
}),
|
||||||
|
db.qRScan.count({
|
||||||
|
where: { qrId: id, ipHash: 'feedback' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Parse feedback data from userAgent field
|
||||||
|
const feedbacks = feedbackEntries.map((entry) => {
|
||||||
|
const parsed = parseFeedback(entry.userAgent || '');
|
||||||
|
return {
|
||||||
|
id: entry.id,
|
||||||
|
rating: parsed.rating,
|
||||||
|
comment: parsed.comment,
|
||||||
|
date: entry.ts,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate stats
|
||||||
|
const allRatings = await db.qRScan.findMany({
|
||||||
|
where: { qrId: id, ipHash: 'feedback' },
|
||||||
|
select: { userAgent: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const ratings = allRatings.map((e) => parseFeedback(e.userAgent || '').rating).filter((r) => r > 0);
|
||||||
|
const avgRating = ratings.length > 0 ? ratings.reduce((a, b) => a + b, 0) / ratings.length : 0;
|
||||||
|
|
||||||
|
// Rating distribution
|
||||||
|
const distribution = {
|
||||||
|
5: ratings.filter((r) => r === 5).length,
|
||||||
|
4: ratings.filter((r) => r === 4).length,
|
||||||
|
3: ratings.filter((r) => r === 3).length,
|
||||||
|
2: ratings.filter((r) => r === 2).length,
|
||||||
|
1: ratings.filter((r) => r === 1).length,
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
feedbacks,
|
||||||
|
stats: {
|
||||||
|
total: totalCount,
|
||||||
|
avgRating: Math.round(avgRating * 10) / 10,
|
||||||
|
distribution,
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(totalCount / limit),
|
||||||
|
hasMore: skip + limit < totalCount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching feedback:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFeedback(userAgent: string): { rating: number; comment: string } {
|
||||||
|
// Format: "rating:4|comment:Great service!"
|
||||||
|
const ratingMatch = userAgent.match(/rating:(\d)/);
|
||||||
|
const commentMatch = userAgent.match(/comment:(.+)/);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rating: ratingMatch ? parseInt(ratingMatch[1]) : 0,
|
||||||
|
comment: commentMatch ? commentMatch[1] : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
37
src/app/api/qrs/public/[slug]/route.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { slug } = await params;
|
||||||
|
|
||||||
|
const qrCode = await db.qRCode.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
content: true,
|
||||||
|
contentType: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!qrCode) {
|
||||||
|
return NextResponse.json({ error: 'QR Code not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (qrCode.status === 'PAUSED') {
|
||||||
|
return NextResponse.json({ error: 'QR Code is paused' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
contentType: qrCode.contentType,
|
||||||
|
content: qrCode.content,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching public QR:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,227 +1,234 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { generateSlug } from '@/lib/hash';
|
import { generateSlug } from '@/lib/hash';
|
||||||
import { createQRSchema, validateRequest } from '@/lib/validationSchemas';
|
import { createQRSchema, validateRequest } from '@/lib/validationSchemas';
|
||||||
import { csrfProtection } from '@/lib/csrf';
|
import { csrfProtection } from '@/lib/csrf';
|
||||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||||
|
|
||||||
// GET /api/qrs - List user's QR codes
|
// GET /api/qrs - List user's QR codes
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const userId = cookies().get('userId')?.value;
|
const userId = cookies().get('userId')?.value;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const qrCodes = await db.qRCode.findMany({
|
const qrCodes = await db.qRCode.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
include: {
|
include: {
|
||||||
_count: {
|
_count: {
|
||||||
select: { scans: true },
|
select: { scans: true },
|
||||||
},
|
},
|
||||||
scans: {
|
scans: {
|
||||||
where: { isUnique: true },
|
where: { isUnique: true },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Transform the data
|
// Transform the data
|
||||||
const transformed = qrCodes.map(qr => ({
|
const transformed = qrCodes.map(qr => ({
|
||||||
...qr,
|
...qr,
|
||||||
scans: qr._count.scans,
|
scans: qr._count.scans,
|
||||||
uniqueScans: qr.scans.length, // Count of scans where isUnique=true
|
uniqueScans: qr.scans.length, // Count of scans where isUnique=true
|
||||||
_count: undefined,
|
_count: undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return NextResponse.json(transformed);
|
return NextResponse.json(transformed);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching QR codes:', error);
|
console.error('Error fetching QR codes:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Internal server error' },
|
{ error: 'Internal server error' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plan limits
|
// Plan limits
|
||||||
const PLAN_LIMITS = {
|
const PLAN_LIMITS = {
|
||||||
FREE: 3,
|
FREE: 3,
|
||||||
PRO: 50,
|
PRO: 50,
|
||||||
BUSINESS: 500,
|
BUSINESS: 500,
|
||||||
};
|
};
|
||||||
|
|
||||||
// POST /api/qrs - Create a new QR code
|
// POST /api/qrs - Create a new QR code
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// CSRF Protection
|
// CSRF Protection
|
||||||
const csrfCheck = csrfProtection(request);
|
const csrfCheck = csrfProtection(request);
|
||||||
if (!csrfCheck.valid) {
|
if (!csrfCheck.valid) {
|
||||||
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
|
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = cookies().get('userId')?.value;
|
const userId = cookies().get('userId')?.value;
|
||||||
console.log('POST /api/qrs - userId from cookie:', userId);
|
|
||||||
|
// Rate Limiting (user-based)
|
||||||
// Rate Limiting (user-based)
|
const clientId = userId || getClientIdentifier(request);
|
||||||
const clientId = userId || getClientIdentifier(request);
|
const rateLimitResult = rateLimit(clientId, RateLimits.QR_CREATE);
|
||||||
const rateLimitResult = rateLimit(clientId, RateLimits.QR_CREATE);
|
|
||||||
|
if (!rateLimitResult.success) {
|
||||||
if (!rateLimitResult.success) {
|
return NextResponse.json(
|
||||||
return NextResponse.json(
|
{
|
||||||
{
|
error: 'Too many requests. Please try again later.',
|
||||||
error: 'Too many requests. Please try again later.',
|
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
|
||||||
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
|
},
|
||||||
},
|
{
|
||||||
{
|
status: 429,
|
||||||
status: 429,
|
headers: {
|
||||||
headers: {
|
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
||||||
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
||||||
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
||||||
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
}
|
||||||
}
|
}
|
||||||
}
|
);
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
if (!userId) {
|
||||||
if (!userId) {
|
return NextResponse.json({ error: 'Unauthorized - no userId cookie' }, { status: 401 });
|
||||||
return NextResponse.json({ error: 'Unauthorized - no userId cookie' }, { status: 401 });
|
}
|
||||||
}
|
|
||||||
|
const user = await db.user.findUnique({
|
||||||
// Check if user exists and get their plan
|
where: { id: userId },
|
||||||
const user = await db.user.findUnique({
|
select: { plan: true },
|
||||||
where: { id: userId },
|
});
|
||||||
select: { plan: true },
|
|
||||||
});
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: `User not found: ${userId}` }, { status: 404 });
|
||||||
console.log('User exists:', !!user);
|
}
|
||||||
|
|
||||||
if (!user) {
|
const body = await request.json();
|
||||||
return NextResponse.json({ error: `User not found: ${userId}` }, { status: 404 });
|
|
||||||
}
|
// Validate request body with Zod (only for non-static QRs or simplified validation)
|
||||||
|
// Note: Static QRs have complex nested content structure, so we do basic validation
|
||||||
const body = await request.json();
|
if (!body.isStatic) {
|
||||||
console.log('Request body:', body);
|
const validation = await validateRequest(createQRSchema, body);
|
||||||
|
if (!validation.success) {
|
||||||
// Validate request body with Zod (only for non-static QRs or simplified validation)
|
return NextResponse.json(validation.error, { status: 400 });
|
||||||
// Note: Static QRs have complex nested content structure, so we do basic validation
|
}
|
||||||
if (!body.isStatic) {
|
}
|
||||||
const validation = await validateRequest(createQRSchema, body);
|
|
||||||
if (!validation.success) {
|
// Check if this is a static QR request
|
||||||
return NextResponse.json(validation.error, { status: 400 });
|
const isStatic = body.isStatic === true;
|
||||||
}
|
|
||||||
}
|
// Only check limits for DYNAMIC QR codes (static QR codes are unlimited)
|
||||||
|
if (!isStatic) {
|
||||||
// Check if this is a static QR request
|
// Count existing dynamic QR codes
|
||||||
const isStatic = body.isStatic === true;
|
const dynamicQRCount = await db.qRCode.count({
|
||||||
|
where: {
|
||||||
// Only check limits for DYNAMIC QR codes (static QR codes are unlimited)
|
userId,
|
||||||
if (!isStatic) {
|
type: 'DYNAMIC',
|
||||||
// Count existing dynamic QR codes
|
},
|
||||||
const dynamicQRCount = await db.qRCode.count({
|
});
|
||||||
where: {
|
|
||||||
userId,
|
const userPlan = user.plan || 'FREE';
|
||||||
type: 'DYNAMIC',
|
const limit = PLAN_LIMITS[userPlan as keyof typeof PLAN_LIMITS] || PLAN_LIMITS.FREE;
|
||||||
},
|
|
||||||
});
|
if (dynamicQRCount >= limit) {
|
||||||
|
return NextResponse.json(
|
||||||
const userPlan = user.plan || 'FREE';
|
{
|
||||||
const limit = PLAN_LIMITS[userPlan as keyof typeof PLAN_LIMITS] || PLAN_LIMITS.FREE;
|
error: 'Limit reached',
|
||||||
|
message: `You have reached the limit of ${limit} dynamic QR codes for your ${userPlan} plan. Please upgrade to create more.`,
|
||||||
if (dynamicQRCount >= limit) {
|
currentCount: dynamicQRCount,
|
||||||
return NextResponse.json(
|
limit,
|
||||||
{
|
plan: userPlan,
|
||||||
error: 'Limit reached',
|
},
|
||||||
message: `You have reached the limit of ${limit} dynamic QR codes for your ${userPlan} plan. Please upgrade to create more.`,
|
{ status: 403 }
|
||||||
currentCount: dynamicQRCount,
|
);
|
||||||
limit,
|
}
|
||||||
plan: userPlan,
|
}
|
||||||
},
|
|
||||||
{ status: 403 }
|
let enrichedContent = body.content;
|
||||||
);
|
|
||||||
}
|
// For STATIC QR codes, calculate what the QR should contain
|
||||||
}
|
if (isStatic) {
|
||||||
|
let qrContent = '';
|
||||||
let enrichedContent = body.content;
|
switch (body.contentType) {
|
||||||
|
case 'URL':
|
||||||
// For STATIC QR codes, calculate what the QR should contain
|
qrContent = body.content.url;
|
||||||
if (isStatic) {
|
break;
|
||||||
let qrContent = '';
|
case 'PHONE':
|
||||||
switch (body.contentType) {
|
qrContent = `tel:${body.content.phone}`;
|
||||||
case 'URL':
|
break;
|
||||||
qrContent = body.content.url;
|
case 'SMS':
|
||||||
break;
|
qrContent = `sms:${body.content.phone}${body.content.message ? `?body=${encodeURIComponent(body.content.message)}` : ''}`;
|
||||||
case 'PHONE':
|
break;
|
||||||
qrContent = `tel:${body.content.phone}`;
|
case 'VCARD':
|
||||||
break;
|
qrContent = `BEGIN:VCARD
|
||||||
case 'SMS':
|
VERSION:3.0
|
||||||
qrContent = `sms:${body.content.phone}${body.content.message ? `?body=${encodeURIComponent(body.content.message)}` : ''}`;
|
FN:${body.content.firstName || ''} ${body.content.lastName || ''}
|
||||||
break;
|
N:${body.content.lastName || ''};${body.content.firstName || ''};;;
|
||||||
case 'VCARD':
|
${body.content.organization ? `ORG:${body.content.organization}` : ''}
|
||||||
qrContent = `BEGIN:VCARD
|
${body.content.title ? `TITLE:${body.content.title}` : ''}
|
||||||
VERSION:3.0
|
${body.content.email ? `EMAIL:${body.content.email}` : ''}
|
||||||
FN:${body.content.firstName || ''} ${body.content.lastName || ''}
|
${body.content.phone ? `TEL:${body.content.phone}` : ''}
|
||||||
N:${body.content.lastName || ''};${body.content.firstName || ''};;;
|
END:VCARD`;
|
||||||
${body.content.organization ? `ORG:${body.content.organization}` : ''}
|
break;
|
||||||
${body.content.title ? `TITLE:${body.content.title}` : ''}
|
case 'GEO':
|
||||||
${body.content.email ? `EMAIL:${body.content.email}` : ''}
|
const lat = body.content.latitude || 0;
|
||||||
${body.content.phone ? `TEL:${body.content.phone}` : ''}
|
const lon = body.content.longitude || 0;
|
||||||
END:VCARD`;
|
const label = body.content.label ? `?q=${encodeURIComponent(body.content.label)}` : '';
|
||||||
break;
|
qrContent = `geo:${lat},${lon}${label}`;
|
||||||
case 'GEO':
|
break;
|
||||||
const lat = body.content.latitude || 0;
|
case 'TEXT':
|
||||||
const lon = body.content.longitude || 0;
|
qrContent = body.content.text;
|
||||||
const label = body.content.label ? `?q=${encodeURIComponent(body.content.label)}` : '';
|
break;
|
||||||
qrContent = `geo:${lat},${lon}${label}`;
|
case 'WHATSAPP':
|
||||||
break;
|
qrContent = `https://wa.me/${body.content.phone}${body.content.message ? `?text=${encodeURIComponent(body.content.message)}` : ''}`;
|
||||||
case 'TEXT':
|
break;
|
||||||
qrContent = body.content.text;
|
case 'PDF':
|
||||||
break;
|
qrContent = body.content.fileUrl || 'https://example.com/file.pdf';
|
||||||
case 'WHATSAPP':
|
break;
|
||||||
qrContent = `https://wa.me/${body.content.phone}${body.content.message ? `?text=${encodeURIComponent(body.content.message)}` : ''}`;
|
case 'APP':
|
||||||
break;
|
qrContent = body.content.fallbackUrl || body.content.iosUrl || body.content.androidUrl || 'https://example.com';
|
||||||
default:
|
break;
|
||||||
qrContent = body.content.url || 'https://example.com';
|
case 'COUPON':
|
||||||
}
|
qrContent = `Coupon: ${body.content.code || 'CODE'} - ${body.content.discount || 'Discount'}`;
|
||||||
|
break;
|
||||||
// Add qrContent to the content object
|
case 'FEEDBACK':
|
||||||
enrichedContent = {
|
qrContent = body.content.feedbackUrl || 'https://example.com/feedback';
|
||||||
...body.content,
|
break;
|
||||||
qrContent // This is what the QR code should actually contain
|
default:
|
||||||
};
|
qrContent = body.content.url || 'https://example.com';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate slug for the QR code
|
// Add qrContent to the content object
|
||||||
const slug = generateSlug(body.title);
|
enrichedContent = {
|
||||||
|
...body.content,
|
||||||
// Create QR code
|
qrContent // This is what the QR code should actually contain
|
||||||
const qrCode = await db.qRCode.create({
|
};
|
||||||
data: {
|
}
|
||||||
userId,
|
|
||||||
title: body.title,
|
// Generate slug for the QR code
|
||||||
type: isStatic ? 'STATIC' : 'DYNAMIC',
|
const slug = generateSlug(body.title);
|
||||||
contentType: body.contentType,
|
|
||||||
content: enrichedContent,
|
// Create QR code
|
||||||
tags: body.tags || [],
|
const qrCode = await db.qRCode.create({
|
||||||
style: body.style || {
|
data: {
|
||||||
foregroundColor: '#000000',
|
userId,
|
||||||
backgroundColor: '#FFFFFF',
|
title: body.title,
|
||||||
cornerStyle: 'square',
|
type: isStatic ? 'STATIC' : 'DYNAMIC',
|
||||||
size: 200,
|
contentType: body.contentType,
|
||||||
},
|
content: enrichedContent,
|
||||||
slug,
|
tags: body.tags || [],
|
||||||
status: 'ACTIVE',
|
style: body.style || {
|
||||||
},
|
foregroundColor: '#000000',
|
||||||
});
|
backgroundColor: '#FFFFFF',
|
||||||
|
cornerStyle: 'square',
|
||||||
return NextResponse.json(qrCode);
|
size: 200,
|
||||||
} catch (error) {
|
},
|
||||||
console.error('Error creating QR code:', error);
|
slug,
|
||||||
return NextResponse.json(
|
status: 'ACTIVE',
|
||||||
{ error: 'Internal server error', details: String(error) },
|
},
|
||||||
{ status: 500 }
|
});
|
||||||
);
|
|
||||||
}
|
return NextResponse.json(qrCode);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating QR code:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error', details: String(error) },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
82
src/app/api/upload/route.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
import { uploadFileToR2 } from '@/lib/r2';
|
||||||
|
import { env } from '@/lib/env';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// 1. Authentication Check
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
let userId = session?.user?.id;
|
||||||
|
|
||||||
|
// Fallback: Check for simple-login cookie if no NextAuth session
|
||||||
|
if (!userId) {
|
||||||
|
const cookieUserId = request.cookies.get('userId')?.value;
|
||||||
|
if (cookieUserId) {
|
||||||
|
// Verify user exists
|
||||||
|
const user = await db.user.findUnique({
|
||||||
|
where: { id: cookieUserId },
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
if (user) {
|
||||||
|
userId = user.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return new NextResponse('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Parse Form Data
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get('file') as File | null;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'No file provided' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Validation
|
||||||
|
// Check file size (default 10MB)
|
||||||
|
const MAX_SIZE = parseInt(env.MAX_UPLOAD_SIZE || '10485760');
|
||||||
|
if (file.size > MAX_SIZE) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `File too large. Maximum size: ${MAX_SIZE / 1024 / 1024}MB` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file type (allow images and PDFs)
|
||||||
|
const allowedTypes = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'];
|
||||||
|
if (!allowedTypes.includes(file.type)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid file type. Only PDF and Images are allowed.' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Upload to R2
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const publicUrl = await uploadFileToR2(buffer, file.name, file.type);
|
||||||
|
|
||||||
|
// 5. Success
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
url: publicUrl,
|
||||||
|
filename: file.name,
|
||||||
|
type: file.type
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error during upload' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
167
src/app/coupon/[slug]/page.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { Copy, Check, ExternalLink, Gift } from 'lucide-react';
|
||||||
|
|
||||||
|
interface CouponData {
|
||||||
|
code: string;
|
||||||
|
discount: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
expiryDate?: string;
|
||||||
|
redeemUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CouponPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const slug = params.slug as string;
|
||||||
|
const [coupon, setCoupon] = useState<CouponData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchCoupon() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/qrs/public/${slug}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.contentType === 'COUPON') {
|
||||||
|
setCoupon(data.content as CouponData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching coupon:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchCoupon();
|
||||||
|
}, [slug]);
|
||||||
|
|
||||||
|
const copyCode = async () => {
|
||||||
|
if (coupon?.code) {
|
||||||
|
await navigator.clipboard.writeText(coupon.code);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Loading
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-rose-50 to-pink-100">
|
||||||
|
<div className="w-10 h-10 border-3 border-pink-200 border-t-pink-600 rounded-full animate-spin"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not found
|
||||||
|
if (!coupon) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-rose-50 to-pink-100 px-6">
|
||||||
|
<div className="text-center bg-white rounded-2xl p-8 shadow-lg">
|
||||||
|
<p className="text-gray-500 text-lg">This coupon is not available.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isExpired = coupon.expiryDate && new Date(coupon.expiryDate) < new Date();
|
||||||
|
|
||||||
|
return (<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#FAF8F5] via-[#C6C0B3] to-[#4C5F4E] px-6 py-12">
|
||||||
|
<div className="max-w-sm w-full">
|
||||||
|
{/* Card */}
|
||||||
|
<div className="bg-white rounded-3xl shadow-xl overflow-hidden">
|
||||||
|
{/* Colorful Header */}
|
||||||
|
<div className="bg-gradient-to-br from-[#DB5375] to-[#B3FFB3] text-gray-900 p-8 text-center relative overflow-hidden">
|
||||||
|
{/* Decorative circles */}
|
||||||
|
<div className="absolute top-0 right-0 w-32 h-32 bg-white/10 rounded-full -translate-y-1/2 translate-x-1/2"></div>
|
||||||
|
<div className="absolute bottom-0 left-0 w-24 h-24 bg-white/10 rounded-full translate-y-1/2 -translate-x-1/2"></div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<div className="w-14 h-14 bg-[#DB5375]/10 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Gift className="w-7 h-7 text-[#DB5375]" />
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-700 text-sm mb-1">{coupon.title || 'Special Offer'}</p>
|
||||||
|
<p className="text-4xl font-bold tracking-tight text-gray-900">{coupon.discount}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dotted line with circles */}
|
||||||
|
<div className="relative py-4">
|
||||||
|
<div className="relative py-4">
|
||||||
|
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-5 h-10 bg-gradient-to-br from-[#FAF8F5] via-[#C6C0B3] to-[#4C5F4E] rounded-r-full"></div>
|
||||||
|
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-5 h-10 bg-gradient-to-br from-[#FAF8F5] via-[#C6C0B3] to-[#4C5F4E] rounded-l-full"></div>
|
||||||
|
<div className="border-t-2 border-dashed border-gray-200 mx-8"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="px-8 pb-8">
|
||||||
|
{coupon.description && (
|
||||||
|
<p className="text-gray-500 text-sm text-center mb-6">{coupon.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Code Box */}
|
||||||
|
<div className="bg-gray-50 rounded-2xl p-5 mb-4 border border-emerald-100">
|
||||||
|
<p className="text-xs text-emerald-600 text-center mb-2 font-medium uppercase tracking-wider">Your Code</p>
|
||||||
|
<div className="flex items-center justify-center gap-3">
|
||||||
|
<code className="text-2xl font-mono font-bold text-gray-900 tracking-wider">
|
||||||
|
{coupon.code}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={copyCode}
|
||||||
|
className={`p-2.5 rounded-xl transition-all ${copied
|
||||||
|
? 'bg-emerald-100 text-emerald-600'
|
||||||
|
: 'bg-white text-gray-500 hover:text-rose-500 shadow-sm hover:shadow'
|
||||||
|
}`}
|
||||||
|
aria-label="Copy code"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-5 h-5" /> : <Copy className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{copied && (
|
||||||
|
<p className="text-emerald-600 text-xs text-center mt-2 font-medium">Copied!</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expiry */}
|
||||||
|
{coupon.expiryDate && (
|
||||||
|
<p className={`text-sm text-center mb-6 font-medium ${isExpired ? 'text-red-500' : 'text-gray-400'}`}>
|
||||||
|
{isExpired
|
||||||
|
? '⚠️ This coupon has expired'
|
||||||
|
: `Valid until ${new Date(coupon.expiryDate).toLocaleDateString('en-US', {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
})}`
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Redeem Button */}
|
||||||
|
{coupon.redeemUrl && !isExpired && (
|
||||||
|
<a
|
||||||
|
href={coupon.redeemUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="block w-full py-4 rounded-xl font-semibold text-center bg-gradient-to-r from-[#076653] to-[#0C342C] text-white hover:from-[#087861] hover:to-[#0E4036] transition-all shadow-lg shadow-emerald-200"
|
||||||
|
>
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
Redeem Now
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<p className="text-center text-sm text-white/60 mt-6">
|
||||||
|
Powered by QR Master
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
src/app/layout.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
import '@/styles/globals.css';
|
||||||
|
import { ToastContainer } from '@/components/ui/Toast';
|
||||||
|
import AuthProvider from '@/components/SessionProvider';
|
||||||
|
import { PostHogProvider } from '@/components/PostHogProvider';
|
||||||
|
import CookieBanner from '@/components/CookieBanner';
|
||||||
|
|
||||||
|
const isIndexable = process.env.NEXT_PUBLIC_INDEXABLE === 'true';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
metadataBase: new URL('https://www.qrmaster.net'),
|
||||||
|
title: {
|
||||||
|
default: 'QR Master – Smart QR Generator & Analytics',
|
||||||
|
template: '%s | QR Master',
|
||||||
|
},
|
||||||
|
description: 'Create dynamic QR codes, track scans, and scale campaigns with secure analytics.',
|
||||||
|
keywords: 'QR code, QR generator, dynamic QR, QR tracking, QR analytics, branded QR, bulk QR generator',
|
||||||
|
robots: isIndexable
|
||||||
|
? { index: true, follow: true }
|
||||||
|
: { index: false, follow: false },
|
||||||
|
icons: {
|
||||||
|
icon: [
|
||||||
|
{ url: '/favicon.svg', type: 'image/svg+xml' },
|
||||||
|
{ url: '/logo.svg', type: 'image/svg+xml' },
|
||||||
|
],
|
||||||
|
apple: '/logo.svg',
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
site: '@qrmaster',
|
||||||
|
images: ['https://www.qrmaster.net/static/og-image.png'],
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
type: 'website',
|
||||||
|
siteName: 'QR Master',
|
||||||
|
title: 'QR Master – Smart QR Generator & Analytics',
|
||||||
|
description: 'Create dynamic QR codes, track scans, and scale campaigns with secure analytics.',
|
||||||
|
url: 'https://www.qrmaster.net',
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: 'https://www.qrmaster.net/static/og-image.png',
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
alt: 'QR Master - Dynamic QR Code Generator and Analytics Platform',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
locale: 'en_US',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body className="font-sans">
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<PostHogProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
{children}
|
||||||
|
</AuthProvider>
|
||||||
|
<CookieBanner />
|
||||||
|
<ToastContainer />
|
||||||
|
</PostHogProvider>
|
||||||
|
</Suspense>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,212 +1,240 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { hashIP } from '@/lib/hash';
|
import { hashIP } from '@/lib/hash';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ slug: string }> }
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
|
|
||||||
// Fetch QR code by slug
|
// Fetch QR code by slug
|
||||||
const qrCode = await db.qRCode.findUnique({
|
const qrCode = await db.qRCode.findUnique({
|
||||||
where: { slug },
|
where: { slug },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
content: true,
|
content: true,
|
||||||
contentType: true,
|
contentType: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!qrCode) {
|
if (!qrCode) {
|
||||||
return new NextResponse('QR Code not found', { status: 404 });
|
return new NextResponse('QR Code not found', { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track scan (fire and forget)
|
// Track scan (fire and forget)
|
||||||
trackScan(qrCode.id, request).catch(console.error);
|
trackScan(qrCode.id, request).catch(console.error);
|
||||||
|
|
||||||
// Determine destination URL
|
// Determine destination URL
|
||||||
let destination = '';
|
let destination = '';
|
||||||
const content = qrCode.content as any;
|
const content = qrCode.content as any;
|
||||||
|
|
||||||
switch (qrCode.contentType) {
|
switch (qrCode.contentType) {
|
||||||
case 'URL':
|
case 'URL':
|
||||||
destination = content.url || 'https://example.com';
|
destination = content.url || 'https://example.com';
|
||||||
break;
|
break;
|
||||||
case 'PHONE':
|
case 'PHONE':
|
||||||
destination = `tel:${content.phone}`;
|
destination = `tel:${content.phone}`;
|
||||||
break;
|
break;
|
||||||
case 'SMS':
|
case 'SMS':
|
||||||
destination = `sms:${content.phone}${content.message ? `?body=${encodeURIComponent(content.message)}` : ''}`;
|
destination = `sms:${content.phone}${content.message ? `?body=${encodeURIComponent(content.message)}` : ''}`;
|
||||||
break;
|
break;
|
||||||
case 'WHATSAPP':
|
case 'WHATSAPP':
|
||||||
destination = `https://wa.me/${content.phone}${content.message ? `?text=${encodeURIComponent(content.message)}` : ''}`;
|
destination = `https://wa.me/${content.phone}${content.message ? `?text=${encodeURIComponent(content.message)}` : ''}`;
|
||||||
break;
|
break;
|
||||||
case 'VCARD':
|
case 'VCARD':
|
||||||
// For vCard, redirect to display page
|
// For vCard, redirect to display page
|
||||||
const baseUrlVcard = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050';
|
const baseUrlVcard = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050';
|
||||||
destination = `${baseUrlVcard}/vcard?firstName=${encodeURIComponent(content.firstName || '')}&lastName=${encodeURIComponent(content.lastName || '')}&email=${encodeURIComponent(content.email || '')}&phone=${encodeURIComponent(content.phone || '')}&organization=${encodeURIComponent(content.organization || '')}&title=${encodeURIComponent(content.title || '')}`;
|
destination = `${baseUrlVcard}/vcard?firstName=${encodeURIComponent(content.firstName || '')}&lastName=${encodeURIComponent(content.lastName || '')}&email=${encodeURIComponent(content.email || '')}&phone=${encodeURIComponent(content.phone || '')}&organization=${encodeURIComponent(content.organization || '')}&title=${encodeURIComponent(content.title || '')}`;
|
||||||
break;
|
break;
|
||||||
case 'GEO':
|
case 'GEO':
|
||||||
// For location, redirect to Google Maps (works on desktop and mobile)
|
// For location, redirect to Google Maps (works on desktop and mobile)
|
||||||
const lat = content.latitude || 0;
|
const lat = content.latitude || 0;
|
||||||
const lon = content.longitude || 0;
|
const lon = content.longitude || 0;
|
||||||
destination = `https://maps.google.com/?q=${lat},${lon}`;
|
destination = `https://maps.google.com/?q=${lat},${lon}`;
|
||||||
break;
|
break;
|
||||||
case 'TEXT':
|
case 'TEXT':
|
||||||
// For plain text, redirect to a display page
|
// For plain text, redirect to a display page
|
||||||
const baseUrlText = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050';
|
const baseUrlText = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050';
|
||||||
destination = `${baseUrlText}/display?text=${encodeURIComponent(content.text || '')}`;
|
destination = `${baseUrlText}/display?text=${encodeURIComponent(content.text || '')}`;
|
||||||
break;
|
break;
|
||||||
default:
|
case 'PDF':
|
||||||
destination = 'https://example.com';
|
// Direct link to file
|
||||||
}
|
destination = content.fileUrl || 'https://example.com/file.pdf';
|
||||||
|
break;
|
||||||
// Preserve UTM parameters
|
case 'APP':
|
||||||
const searchParams = request.nextUrl.searchParams;
|
// Smart device detection for app stores
|
||||||
const utmParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
|
const userAgent = request.headers.get('user-agent') || '';
|
||||||
const preservedParams = new URLSearchParams();
|
const isIOS = /iphone|ipad|ipod/i.test(userAgent);
|
||||||
|
const isAndroid = /android/i.test(userAgent);
|
||||||
utmParams.forEach(param => {
|
|
||||||
const value = searchParams.get(param);
|
if (isIOS && content.iosUrl) {
|
||||||
if (value) {
|
destination = content.iosUrl;
|
||||||
preservedParams.set(param, value);
|
} else if (isAndroid && content.androidUrl) {
|
||||||
}
|
destination = content.androidUrl;
|
||||||
});
|
} else {
|
||||||
|
destination = content.fallbackUrl || content.iosUrl || content.androidUrl || 'https://example.com';
|
||||||
// Add preserved params to destination
|
}
|
||||||
if (preservedParams.toString() && destination.startsWith('http')) {
|
break;
|
||||||
const separator = destination.includes('?') ? '&' : '?';
|
case 'COUPON':
|
||||||
destination = `${destination}${separator}${preservedParams.toString()}`;
|
// Redirect to coupon display page
|
||||||
}
|
const baseUrlCoupon = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050';
|
||||||
|
destination = `${baseUrlCoupon}/coupon/${slug}`;
|
||||||
// Return 307 redirect (temporary redirect that preserves method)
|
break;
|
||||||
return NextResponse.redirect(destination, { status: 307 });
|
case 'FEEDBACK':
|
||||||
} catch (error) {
|
// Redirect to feedback form page
|
||||||
console.error('QR redirect error:', error);
|
const baseUrlFeedback = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050';
|
||||||
return new NextResponse('Internal server error', { status: 500 });
|
destination = `${baseUrlFeedback}/feedback/${slug}`;
|
||||||
}
|
break;
|
||||||
}
|
default:
|
||||||
|
destination = 'https://example.com';
|
||||||
async function trackScan(qrId: string, request: NextRequest) {
|
}
|
||||||
try {
|
|
||||||
const userAgent = request.headers.get('user-agent') || '';
|
// Preserve UTM parameters
|
||||||
const referer = request.headers.get('referer') || '';
|
const searchParams = request.nextUrl.searchParams;
|
||||||
const ip = request.headers.get('x-forwarded-for') ||
|
const utmParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
|
||||||
request.headers.get('x-real-ip') ||
|
const preservedParams = new URLSearchParams();
|
||||||
'unknown';
|
|
||||||
|
utmParams.forEach(param => {
|
||||||
// Check DNT header
|
const value = searchParams.get(param);
|
||||||
const dnt = request.headers.get('dnt');
|
if (value) {
|
||||||
if (dnt === '1') {
|
preservedParams.set(param, value);
|
||||||
// Respect Do Not Track - only increment counter
|
}
|
||||||
await db.qRScan.create({
|
});
|
||||||
data: {
|
|
||||||
qrId,
|
// Add preserved params to destination
|
||||||
ipHash: 'dnt',
|
if (preservedParams.toString() && destination.startsWith('http')) {
|
||||||
isUnique: false,
|
const separator = destination.includes('?') ? '&' : '?';
|
||||||
},
|
destination = `${destination}${separator}${preservedParams.toString()}`;
|
||||||
});
|
}
|
||||||
return;
|
|
||||||
}
|
// Return 307 redirect (temporary redirect that preserves method)
|
||||||
|
return NextResponse.redirect(destination, { status: 307 });
|
||||||
// Hash IP for privacy
|
} catch (error) {
|
||||||
const ipHash = hashIP(ip);
|
console.error('QR redirect error:', error);
|
||||||
|
return new NextResponse('Internal server error', { status: 500 });
|
||||||
// Device Detection Logic:
|
}
|
||||||
// 1. Windows or Linux -> Always Desktop
|
}
|
||||||
// 2. Explicit iPad/Tablet keywords -> Tablet
|
|
||||||
// 3. Mac + Chrome browser -> Desktop (real Mac users often use Chrome)
|
async function trackScan(qrId: string, request: NextRequest) {
|
||||||
// 4. Mac + Safari + No Referrer -> Likely iPad scanning a QR code
|
try {
|
||||||
// 5. Mobile keywords -> Mobile
|
const userAgent = request.headers.get('user-agent') || '';
|
||||||
// 6. Everything else -> Desktop
|
const referer = request.headers.get('referer') || '';
|
||||||
|
const ip = request.headers.get('x-forwarded-for') ||
|
||||||
const isWindows = /windows/i.test(userAgent);
|
request.headers.get('x-real-ip') ||
|
||||||
const isLinux = /linux/i.test(userAgent) && !/android/i.test(userAgent);
|
'unknown';
|
||||||
const isExplicitTablet = /tablet|ipad|playbook|silk/i.test(userAgent);
|
|
||||||
const isAndroidTablet = /android/i.test(userAgent) && !/mobile/i.test(userAgent);
|
// Check DNT header
|
||||||
const isMacintosh = /macintosh/i.test(userAgent);
|
const dnt = request.headers.get('dnt');
|
||||||
const isChrome = /chrome/i.test(userAgent);
|
if (dnt === '1') {
|
||||||
const isSafari = /safari/i.test(userAgent) && !isChrome;
|
// Respect Do Not Track - only increment counter
|
||||||
const hasReferrer = !!referer;
|
await db.qRScan.create({
|
||||||
|
data: {
|
||||||
// iPad in desktop mode: Mac + Safari (no Chrome) + No Referrer (physical scan)
|
qrId,
|
||||||
const isLikelyiPadScan = isMacintosh && isSafari && !hasReferrer;
|
ipHash: 'dnt',
|
||||||
|
isUnique: false,
|
||||||
let device: string;
|
},
|
||||||
if (isWindows || isLinux) {
|
});
|
||||||
device = 'desktop';
|
return;
|
||||||
} else if (isExplicitTablet || isAndroidTablet || isLikelyiPadScan) {
|
}
|
||||||
device = 'tablet';
|
|
||||||
} else if (/mobile|iphone/i.test(userAgent)) {
|
// Hash IP for privacy
|
||||||
device = 'mobile';
|
const ipHash = hashIP(ip);
|
||||||
} else if (isMacintosh && isChrome) {
|
|
||||||
device = 'desktop'; // Mac with Chrome = real desktop
|
// Device Detection Logic:
|
||||||
} else if (isMacintosh && hasReferrer) {
|
// 1. Windows or Linux -> Always Desktop
|
||||||
device = 'desktop'; // Mac with referrer = probably clicked a link on desktop
|
// 2. Explicit iPad/Tablet keywords -> Tablet
|
||||||
} else {
|
// 3. Mac + Chrome browser -> Desktop (real Mac users often use Chrome)
|
||||||
device = 'desktop'; // Default fallback
|
// 4. Mac + Safari + No Referrer -> Likely iPad scanning a QR code
|
||||||
}
|
// 5. Mobile keywords -> Mobile
|
||||||
|
// 6. Everything else -> Desktop
|
||||||
// Detect OS
|
|
||||||
let os = 'unknown';
|
const isWindows = /windows/i.test(userAgent);
|
||||||
if (/windows/i.test(userAgent)) os = 'Windows';
|
const isLinux = /linux/i.test(userAgent) && !/android/i.test(userAgent);
|
||||||
else if (/mac/i.test(userAgent)) os = 'macOS';
|
const isExplicitTablet = /tablet|ipad|playbook|silk/i.test(userAgent);
|
||||||
else if (/linux/i.test(userAgent)) os = 'Linux';
|
const isAndroidTablet = /android/i.test(userAgent) && !/mobile/i.test(userAgent);
|
||||||
else if (/android/i.test(userAgent)) os = 'Android';
|
const isMacintosh = /macintosh/i.test(userAgent);
|
||||||
else if (/ios|iphone|ipad/i.test(userAgent)) os = 'iOS';
|
const isChrome = /chrome/i.test(userAgent);
|
||||||
|
const isSafari = /safari/i.test(userAgent) && !isChrome;
|
||||||
// Get country from header (Vercel/Cloudflare provide this)
|
const hasReferrer = !!referer;
|
||||||
const country = request.headers.get('x-vercel-ip-country') ||
|
|
||||||
request.headers.get('cf-ipcountry') ||
|
// iPad in desktop mode: Mac + Safari (no Chrome) + No Referrer (physical scan)
|
||||||
'unknown';
|
const isLikelyiPadScan = isMacintosh && isSafari && !hasReferrer;
|
||||||
|
|
||||||
// Extract UTM parameters
|
let device: string;
|
||||||
const searchParams = request.nextUrl.searchParams;
|
if (isWindows || isLinux) {
|
||||||
const utmSource = searchParams.get('utm_source');
|
device = 'desktop';
|
||||||
const utmMedium = searchParams.get('utm_medium');
|
} else if (isExplicitTablet || isAndroidTablet || isLikelyiPadScan) {
|
||||||
const utmCampaign = searchParams.get('utm_campaign');
|
device = 'tablet';
|
||||||
|
} else if (/mobile|iphone/i.test(userAgent)) {
|
||||||
// Check if this is a unique scan (first scan from this IP + Device today)
|
device = 'mobile';
|
||||||
// We include a simplified device fingerprint so different devices on same IP count as unique
|
} else if (isMacintosh && isChrome) {
|
||||||
const deviceFingerprint = hashIP(userAgent.substring(0, 100)); // Hash the user agent for privacy
|
device = 'desktop'; // Mac with Chrome = real desktop
|
||||||
const today = new Date();
|
} else if (isMacintosh && hasReferrer) {
|
||||||
today.setHours(0, 0, 0, 0);
|
device = 'desktop'; // Mac with referrer = probably clicked a link on desktop
|
||||||
|
} else {
|
||||||
const existingScan = await db.qRScan.findFirst({
|
device = 'desktop'; // Default fallback
|
||||||
where: {
|
}
|
||||||
qrId,
|
|
||||||
ipHash,
|
// Detect OS
|
||||||
userAgent: {
|
let os = 'unknown';
|
||||||
startsWith: userAgent.substring(0, 50), // Match same device type
|
if (/windows/i.test(userAgent)) os = 'Windows';
|
||||||
},
|
else if (/mac/i.test(userAgent)) os = 'macOS';
|
||||||
ts: {
|
else if (/linux/i.test(userAgent)) os = 'Linux';
|
||||||
gte: today,
|
else if (/android/i.test(userAgent)) os = 'Android';
|
||||||
},
|
else if (/ios|iphone|ipad/i.test(userAgent)) os = 'iOS';
|
||||||
},
|
|
||||||
});
|
// Get country from header (Vercel/Cloudflare provide this)
|
||||||
|
const country = request.headers.get('x-vercel-ip-country') ||
|
||||||
const isUnique = !existingScan;
|
request.headers.get('cf-ipcountry') ||
|
||||||
|
'unknown';
|
||||||
// Create scan record
|
|
||||||
await db.qRScan.create({
|
// Extract UTM parameters
|
||||||
data: {
|
const searchParams = request.nextUrl.searchParams;
|
||||||
qrId,
|
const utmSource = searchParams.get('utm_source');
|
||||||
ipHash,
|
const utmMedium = searchParams.get('utm_medium');
|
||||||
userAgent: userAgent.substring(0, 255),
|
const utmCampaign = searchParams.get('utm_campaign');
|
||||||
device,
|
|
||||||
os,
|
// Check if this is a unique scan (first scan from this IP + Device today)
|
||||||
country,
|
// We include a simplified device fingerprint so different devices on same IP count as unique
|
||||||
referrer: referer.substring(0, 255),
|
const deviceFingerprint = hashIP(userAgent.substring(0, 100)); // Hash the user agent for privacy
|
||||||
utmSource,
|
const today = new Date();
|
||||||
utmMedium,
|
today.setHours(0, 0, 0, 0);
|
||||||
utmCampaign,
|
|
||||||
isUnique,
|
const existingScan = await db.qRScan.findFirst({
|
||||||
},
|
where: {
|
||||||
});
|
qrId,
|
||||||
} catch (error) {
|
ipHash,
|
||||||
console.error('Error tracking scan:', error);
|
userAgent: {
|
||||||
// Don't throw - this is fire and forget
|
startsWith: userAgent.substring(0, 50), // Match same device type
|
||||||
}
|
},
|
||||||
|
ts: {
|
||||||
|
gte: today,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isUnique = !existingScan;
|
||||||
|
|
||||||
|
// Create scan record
|
||||||
|
await db.qRScan.create({
|
||||||
|
data: {
|
||||||
|
qrId,
|
||||||
|
ipHash,
|
||||||
|
userAgent: userAgent.substring(0, 255),
|
||||||
|
device,
|
||||||
|
os,
|
||||||
|
country,
|
||||||
|
referrer: referer.substring(0, 255),
|
||||||
|
utmSource,
|
||||||
|
utmMedium,
|
||||||
|
utmCampaign,
|
||||||
|
isUnique,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error tracking scan:', error);
|
||||||
|
// Don't throw - this is fire and forget
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,21 +1,21 @@
|
|||||||
import { MetadataRoute } from 'next';
|
import { MetadataRoute } from 'next';
|
||||||
|
|
||||||
export default function robots(): MetadataRoute.Robots {
|
export default function robots(): MetadataRoute.Robots {
|
||||||
const baseUrl = 'https://www.qrmaster.net';
|
const baseUrl = 'https://www.qrmaster.net';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rules: {
|
rules: [
|
||||||
userAgent: '*',
|
{
|
||||||
allow: ['/', '/_next/static/', '/_next/image/'],
|
userAgent: '*',
|
||||||
disallow: [
|
allow: '/',
|
||||||
'/api/',
|
disallow: [
|
||||||
'/dashboard/',
|
'/api/',
|
||||||
'/create/',
|
'/dashboard/',
|
||||||
'/settings/',
|
'/create/',
|
||||||
'/newsletter/',
|
'/settings/',
|
||||||
'/cdn-cgi/',
|
],
|
||||||
],
|
},
|
||||||
},
|
],
|
||||||
sitemap: `${baseUrl}/sitemap.xml`,
|
sitemap: `${baseUrl}/sitemap.xml`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
|||||||
'paypal-qr-code',
|
'paypal-qr-code',
|
||||||
'zoom-qr-code',
|
'zoom-qr-code',
|
||||||
'teams-qr-code',
|
'teams-qr-code',
|
||||||
|
'barcode-generator',
|
||||||
];
|
];
|
||||||
|
|
||||||
// All blog posts
|
// All blog posts
|
||||||
|
|||||||
@@ -1,297 +1,274 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useState, Suspense } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
function VCardContent() {
|
export default function VCardPage() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [firstName, setFirstName] = useState('');
|
const [firstName, setFirstName] = useState('');
|
||||||
const [lastName, setLastName] = useState('');
|
const [lastName, setLastName] = useState('');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [phone, setPhone] = useState('');
|
const [phone, setPhone] = useState('');
|
||||||
const [organization, setOrganization] = useState('');
|
const [organization, setOrganization] = useState('');
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [hasAutoDownloaded, setHasAutoDownloaded] = useState(false);
|
const [hasAutoDownloaded, setHasAutoDownloaded] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const firstNameParam = searchParams.get('firstName');
|
const firstNameParam = searchParams.get('firstName');
|
||||||
const lastNameParam = searchParams.get('lastName');
|
const lastNameParam = searchParams.get('lastName');
|
||||||
const emailParam = searchParams.get('email');
|
const emailParam = searchParams.get('email');
|
||||||
const phoneParam = searchParams.get('phone');
|
const phoneParam = searchParams.get('phone');
|
||||||
const organizationParam = searchParams.get('organization');
|
const organizationParam = searchParams.get('organization');
|
||||||
const titleParam = searchParams.get('title');
|
const titleParam = searchParams.get('title');
|
||||||
|
|
||||||
if (firstNameParam) setFirstName(firstNameParam);
|
if (firstNameParam) setFirstName(firstNameParam);
|
||||||
if (lastNameParam) setLastName(lastNameParam);
|
if (lastNameParam) setLastName(lastNameParam);
|
||||||
if (emailParam) setEmail(emailParam);
|
if (emailParam) setEmail(emailParam);
|
||||||
if (phoneParam) setPhone(phoneParam);
|
if (phoneParam) setPhone(phoneParam);
|
||||||
if (organizationParam) setOrganization(organizationParam);
|
if (organizationParam) setOrganization(organizationParam);
|
||||||
if (titleParam) setTitle(titleParam);
|
if (titleParam) setTitle(titleParam);
|
||||||
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
// Auto-download after 500ms (only once)
|
// Auto-download after 500ms (only once)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ((firstName || lastName) && !hasAutoDownloaded) {
|
if ((firstName || lastName) && !hasAutoDownloaded) {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
handleSaveContact();
|
handleSaveContact();
|
||||||
setHasAutoDownloaded(true);
|
setHasAutoDownloaded(true);
|
||||||
}, 500);
|
}, 500);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}, [firstName, lastName, hasAutoDownloaded]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [firstName, lastName, hasAutoDownloaded]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const handleSaveContact = () => {
|
const handleSaveContact = () => {
|
||||||
// Generate vCard format
|
// Generate vCard format
|
||||||
const vCard = `BEGIN:VCARD
|
const vCard = `BEGIN:VCARD
|
||||||
VERSION:3.0
|
VERSION:3.0
|
||||||
FN:${firstName} ${lastName}
|
FN:${firstName} ${lastName}
|
||||||
N:${lastName};${firstName};;;
|
N:${lastName};${firstName};;;
|
||||||
${organization ? `ORG:${organization}` : ''}
|
${organization ? `ORG:${organization}` : ''}
|
||||||
${title ? `TITLE:${title}` : ''}
|
${title ? `TITLE:${title}` : ''}
|
||||||
${email ? `EMAIL:${email}` : ''}
|
${email ? `EMAIL:${email}` : ''}
|
||||||
${phone ? `TEL:${phone}` : ''}
|
${phone ? `TEL:${phone}` : ''}
|
||||||
END:VCARD`;
|
END:VCARD`;
|
||||||
|
|
||||||
// Create a blob and download
|
// Create a blob and download
|
||||||
const blob = new Blob([vCard], { type: 'text/vcard;charset=utf-8' });
|
const blob = new Blob([vCard], { type: 'text/vcard;charset=utf-8' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = url;
|
link.href = url;
|
||||||
link.download = `${firstName}_${lastName}.vcf`;
|
link.download = `${firstName}_${lastName}.vcf`;
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Show loading or error state
|
// Show loading or error state
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||||
backgroundColor: '#ffffff'
|
backgroundColor: '#ffffff'
|
||||||
}}>
|
}}>
|
||||||
<div style={{ textAlign: 'center' }}>
|
<div style={{ textAlign: 'center' }}>
|
||||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>👤</div>
|
<div style={{ fontSize: '48px', marginBottom: '16px' }}>👤</div>
|
||||||
<p style={{ color: '#666' }}>Loading contact...</p>
|
<p style={{ color: '#666' }}>Loading contact...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!firstName && !lastName) {
|
if (!firstName && !lastName) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||||
padding: '20px',
|
padding: '20px',
|
||||||
backgroundColor: '#ffffff'
|
backgroundColor: '#ffffff'
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
<div style={{
|
||||||
maxWidth: '420px',
|
maxWidth: '420px',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
padding: '48px 32px',
|
padding: '48px 32px',
|
||||||
textAlign: 'center'
|
textAlign: 'center'
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: '64px',
|
fontSize: '64px',
|
||||||
marginBottom: '24px',
|
marginBottom: '24px',
|
||||||
opacity: 0.3
|
opacity: 0.3
|
||||||
}}>👤</div>
|
}}>👤</div>
|
||||||
<h1 style={{
|
<h1 style={{
|
||||||
fontSize: '22px',
|
fontSize: '22px',
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
marginBottom: '12px',
|
marginBottom: '12px',
|
||||||
color: '#1a1a1a'
|
color: '#1a1a1a'
|
||||||
}}>No Contact Found</h1>
|
}}>No Contact Found</h1>
|
||||||
<p style={{
|
<p style={{
|
||||||
color: '#666',
|
color: '#666',
|
||||||
fontSize: '15px',
|
fontSize: '15px',
|
||||||
lineHeight: '1.6'
|
lineHeight: '1.6'
|
||||||
}}>This QR code doesn't contain any contact information.</p>
|
}}>This QR code doesn't contain any contact information.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||||
padding: '20px',
|
padding: '20px',
|
||||||
backgroundColor: '#ffffff'
|
backgroundColor: '#ffffff'
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
<div style={{
|
||||||
maxWidth: '420px',
|
maxWidth: '420px',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
padding: '48px 32px'
|
padding: '48px 32px'
|
||||||
}}>
|
}}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div style={{ textAlign: 'center', marginBottom: '32px' }}>
|
<div style={{ textAlign: 'center', marginBottom: '32px' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: '80px',
|
width: '80px',
|
||||||
height: '80px',
|
height: '80px',
|
||||||
margin: '0 auto 20px',
|
margin: '0 auto 20px',
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
backgroundColor: '#f0f0f0',
|
backgroundColor: '#f0f0f0',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
fontSize: '36px'
|
fontSize: '36px'
|
||||||
}}>👤</div>
|
}}>👤</div>
|
||||||
|
|
||||||
<h1 style={{
|
<h1 style={{
|
||||||
fontSize: '28px',
|
fontSize: '28px',
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
marginBottom: '8px',
|
marginBottom: '8px',
|
||||||
color: '#1a1a1a',
|
color: '#1a1a1a',
|
||||||
letterSpacing: '-0.02em'
|
letterSpacing: '-0.02em'
|
||||||
}}>
|
}}>
|
||||||
{firstName} {lastName}
|
{firstName} {lastName}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{(title || organization) && (
|
{(title || organization) && (
|
||||||
<p style={{
|
<p style={{
|
||||||
color: '#666',
|
color: '#666',
|
||||||
fontSize: '16px',
|
fontSize: '16px',
|
||||||
marginTop: '4px'
|
marginTop: '4px'
|
||||||
}}>
|
}}>
|
||||||
{title && organization && `${title} at ${organization}`}
|
{title && organization && `${title} at ${organization}`}
|
||||||
{title && !organization && title}
|
{title && !organization && title}
|
||||||
{!title && organization && organization}
|
{!title && organization && organization}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contact Details */}
|
{/* Contact Details */}
|
||||||
<div style={{ marginBottom: '32px' }}>
|
<div style={{ marginBottom: '32px' }}>
|
||||||
{email && (
|
{email && (
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '16px 20px',
|
padding: '16px 20px',
|
||||||
backgroundColor: '#f8f8f8',
|
backgroundColor: '#f8f8f8',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
marginBottom: '12px',
|
marginBottom: '12px',
|
||||||
border: '1px solid #f0f0f0'
|
border: '1px solid #f0f0f0'
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
color: '#888',
|
color: '#888',
|
||||||
marginBottom: '6px',
|
marginBottom: '6px',
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: '0.5px',
|
letterSpacing: '0.5px',
|
||||||
fontWeight: '500'
|
fontWeight: '500'
|
||||||
}}>Email</div>
|
}}>Email</div>
|
||||||
<a href={`mailto:${email}`} style={{
|
<a href={`mailto:${email}`} style={{
|
||||||
fontSize: '15px',
|
fontSize: '15px',
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
color: '#2563eb',
|
color: '#2563eb',
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
wordBreak: 'break-all'
|
wordBreak: 'break-all'
|
||||||
}}>{email}</a>
|
}}>{email}</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{phone && (
|
{phone && (
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '16px 20px',
|
padding: '16px 20px',
|
||||||
backgroundColor: '#f8f8f8',
|
backgroundColor: '#f8f8f8',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
border: '1px solid #f0f0f0'
|
border: '1px solid #f0f0f0'
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
color: '#888',
|
color: '#888',
|
||||||
marginBottom: '6px',
|
marginBottom: '6px',
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: '0.5px',
|
letterSpacing: '0.5px',
|
||||||
fontWeight: '500'
|
fontWeight: '500'
|
||||||
}}>Phone</div>
|
}}>Phone</div>
|
||||||
<a href={`tel:${phone}`} style={{
|
<a href={`tel:${phone}`} style={{
|
||||||
fontSize: '15px',
|
fontSize: '15px',
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
color: '#2563eb',
|
color: '#2563eb',
|
||||||
textDecoration: 'none'
|
textDecoration: 'none'
|
||||||
}}>{phone}</a>
|
}}>{phone}</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Save Button */}
|
{/* Save Button */}
|
||||||
<button
|
<button
|
||||||
onClick={handleSaveContact}
|
onClick={handleSaveContact}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
padding: '16px',
|
padding: '16px',
|
||||||
fontSize: '16px',
|
fontSize: '16px',
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
backgroundColor: '#2563eb',
|
backgroundColor: '#2563eb',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'all 0.2s',
|
transition: 'all 0.2s',
|
||||||
boxShadow: '0 2px 8px rgba(37, 99, 235, 0.2)'
|
boxShadow: '0 2px 8px rgba(37, 99, 235, 0.2)'
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.backgroundColor = '#1d4ed8';
|
e.currentTarget.style.backgroundColor = '#1d4ed8';
|
||||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(37, 99, 235, 0.3)';
|
e.currentTarget.style.boxShadow = '0 4px 12px rgba(37, 99, 235, 0.3)';
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.backgroundColor = '#2563eb';
|
e.currentTarget.style.backgroundColor = '#2563eb';
|
||||||
e.currentTarget.style.transform = 'translateY(0)';
|
e.currentTarget.style.transform = 'translateY(0)';
|
||||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(37, 99, 235, 0.2)';
|
e.currentTarget.style.boxShadow = '0 2px 8px rgba(37, 99, 235, 0.2)';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Save to Contacts
|
Save to Contacts
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p style={{
|
<p style={{
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
fontSize: '13px',
|
fontSize: '13px',
|
||||||
color: '#999',
|
color: '#999',
|
||||||
marginTop: '16px',
|
marginTop: '16px',
|
||||||
lineHeight: '1.5'
|
lineHeight: '1.5'
|
||||||
}}>
|
}}>
|
||||||
Add this contact to your address book
|
Add this contact to your address book
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VCardPage() {
|
|
||||||
return (
|
|
||||||
<Suspense fallback={
|
|
||||||
<div style={{
|
|
||||||
minHeight: '100vh',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
||||||
backgroundColor: '#ffffff'
|
|
||||||
}}>
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
|
||||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>👤</div>
|
|
||||||
<p style={{ color: '#666' }}>Loading...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}>
|
|
||||||
<VCardContent />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,102 +1,104 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, useRef } from 'react';
|
import { useEffect, useState, useRef } from 'react';
|
||||||
import { usePathname, useSearchParams } from 'next/navigation';
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
import posthog from 'posthog-js';
|
import posthog from 'posthog-js';
|
||||||
|
|
||||||
export function PostHogPageView() {
|
export function PostHogProvider({ children }: { children: React.ReactNode }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
// Track page views
|
const initializationAttempted = useRef(false);
|
||||||
useEffect(() => {
|
|
||||||
const cookieConsent = localStorage.getItem('cookieConsent');
|
// Initialize PostHog once
|
||||||
|
useEffect(() => {
|
||||||
if (cookieConsent === 'accepted' && pathname) {
|
// Prevent double initialization in React Strict Mode
|
||||||
let url = window.origin + pathname;
|
if (initializationAttempted.current) return;
|
||||||
if (searchParams && searchParams.toString()) {
|
initializationAttempted.current = true;
|
||||||
url = url + `?${searchParams.toString()}`;
|
|
||||||
}
|
const cookieConsent = localStorage.getItem('cookieConsent');
|
||||||
|
|
||||||
posthog.capture('$pageview', {
|
if (cookieConsent === 'accepted') {
|
||||||
$current_url: url,
|
const apiKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
|
||||||
});
|
const apiHost = process.env.NEXT_PUBLIC_POSTHOG_HOST;
|
||||||
}
|
|
||||||
}, [pathname, searchParams]);
|
if (!apiKey) {
|
||||||
|
console.warn('PostHog API key not configured');
|
||||||
return null;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PostHogProvider({ children }: { children: React.ReactNode }) {
|
// Check if already initialized (using _loaded property)
|
||||||
const initializationAttempted = useRef(false);
|
if (!(posthog as any)._loaded) {
|
||||||
|
posthog.init(apiKey, {
|
||||||
// Initialize PostHog once
|
api_host: apiHost || 'https://us.i.posthog.com',
|
||||||
useEffect(() => {
|
person_profiles: 'identified_only',
|
||||||
// Prevent double initialization in React Strict Mode
|
capture_pageview: false, // Manual pageview tracking
|
||||||
if (initializationAttempted.current) return;
|
capture_pageleave: true,
|
||||||
initializationAttempted.current = true;
|
autocapture: true,
|
||||||
|
respect_dnt: true,
|
||||||
const cookieConsent = localStorage.getItem('cookieConsent');
|
opt_out_capturing_by_default: false,
|
||||||
|
});
|
||||||
// Check if we should initialize based on consent
|
|
||||||
// If not accepted yet, we don't init. CookieBanner deals with setting 'accepted' and reloading or calling init.
|
// Enable debug mode in development
|
||||||
// Ideally we should listen to consent changes, but for now matching previous behavior.
|
if (process.env.NODE_ENV === 'development') {
|
||||||
if (cookieConsent === 'accepted') {
|
posthog.debug();
|
||||||
const apiKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
|
}
|
||||||
const apiHost = process.env.NEXT_PUBLIC_POSTHOG_HOST;
|
|
||||||
|
// Set initialized immediately after init
|
||||||
if (!apiKey) {
|
setIsInitialized(true);
|
||||||
console.warn('PostHog API key not configured');
|
} else {
|
||||||
return;
|
setIsInitialized(true); // Already loaded
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (!(posthog as any)._loaded) {
|
|
||||||
posthog.init(apiKey, {
|
// NO cleanup function - PostHog should persist across page navigation
|
||||||
api_host: apiHost || 'https://us.i.posthog.com',
|
}, []);
|
||||||
person_profiles: 'identified_only',
|
|
||||||
capture_pageview: false, // We handle this manually
|
// Track page views ONLY after PostHog is initialized
|
||||||
capture_pageleave: true,
|
useEffect(() => {
|
||||||
autocapture: true,
|
const cookieConsent = localStorage.getItem('cookieConsent');
|
||||||
respect_dnt: true,
|
|
||||||
opt_out_capturing_by_default: false,
|
if (cookieConsent === 'accepted' && pathname && isInitialized) {
|
||||||
});
|
let url = window.origin + pathname;
|
||||||
|
if (searchParams && searchParams.toString()) {
|
||||||
if (process.env.NODE_ENV === 'development') {
|
url = url + `?${searchParams.toString()}`;
|
||||||
posthog.debug();
|
}
|
||||||
}
|
|
||||||
}
|
posthog.capture('$pageview', {
|
||||||
}
|
$current_url: url,
|
||||||
}, []);
|
});
|
||||||
|
}
|
||||||
return <>{children}</>;
|
}, [pathname, searchParams, isInitialized]); // Added isInitialized dependency
|
||||||
}
|
|
||||||
|
return <>{children}</>;
|
||||||
/**
|
}
|
||||||
* Helper function to identify user after login
|
|
||||||
*/
|
/**
|
||||||
export function identifyUser(userId: string, traits?: Record<string, any>) {
|
* Helper function to identify user after login
|
||||||
const cookieConsent = localStorage.getItem('cookieConsent');
|
*/
|
||||||
if (cookieConsent === 'accepted' && (posthog as any)._loaded) {
|
export function identifyUser(userId: string, traits?: Record<string, any>) {
|
||||||
posthog.identify(userId, traits);
|
const cookieConsent = localStorage.getItem('cookieConsent');
|
||||||
}
|
if (cookieConsent === 'accepted' && (posthog as any)._loaded) {
|
||||||
}
|
posthog.identify(userId, traits);
|
||||||
|
}
|
||||||
/**
|
}
|
||||||
* Helper function to track custom events
|
|
||||||
*/
|
/**
|
||||||
export function trackEvent(eventName: string, properties?: Record<string, any>) {
|
* Helper function to track custom events
|
||||||
const cookieConsent = localStorage.getItem('cookieConsent');
|
*/
|
||||||
if (cookieConsent === 'accepted' && (posthog as any)._loaded) {
|
export function trackEvent(eventName: string, properties?: Record<string, any>) {
|
||||||
posthog.capture(eventName, properties);
|
const cookieConsent = localStorage.getItem('cookieConsent');
|
||||||
}
|
if (cookieConsent === 'accepted' && (posthog as any)._loaded) {
|
||||||
}
|
posthog.capture(eventName, properties);
|
||||||
|
}
|
||||||
/**
|
}
|
||||||
* Helper function to reset user on logout
|
|
||||||
*/
|
/**
|
||||||
export function resetUser() {
|
* Helper function to reset user on logout
|
||||||
const cookieConsent = localStorage.getItem('cookieConsent');
|
*/
|
||||||
if (cookieConsent === 'accepted' && (posthog as any)._loaded) {
|
export function resetUser() {
|
||||||
posthog.reset();
|
const cookieConsent = localStorage.getItem('cookieConsent');
|
||||||
}
|
if (cookieConsent === 'accepted' && (posthog as any)._loaded) {
|
||||||
}
|
posthog.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,30 +1,23 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
interface SeoJsonLdProps {
|
interface SeoJsonLdProps {
|
||||||
data: object | object[];
|
data: object | object[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SeoJsonLd({ data }: SeoJsonLdProps) {
|
export default function SeoJsonLd({ data }: SeoJsonLdProps) {
|
||||||
const jsonLdArray = Array.isArray(data) ? data : [data];
|
const jsonLdArray = Array.isArray(data) ? data : [data];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{jsonLdArray.map((item, index) => {
|
{jsonLdArray.map((item, index) => (
|
||||||
// Only add @context if it doesn't already exist in the item
|
<script
|
||||||
const schema = (item as any)['@context']
|
key={index}
|
||||||
? item
|
type="application/ld+json"
|
||||||
: { '@context': 'https://schema.org', ...item };
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: JSON.stringify(item, null, 0),
|
||||||
return (
|
}}
|
||||||
<script
|
/>
|
||||||
key={index}
|
))}
|
||||||
type="application/ld+json"
|
</>
|
||||||
dangerouslySetInnerHTML={{
|
);
|
||||||
__html: JSON.stringify(schema, null, 0),
|
}
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ END:VCARD`;
|
|||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<DropdownItem onClick={() => window.location.href = `/qr/${qr.id}`}>View Details</DropdownItem>
|
||||||
<DropdownItem onClick={() => downloadQR('png')}>Download PNG</DropdownItem>
|
<DropdownItem onClick={() => downloadQR('png')}>Download PNG</DropdownItem>
|
||||||
<DropdownItem onClick={() => downloadQR('svg')}>Download SVG</DropdownItem>
|
<DropdownItem onClick={() => downloadQR('svg')}>Download SVG</DropdownItem>
|
||||||
{qr.type === 'DYNAMIC' && (
|
{qr.type === 'DYNAMIC' && (
|
||||||
@@ -246,6 +247,15 @@ END:VCARD`;
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -89,4 +89,4 @@ export const FAQ: React.FC<FAQProps> = ({ t }) => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -82,4 +82,4 @@ export const Features: React.FC<FeaturesProps> = ({ t }) => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -22,7 +22,8 @@ import {
|
|||||||
MapPin,
|
MapPin,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
Video,
|
Video,
|
||||||
Users
|
Users,
|
||||||
|
Barcode
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const TOOLS = [
|
const TOOLS = [
|
||||||
@@ -177,6 +178,14 @@ const TOOLS = [
|
|||||||
href: '/tools/teams-qr-code',
|
href: '/tools/teams-qr-code',
|
||||||
color: 'text-violet-500',
|
color: 'text-violet-500',
|
||||||
bg: 'bg-violet-50'
|
bg: 'bg-violet-50'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Barcode,
|
||||||
|
name: 'Barcode',
|
||||||
|
description: 'Create standard barcodes',
|
||||||
|
href: '/tools/barcode-generator',
|
||||||
|
color: 'text-slate-900',
|
||||||
|
bg: 'bg-slate-100'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -213,9 +222,18 @@ export function FreeToolsGrid() {
|
|||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
className="text-center mb-16"
|
className="text-center mb-16"
|
||||||
>
|
>
|
||||||
<h2 className="text-3xl lg:text-4xl font-bold text-slate-900 mb-4">
|
<div className="flex flex-col md:flex-row items-center justify-center gap-3 mb-4">
|
||||||
More Free QR Code Tools
|
<h2 className="text-3xl lg:text-4xl font-bold text-slate-900">
|
||||||
</h2>
|
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">
|
<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.
|
Create specialized QR codes for every need. Completely free and no signup required.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -6,19 +6,77 @@ import { Button } from '@/components/ui/Button';
|
|||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Globe, User, MapPin, Phone, CheckCircle2, ArrowRight } from 'lucide-react';
|
import { Globe, User, MapPin, Phone, CheckCircle2, ArrowRight, FileText, Ticket, Smartphone, Star } from 'lucide-react';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
// 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 {
|
interface HeroProps {
|
||||||
t: any; // i18n translation function
|
t: any; // i18n translation function
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Hero: React.FC<HeroProps> = ({ t }) => {
|
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 = {
|
const containerjs = {
|
||||||
hidden: { opacity: 0 },
|
hidden: { opacity: 0 },
|
||||||
@@ -66,9 +124,9 @@ export const Hero: React.FC<HeroProps> = ({ t }) => {
|
|||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
className="space-y-6"
|
className="space-y-6"
|
||||||
>
|
>
|
||||||
<h2 className="text-5xl lg:text-6xl font-bold text-gray-900 leading-tight">
|
<h1 className="text-5xl lg:text-6xl font-bold text-gray-900 leading-tight">
|
||||||
{t.hero.title}
|
{t.hero.title}
|
||||||
</h2>
|
</h1>
|
||||||
|
|
||||||
<p className="text-xl text-gray-600 leading-relaxed max-w-2xl">
|
<p className="text-xl text-gray-600 leading-relaxed max-w-2xl">
|
||||||
{t.hero.subtitle}
|
{t.hero.subtitle}
|
||||||
@@ -113,37 +171,34 @@ export const Hero: React.FC<HeroProps> = ({ t }) => {
|
|||||||
|
|
||||||
{/* Right Preview Widget */}
|
{/* Right Preview Widget */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<motion.div
|
<div className="relative perspective-[1000px]">
|
||||||
variants={containerjs}
|
<div className="grid grid-cols-2 gap-4">
|
||||||
initial="hidden"
|
{[
|
||||||
animate="show"
|
{
|
||||||
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 },
|
||||||
{templateCards.map((card, index) => (
|
delay: 3 // Starts at 3s
|
||||||
<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`}>
|
front: { title: 'Contact Card', color: 'bg-purple-500/10 text-purple-600', icon: User },
|
||||||
<card.icon className="w-6 h-6" />
|
back: { title: 'Coupon / Deals', color: 'bg-red-500/10 text-red-600', icon: Ticket },
|
||||||
</div>
|
delay: 5 // +2s
|
||||||
<p className="font-semibold text-gray-800 group-hover:text-gray-900">{card.title}</p>
|
},
|
||||||
</Card>
|
{
|
||||||
</motion.div>
|
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 },
|
||||||
</motion.div>
|
delay: 7 // +2s
|
||||||
|
},
|
||||||
{/* Floating Badge */}
|
{
|
||||||
<motion.div
|
front: { title: 'Phone Number', color: 'bg-pink-500/10 text-pink-600', icon: Phone },
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
back: { title: 'Feedback', color: 'bg-yellow-500/10 text-yellow-600', icon: Star },
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
delay: 9 // +2s
|
||||||
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"
|
].map((card, index) => (
|
||||||
>
|
<FlippingCard key={index} {...card} />
|
||||||
<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>
|
</div>
|
||||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-white"></span>
|
</div>
|
||||||
</span>
|
|
||||||
{t.hero.engagement_badge}
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -152,4 +207,4 @@ export const Hero: React.FC<HeroProps> = ({ t }) => {
|
|||||||
<div className="absolute bottom-0 left-0 w-full h-32 bg-gradient-to-b from-transparent to-gray-50 pointer-events-none" />
|
<div className="absolute bottom-0 left-0 w-full h-32 bg-gradient-to-b from-transparent to-gray-50 pointer-events-none" />
|
||||||
</section >
|
</section >
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -8,7 +8,6 @@ import { Input } from '@/components/ui/Input';
|
|||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
import { calculateContrast } from '@/lib/utils';
|
import { calculateContrast } from '@/lib/utils';
|
||||||
import AdBanner from '@/components/ads/AdBanner';
|
|
||||||
|
|
||||||
interface InstantGeneratorProps {
|
interface InstantGeneratorProps {
|
||||||
t: any; // i18n translation function
|
t: any; // i18n translation function
|
||||||
@@ -280,4 +279,4 @@ export const InstantGenerator: React.FC<InstantGeneratorProps> = ({ t }) => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -138,4 +138,4 @@ export const Pricing: React.FC<PricingProps> = ({ t }) => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -18,7 +18,8 @@ import {
|
|||||||
Bitcoin,
|
Bitcoin,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
Video,
|
Video,
|
||||||
Users
|
Users,
|
||||||
|
Barcode
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const tools = [
|
const tools = [
|
||||||
@@ -29,7 +30,7 @@ const tools = [
|
|||||||
{ name: 'Email', href: '/tools/email-qr-code', icon: Mail, color: 'text-amber-500', bgColor: 'bg-amber-50' },
|
{ name: 'Email', href: '/tools/email-qr-code', icon: Mail, color: 'text-amber-500', bgColor: 'bg-amber-50' },
|
||||||
{ name: 'SMS', href: '/tools/sms-qr-code', icon: MessageSquare, color: 'text-cyan-500', bgColor: 'bg-cyan-50' },
|
{ name: 'SMS', href: '/tools/sms-qr-code', icon: MessageSquare, color: 'text-cyan-500', bgColor: 'bg-cyan-50' },
|
||||||
{ name: 'Instagram', href: '/tools/instagram-qr-code', icon: Instagram, color: 'text-pink-600', bgColor: 'bg-pink-50' },
|
{ name: 'Instagram', href: '/tools/instagram-qr-code', icon: Instagram, color: 'text-pink-600', bgColor: 'bg-pink-50' },
|
||||||
{ name: 'TikTok', href: '/tools/tiktok-qr-code', icon: Music, color: 'text-slate-800', bgColor: 'bg-slate-100' },
|
{ name: 'Barcode', href: '/tools/barcode-generator', icon: Barcode, color: 'text-slate-900', bgColor: 'bg-slate-100' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function RelatedTools() {
|
export function RelatedTools() {
|
||||||
|
|||||||
@@ -95,4 +95,4 @@ export const StaticVsDynamic: React.FC<StaticVsDynamicProps> = ({ t }) => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -32,4 +32,4 @@ export const StatsStrip: React.FC<StatsStripProps> = ({ t }) => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -67,4 +67,4 @@ export const TemplateCards: React.FC<TemplateCardsProps> = ({ t }) => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -9,10 +9,7 @@
|
|||||||
"create_qr": "QR erstellen",
|
"create_qr": "QR erstellen",
|
||||||
"bulk_creation": "Massen-Erstellung",
|
"bulk_creation": "Massen-Erstellung",
|
||||||
"analytics": "Analytik",
|
"analytics": "Analytik",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen"
|
||||||
"cta": "Kostenlos loslegen",
|
|
||||||
"tools": "Kostenlose Tools",
|
|
||||||
"all_free": "Alle Generatoren sind 100% kostenlos"
|
|
||||||
},
|
},
|
||||||
"hero": {
|
"hero": {
|
||||||
"badge": "Kostenloser QR-Code-Generator",
|
"badge": "Kostenloser QR-Code-Generator",
|
||||||
@@ -67,8 +64,6 @@
|
|||||||
"demo_note": "Dies ist ein Demo-QR-Code"
|
"demo_note": "Dies ist ein Demo-QR-Code"
|
||||||
},
|
},
|
||||||
"static_vs_dynamic": {
|
"static_vs_dynamic": {
|
||||||
"title": "Warum dynamische QR-Codes Geld sparen",
|
|
||||||
"description": "Hören Sie auf, Materialien neu zu drucken. Ändern Sie Zielorte sofort und verfolgen Sie jeden Scan.",
|
|
||||||
"static": {
|
"static": {
|
||||||
"title": "Statische QR-Codes",
|
"title": "Statische QR-Codes",
|
||||||
"subtitle": "Immer kostenlos",
|
"subtitle": "Immer kostenlos",
|
||||||
@@ -102,10 +97,6 @@
|
|||||||
"title": "Vollständige Anpassung",
|
"title": "Vollständige Anpassung",
|
||||||
"description": "Branden Sie Ihre QR-Codes mit individuellen Farben, Logos und Styling-Optionen."
|
"description": "Branden Sie Ihre QR-Codes mit individuellen Farben, Logos und Styling-Optionen."
|
||||||
},
|
},
|
||||||
"unlimited": {
|
|
||||||
"title": "Unbegrenzte statische QR-Codes",
|
|
||||||
"description": "Erstellen Sie so viele statische QR-Codes wie Sie möchten. Für immer kostenlos, ohne Limits."
|
|
||||||
},
|
|
||||||
"bulk": {
|
"bulk": {
|
||||||
"title": "Bulk-Operationen",
|
"title": "Bulk-Operationen",
|
||||||
"description": "Erstellen Sie hunderte von QR-Codes auf einmal mit CSV-Import und Batch-Verarbeitung."
|
"description": "Erstellen Sie hunderte von QR-Codes auf einmal mit CSV-Import und Batch-Verarbeitung."
|
||||||
@@ -379,22 +370,5 @@
|
|||||||
"loading": "Lädt...",
|
"loading": "Lädt...",
|
||||||
"error": "Ein Fehler ist aufgetreten",
|
"error": "Ein Fehler ist aufgetreten",
|
||||||
"success": "Erfolgreich!"
|
"success": "Erfolgreich!"
|
||||||
},
|
|
||||||
"footer": {
|
|
||||||
"product": "Produkt",
|
|
||||||
"features": "Funktionen",
|
|
||||||
"pricing": "Preise",
|
|
||||||
"faq": "FAQ",
|
|
||||||
"blog": "Blog",
|
|
||||||
"resources": "Ressourcen",
|
|
||||||
"full_pricing": "Alle Preise",
|
|
||||||
"all_questions": "Alle Fragen",
|
|
||||||
"all_articles": "Alle Artikel",
|
|
||||||
"get_started": "Loslegen",
|
|
||||||
"legal": "Rechtliches",
|
|
||||||
"privacy_policy": "Datenschutzerklärung",
|
|
||||||
"tagline": "Erstellen Sie individuelle QR-Codes in Sekunden mit erweitertem Tracking und Analytik.",
|
|
||||||
"newsletter": "Newsletter Anmeldung",
|
|
||||||
"rights_reserved": "QR Master. Alle Rechte vorbehalten."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,7 +27,7 @@ export const blogPosts: Record<string, BlogPostData> = {
|
|||||||
dateModified: '2025-10-16T09:00:00Z',
|
dateModified: '2025-10-16T09:00:00Z',
|
||||||
readTime: '15 Min',
|
readTime: '15 Min',
|
||||||
category: 'Analytics',
|
category: 'Analytics',
|
||||||
image: '/blog/qr-code-analytics-hero.webp',
|
image: '/blog/qr-code-analytics-hero-v2.png',
|
||||||
imageAlt: 'QR Code Analytics dashboard displaying scan metrics and user data',
|
imageAlt: 'QR Code Analytics dashboard displaying scan metrics and user data',
|
||||||
author: 'QR Master Team',
|
author: 'QR Master Team',
|
||||||
authorUrl: 'https://www.qrmaster.net/about',
|
authorUrl: 'https://www.qrmaster.net/about',
|
||||||
@@ -88,7 +88,7 @@ export const blogPosts: Record<string, BlogPostData> = {
|
|||||||
<p>Measure downstream actions after the scan—form submissions, purchases, app downloads, or content engagement. Integrate with your CRM and marketing stack to attribute revenue to specific QR campaigns.</p>
|
<p>Measure downstream actions after the scan—form submissions, purchases, app downloads, or content engagement. Integrate with your CRM and marketing stack to attribute revenue to specific QR campaigns.</p>
|
||||||
|
|
||||||
<div class="my-8">
|
<div class="my-8">
|
||||||
<img src="/blog/qr-code-analytics-dashboard.png" alt="QR Code Analytics dashboard showing real-time scan data" class="rounded-lg shadow-lg w-full" />
|
<img loading="lazy" src="/blog/qr-code-analytics-dashboard.png" alt="QR Code Analytics dashboard showing real-time scan data" class="rounded-lg shadow-lg w-full" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>Advanced Campaign Tracking Strategies</h2>
|
<h2>Advanced Campaign Tracking Strategies</h2>
|
||||||
@@ -148,7 +148,7 @@ export const blogPosts: Record<string, BlogPostData> = {
|
|||||||
dateModified: '2025-10-18T09:00:00Z',
|
dateModified: '2025-10-18T09:00:00Z',
|
||||||
readTime: '12 Min',
|
readTime: '12 Min',
|
||||||
category: 'Tracking & Analytics',
|
category: 'Tracking & Analytics',
|
||||||
image: '/blog/qr-code-tracking-guide-hero.webp',
|
image: '/blog/qr-code-tracking-hero-v2.png',
|
||||||
imageAlt: 'QR Code Tracking and analytics dashboard visualization',
|
imageAlt: 'QR Code Tracking and analytics dashboard visualization',
|
||||||
author: 'QR Master Team',
|
author: 'QR Master Team',
|
||||||
authorUrl: 'https://www.qrmaster.net/about',
|
authorUrl: 'https://www.qrmaster.net/about',
|
||||||
@@ -349,7 +349,7 @@ app.get('/qr/:id', async (req, res) => {
|
|||||||
<p>Privacy Note: Always hash IP addresses, respect Do Not Track headers, and comply with GDPR when collecting scan data.</p>
|
<p>Privacy Note: Always hash IP addresses, respect Do Not Track headers, and comply with GDPR when collecting scan data.</p>
|
||||||
|
|
||||||
<div class="my-8">
|
<div class="my-8">
|
||||||
<img src="/blog/qr-code-tracking-guide-body.png" alt="Person using QR Code Tracking on mobile device in office" class="rounded-lg shadow-lg w-full" />
|
<img loading="lazy" src="/blog/qr-code-tracking-guide-body.png" alt="Person using QR Code Tracking on mobile device in office" class="rounded-lg shadow-lg w-full" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>QR Code Tracking Tools Comparison</h2>
|
<h2>QR Code Tracking Tools Comparison</h2>
|
||||||
@@ -674,7 +674,7 @@ app.get('/qr/:id', async (req, res) => {
|
|||||||
dateModified: '2025-10-17T09:00:00Z',
|
dateModified: '2025-10-17T09:00:00Z',
|
||||||
readTime: '10 Min',
|
readTime: '10 Min',
|
||||||
category: 'QR Code Basics',
|
category: 'QR Code Basics',
|
||||||
image: '/blog/static-vs-dynamic-qr-codes-hero.png',
|
image: '/blog/dynamic-vs-static-hero-v2.png',
|
||||||
imageAlt: 'Comparison graphic showing features of static versus dynamic QR codes',
|
imageAlt: 'Comparison graphic showing features of static versus dynamic QR codes',
|
||||||
author: 'QR Master Team',
|
author: 'QR Master Team',
|
||||||
authorUrl: 'https://www.qrmaster.net/about',
|
authorUrl: 'https://www.qrmaster.net/about',
|
||||||
@@ -710,7 +710,7 @@ app.get('/qr/:id', async (req, res) => {
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="my-8">
|
<div class="my-8">
|
||||||
<img src="/blog/static-vs-dynamic-qr-codes-body.png" alt="Visual comparison of static and dynamic QR code patterns" class="rounded-lg shadow-lg w-full" />
|
<img loading="lazy" src="/blog/static-vs-dynamic-qr-codes-body.png" alt="Visual comparison of static and dynamic QR code patterns" class="rounded-lg shadow-lg w-full" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>Direct Comparison: Static vs Dynamic</h2>
|
<h2>Direct Comparison: Static vs Dynamic</h2>
|
||||||
@@ -877,7 +877,7 @@ app.get('/qr/:id', async (req, res) => {
|
|||||||
<p>Go to the <a href="/signup">QR Master Bulk Dashboard</a> (requires Business plan). Click "Upload CSV" and select your file. The system will validate your rows to ensure no missing URLs.</p>
|
<p>Go to the <a href="/signup">QR Master Bulk Dashboard</a> (requires Business plan). Click "Upload CSV" and select your file. The system will validate your rows to ensure no missing URLs.</p>
|
||||||
|
|
||||||
<div class="my-8">
|
<div class="my-8">
|
||||||
<img src="/blog/2-body.webp" alt="Screenshot of bulk CSV upload interface" class="rounded-lg shadow-lg w-full" />
|
<img loading="lazy" src="/blog/2-body.webp" alt="Screenshot of bulk CSV upload interface" class="rounded-lg shadow-lg w-full" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>Step 4: Customize Design</h3>
|
<h3>Step 4: Customize Design</h3>
|
||||||
@@ -1212,7 +1212,7 @@ const response = await fetch('https://api.qrmaster.net/v1/bulk', {
|
|||||||
<h2>Step 2: Create Your QR Code with QR Master</h2>
|
<h2>Step 2: Create Your QR Code with QR Master</h2>
|
||||||
|
|
||||||
<div class="my-8">
|
<div class="my-8">
|
||||||
<img src="/blog/restaurant-qr-body.png" alt="Customer scanning QR code menu at restaurant" class="rounded-lg shadow-lg w-full" />
|
<img loading="lazy" src="/blog/restaurant-qr-body.png" alt="Customer scanning QR code menu at restaurant" class="rounded-lg shadow-lg w-full" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p>Using a <a href="/dynamic-qr-code-generator">dynamic QR code generator</a> is essential for restaurants. Unlike static codes, dynamic QR codes let you:</p>
|
<p>Using a <a href="/dynamic-qr-code-generator">dynamic QR code generator</a> is essential for restaurants. Unlike static codes, dynamic QR codes let you:</p>
|
||||||
@@ -1354,7 +1354,7 @@ const response = await fetch('https://api.qrmaster.net/v1/bulk', {
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="my-8">
|
<div class="my-8">
|
||||||
<img src="/blog/vcard-qr-body.png" alt="Business professionals exchanging digital business cards" class="rounded-lg shadow-lg w-full" />
|
<img loading="lazy" src="/blog/vcard-qr-body.png" alt="Business professionals exchanging digital business cards" class="rounded-lg shadow-lg w-full" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>Information You Can Include in a vCard</h2>
|
<h2>Information You Can Include in a vCard</h2>
|
||||||
@@ -1480,7 +1480,7 @@ const response = await fetch('https://api.qrmaster.net/v1/bulk', {
|
|||||||
<p>QR codes have become essential tools for small businesses looking to bridge the gap between physical and digital experiences. From contactless payments to customer feedback, <strong>QR codes for small business</strong> offer affordable, versatile solutions that previously required expensive custom apps.</p>
|
<p>QR codes have become essential tools for small businesses looking to bridge the gap between physical and digital experiences. From contactless payments to customer feedback, <strong>QR codes for small business</strong> offer affordable, versatile solutions that previously required expensive custom apps.</p>
|
||||||
|
|
||||||
<div class="my-8">
|
<div class="my-8">
|
||||||
<img src="/blog/small-business-body.png" alt="Customer scanning QR code at retail checkout" class="rounded-lg shadow-lg w-full" />
|
<img loading="lazy" src="/blog/small-business-body.png" alt="Customer scanning QR code at retail checkout" class="rounded-lg shadow-lg w-full" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>Top 10 QR Code Use Cases for Small Business</h2>
|
<h2>Top 10 QR Code Use Cases for Small Business</h2>
|
||||||
@@ -1610,7 +1610,7 @@ const response = await fetch('https://api.qrmaster.net/v1/bulk', {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="my-8">
|
<div class="my-8">
|
||||||
<img src="/blog/qr-sizes-body.png" alt="Various QR code print sizes comparison" class="rounded-lg shadow-lg w-full" />
|
<img loading="lazy" src="/blog/qr-sizes-body.png" alt="Various QR code print sizes comparison" class="rounded-lg shadow-lg w-full" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>QR Code Sizes by Application</h2>
|
<h2>QR Code Sizes by Application</h2>
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ const globalForPrisma = globalThis as unknown as {
|
|||||||
|
|
||||||
export const db =
|
export const db =
|
||||||
globalForPrisma.prisma ??
|
globalForPrisma.prisma ??
|
||||||
new PrismaClient({
|
new PrismaClient();
|
||||||
log: ['query'],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;
|
||||||
@@ -1,27 +1,35 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const envSchema = z.object({
|
const envSchema = z.object({
|
||||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||||
PORT: z.string().default('3000'),
|
PORT: z.string().default('3000'),
|
||||||
DATABASE_URL: z.string().default('postgresql://postgres:postgres@localhost:5432/qrmaster?schema=public'),
|
DATABASE_URL: z.string().default('postgresql://postgres:postgres@localhost:5432/qrmaster?schema=public'),
|
||||||
NEXTAUTH_URL: z.string().default('http://localhost:3050'),
|
NEXTAUTH_URL: z.string().default('http://localhost:3050'),
|
||||||
NEXTAUTH_SECRET: z.string().default('development-secret-change-in-production'),
|
NEXTAUTH_SECRET: z.string().default('development-secret-change-in-production'),
|
||||||
GOOGLE_CLIENT_ID: z.string().optional(),
|
GOOGLE_CLIENT_ID: z.string().optional(),
|
||||||
GOOGLE_CLIENT_SECRET: z.string().optional(),
|
GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||||||
REDIS_URL: z.string().optional(),
|
REDIS_URL: z.string().optional(),
|
||||||
IP_SALT: z.string().default('development-salt-change-in-production'),
|
IP_SALT: z.string().default('development-salt-change-in-production'),
|
||||||
ENABLE_DEMO: z.string().default('false'),
|
ENABLE_DEMO: z.string().default('false'),
|
||||||
});
|
|
||||||
|
// Cloudflare R2 (S3 Compatible)
|
||||||
// During build, we might not have all env vars, so we'll use defaults
|
R2_ACCOUNT_ID: z.string().optional(),
|
||||||
const isBuildTime = process.env.NEXT_PHASE === 'phase-production-build' || !process.env.DATABASE_URL;
|
R2_ACCESS_KEY_ID: z.string().optional(),
|
||||||
|
R2_SECRET_ACCESS_KEY: z.string().optional(),
|
||||||
export const env = isBuildTime
|
R2_BUCKET_NAME: z.string().default('qrmaster-menus'),
|
||||||
? envSchema.parse({
|
R2_PUBLIC_URL: z.string().optional(),
|
||||||
...process.env,
|
MAX_UPLOAD_SIZE: z.string().default('10485760'), // 10MB default
|
||||||
DATABASE_URL: process.env.DATABASE_URL || 'postgresql://postgres:postgres@db:5432/qrmaster?schema=public',
|
});
|
||||||
NEXTAUTH_URL: process.env.NEXTAUTH_URL || 'http://localhost:3050',
|
|
||||||
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET || 'development-secret-change-in-production',
|
// During build, we might not have all env vars, so we'll use defaults
|
||||||
IP_SALT: process.env.IP_SALT || 'development-salt-change-in-production',
|
const isBuildTime = process.env.NEXT_PHASE === 'phase-production-build' || !process.env.DATABASE_URL;
|
||||||
})
|
|
||||||
|
export const env = isBuildTime
|
||||||
|
? envSchema.parse({
|
||||||
|
...process.env,
|
||||||
|
DATABASE_URL: process.env.DATABASE_URL || 'postgresql://postgres:postgres@db:5432/qrmaster?schema=public',
|
||||||
|
NEXTAUTH_URL: process.env.NEXTAUTH_URL || 'http://localhost:3050',
|
||||||
|
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET || 'development-secret-change-in-production',
|
||||||
|
IP_SALT: process.env.IP_SALT || 'development-salt-change-in-production',
|
||||||
|
})
|
||||||
: envSchema.parse(process.env);
|
: envSchema.parse(process.env);
|
||||||
@@ -8,7 +8,7 @@ const INDEXNOW_ENDPOINT = 'https://api.indexnow.org/indexnow';
|
|||||||
const HOST = 'www.qrmaster.net';
|
const HOST = 'www.qrmaster.net';
|
||||||
// You need to generate a key from https://www.bing.com/indexnow and place it in your public folder
|
// You need to generate a key from https://www.bing.com/indexnow and place it in your public folder
|
||||||
// For now, we'll assume a key exists or is provided via env
|
// For now, we'll assume a key exists or is provided via env
|
||||||
const KEY = process.env.INDEXNOW_KEY || 'your-indexnow-key';
|
const KEY = process.env.INDEXNOW_KEY || 'bb6dfaacf1ed41a880281c426c54ed7c';
|
||||||
const KEY_LOCATION = `https://${HOST}/${KEY}.txt`;
|
const KEY_LOCATION = `https://${HOST}/${KEY}.txt`;
|
||||||
|
|
||||||
export async function submitToIndexNow(urls: string[]) {
|
export async function submitToIndexNow(urls: string[]) {
|
||||||
@@ -48,6 +48,7 @@ export function getAllIndexableUrls(): string[] {
|
|||||||
|
|
||||||
// Free tools
|
// Free tools
|
||||||
const freeTools = [
|
const freeTools = [
|
||||||
|
'barcode-generator', // Added as per request
|
||||||
'url-qr-code', 'vcard-qr-code', 'text-qr-code', 'email-qr-code', 'sms-qr-code',
|
'url-qr-code', 'vcard-qr-code', 'text-qr-code', 'email-qr-code', 'sms-qr-code',
|
||||||
'wifi-qr-code', 'crypto-qr-code', 'event-qr-code', 'facebook-qr-code',
|
'wifi-qr-code', 'crypto-qr-code', 'event-qr-code', 'facebook-qr-code',
|
||||||
'instagram-qr-code', 'twitter-qr-code', 'youtube-qr-code', 'whatsapp-qr-code',
|
'instagram-qr-code', 'twitter-qr-code', 'youtube-qr-code', 'whatsapp-qr-code',
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,269 +1,245 @@
|
|||||||
export interface BreadcrumbItem {
|
export interface BreadcrumbItem {
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BlogPost {
|
export interface BlogPost {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
author: string;
|
author: string;
|
||||||
authorUrl: string;
|
authorUrl: string;
|
||||||
datePublished: string;
|
datePublished: string;
|
||||||
dateModified: string;
|
dateModified: string;
|
||||||
image: string;
|
image: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FAQItem {
|
export interface FAQItem {
|
||||||
question: string;
|
question: string;
|
||||||
answer: string;
|
answer: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductOffer {
|
export interface ProductOffer {
|
||||||
name: string;
|
name: string;
|
||||||
price: string;
|
price: string;
|
||||||
priceCurrency: string;
|
priceCurrency: string;
|
||||||
availability: string;
|
availability: string;
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HowToStep {
|
export interface HowToStep {
|
||||||
name: string;
|
name: string;
|
||||||
text: string;
|
text: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HowToTask {
|
export interface HowToTask {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
steps: HowToStep[];
|
steps: HowToStep[];
|
||||||
totalTime?: string;
|
totalTime?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE_URL = 'https://www.qrmaster.net';
|
export function organizationSchema() {
|
||||||
|
return {
|
||||||
function toAbsoluteUrl(path: string): string {
|
'@context': 'https://schema.org',
|
||||||
if (path.startsWith('http')) return path;
|
'@type': 'Organization',
|
||||||
return `${BASE_URL}${path.startsWith('/') ? '' : '/'}${path}`;
|
'@id': 'https://www.qrmaster.net/#organization',
|
||||||
}
|
name: 'QR Master',
|
||||||
|
alternateName: 'QRMaster',
|
||||||
export function organizationSchema() {
|
url: 'https://www.qrmaster.net',
|
||||||
return {
|
logo: {
|
||||||
'@context': 'https://schema.org',
|
'@type': 'ImageObject',
|
||||||
'@type': 'Organization',
|
url: 'https://www.qrmaster.net/static/og-image.png',
|
||||||
'@id': `${BASE_URL}/#organization`,
|
width: 1200,
|
||||||
name: 'QR Master',
|
height: 630,
|
||||||
alternateName: 'QRMaster',
|
},
|
||||||
url: BASE_URL,
|
image: 'https://www.qrmaster.net/static/og-image.png',
|
||||||
logo: {
|
sameAs: [
|
||||||
'@type': 'ImageObject',
|
'https://twitter.com/qrmaster',
|
||||||
url: `${BASE_URL}/og-image.png`,
|
],
|
||||||
width: 1200,
|
contactPoint: {
|
||||||
height: 630,
|
'@type': 'ContactPoint',
|
||||||
},
|
contactType: 'Customer Support',
|
||||||
image: `${BASE_URL}/og-image.png`,
|
email: 'support@qrmaster.net',
|
||||||
sameAs: [
|
availableLanguage: ['English', 'German'],
|
||||||
'https://twitter.com/qrmaster',
|
},
|
||||||
],
|
description: 'B2B SaaS platform for dynamic QR code generation with analytics, branding, and bulk generation for enterprise marketing campaigns.',
|
||||||
contactPoint: {
|
slogan: 'Dynamic QR codes that work smarter',
|
||||||
'@type': 'ContactPoint',
|
foundingDate: '2025',
|
||||||
contactType: 'Customer Support',
|
areaServed: 'Worldwide',
|
||||||
email: 'support@qrmaster.net',
|
serviceType: 'Software as a Service',
|
||||||
availableLanguage: ['English', 'German'],
|
priceRange: '$0 - $29',
|
||||||
},
|
knowsAbout: [
|
||||||
description: 'B2B SaaS platform for dynamic QR code generation with analytics, branding, and bulk generation for enterprise marketing campaigns.',
|
'QR Code Generation',
|
||||||
slogan: 'Dynamic QR codes that work smarter',
|
'Marketing Analytics',
|
||||||
foundingDate: '2025',
|
'Campaign Tracking',
|
||||||
areaServed: 'Worldwide',
|
'Dynamic QR Codes',
|
||||||
knowsAbout: [
|
'Bulk QR Generation',
|
||||||
'QR Code Generation',
|
],
|
||||||
'Marketing Analytics',
|
hasOfferCatalog: {
|
||||||
'Campaign Tracking',
|
'@type': 'OfferCatalog',
|
||||||
'Dynamic QR Codes',
|
name: 'QR Master Plans',
|
||||||
'Bulk QR Generation',
|
itemListElement: [
|
||||||
],
|
{
|
||||||
hasOfferCatalog: {
|
'@type': 'Offer',
|
||||||
'@type': 'OfferCatalog',
|
itemOffered: {
|
||||||
name: 'QR Master Plans',
|
'@type': 'SoftwareApplication',
|
||||||
itemListElement: [
|
name: 'QR Master Free',
|
||||||
{
|
applicationCategory: 'BusinessApplication',
|
||||||
'@type': 'Offer',
|
operatingSystem: 'Web Browser',
|
||||||
itemOffered: {
|
},
|
||||||
'@type': 'SoftwareApplication',
|
},
|
||||||
name: 'QR Master Free',
|
{
|
||||||
applicationCategory: 'BusinessApplication',
|
'@type': 'Offer',
|
||||||
operatingSystem: 'Web Browser',
|
itemOffered: {
|
||||||
offers: {
|
'@type': 'SoftwareApplication',
|
||||||
'@type': 'Offer',
|
name: 'QR Master Pro',
|
||||||
price: '0',
|
applicationCategory: 'BusinessApplication',
|
||||||
priceCurrency: 'EUR',
|
operatingSystem: 'Web Browser',
|
||||||
},
|
},
|
||||||
aggregateRating: {
|
},
|
||||||
'@type': 'AggregateRating',
|
],
|
||||||
ratingValue: '4.8',
|
},
|
||||||
ratingCount: '1250',
|
inLanguage: 'en',
|
||||||
},
|
mainEntityOfPage: 'https://www.qrmaster.net',
|
||||||
},
|
};
|
||||||
},
|
}
|
||||||
{
|
|
||||||
'@type': 'Offer',
|
export function websiteSchema() {
|
||||||
itemOffered: {
|
return {
|
||||||
'@type': 'SoftwareApplication',
|
'@context': 'https://schema.org',
|
||||||
name: 'QR Master Pro',
|
'@type': 'WebSite',
|
||||||
applicationCategory: 'BusinessApplication',
|
'@id': 'https://www.qrmaster.net/#website',
|
||||||
operatingSystem: 'Web Browser',
|
name: 'QR Master',
|
||||||
offers: {
|
url: 'https://www.qrmaster.net',
|
||||||
'@type': 'Offer',
|
inLanguage: 'en',
|
||||||
price: '9',
|
mainEntityOfPage: 'https://www.qrmaster.net',
|
||||||
priceCurrency: 'EUR',
|
publisher: {
|
||||||
},
|
'@id': 'https://www.qrmaster.net/#organization',
|
||||||
aggregateRating: {
|
},
|
||||||
'@type': 'AggregateRating',
|
potentialAction: {
|
||||||
ratingValue: '4.9',
|
'@type': 'SearchAction',
|
||||||
ratingCount: '850',
|
target: {
|
||||||
},
|
'@type': 'EntryPoint',
|
||||||
},
|
urlTemplate: 'https://www.qrmaster.net/blog?q={search_term_string}',
|
||||||
},
|
},
|
||||||
],
|
'query-input': 'required name=search_term_string',
|
||||||
},
|
},
|
||||||
mainEntityOfPage: BASE_URL,
|
};
|
||||||
};
|
}
|
||||||
}
|
|
||||||
|
export function breadcrumbSchema(items: BreadcrumbItem[]) {
|
||||||
export function websiteSchema() {
|
return {
|
||||||
return {
|
'@context': 'https://schema.org',
|
||||||
'@context': 'https://schema.org',
|
'@type': 'BreadcrumbList',
|
||||||
'@type': 'WebSite',
|
'@id': `https://www.qrmaster.net${items[items.length - 1]?.url}#breadcrumb`,
|
||||||
'@id': `${BASE_URL}/#website`,
|
inLanguage: 'en',
|
||||||
name: 'QR Master',
|
mainEntityOfPage: `https://www.qrmaster.net${items[items.length - 1]?.url}`,
|
||||||
url: BASE_URL,
|
itemListElement: items.map((item, index) => ({
|
||||||
inLanguage: 'en',
|
'@type': 'ListItem',
|
||||||
mainEntityOfPage: BASE_URL,
|
position: index + 1,
|
||||||
publisher: {
|
name: item.name,
|
||||||
'@id': `${BASE_URL}/#organization`,
|
item: `https://www.qrmaster.net${item.url}`,
|
||||||
},
|
})),
|
||||||
potentialAction: {
|
};
|
||||||
'@type': 'SearchAction',
|
}
|
||||||
target: {
|
|
||||||
'@type': 'EntryPoint',
|
export function blogPostingSchema(post: BlogPost) {
|
||||||
urlTemplate: `${BASE_URL}/blog?q={search_term_string}`,
|
return {
|
||||||
},
|
'@context': 'https://schema.org',
|
||||||
'query-input': 'required name=search_term_string',
|
'@type': 'BlogPosting',
|
||||||
},
|
'@id': `https://www.qrmaster.net/blog/${post.slug}#article`,
|
||||||
};
|
headline: post.title,
|
||||||
}
|
description: post.description,
|
||||||
|
image: post.image,
|
||||||
export function breadcrumbSchema(items: BreadcrumbItem[]) {
|
datePublished: post.datePublished,
|
||||||
return {
|
dateModified: post.dateModified,
|
||||||
'@context': 'https://schema.org',
|
inLanguage: 'en',
|
||||||
'@type': 'BreadcrumbList',
|
mainEntityOfPage: `https://www.qrmaster.net/blog/${post.slug}`,
|
||||||
'@id': `${BASE_URL}${items[items.length - 1]?.url}#breadcrumb`,
|
author: {
|
||||||
inLanguage: 'en',
|
'@type': 'Person',
|
||||||
mainEntityOfPage: `${BASE_URL}${items[items.length - 1]?.url}`,
|
name: post.author,
|
||||||
itemListElement: items.map((item, index) => ({
|
url: post.authorUrl,
|
||||||
'@type': 'ListItem',
|
},
|
||||||
position: index + 1,
|
publisher: {
|
||||||
name: item.name,
|
'@type': 'Organization',
|
||||||
item: toAbsoluteUrl(item.url),
|
name: 'QR Master',
|
||||||
})),
|
url: 'https://www.qrmaster.net',
|
||||||
};
|
logo: {
|
||||||
}
|
'@type': 'ImageObject',
|
||||||
|
url: 'https://www.qrmaster.net/static/og-image.png',
|
||||||
export function blogPostingSchema(post: BlogPost) {
|
width: 1200,
|
||||||
return {
|
height: 630,
|
||||||
'@context': 'https://schema.org',
|
},
|
||||||
'@type': 'BlogPosting',
|
},
|
||||||
'@id': `${BASE_URL}/blog/${post.slug}#article`,
|
isPartOf: {
|
||||||
headline: post.title,
|
'@type': 'Blog',
|
||||||
description: post.description,
|
'@id': 'https://www.qrmaster.net/blog#blog',
|
||||||
image: toAbsoluteUrl(post.image),
|
name: 'QR Master Blog',
|
||||||
datePublished: post.datePublished,
|
url: 'https://www.qrmaster.net/blog',
|
||||||
dateModified: post.dateModified,
|
},
|
||||||
inLanguage: 'en',
|
};
|
||||||
mainEntityOfPage: `${BASE_URL}/blog/${post.slug}`,
|
}
|
||||||
author: {
|
|
||||||
'@type': 'Person',
|
export function faqPageSchema(faqs: FAQItem[]) {
|
||||||
name: post.author,
|
return {
|
||||||
url: post.authorUrl,
|
'@context': 'https://schema.org',
|
||||||
},
|
'@type': 'FAQPage',
|
||||||
publisher: {
|
'@id': 'https://www.qrmaster.net/faq#faqpage',
|
||||||
'@type': 'Organization',
|
inLanguage: 'en',
|
||||||
name: 'QR Master',
|
mainEntityOfPage: 'https://www.qrmaster.net/faq',
|
||||||
url: BASE_URL,
|
mainEntity: faqs.map((faq) => ({
|
||||||
logo: {
|
'@type': 'Question',
|
||||||
'@type': 'ImageObject',
|
name: faq.question,
|
||||||
url: `${BASE_URL}/og-image.png`,
|
acceptedAnswer: {
|
||||||
width: 1200,
|
'@type': 'Answer',
|
||||||
height: 630,
|
text: faq.answer,
|
||||||
},
|
},
|
||||||
},
|
})),
|
||||||
isPartOf: {
|
};
|
||||||
'@type': 'Blog',
|
}
|
||||||
'@id': `${BASE_URL}/blog#blog`,
|
|
||||||
name: 'QR Master Blog',
|
export function productSchema(product: { name: string; description: string; offers: ProductOffer[] }) {
|
||||||
url: `${BASE_URL}/blog`,
|
return {
|
||||||
},
|
'@context': 'https://schema.org',
|
||||||
};
|
'@type': 'Product',
|
||||||
}
|
'@id': 'https://www.qrmaster.net/pricing#product',
|
||||||
|
name: product.name,
|
||||||
export function faqPageSchema(faqs: FAQItem[]) {
|
description: product.description,
|
||||||
return {
|
inLanguage: 'en',
|
||||||
'@context': 'https://schema.org',
|
mainEntityOfPage: 'https://www.qrmaster.net/pricing',
|
||||||
'@type': 'FAQPage',
|
brand: {
|
||||||
'@id': `${BASE_URL}/faq#faqpage`,
|
'@type': 'Organization',
|
||||||
inLanguage: 'en',
|
name: 'QR Master',
|
||||||
mainEntityOfPage: `${BASE_URL}/faq`,
|
},
|
||||||
mainEntity: faqs.map((faq) => ({
|
offers: product.offers.map((offer) => ({
|
||||||
'@type': 'Question',
|
'@type': 'Offer',
|
||||||
name: faq.question,
|
name: offer.name,
|
||||||
acceptedAnswer: {
|
price: offer.price,
|
||||||
'@type': 'Answer',
|
priceCurrency: offer.priceCurrency,
|
||||||
text: faq.answer,
|
availability: offer.availability,
|
||||||
},
|
url: offer.url,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function productSchema(product: { name: string; description: string; offers: ProductOffer[] }) {
|
export function howToSchema(task: HowToTask) {
|
||||||
return {
|
return {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'Product',
|
'@type': 'HowTo',
|
||||||
'@id': `${BASE_URL}/pricing#product`,
|
'@id': `https://www.qrmaster.net/blog/${task.name.toLowerCase().replace(/\s+/g, '-')}#howto`,
|
||||||
name: product.name,
|
name: task.name,
|
||||||
description: product.description,
|
description: task.description,
|
||||||
inLanguage: 'en',
|
inLanguage: 'en',
|
||||||
mainEntityOfPage: `${BASE_URL}/pricing`,
|
mainEntityOfPage: `https://www.qrmaster.net/blog/${task.name.toLowerCase().replace(/\s+/g, '-')}`,
|
||||||
brand: {
|
totalTime: task.totalTime || 'PT5M',
|
||||||
'@type': 'Organization',
|
step: task.steps.map((step, index) => ({
|
||||||
name: 'QR Master',
|
'@type': 'HowToStep',
|
||||||
},
|
position: index + 1,
|
||||||
offers: product.offers.map((offer) => ({
|
name: step.name,
|
||||||
'@type': 'Offer',
|
text: step.text,
|
||||||
name: offer.name,
|
url: step.url,
|
||||||
price: offer.price,
|
})),
|
||||||
priceCurrency: offer.priceCurrency,
|
};
|
||||||
availability: offer.availability,
|
}
|
||||||
url: toAbsoluteUrl(offer.url),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function howToSchema(task: HowToTask) {
|
|
||||||
return {
|
|
||||||
'@context': 'https://schema.org',
|
|
||||||
'@type': 'HowTo',
|
|
||||||
'@id': `${BASE_URL}/blog/${task.name.toLowerCase().replace(/\s+/g, '-')}#howto`,
|
|
||||||
name: task.name,
|
|
||||||
description: task.description,
|
|
||||||
inLanguage: 'en',
|
|
||||||
mainEntityOfPage: `${BASE_URL}/blog/${task.name.toLowerCase().replace(/\s+/g, '-')}`,
|
|
||||||
totalTime: task.totalTime || 'PT5M',
|
|
||||||
step: task.steps.map((step, index) => ({
|
|
||||||
'@type': 'HowToStep',
|
|
||||||
position: index + 1,
|
|
||||||
name: step.name,
|
|
||||||
text: step.text,
|
|
||||||
url: step.url ? toAbsoluteUrl(step.url) : undefined,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,186 +1,186 @@
|
|||||||
/**
|
/**
|
||||||
* Zod Validation Schemas for API endpoints
|
* Zod Validation Schemas for API endpoints
|
||||||
* Centralized validation logic for type-safety and security
|
* Centralized validation logic for type-safety and security
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// QR Code Schemas
|
// QR Code Schemas
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
export const qrStyleSchema = z.object({
|
export const qrStyleSchema = z.object({
|
||||||
fgColor: z.string().regex(/^#[0-9A-F]{6}$/i, 'Invalid foreground color format').optional(),
|
fgColor: z.string().regex(/^#[0-9A-F]{6}$/i, 'Invalid foreground color format').optional(),
|
||||||
bgColor: z.string().regex(/^#[0-9A-F]{6}$/i, 'Invalid background color format').optional(),
|
bgColor: z.string().regex(/^#[0-9A-F]{6}$/i, 'Invalid background color format').optional(),
|
||||||
cornerStyle: z.enum(['square', 'rounded']).optional(),
|
cornerStyle: z.enum(['square', 'rounded']).optional(),
|
||||||
size: z.number().min(100).max(1000).optional(),
|
size: z.number().min(100).max(1000).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createQRSchema = z.object({
|
export const createQRSchema = z.object({
|
||||||
title: z.string()
|
title: z.string()
|
||||||
.min(1, 'Title is required')
|
.min(1, 'Title is required')
|
||||||
.max(100, 'Title must be less than 100 characters'),
|
.max(100, 'Title must be less than 100 characters'),
|
||||||
|
|
||||||
content: z.record(z.any()), // Accept any object structure for content
|
content: z.record(z.any()), // Accept any object structure for content
|
||||||
|
|
||||||
isStatic: z.boolean().optional(),
|
isStatic: z.boolean().optional(),
|
||||||
|
|
||||||
contentType: z.enum(['URL', 'VCARD', 'GEO', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT'], {
|
contentType: z.enum(['URL', 'VCARD', 'GEO', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT', 'PDF', 'APP', 'COUPON', 'FEEDBACK'], {
|
||||||
errorMap: () => ({ message: 'Invalid content type' })
|
errorMap: () => ({ message: 'Invalid content type' })
|
||||||
}),
|
}),
|
||||||
|
|
||||||
tags: z.array(z.string()).optional(),
|
tags: z.array(z.string()).optional(),
|
||||||
|
|
||||||
style: z.object({
|
style: z.object({
|
||||||
foregroundColor: z.string().optional(),
|
foregroundColor: z.string().optional(),
|
||||||
backgroundColor: z.string().optional(),
|
backgroundColor: z.string().optional(),
|
||||||
cornerStyle: z.enum(['square', 'rounded']).optional(),
|
cornerStyle: z.enum(['square', 'rounded']).optional(),
|
||||||
size: z.number().optional(),
|
size: z.number().optional(),
|
||||||
}).optional(),
|
}).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateQRSchema = z.object({
|
export const updateQRSchema = z.object({
|
||||||
title: z.string()
|
title: z.string()
|
||||||
.min(1, 'Title is required')
|
.min(1, 'Title is required')
|
||||||
.max(100, 'Title must be less than 100 characters')
|
.max(100, 'Title must be less than 100 characters')
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|
||||||
content: z.string()
|
content: z.string()
|
||||||
.min(1, 'Content is required')
|
.min(1, 'Content is required')
|
||||||
.max(5000, 'Content must be less than 5000 characters')
|
.max(5000, 'Content must be less than 5000 characters')
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|
||||||
style: qrStyleSchema.optional(),
|
style: qrStyleSchema.optional(),
|
||||||
|
|
||||||
isActive: z.boolean().optional(),
|
isActive: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const bulkQRSchema = z.object({
|
export const bulkQRSchema = z.object({
|
||||||
qrs: z.array(
|
qrs: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
title: z.string().min(1).max(100),
|
title: z.string().min(1).max(100),
|
||||||
content: z.string().min(1).max(5000),
|
content: z.string().min(1).max(5000),
|
||||||
contentType: z.enum(['URL', 'VCARD', 'GEO', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT']),
|
contentType: z.enum(['URL', 'VCARD', 'GEO', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT', 'PDF', 'APP', 'COUPON', 'FEEDBACK']),
|
||||||
})
|
})
|
||||||
).min(1, 'At least one QR code is required')
|
).min(1, 'At least one QR code is required')
|
||||||
.max(100, 'Maximum 100 QR codes per bulk creation'),
|
.max(100, 'Maximum 100 QR codes per bulk creation'),
|
||||||
});
|
});
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Authentication Schemas
|
// Authentication Schemas
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
export const loginSchema = z.object({
|
export const loginSchema = z.object({
|
||||||
email: z.string()
|
email: z.string()
|
||||||
.email('Invalid email format')
|
.email('Invalid email format')
|
||||||
.toLowerCase(),
|
.toLowerCase(),
|
||||||
|
|
||||||
password: z.string()
|
password: z.string()
|
||||||
.min(1, 'Password is required'),
|
.min(1, 'Password is required'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const signupSchema = z.object({
|
export const signupSchema = z.object({
|
||||||
name: z.string()
|
name: z.string()
|
||||||
.min(2, 'Name must be at least 2 characters')
|
.min(2, 'Name must be at least 2 characters')
|
||||||
.max(100, 'Name must be less than 100 characters')
|
.max(100, 'Name must be less than 100 characters')
|
||||||
.trim(),
|
.trim(),
|
||||||
|
|
||||||
email: z.string()
|
email: z.string()
|
||||||
.email('Invalid email format')
|
.email('Invalid email format')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.trim(),
|
.trim(),
|
||||||
|
|
||||||
password: z.string()
|
password: z.string()
|
||||||
.min(8, 'Password must be at least 8 characters')
|
.min(8, 'Password must be at least 8 characters')
|
||||||
.max(100, 'Password must be less than 100 characters'),
|
.max(100, 'Password must be less than 100 characters'),
|
||||||
// Password complexity rules removed for easier testing
|
// Password complexity rules removed for easier testing
|
||||||
});
|
});
|
||||||
|
|
||||||
export const forgotPasswordSchema = z.object({
|
export const forgotPasswordSchema = z.object({
|
||||||
email: z.string()
|
email: z.string()
|
||||||
.email('Invalid email format')
|
.email('Invalid email format')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.trim(),
|
.trim(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resetPasswordSchema = z.object({
|
export const resetPasswordSchema = z.object({
|
||||||
token: z.string().min(1, 'Reset token is required'),
|
token: z.string().min(1, 'Reset token is required'),
|
||||||
password: z.string()
|
password: z.string()
|
||||||
.min(8, 'Password must be at least 8 characters')
|
.min(8, 'Password must be at least 8 characters')
|
||||||
.max(100, 'Password must be less than 100 characters'),
|
.max(100, 'Password must be less than 100 characters'),
|
||||||
// Password complexity rules removed for easier testing
|
// Password complexity rules removed for easier testing
|
||||||
});
|
});
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Settings Schemas
|
// Settings Schemas
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
export const updateProfileSchema = z.object({
|
export const updateProfileSchema = z.object({
|
||||||
name: z.string()
|
name: z.string()
|
||||||
.min(2, 'Name must be at least 2 characters')
|
.min(2, 'Name must be at least 2 characters')
|
||||||
.max(100, 'Name must be less than 100 characters')
|
.max(100, 'Name must be less than 100 characters')
|
||||||
.trim(),
|
.trim(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const changePasswordSchema = z.object({
|
export const changePasswordSchema = z.object({
|
||||||
currentPassword: z.string()
|
currentPassword: z.string()
|
||||||
.min(1, 'Current password is required'),
|
.min(1, 'Current password is required'),
|
||||||
|
|
||||||
newPassword: z.string()
|
newPassword: z.string()
|
||||||
.min(8, 'Password must be at least 8 characters')
|
.min(8, 'Password must be at least 8 characters')
|
||||||
.max(100, 'Password must be less than 100 characters'),
|
.max(100, 'Password must be less than 100 characters'),
|
||||||
// Password complexity rules removed for easier testing
|
// Password complexity rules removed for easier testing
|
||||||
});
|
});
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Stripe Schemas
|
// Stripe Schemas
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
export const createCheckoutSchema = z.object({
|
export const createCheckoutSchema = z.object({
|
||||||
priceId: z.string().min(1, 'Price ID is required'),
|
priceId: z.string().min(1, 'Price ID is required'),
|
||||||
});
|
});
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Newsletter Schemas
|
// Newsletter Schemas
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
export const newsletterSubscribeSchema = z.object({
|
export const newsletterSubscribeSchema = z.object({
|
||||||
email: z.string()
|
email: z.string()
|
||||||
.email('Invalid email format')
|
.email('Invalid email format')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.trim()
|
.trim()
|
||||||
.max(255, 'Email must be less than 255 characters'),
|
.max(255, 'Email must be less than 255 characters'),
|
||||||
});
|
});
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Helper: Format Zod Errors
|
// Helper: Format Zod Errors
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
export function formatZodError(error: z.ZodError) {
|
export function formatZodError(error: z.ZodError) {
|
||||||
return {
|
return {
|
||||||
error: 'Validation failed',
|
error: 'Validation failed',
|
||||||
details: error.errors.map(err => ({
|
details: error.errors.map(err => ({
|
||||||
field: err.path.join('.'),
|
field: err.path.join('.'),
|
||||||
message: err.message,
|
message: err.message,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Helper: Validate with Zod
|
// Helper: Validate with Zod
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
export async function validateRequest<T>(
|
export async function validateRequest<T>(
|
||||||
schema: z.ZodSchema<T>,
|
schema: z.ZodSchema<T>,
|
||||||
data: unknown
|
data: unknown
|
||||||
): Promise<{ success: true; data: T } | { success: false; error: any }> {
|
): Promise<{ success: true; data: T } | { success: false; error: any }> {
|
||||||
try {
|
try {
|
||||||
const validatedData = schema.parse(data);
|
const validatedData = schema.parse(data);
|
||||||
return { success: true, data: validatedData };
|
return { success: true, data: validatedData };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
return { success: false, error: formatZodError(error) };
|
return { success: false, error: formatZodError(error) };
|
||||||
}
|
}
|
||||||
return { success: false, error: { error: 'Invalid request data' } };
|
return { success: false, error: { error: 'Invalid request data' } };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export function middleware(req: NextRequest) {
|
|||||||
'/bulk-qr-code-generator',
|
'/bulk-qr-code-generator',
|
||||||
'/qr-code-tracking',
|
'/qr-code-tracking',
|
||||||
'/reprint-calculator',
|
'/reprint-calculator',
|
||||||
|
'/barcode-generator',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Check if path is public
|
// Check if path is public
|
||||||
|
|||||||
26
src/types/next-auth.d.ts
vendored
@@ -1,15 +1,13 @@
|
|||||||
import { DefaultSession } from 'next-auth';
|
import { DefaultSession } from 'next-auth';
|
||||||
|
|
||||||
declare module 'next-auth' {
|
declare module 'next-auth' {
|
||||||
interface Session {
|
interface Session {
|
||||||
user: {
|
user: {
|
||||||
id: string;
|
id: string;
|
||||||
plan?: string | null;
|
} & DefaultSession['user'];
|
||||||
} & DefaultSession['user'];
|
}
|
||||||
}
|
|
||||||
|
interface User {
|
||||||
interface User {
|
id: string;
|
||||||
id: string;
|
}
|
||||||
plan?: string | null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||