Wichige änderung an DB
This commit is contained in:
parent
2f0208ebf9
commit
f31992b952
|
|
@ -313,3 +313,9 @@ For issues:
|
||||||
|
|
||||||
🎉 **Congratulations!** You've successfully migrated from Supabase to local PostgreSQL!
|
🎉 **Congratulations!** You've successfully migrated from Supabase to local PostgreSQL!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
226
README.md
226
README.md
|
|
@ -1,28 +1,31 @@
|
||||||
# QR Master - Create Custom QR Codes in Seconds
|
# QR Master - Create Custom QR Codes in Seconds
|
||||||
|
|
||||||
A production-ready SaaS application for creating and managing QR codes with advanced tracking, analytics, and integrations.
|
A production-ready SaaS application for creating and managing QR codes with advanced tracking, analytics, and Stripe payment integration.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- 🎨 **Custom QR Codes** - Create static and dynamic QR codes with full customization
|
- 🎨 **Custom QR Codes** - Create static and dynamic QR codes with full customization
|
||||||
- 📊 **Advanced Analytics** - Track scans, locations, devices, and user behavior
|
- 📊 **Advanced Analytics** - Track scans, locations, devices, and user behavior
|
||||||
- 🔄 **Dynamic Content** - Edit QR code destinations anytime without reprinting
|
- 🔄 **Dynamic Content** - Edit QR code destinations anytime without reprinting
|
||||||
- 📦 **Bulk Operations** - Import CSV/Excel files to create multiple QR codes at once
|
- 📦 **Bulk Operations** - Import CSV/Excel files to create up to 1,000 QR codes at once
|
||||||
- 🔌 **Integrations** - Connect with Zapier, Airtable, and Google Sheets
|
- 💳 **Stripe Integration** - FREE, PRO, and BUSINESS subscription plans with secure billing
|
||||||
- 🌍 **Multi-language** - Support for English and German (i18n)
|
- 🎨 **Custom Branding** - Logo upload, custom colors (PRO+ plans)
|
||||||
- 🔒 **Privacy-First** - Respects user privacy with hashed IPs and DNT headers
|
- 🌍 **SEO Optimized** - Schema.org structured data, meta tags, breadcrumbs
|
||||||
|
- 🔒 **Privacy-First** - GDPR-compliant, hashed IPs, DNT headers respected
|
||||||
- 📱 **Responsive Design** - Works perfectly on all devices
|
- 📱 **Responsive Design** - Works perfectly on all devices
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Frontend**: Next.js 14 (App Router), TypeScript, Tailwind CSS
|
- **Frontend**: Next.js 14 (App Router), TypeScript, Tailwind CSS
|
||||||
- **Backend**: Next.js API Routes, Prisma ORM
|
- **Backend**: Next.js API Routes, Prisma ORM
|
||||||
- **Database**: PostgreSQL
|
- **Database**: PostgreSQL (with Prisma migrations)
|
||||||
- **Cache**: Redis (optional)
|
- **Cache**: Redis (optional)
|
||||||
- **Auth**: NextAuth.js (Credentials + Google OAuth)
|
- **Auth**: NextAuth.js (Credentials + Google OAuth)
|
||||||
|
- **Payments**: Stripe (Subscriptions & Webhooks)
|
||||||
- **QR Generation**: qrcode library
|
- **QR Generation**: qrcode library
|
||||||
- **Charts**: Chart.js with react-chartjs-2
|
- **Bulk Processing**: Papa Parse (CSV), XLSX, JSZip
|
||||||
- **i18n**: i18next
|
- **Analytics**: PostHog (optional)
|
||||||
|
- **SEO**: next-sitemap, Schema.org structured data
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
|
@ -63,12 +66,18 @@ Edit `.env` and set:
|
||||||
npm run docker:dev
|
npm run docker:dev
|
||||||
```
|
```
|
||||||
|
|
||||||
5. Run migrations and seed:
|
5. Run database migrations and seed:
|
||||||
```bash
|
```bash
|
||||||
npm run db:migrate
|
npx prisma migrate dev
|
||||||
npm run db:seed
|
npm run db:seed
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Note**: If you get migration errors, you can reset the database:
|
||||||
|
> ```bash
|
||||||
|
> npx prisma migrate reset
|
||||||
|
> ```
|
||||||
|
> This will drop the database, recreate it, run all migrations, and seed data.
|
||||||
|
|
||||||
6. Start development server:
|
6. Start development server:
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
@ -76,8 +85,8 @@ npm run dev
|
||||||
|
|
||||||
7. Access the application:
|
7. Access the application:
|
||||||
- **App**: http://localhost:3050
|
- **App**: http://localhost:3050
|
||||||
- **Database UI**: http://localhost:8080 (Adminer)
|
- **Database UI**: http://localhost:8080 (Adminer - username: `root`, password: `root`)
|
||||||
- **Database**: localhost:5432
|
- **Database**: localhost:5435 (username: `postgres`, password: `postgres`)
|
||||||
- **Redis**: localhost:6379
|
- **Redis**: localhost:6379
|
||||||
|
|
||||||
#### Option 2: Full Docker (Production)
|
#### Option 2: Full Docker (Production)
|
||||||
|
|
@ -108,10 +117,13 @@ docker-compose exec web npx prisma migrate deploy
|
||||||
|
|
||||||
## Demo Account
|
## Demo Account
|
||||||
|
|
||||||
Use these credentials to test the application:
|
After running `npm run db:seed`, use these credentials to test the application:
|
||||||
|
|
||||||
- **Email**: demo@qrmaster.com
|
- **Email**: demo@qrmaster.com
|
||||||
- **Password**: demo123
|
- **Password**: demo123
|
||||||
|
- **Plan**: FREE (3 QR codes limit)
|
||||||
|
|
||||||
|
The seed script also creates sample QR codes for testing.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|
@ -119,25 +131,28 @@ Use these credentials to test the application:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Development
|
# Development
|
||||||
npm run dev # Start Next.js dev server
|
npm run dev # Start Next.js dev server (port 3050)
|
||||||
npm run build # Build for production
|
npm run build # Build for production
|
||||||
npm run start # Start production server
|
npm run start # Start production server
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
npm run db:generate # Generate Prisma Client
|
npm run db:generate # Generate Prisma Client
|
||||||
npm run db:migrate # Run migrations (dev)
|
npm run db:migrate # Run migrations (dev mode)
|
||||||
npm run db:deploy # Deploy migrations (prod)
|
npm run db:deploy # Deploy migrations (production)
|
||||||
npm run db:seed # Seed database
|
npm run db:seed # Seed database with demo data
|
||||||
npm run db:studio # Open Prisma Studio
|
npm run db:studio # Open Prisma Studio UI
|
||||||
|
npx prisma migrate reset # Reset database (drop, recreate, migrate, seed)
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
npm run docker:dev # Start DB & Redis only
|
npm run docker:dev # Start DB & Redis only
|
||||||
npm run docker:dev:stop # Stop dev services
|
npm run docker:dev:stop # Stop dev services
|
||||||
npm run docker:prod # Start full stack
|
npm run docker:dev:clean # Stop and clean containers
|
||||||
|
npm run docker:prod # Start full stack (production)
|
||||||
npm run docker:stop # Stop all services
|
npm run docker:stop # Stop all services
|
||||||
npm run docker:logs # View logs
|
npm run docker:logs # View container logs
|
||||||
npm run docker:db # PostgreSQL CLI
|
npm run docker:db # PostgreSQL CLI
|
||||||
npm run docker:redis # Redis CLI
|
npm run docker:redis # Redis CLI
|
||||||
|
npm run docker:backup # Backup database to SQL file
|
||||||
```
|
```
|
||||||
|
|
||||||
### Local Development (without Docker)
|
### Local Development (without Docker)
|
||||||
|
|
@ -151,12 +166,12 @@ npm install
|
||||||
|
|
||||||
3. Configure `.env` with local database URL:
|
3. Configure `.env` with local database URL:
|
||||||
```env
|
```env
|
||||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/qrmaster?schema=public
|
DATABASE_URL=postgresql://postgres:postgres@localhost:5435/qrmaster?schema=public
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Run migrations:
|
4. Run migrations and seed:
|
||||||
```bash
|
```bash
|
||||||
npm run db:migrate
|
npx prisma migrate dev
|
||||||
npm run db:seed
|
npm run db:seed
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -165,6 +180,24 @@ npm run db:seed
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Resetting the Database
|
||||||
|
|
||||||
|
If you need to reset your database (drop all tables, recreate, and reseed):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Full reset (drops database, reruns migrations, seeds data)
|
||||||
|
npx prisma migrate reset
|
||||||
|
|
||||||
|
# Or manually:
|
||||||
|
npx prisma migrate reset --skip-seed # Reset without seeding
|
||||||
|
npm run db:seed # Then seed manually
|
||||||
|
```
|
||||||
|
|
||||||
|
This is useful when:
|
||||||
|
- Schema has changed significantly
|
||||||
|
- You have migration conflicts
|
||||||
|
- You want to start fresh with clean data
|
||||||
|
|
||||||
### Project Structure
|
### Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
@ -197,16 +230,28 @@ qr-master/
|
||||||
|
|
||||||
### QR Codes
|
### QR Codes
|
||||||
- `GET /api/qrs` - List all QR codes
|
- `GET /api/qrs` - List all QR codes
|
||||||
- `POST /api/qrs` - Create a new QR code
|
- `POST /api/qrs` - Create a new QR code (dynamic or static)
|
||||||
|
- `POST /api/qrs/static` - Create a static QR code
|
||||||
- `GET /api/qrs/[id]` - Get QR code details
|
- `GET /api/qrs/[id]` - Get QR code details
|
||||||
- `PATCH /api/qrs/[id]` - Update QR code
|
- `PATCH /api/qrs/[id]` - Update QR code
|
||||||
- `DELETE /api/qrs/[id]` - Delete QR code
|
- `DELETE /api/qrs/[id]` - Delete QR code
|
||||||
|
- `DELETE /api/qrs/delete-all` - Delete all user's QR codes
|
||||||
|
|
||||||
### Analytics
|
### Analytics
|
||||||
- `GET /api/analytics/summary` - Get analytics summary
|
- `GET /api/analytics/summary` - Get analytics summary for a QR code
|
||||||
|
|
||||||
### Bulk Operations
|
### User & Settings
|
||||||
- `POST /api/bulk` - Import QR codes from CSV/Excel
|
- `GET /api/user/plan` - Get current user plan
|
||||||
|
- `GET /api/user/stats` - Get user statistics
|
||||||
|
- `POST /api/user/password` - Update password
|
||||||
|
- `POST /api/user/profile` - Update profile
|
||||||
|
- `DELETE /api/user/delete` - Delete account
|
||||||
|
|
||||||
|
### Stripe Payments
|
||||||
|
- `POST /api/stripe/checkout` - Create checkout session
|
||||||
|
- `POST /api/stripe/portal` - Create customer portal session
|
||||||
|
- `POST /api/stripe/webhook` - Handle Stripe webhooks
|
||||||
|
- `POST /api/stripe/cancel-subscription` - Cancel subscription
|
||||||
|
|
||||||
### Public Redirect
|
### Public Redirect
|
||||||
- `GET /r/[slug]` - Redirect and track QR code scan
|
- `GET /r/[slug]` - Redirect and track QR code scan
|
||||||
|
|
@ -215,24 +260,67 @@ qr-master/
|
||||||
|
|
||||||
| Variable | Description | Required | Default |
|
| Variable | Description | Required | Default |
|
||||||
|----------|-------------|----------|---------|
|
|----------|-------------|----------|---------|
|
||||||
| `DATABASE_URL` | PostgreSQL connection string | Yes | `postgresql://postgres:postgres@localhost:5432/qrmaster?schema=public` |
|
| `DATABASE_URL` | PostgreSQL connection string | Yes | `postgresql://postgres:postgres@localhost:5435/qrmaster?schema=public` |
|
||||||
| `NEXTAUTH_URL` | Application URL | Yes | `http://localhost:3050` |
|
| `NEXTAUTH_URL` | Application URL | Yes | `http://localhost:3050` |
|
||||||
| `NEXTAUTH_SECRET` | Secret for JWT encryption | Yes | Generate with `openssl rand -base64 32` |
|
| `NEXTAUTH_SECRET` | Secret for JWT encryption | Yes | Generate with `openssl rand -base64 32` |
|
||||||
| `IP_SALT` | Salt for IP hashing | Yes | Generate with `openssl rand -base64 32` |
|
| `IP_SALT` | Salt for IP hashing (privacy) | Yes | Generate with `openssl rand -base64 32` |
|
||||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | No | - |
|
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | No | - |
|
||||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | No | - |
|
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | No | - |
|
||||||
|
| `STRIPE_SECRET_KEY` | Stripe secret key | No | - |
|
||||||
|
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret | No | - |
|
||||||
|
| `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Stripe public key | No | - |
|
||||||
|
| `NEXT_PUBLIC_INDEXABLE` | Allow search engine indexing | No | `false` (set to `true` in production) |
|
||||||
| `REDIS_URL` | Redis connection string | No | `redis://redis:6379` |
|
| `REDIS_URL` | Redis connection string | No | `redis://redis:6379` |
|
||||||
| `ENABLE_DEMO` | Enable demo mode | No | `false` |
|
| `NEXT_PUBLIC_POSTHOG_KEY` | PostHog analytics key | No | - |
|
||||||
|
| `NEXT_PUBLIC_POSTHOG_HOST` | PostHog host URL | No | - |
|
||||||
|
|
||||||
**Note**: Copy `env.example` to `.env` and update the values before starting.
|
**Note**: Copy `env.example` to `.env` and update the values before starting.
|
||||||
|
|
||||||
## Security
|
### Generating Secrets
|
||||||
|
|
||||||
- IP addresses are hashed with salt before storage
|
```bash
|
||||||
- Respects Do Not Track (DNT) headers
|
# Generate NEXTAUTH_SECRET
|
||||||
- CORS protection enabled
|
openssl rand -base64 32
|
||||||
- Rate limiting on API endpoints
|
|
||||||
- Secure session management with NextAuth.js
|
# Generate IP_SALT
|
||||||
|
openssl rand -base64 32
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security & Privacy
|
||||||
|
|
||||||
|
- **IP Hashing**: IP addresses are hashed with salt before storage (GDPR-compliant)
|
||||||
|
- **DNT Respect**: Honors Do Not Track browser headers
|
||||||
|
- **Rate Limiting**: API endpoints protected against abuse
|
||||||
|
- **CSRF Protection**: Token-based CSRF validation on mutations
|
||||||
|
- **Secure Sessions**: NextAuth.js with encrypted JWT tokens
|
||||||
|
- **Stripe Security**: PCI-compliant payment processing
|
||||||
|
- **SQL Injection Prevention**: Prisma ORM parameterized queries
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
The application uses PostgreSQL with Prisma ORM. Key models:
|
||||||
|
|
||||||
|
- **User**: User accounts with Stripe subscription data
|
||||||
|
- **QRCode**: QR code records (static/dynamic, multiple content types)
|
||||||
|
- **QRScan**: Scan analytics data (hashed IP, device, location, UTM params)
|
||||||
|
- **Integration**: Third-party integrations (Zapier, etc.)
|
||||||
|
- **Account/Session**: NextAuth authentication data
|
||||||
|
|
||||||
|
### Supported QR Code Types
|
||||||
|
|
||||||
|
- **URL**: Website links
|
||||||
|
- **VCARD**: Contact cards (name, email, phone, company)
|
||||||
|
- **GEO**: GPS locations
|
||||||
|
- **PHONE**: Phone numbers (tel: links)
|
||||||
|
- **TEXT**: Plain text
|
||||||
|
- **SMS**: SMS messages
|
||||||
|
- **WHATSAPP**: WhatsApp messages
|
||||||
|
|
||||||
|
### Plans
|
||||||
|
|
||||||
|
- **FREE**: 3 dynamic QR codes, unlimited static
|
||||||
|
- **PRO**: 50 codes, custom branding, advanced analytics
|
||||||
|
- **BUSINESS**: 500 codes, bulk upload, API access, priority support
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
|
|
@ -263,6 +351,72 @@ For detailed deployment instructions, see [DOCKER_SETUP.md](DOCKER_SETUP.md).
|
||||||
|
|
||||||
**Note**: For Vercel deployment, you'll need to set up a PostgreSQL database separately.
|
**Note**: For Vercel deployment, you'll need to set up a PostgreSQL database separately.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Database Issues
|
||||||
|
|
||||||
|
**Problem**: Migration errors or schema conflicts
|
||||||
|
```bash
|
||||||
|
# Solution: Reset the database
|
||||||
|
npx prisma migrate reset
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem**: "Error: P1001: Can't reach database server"
|
||||||
|
```bash
|
||||||
|
# Check if Docker containers are running
|
||||||
|
docker ps
|
||||||
|
|
||||||
|
# Restart database
|
||||||
|
npm run docker:dev:stop
|
||||||
|
npm run docker:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem**: Prisma Client out of sync
|
||||||
|
```bash
|
||||||
|
# Regenerate Prisma Client
|
||||||
|
npx prisma generate
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem**: Need to start completely fresh
|
||||||
|
```bash
|
||||||
|
# Stop all Docker containers
|
||||||
|
npm run docker:dev:stop
|
||||||
|
|
||||||
|
# Remove volumes (⚠️ deletes all data)
|
||||||
|
docker volume prune
|
||||||
|
|
||||||
|
# Restart everything
|
||||||
|
npm run docker:dev
|
||||||
|
npx prisma migrate dev
|
||||||
|
npm run db:seed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Port Already in Use
|
||||||
|
|
||||||
|
If port 3050 is already in use:
|
||||||
|
```bash
|
||||||
|
# Find and kill the process (Windows)
|
||||||
|
netstat -ano | findstr :3050
|
||||||
|
taskkill /PID <PID> /F
|
||||||
|
|
||||||
|
# Or change the port in package.json
|
||||||
|
"dev": "next dev -p 3051"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Issues
|
||||||
|
|
||||||
|
**Problem**: Permission denied errors
|
||||||
|
```bash
|
||||||
|
# Windows: Run PowerShell as Administrator
|
||||||
|
# Linux/Mac: Use sudo for docker commands
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem**: Out of disk space
|
||||||
|
```bash
|
||||||
|
# Clean up Docker
|
||||||
|
docker system prune -a
|
||||||
|
```
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
1. Fork the repository
|
1. Fork the repository
|
||||||
|
|
|
||||||
22
env.example
22
env.example
|
|
@ -4,8 +4,8 @@ PORT=3000
|
||||||
|
|
||||||
# Database Configuration (PostgreSQL)
|
# Database Configuration (PostgreSQL)
|
||||||
# For local development (without Docker):
|
# For local development (without Docker):
|
||||||
# DATABASE_URL=postgresql://postgres:postgres@localhost:5432/qrmaster?schema=public
|
# DATABASE_URL=postgresql://postgres:postgres@localhost:5435/qrmaster?schema=public
|
||||||
# For Docker Compose:
|
# For Docker Compose (internal Docker network):
|
||||||
DATABASE_URL=postgresql://postgres:postgres@db:5432/qrmaster?schema=public
|
DATABASE_URL=postgresql://postgres:postgres@db:5432/qrmaster?schema=public
|
||||||
|
|
||||||
# NextAuth Configuration
|
# NextAuth Configuration
|
||||||
|
|
@ -26,3 +26,21 @@ IP_SALT=your-ip-salt-here-change-in-production
|
||||||
# Features
|
# Features
|
||||||
ENABLE_DEMO=false
|
ENABLE_DEMO=false
|
||||||
|
|
||||||
|
# SEO Configuration
|
||||||
|
# Set to 'true' in production to allow search engine indexing
|
||||||
|
NEXT_PUBLIC_INDEXABLE=true
|
||||||
|
|
||||||
|
# Stripe Payment Configuration (Optional - for subscription payments)
|
||||||
|
# Get your keys from: https://dashboard.stripe.com/apikeys
|
||||||
|
STRIPE_SECRET_KEY=
|
||||||
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
|
||||||
|
|
||||||
|
# Stripe Price IDs (create these in your Stripe dashboard)
|
||||||
|
# NEXT_PUBLIC_STRIPE_FREE_PRICE_ID=price_xxx
|
||||||
|
# NEXT_PUBLIC_STRIPE_PRO_PRICE_ID=price_xxx
|
||||||
|
# NEXT_PUBLIC_STRIPE_BUSINESS_PRICE_ID=price_xxx
|
||||||
|
|
||||||
|
# Analytics (Optional - PostHog)
|
||||||
|
NEXT_PUBLIC_POSTHOG_KEY=
|
||||||
|
NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,11 @@
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
images: {
|
images: {
|
||||||
unoptimized: true,
|
unoptimized: false,
|
||||||
domains: ['www.qrmaster.com', 'qrmaster.com', 'images.qrmaster.com']
|
domains: ['www.qrmaster.com', 'qrmaster.com', 'images.qrmaster.com'],
|
||||||
|
formats: ['image/webp', 'image/avif'],
|
||||||
|
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
||||||
|
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
|
||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
serverComponentsExternalPackages: ['@prisma/client', 'bcryptjs']
|
serverComponentsExternalPackages: ['@prisma/client', 'bcryptjs']
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -41,6 +41,7 @@
|
||||||
"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.276.0",
|
||||||
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
|
binaryTargets = ["native", "debian-openssl-3.0.x"]
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
|
|
@ -101,10 +102,9 @@ enum QRType {
|
||||||
|
|
||||||
enum ContentType {
|
enum ContentType {
|
||||||
URL
|
URL
|
||||||
WIFI
|
|
||||||
VCARD
|
VCARD
|
||||||
|
GEO
|
||||||
PHONE
|
PHONE
|
||||||
EMAIL
|
|
||||||
SMS
|
SMS
|
||||||
TEXT
|
TEXT
|
||||||
WHATSAPP
|
WHATSAPP
|
||||||
|
|
|
||||||
|
|
@ -50,11 +50,18 @@ async function main() {
|
||||||
slug: 'company-website-qr',
|
slug: 'company-website-qr',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Contact Email',
|
title: 'Contact Card',
|
||||||
contentType: 'EMAIL' as const,
|
contentType: 'VCARD' as const,
|
||||||
content: { email: 'contact@company.com', subject: 'Inquiry' },
|
content: {
|
||||||
tags: ['contact', 'email'],
|
firstName: 'John',
|
||||||
slug: 'contact-email-qr',
|
lastName: 'Doe',
|
||||||
|
email: 'john@company.com',
|
||||||
|
phone: '+1234567890',
|
||||||
|
organization: 'Example Corp',
|
||||||
|
title: 'CEO'
|
||||||
|
},
|
||||||
|
tags: ['contact', 'vcard'],
|
||||||
|
slug: 'contact-card-qr',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Event Details',
|
title: 'Event Details',
|
||||||
|
|
|
||||||
|
|
@ -331,19 +331,19 @@ export default function AnalyticsPage() {
|
||||||
<Table>
|
<Table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Country</th>
|
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">Country</th>
|
||||||
<th>Scans</th>
|
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">Scans</th>
|
||||||
<th>Percentage</th>
|
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">Percentage</th>
|
||||||
<th>Trend</th>
|
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">Trend</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{analyticsData.countryStats.map((country: any, index: number) => (
|
{analyticsData.countryStats.map((country: any, index: number) => (
|
||||||
<tr key={index}>
|
<tr key={index} className="border-b transition-colors hover:bg-gray-50/50">
|
||||||
<td>{country.country}</td>
|
<td className="px-4 py-4 align-middle">{country.country}</td>
|
||||||
<td>{country.count.toLocaleString()}</td>
|
<td className="px-4 py-4 align-middle">{country.count.toLocaleString()}</td>
|
||||||
<td>{country.percentage}%</td>
|
<td className="px-4 py-4 align-middle">{country.percentage}%</td>
|
||||||
<td>
|
<td className="px-4 py-4 align-middle">
|
||||||
<Badge variant="success">↑</Badge>
|
<Badge variant="success">↑</Badge>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -366,27 +366,27 @@ export default function AnalyticsPage() {
|
||||||
<Table>
|
<Table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>QR Code</th>
|
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">QR Code</th>
|
||||||
<th>Type</th>
|
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">Type</th>
|
||||||
<th>Total Scans</th>
|
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">Total Scans</th>
|
||||||
<th>Unique Scans</th>
|
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">Unique Scans</th>
|
||||||
<th>Conversion</th>
|
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">Conversion</th>
|
||||||
<th>Trend</th>
|
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">Trend</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{analyticsData.qrPerformance.map((qr: any) => (
|
{analyticsData.qrPerformance.map((qr: any) => (
|
||||||
<tr key={qr.id}>
|
<tr key={qr.id} className="border-b transition-colors hover:bg-gray-50/50">
|
||||||
<td className="font-medium">{qr.title}</td>
|
<td className="px-4 py-4 align-middle font-medium">{qr.title}</td>
|
||||||
<td>
|
<td className="px-4 py-4 align-middle">
|
||||||
<Badge variant={qr.type === 'DYNAMIC' ? 'info' : 'default'}>
|
<Badge variant={qr.type === 'DYNAMIC' ? 'info' : 'default'}>
|
||||||
{qr.type}
|
{qr.type}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td>{qr.totalScans.toLocaleString()}</td>
|
<td className="px-4 py-4 align-middle">{qr.totalScans.toLocaleString()}</td>
|
||||||
<td>{qr.uniqueScans.toLocaleString()}</td>
|
<td className="px-4 py-4 align-middle">{qr.uniqueScans.toLocaleString()}</td>
|
||||||
<td>{qr.conversion}%</td>
|
<td className="px-4 py-4 align-middle">{qr.conversion}%</td>
|
||||||
<td>
|
<td className="px-4 py-4 align-middle">
|
||||||
<Badge variant={qr.totalScans > 0 ? 'success' : 'default'}>
|
<Badge variant={qr.totalScans > 0 ? 'success' : 'default'}>
|
||||||
{qr.totalScans > 0 ? '↑' : '—'}
|
{qr.totalScans > 0 ? '↑' : '—'}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
|
||||||
|
|
@ -233,6 +233,14 @@ export default function BulkCreationPage() {
|
||||||
{ title: 'Product Page', content: 'https://example.com/product' },
|
{ title: 'Product Page', content: 'https://example.com/product' },
|
||||||
{ title: 'Landing Page', content: 'https://example.com/landing' },
|
{ title: 'Landing Page', content: 'https://example.com/landing' },
|
||||||
{ title: 'Contact Form', content: 'https://example.com/contact' },
|
{ title: 'Contact Form', content: 'https://example.com/contact' },
|
||||||
|
{ title: 'About Us', content: 'https://example.com/about' },
|
||||||
|
{ title: 'Pricing Page', content: 'https://example.com/pricing' },
|
||||||
|
{ title: 'FAQ Page', content: 'https://example.com/faq' },
|
||||||
|
{ title: 'Blog Article', content: 'https://example.com/blog/article-1' },
|
||||||
|
{ title: 'Support Portal', content: 'https://example.com/support' },
|
||||||
|
{ title: 'Download Page', content: 'https://example.com/download' },
|
||||||
|
{ title: 'Social Media', content: 'https://instagram.com/yourcompany' },
|
||||||
|
{ title: 'YouTube Video', content: 'https://youtube.com/watch?v=example' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const csv = Papa.unparse(template);
|
const csv = Papa.unparse(template);
|
||||||
|
|
@ -321,8 +329,7 @@ export default function BulkCreationPage() {
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className={`flex items-center ${step === 'upload' ? 'text-primary-600' : 'text-gray-400'}`}>
|
<div className={`flex items-center ${step === 'upload' ? 'text-primary-600' : 'text-gray-400'}`}>
|
||||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${step === 'upload' ? 'bg-primary-600 text-white' : 'bg-gray-200'
|
||||||
step === 'upload' ? 'bg-primary-600 text-white' : 'bg-gray-200'
|
|
||||||
}`}>
|
}`}>
|
||||||
1
|
1
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -330,16 +337,13 @@ export default function BulkCreationPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 h-0.5 bg-gray-200 mx-4">
|
<div className="flex-1 h-0.5 bg-gray-200 mx-4">
|
||||||
<div className={`h-full bg-primary-600 transition-all ${
|
<div className={`h-full bg-primary-600 transition-all ${step === 'preview' || step === 'complete' ? 'w-full' : 'w-0'
|
||||||
step === 'preview' || step === 'complete' ? 'w-full' : 'w-0'
|
|
||||||
}`} />
|
}`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`flex items-center ${
|
<div className={`flex items-center ${step === 'preview' || step === 'complete' ? 'text-primary-600' : 'text-gray-400'
|
||||||
step === 'preview' || step === 'complete' ? 'text-primary-600' : 'text-gray-400'
|
|
||||||
}`}>
|
}`}>
|
||||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${step === 'preview' || step === 'complete' ? 'bg-primary-600 text-white' : 'bg-gray-200'
|
||||||
step === 'preview' || step === 'complete' ? 'bg-primary-600 text-white' : 'bg-gray-200'
|
|
||||||
}`}>
|
}`}>
|
||||||
2
|
2
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -347,14 +351,12 @@ export default function BulkCreationPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 h-0.5 bg-gray-200 mx-4">
|
<div className="flex-1 h-0.5 bg-gray-200 mx-4">
|
||||||
<div className={`h-full bg-primary-600 transition-all ${
|
<div className={`h-full bg-primary-600 transition-all ${step === 'complete' ? 'w-full' : 'w-0'
|
||||||
step === 'complete' ? 'w-full' : 'w-0'
|
|
||||||
}`} />
|
}`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`flex items-center ${step === 'complete' ? 'text-primary-600' : 'text-gray-400'}`}>
|
<div className={`flex items-center ${step === 'complete' ? 'text-primary-600' : 'text-gray-400'}`}>
|
||||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${step === 'complete' ? 'bg-primary-600 text-white' : 'bg-gray-200'
|
||||||
step === 'complete' ? 'bg-primary-600 text-white' : 'bg-gray-200'
|
|
||||||
}`}>
|
}`}>
|
||||||
3
|
3
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -378,8 +380,7 @@ export default function BulkCreationPage() {
|
||||||
|
|
||||||
<div
|
<div
|
||||||
{...getRootProps()}
|
{...getRootProps()}
|
||||||
className={`border-2 border-dashed rounded-lg p-12 text-center cursor-pointer transition-colors ${
|
className={`border-2 border-dashed rounded-lg p-12 text-center cursor-pointer transition-colors ${isDragActive ? 'border-primary-500 bg-primary-50' : 'border-gray-300 hover:border-gray-400'
|
||||||
isDragActive ? 'border-primary-500 bg-primary-50' : 'border-gray-300 hover:border-gray-400'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<input {...getInputProps()} />
|
<input {...getInputProps()} />
|
||||||
|
|
@ -442,6 +443,110 @@ export default function BulkCreationPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Supported QR Code Types Section */}
|
||||||
|
<div className="mt-8">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">📋 Supported QR Code Types</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
This bulk generator creates <strong>static QR codes</strong> for multiple content types. Choose the format that matches your needs:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="border-l-4 border-blue-500 pl-4">
|
||||||
|
<p className="font-semibold text-gray-900 mb-1">🌐 URL - Website Links</p>
|
||||||
|
<p className="text-sm text-gray-600 mb-1">Format: <code className="bg-gray-100 px-2 py-1 rounded text-xs">https://example.com</code></p>
|
||||||
|
<p className="text-xs text-gray-500">Example: Product Page,https://example.com/product</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-l-4 border-purple-500 pl-4">
|
||||||
|
<p className="font-semibold text-gray-900 mb-1">👤 VCARD - Contact Cards</p>
|
||||||
|
<p className="text-sm text-gray-600 mb-1">Format: <code className="bg-gray-100 px-2 py-1 rounded text-xs">FirstName,LastName,Email,Phone,Organization,Title</code></p>
|
||||||
|
<p className="text-xs text-gray-500">Example: John Doe,"John,Doe,john@example.com,+1234567890,Company,CEO"</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-l-4 border-green-500 pl-4">
|
||||||
|
<p className="font-semibold text-gray-900 mb-1">📍 GEO - Locations</p>
|
||||||
|
<p className="text-sm text-gray-600 mb-1">Format: <code className="bg-gray-100 px-2 py-1 rounded text-xs">latitude,longitude,label</code></p>
|
||||||
|
<p className="text-xs text-gray-500">Example: Office Location,"37.7749,-122.4194,Main Office"</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-l-4 border-pink-500 pl-4">
|
||||||
|
<p className="font-semibold text-gray-900 mb-1">📞 PHONE - Phone Numbers</p>
|
||||||
|
<p className="text-sm text-gray-600 mb-1">Format: <code className="bg-gray-100 px-2 py-1 rounded text-xs">+1234567890</code></p>
|
||||||
|
<p className="text-xs text-gray-500">Example: Support Hotline,+1234567890</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-l-4 border-yellow-500 pl-4">
|
||||||
|
<p className="font-semibold text-gray-900 mb-1">📝 TEXT - Plain Text</p>
|
||||||
|
<p className="text-sm text-gray-600 mb-1">Format: <code className="bg-gray-100 px-2 py-1 rounded text-xs">Any text content</code></p>
|
||||||
|
<p className="text-xs text-gray-500">Example: Serial Number,SN-12345-ABCDE</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gradient-to-r from-blue-50 to-purple-50 rounded-lg p-6 mt-6">
|
||||||
|
<h4 className="font-semibold text-gray-900 mb-3">📥 CSV File Format:</h4>
|
||||||
|
<p className="text-sm text-gray-600 mb-3">
|
||||||
|
Your file needs <strong>two columns</strong>: <code className="bg-white px-2 py-1 rounded">title</code> and <code className="bg-white px-2 py-1 rounded">content</code>
|
||||||
|
</p>
|
||||||
|
<div className="bg-white rounded-lg p-4 shadow-sm overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b-2 border-gray-300">
|
||||||
|
<th className="text-left py-2 px-3 font-semibold text-gray-700">title</th>
|
||||||
|
<th className="text-left py-2 px-3 font-semibold text-gray-700">content</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="font-mono text-xs">
|
||||||
|
<tr className="border-b border-gray-200">
|
||||||
|
<td className="py-2 px-3">Product Page</td>
|
||||||
|
<td className="py-2 px-3">https://example.com/product</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-gray-200">
|
||||||
|
<td className="py-2 px-3">John Doe</td>
|
||||||
|
<td className="py-2 px-3">John,Doe,john@example.com,+1234567890,Company,CEO</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-gray-200">
|
||||||
|
<td className="py-2 px-3">Office Location</td>
|
||||||
|
<td className="py-2 px-3">37.7749,-122.4194,Main Office</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-gray-200">
|
||||||
|
<td className="py-2 px-3">Support Hotline</td>
|
||||||
|
<td className="py-2 px-3">+1234567890</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="py-2 px-3">Serial Number</td>
|
||||||
|
<td className="py-2 px-3">SN-12345-ABCDE</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-l-4 border-yellow-500 pl-4">
|
||||||
|
<p className="font-semibold text-gray-900 mb-1">ℹ️ Important Notes</p>
|
||||||
|
<ul className="text-sm text-gray-600 space-y-1">
|
||||||
|
<li>• <strong>Static QR codes</strong> - Cannot be edited after creation</li>
|
||||||
|
<li>• <strong>No tracking or analytics</strong> - Scans are not tracked</li>
|
||||||
|
<li>• <strong>Maximum 1,000 QR codes</strong> per upload</li>
|
||||||
|
<li>• <strong>Download as ZIP</strong> or save to your dashboard</li>
|
||||||
|
<li>• <strong>All QR types supported</strong> - URLs, vCards, locations, phone numbers, and text</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-blue-900">
|
||||||
|
<strong>💡 Tip:</strong> Download the template above to see examples of all 5 QR code types with 11 ready-to-use examples!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,410 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState, useCallback } from 'react';
|
|
||||||
import { useDropzone } from 'react-dropzone';
|
|
||||||
import Papa from 'papaparse';
|
|
||||||
import * as XLSX from 'xlsx';
|
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Badge } from '@/components/ui/Badge';
|
|
||||||
import { Select } from '@/components/ui/Select';
|
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
|
||||||
import { QRCodeSVG } from 'qrcode.react';
|
|
||||||
|
|
||||||
interface BulkQRData {
|
|
||||||
title: string;
|
|
||||||
contentType: string;
|
|
||||||
content: string;
|
|
||||||
tags?: string;
|
|
||||||
type?: 'STATIC' | 'DYNAMIC';
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BulkUploadPage() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [step, setStep] = useState<'upload' | 'preview' | 'complete'>('upload');
|
|
||||||
const [data, setData] = useState<BulkQRData[]>([]);
|
|
||||||
const [mapping, setMapping] = useState<Record<string, string>>({});
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [uploadResult, setUploadResult] = useState<any>(null);
|
|
||||||
|
|
||||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
|
||||||
const file = acceptedFiles[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
|
||||||
|
|
||||||
if (file.name.endsWith('.csv')) {
|
|
||||||
reader.onload = (e) => {
|
|
||||||
const text = e.target?.result as string;
|
|
||||||
const result = Papa.parse(text, { header: true });
|
|
||||||
processData(result.data);
|
|
||||||
};
|
|
||||||
reader.readAsText(file);
|
|
||||||
} else if (file.name.endsWith('.xlsx') || file.name.endsWith('.xls')) {
|
|
||||||
reader.onload = (e) => {
|
|
||||||
const data = new Uint8Array(e.target?.result as ArrayBuffer);
|
|
||||||
const workbook = XLSX.read(data, { type: 'array' });
|
|
||||||
const sheetName = workbook.SheetNames[0];
|
|
||||||
const worksheet = workbook.Sheets[sheetName];
|
|
||||||
const jsonData = XLSX.utils.sheet_to_json(worksheet);
|
|
||||||
processData(jsonData);
|
|
||||||
};
|
|
||||||
reader.readAsArrayBuffer(file);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
|
||||||
onDrop,
|
|
||||||
accept: {
|
|
||||||
'text/csv': ['.csv'],
|
|
||||||
'application/vnd.ms-excel': ['.xls'],
|
|
||||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
|
|
||||||
},
|
|
||||||
maxFiles: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const processData = (rawData: any[]) => {
|
|
||||||
// Auto-detect columns
|
|
||||||
if (rawData.length > 0) {
|
|
||||||
const columns = Object.keys(rawData[0]);
|
|
||||||
const autoMapping: Record<string, string> = {};
|
|
||||||
|
|
||||||
columns.forEach((col) => {
|
|
||||||
const lowerCol = col.toLowerCase();
|
|
||||||
if (lowerCol.includes('title') || lowerCol.includes('name')) {
|
|
||||||
autoMapping.title = col;
|
|
||||||
} else if (lowerCol.includes('type')) {
|
|
||||||
autoMapping.contentType = col;
|
|
||||||
} else if (lowerCol.includes('content') || lowerCol.includes('url') || lowerCol.includes('data')) {
|
|
||||||
autoMapping.content = col;
|
|
||||||
} else if (lowerCol.includes('tag')) {
|
|
||||||
autoMapping.tags = col;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setMapping(autoMapping);
|
|
||||||
}
|
|
||||||
|
|
||||||
setData(rawData);
|
|
||||||
setStep('preview');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpload = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Transform data based on mapping
|
|
||||||
const transformedData = data.map((row: any) => ({
|
|
||||||
title: row[mapping.title] || 'Untitled',
|
|
||||||
contentType: row[mapping.contentType] || 'URL',
|
|
||||||
content: row[mapping.content] || '',
|
|
||||||
tags: row[mapping.tags] || '',
|
|
||||||
type: 'DYNAMIC' as const,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const response = await fetch('/api/bulk', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ qrCodes: transformedData }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const result = await response.json();
|
|
||||||
setUploadResult(result);
|
|
||||||
setStep('complete');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Bulk upload error:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadTemplate = () => {
|
|
||||||
const template = [
|
|
||||||
{ title: 'Product Page', contentType: 'URL', content: 'https://example.com/product', tags: 'product,marketing' },
|
|
||||||
{ title: 'Contact Card', contentType: 'VCARD', content: 'John Doe', tags: 'contact,business' },
|
|
||||||
{ title: 'WiFi Network', contentType: 'WIFI', content: 'NetworkName:password123', tags: 'wifi,office' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const csv = Papa.unparse(template);
|
|
||||||
const blob = new Blob([csv], { type: 'text/csv' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = 'qr-codes-template.csv';
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-6xl mx-auto">
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900">{t('bulk.title')}</h1>
|
|
||||||
<p className="text-gray-600 mt-2">{t('bulk.subtitle')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress Steps */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className={`flex items-center ${step === 'upload' ? 'text-primary-600' : 'text-gray-400'}`}>
|
|
||||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
|
||||||
step === 'upload' ? 'bg-primary-600 text-white' : 'bg-gray-200'
|
|
||||||
}`}>
|
|
||||||
1
|
|
||||||
</div>
|
|
||||||
<span className="ml-3 font-medium">Upload File</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 h-0.5 bg-gray-200 mx-4">
|
|
||||||
<div className={`h-full bg-primary-600 transition-all ${
|
|
||||||
step === 'preview' || step === 'complete' ? 'w-full' : 'w-0'
|
|
||||||
}`} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`flex items-center ${
|
|
||||||
step === 'preview' || step === 'complete' ? 'text-primary-600' : 'text-gray-400'
|
|
||||||
}`}>
|
|
||||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
|
||||||
step === 'preview' || step === 'complete' ? 'bg-primary-600 text-white' : 'bg-gray-200'
|
|
||||||
}`}>
|
|
||||||
2
|
|
||||||
</div>
|
|
||||||
<span className="ml-3 font-medium">Preview & Map</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 h-0.5 bg-gray-200 mx-4">
|
|
||||||
<div className={`h-full bg-primary-600 transition-all ${
|
|
||||||
step === 'complete' ? 'w-full' : 'w-0'
|
|
||||||
}`} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`flex items-center ${step === 'complete' ? 'text-primary-600' : 'text-gray-400'}`}>
|
|
||||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
|
||||||
step === 'complete' ? 'bg-primary-600 text-white' : 'bg-gray-200'
|
|
||||||
}`}>
|
|
||||||
3
|
|
||||||
</div>
|
|
||||||
<span className="ml-3 font-medium">Complete</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Upload Step */}
|
|
||||||
{step === 'upload' && (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-8">
|
|
||||||
<div className="text-center mb-6">
|
|
||||||
<Button variant="outline" onClick={downloadTemplate}>
|
|
||||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
|
||||||
</svg>
|
|
||||||
Download Template
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
{...getRootProps()}
|
|
||||||
className={`border-2 border-dashed rounded-lg p-12 text-center cursor-pointer transition-colors ${
|
|
||||||
isDragActive ? 'border-primary-500 bg-primary-50' : 'border-gray-300 hover:border-gray-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<input {...getInputProps()} />
|
|
||||||
<svg className="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
|
||||||
</svg>
|
|
||||||
<p className="text-lg font-medium text-gray-900 mb-2">
|
|
||||||
{isDragActive ? 'Drop the file here' : 'Drag & drop your file here'}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-500 mb-4">or click to browse</p>
|
|
||||||
<p className="text-xs text-gray-400">Supports CSV, XLS, XLSX (max 1000 rows)</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 grid md:grid-cols-3 gap-4">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
|
||||||
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-gray-900">CSV Format</p>
|
|
||||||
<p className="text-sm text-gray-500">Comma-separated values</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
|
|
||||||
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-gray-900">Excel Format</p>
|
|
||||||
<p className="text-sm text-gray-500">XLS or XLSX files</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
|
|
||||||
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-gray-900">Fast Processing</p>
|
|
||||||
<p className="text-sm text-gray-500">Up to 1000 QR codes</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Preview Step */}
|
|
||||||
{step === 'preview' && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle>Preview & Map Columns</CardTitle>
|
|
||||||
<Badge variant="info">{data.length} rows detected</Badge>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="mb-6 grid md:grid-cols-4 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Title Column</label>
|
|
||||||
<Select
|
|
||||||
value={mapping.title || ''}
|
|
||||||
onChange={(e) => setMapping({ ...mapping, title: e.target.value })}
|
|
||||||
options={Object.keys(data[0] || {}).map((col) => ({ value: col, label: col }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Content Type Column</label>
|
|
||||||
<Select
|
|
||||||
value={mapping.contentType || ''}
|
|
||||||
onChange={(e) => setMapping({ ...mapping, contentType: e.target.value })}
|
|
||||||
options={Object.keys(data[0] || {}).map((col) => ({ value: col, label: col }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Content Column</label>
|
|
||||||
<Select
|
|
||||||
value={mapping.content || ''}
|
|
||||||
onChange={(e) => setMapping({ ...mapping, content: e.target.value })}
|
|
||||||
options={Object.keys(data[0] || {}).map((col) => ({ value: col, label: col }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Tags Column (Optional)</label>
|
|
||||||
<Select
|
|
||||||
value={mapping.tags || ''}
|
|
||||||
onChange={(e) => setMapping({ ...mapping, tags: e.target.value })}
|
|
||||||
options={[
|
|
||||||
{ value: '', label: 'None' },
|
|
||||||
...Object.keys(data[0] || {}).map((col) => ({ value: col, label: col }))
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b">
|
|
||||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Preview</th>
|
|
||||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Title</th>
|
|
||||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Type</th>
|
|
||||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Content</th>
|
|
||||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Tags</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{data.slice(0, 5).map((row: any, index) => (
|
|
||||||
<tr key={index} className="border-b">
|
|
||||||
<td className="py-3 px-4">
|
|
||||||
<QRCodeSVG
|
|
||||||
value={row[mapping.content] || 'https://example.com'}
|
|
||||||
size={40}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-sm text-gray-900">
|
|
||||||
{row[mapping.title] || 'Untitled'}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-sm text-gray-900">
|
|
||||||
{row[mapping.contentType] || 'URL'}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-sm text-gray-900">
|
|
||||||
{(row[mapping.content] || '').substring(0, 30)}...
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-sm text-gray-900">
|
|
||||||
{row[mapping.tags] || '-'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{data.length > 5 && (
|
|
||||||
<p className="text-sm text-gray-500 mt-4 text-center">
|
|
||||||
Showing 5 of {data.length} rows
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-between mt-6">
|
|
||||||
<Button variant="outline" onClick={() => setStep('upload')}>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleUpload} loading={loading}>
|
|
||||||
Create {data.length} QR Codes
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Complete Step */}
|
|
||||||
{step === 'complete' && (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-12 text-center">
|
|
||||||
<div className="w-20 h-20 bg-success-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
|
||||||
<svg className="w-10 h-10 text-success-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Upload Complete!</h2>
|
|
||||||
<p className="text-gray-600 mb-8">
|
|
||||||
Successfully created {data.length} QR codes
|
|
||||||
</p>
|
|
||||||
<div className="flex justify-center space-x-4">
|
|
||||||
<Button variant="outline" onClick={() => window.location.href = '/dashboard'}>
|
|
||||||
View Dashboard
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => {
|
|
||||||
setStep('upload');
|
|
||||||
setData([]);
|
|
||||||
setMapping({});
|
|
||||||
}}>
|
|
||||||
Upload More
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -26,7 +26,6 @@ export default function CreatePage() {
|
||||||
const [contentType, setContentType] = useState('URL');
|
const [contentType, setContentType] = useState('URL');
|
||||||
const [content, setContent] = useState<any>({ url: '' });
|
const [content, setContent] = useState<any>({ url: '' });
|
||||||
const [isDynamic, setIsDynamic] = useState(true);
|
const [isDynamic, setIsDynamic] = useState(true);
|
||||||
const [tags, setTags] = useState('');
|
|
||||||
|
|
||||||
// Style state
|
// Style state
|
||||||
const [foregroundColor, setForegroundColor] = useState('#000000');
|
const [foregroundColor, setForegroundColor] = useState('#000000');
|
||||||
|
|
@ -61,8 +60,8 @@ export default function CreatePage() {
|
||||||
|
|
||||||
const contentTypes = [
|
const contentTypes = [
|
||||||
{ value: 'URL', label: 'URL / Website' },
|
{ value: 'URL', label: 'URL / Website' },
|
||||||
{ value: 'WIFI', label: 'WiFi Network' },
|
{ value: 'VCARD', label: 'Contact Card' },
|
||||||
{ value: 'EMAIL', label: 'Email' },
|
{ value: 'GEO', label: 'Location/Maps' },
|
||||||
{ value: 'PHONE', label: 'Phone Number' },
|
{ value: 'PHONE', label: 'Phone Number' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -73,12 +72,15 @@ export default function CreatePage() {
|
||||||
return content.url || 'https://example.com';
|
return content.url || 'https://example.com';
|
||||||
case 'PHONE':
|
case 'PHONE':
|
||||||
return `tel:${content.phone || '+1234567890'}`;
|
return `tel:${content.phone || '+1234567890'}`;
|
||||||
case 'EMAIL':
|
|
||||||
return `mailto:${content.email || 'email@example.com'}${content.subject ? `?subject=${encodeURIComponent(content.subject)}` : ''}`;
|
|
||||||
case 'SMS':
|
case 'SMS':
|
||||||
return `sms:${content.phone || '+1234567890'}${content.message ? `?body=${encodeURIComponent(content.message)}` : ''}`;
|
return `sms:${content.phone || '+1234567890'}${content.message ? `?body=${encodeURIComponent(content.message)}` : ''}`;
|
||||||
case 'WIFI':
|
case 'VCARD':
|
||||||
return `WIFI:T:${content.security || 'WPA'};S:${content.ssid || 'NetworkName'};P:${content.password || ''};H:false;;`;
|
return `BEGIN:VCARD\nVERSION:3.0\nFN:${content.firstName || 'John'} ${content.lastName || 'Doe'}\nORG:${content.organization || 'Company'}\nTITLE:${content.title || 'Position'}\nEMAIL:${content.email || 'email@example.com'}\nTEL:${content.phone || '+1234567890'}\nEND:VCARD`;
|
||||||
|
case 'GEO':
|
||||||
|
const lat = content.latitude || 37.7749;
|
||||||
|
const lon = content.longitude || -122.4194;
|
||||||
|
const label = content.label ? `?q=${encodeURIComponent(content.label)}` : '';
|
||||||
|
return `geo:${lat},${lon}${label}`;
|
||||||
case 'TEXT':
|
case 'TEXT':
|
||||||
return content.text || 'Sample text';
|
return content.text || 'Sample text';
|
||||||
case 'WHATSAPP':
|
case 'WHATSAPP':
|
||||||
|
|
@ -158,7 +160,7 @@ export default function CreatePage() {
|
||||||
contentType,
|
contentType,
|
||||||
content,
|
content,
|
||||||
isStatic: !isDynamic,
|
isStatic: !isDynamic,
|
||||||
tags: tags.split(',').map(t => t.trim()).filter(Boolean),
|
tags: [],
|
||||||
style: {
|
style: {
|
||||||
// FREE users can only use black/white
|
// FREE users can only use black/white
|
||||||
foregroundColor: canCustomizeColors ? foregroundColor : '#000000',
|
foregroundColor: canCustomizeColors ? foregroundColor : '#000000',
|
||||||
|
|
@ -220,49 +222,76 @@ export default function CreatePage() {
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'EMAIL':
|
case 'VCARD':
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Input
|
||||||
|
label="First Name"
|
||||||
|
value={content.firstName || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, firstName: e.target.value })}
|
||||||
|
placeholder="John"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Last Name"
|
||||||
|
value={content.lastName || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, lastName: e.target.value })}
|
||||||
|
placeholder="Doe"
|
||||||
|
required
|
||||||
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Email Address"
|
label="Email Address"
|
||||||
type="email"
|
type="email"
|
||||||
value={content.email || ''}
|
value={content.email || ''}
|
||||||
onChange={(e) => setContent({ ...content, email: e.target.value })}
|
onChange={(e) => setContent({ ...content, email: e.target.value })}
|
||||||
placeholder="contact@example.com"
|
placeholder="john@example.com"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Subject (optional)"
|
label="Phone Number"
|
||||||
value={content.subject || ''}
|
value={content.phone || ''}
|
||||||
onChange={(e) => setContent({ ...content, subject: e.target.value })}
|
onChange={(e) => setContent({ ...content, phone: e.target.value })}
|
||||||
placeholder="Email subject"
|
placeholder="+1234567890"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Company/Organization"
|
||||||
|
value={content.organization || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, organization: e.target.value })}
|
||||||
|
placeholder="Company Name"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Job Title"
|
||||||
|
value={content.title || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, title: e.target.value })}
|
||||||
|
placeholder="CEO"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
case 'WIFI':
|
case 'GEO':
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Input
|
<Input
|
||||||
label="Network Name (SSID)"
|
label="Latitude"
|
||||||
value={content.ssid || ''}
|
type="number"
|
||||||
onChange={(e) => setContent({ ...content, ssid: e.target.value })}
|
step="any"
|
||||||
|
value={content.latitude || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, latitude: parseFloat(e.target.value) || 0 })}
|
||||||
|
placeholder="37.7749"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Password"
|
label="Longitude"
|
||||||
type="password"
|
type="number"
|
||||||
value={content.password || ''}
|
step="any"
|
||||||
onChange={(e) => setContent({ ...content, password: e.target.value })}
|
value={content.longitude || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, longitude: parseFloat(e.target.value) || 0 })}
|
||||||
|
placeholder="-122.4194"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
<Select
|
<Input
|
||||||
label="Security"
|
label="Location Label (optional)"
|
||||||
value={content.security || 'WPA'}
|
value={content.label || ''}
|
||||||
onChange={(e) => setContent({ ...content, security: e.target.value })}
|
onChange={(e) => setContent({ ...content, label: e.target.value })}
|
||||||
options={[
|
placeholder="Golden Gate Bridge"
|
||||||
{ value: 'WPA', label: 'WPA/WPA2' },
|
|
||||||
{ value: 'WEP', label: 'WEP' },
|
|
||||||
{ value: 'nopass', label: 'No Password' },
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -318,13 +347,6 @@ export default function CreatePage() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{renderContentFields()}
|
{renderContentFields()}
|
||||||
|
|
||||||
<Input
|
|
||||||
label="Tags (comma-separated)"
|
|
||||||
value={tags}
|
|
||||||
onChange={(e) => setTags(e.target.value)}
|
|
||||||
placeholder="marketing, campaign, 2025"
|
|
||||||
/>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,10 +86,10 @@ export default function DashboardPage() {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '5',
|
id: '5',
|
||||||
title: 'Contact Email',
|
title: 'Contact Card',
|
||||||
type: 'DYNAMIC' as const,
|
type: 'DYNAMIC' as const,
|
||||||
contentType: 'EMAIL',
|
contentType: 'VCARD',
|
||||||
slug: 'contact-email-qr',
|
slug: 'contact-card-qr',
|
||||||
status: 'ACTIVE' as const,
|
status: 'ACTIVE' as const,
|
||||||
createdAt: '2025-08-07T10:04:00Z',
|
createdAt: '2025-08-07T10:04:00Z',
|
||||||
scans: 0,
|
scans: 0,
|
||||||
|
|
@ -255,7 +255,7 @@ export default function DashboardPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/qrs/${id}`, {
|
const response = await fetchWithCsrf(`/api/qrs/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -154,21 +154,75 @@ export default function EditQRPage() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{qrCode.contentType === 'EMAIL' && (
|
{qrCode.contentType === 'VCARD' && (
|
||||||
<>
|
<>
|
||||||
|
<Input
|
||||||
|
label="First Name"
|
||||||
|
value={content.firstName || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, firstName: e.target.value })}
|
||||||
|
placeholder="John"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Last Name"
|
||||||
|
value={content.lastName || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, lastName: e.target.value })}
|
||||||
|
placeholder="Doe"
|
||||||
|
required
|
||||||
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Email"
|
label="Email"
|
||||||
type="email"
|
type="email"
|
||||||
value={content.email || ''}
|
value={content.email || ''}
|
||||||
onChange={(e) => setContent({ ...content, email: e.target.value })}
|
onChange={(e) => setContent({ ...content, email: e.target.value })}
|
||||||
placeholder="email@example.com"
|
placeholder="john@example.com"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Phone"
|
||||||
|
value={content.phone || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, phone: e.target.value })}
|
||||||
|
placeholder="+1234567890"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Organization"
|
||||||
|
value={content.organization || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, organization: e.target.value })}
|
||||||
|
placeholder="Company Name"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Job Title"
|
||||||
|
value={content.title || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, title: e.target.value })}
|
||||||
|
placeholder="CEO"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{qrCode.contentType === 'GEO' && (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
label="Latitude"
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
value={content.latitude || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, latitude: parseFloat(e.target.value) || 0 })}
|
||||||
|
placeholder="37.7749"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Subject (Optional)"
|
label="Longitude"
|
||||||
value={content.subject || ''}
|
type="number"
|
||||||
onChange={(e) => setContent({ ...content, subject: e.target.value })}
|
step="any"
|
||||||
placeholder="Email subject"
|
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"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -55,12 +55,6 @@ export default function LoginPage() {
|
||||||
window.location.href = '/api/auth/google';
|
window.location.href = '/api/auth/google';
|
||||||
};
|
};
|
||||||
|
|
||||||
// Demo login
|
|
||||||
const handleDemoLogin = () => {
|
|
||||||
setEmail('demo@qrmaster.com');
|
|
||||||
setPassword('demo123');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
|
|
@ -149,18 +143,6 @@ export default function LoginPage() {
|
||||||
</svg>
|
</svg>
|
||||||
Sign in with Google
|
Sign in with Google
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="w-full"
|
|
||||||
onClick={handleDemoLogin}
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
Use Demo Account
|
|
||||||
</Button>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-6 text-center">
|
<div className="mt-6 text-center">
|
||||||
|
|
|
||||||
|
|
@ -144,193 +144,6 @@ const blogPosts: Record<string, BlogPostData> = {
|
||||||
<p>Whether you're deploying QR codes for event tracking, print marketing, bulk generation, or API-driven automation, scan analytics provides the data foundation for smarter, more effective campaigns. Start leveraging QR analytics today to unlock the full potential of your QR marketing strategy.</p>
|
<p>Whether you're deploying QR codes for event tracking, print marketing, bulk generation, or API-driven automation, scan analytics provides the data foundation for smarter, more effective campaigns. Start leveraging QR analytics today to unlock the full potential of your QR marketing strategy.</p>
|
||||||
</div>`,
|
</div>`,
|
||||||
},
|
},
|
||||||
'dynamische-vs-statische-qr-codes': {
|
|
||||||
slug: 'dynamische-vs-statische-qr-codes',
|
|
||||||
title: 'Dynamische vs. Statische QR-Codes: Der ultimative Vergleich',
|
|
||||||
excerpt: 'Entdecken Sie die wichtigsten Unterschiede zwischen dynamischen und statischen QR-Codes und wählen Sie die richtige Option für Ihre Kampagne.',
|
|
||||||
date: 'October 15, 2025',
|
|
||||||
datePublished: '2025-10-15T09:00:00Z',
|
|
||||||
dateModified: '2025-10-15T09:00:00Z',
|
|
||||||
readTime: '6 Min',
|
|
||||||
category: 'Grundlagen',
|
|
||||||
image: 'https://images.unsplash.com/photo-1603791440384-56cd371ee9a7?w=1200&q=80',
|
|
||||||
imageAlt: 'Vergleich zwischen dynamischen und statischen QR-Codes mit Diagrammen',
|
|
||||||
author: 'QR Master Team',
|
|
||||||
authorUrl: 'https://www.qrmaster.com/about',
|
|
||||||
content: `<div class="blog-content">
|
|
||||||
<h2>Was sind statische QR-Codes?</h2>
|
|
||||||
<p>Statische QR-Codes enthalten fest eingebettete Informationen, die nach der Erstellung nicht mehr geändert werden können. Der QR-Code speichert die Daten direkt – zum Beispiel eine URL, einen Text oder Kontaktdaten. Sobald der Code gedruckt ist, bleibt sein Inhalt permanent.</p>
|
|
||||||
|
|
||||||
<h3>Vorteile statischer QR-Codes</h3>
|
|
||||||
<p><strong>Für immer gültig:</strong> Statische QR-Codes funktionieren unabhängig von Servern oder Abonnements. Sie sind ideal für Anwendungen, bei denen Sie garantiert langfristige Verfügbarkeit benötigen.</p>
|
|
||||||
<p><strong>Keine laufenden Kosten:</strong> Da keine Server-Infrastruktur erforderlich ist, fallen keine monatlichen Gebühren an.</p>
|
|
||||||
<p><strong>Schneller Scan:</strong> Direkter Zugriff auf Inhalte ohne Umleitung über Server.</p>
|
|
||||||
|
|
||||||
<h3>Nachteile statischer QR-Codes</h3>
|
|
||||||
<p><strong>Nicht editierbar:</strong> Nach dem Druck können Sie die verlinkten Inhalte nicht mehr ändern.</p>
|
|
||||||
<p><strong>Keine Analytics:</strong> Sie können nicht nachverfolgen, wie oft der Code gescannt wurde.</p>
|
|
||||||
<p><strong>Größer bei langen URLs:</strong> Je mehr Daten eingebettet sind, desto komplexer und größer wird der QR-Code.</p>
|
|
||||||
|
|
||||||
<h2>Was sind dynamische QR-Codes?</h2>
|
|
||||||
<p>Dynamische QR-Codes enthalten eine kurze Weiterleitungs-URL, die auf einen Server verweist. Der Server speichert die eigentliche Ziel-URL. Das bedeutet, dass Sie die Ziel-URL jederzeit ändern können, ohne den gedruckten QR-Code neu erstellen zu müssen.</p>
|
|
||||||
|
|
||||||
<h3>Vorteile dynamischer QR-Codes</h3>
|
|
||||||
<p><strong>Editierbar:</strong> Ändern Sie die Ziel-URL jederzeit, auch nach dem Druck.</p>
|
|
||||||
<p><strong>Detaillierte Analytics:</strong> Verfolgen Sie Scans nach Standort, Gerät, Zeit und mehr.</p>
|
|
||||||
<p><strong>Retargeting-fähig:</strong> Nutzen Sie Scan-Daten für personalisierte Marketing-Kampagnen.</p>
|
|
||||||
<p><strong>Kompakter Code:</strong> Da nur eine kurze Umleitungs-URL eingebettet ist, bleibt der QR-Code kleiner und einfacher zu scannen.</p>
|
|
||||||
|
|
||||||
<h3>Nachteile dynamischer QR-Codes</h3>
|
|
||||||
<p><strong>Erfordert Abo:</strong> Dynamische QR-Codes benötigen eine aktive Server-Infrastruktur, was meist mit monatlichen Kosten verbunden ist.</p>
|
|
||||||
<p><strong>Abhängig vom Server:</strong> Wenn der Server ausfällt oder das Abo abläuft, funktioniert der QR-Code nicht mehr.</p>
|
|
||||||
|
|
||||||
<h2>Vergleichstabelle: Statisch vs. Dynamisch</h2>
|
|
||||||
<div class="overflow-x-auto my-8">
|
|
||||||
<table class="min-w-full border-collapse border border-gray-300">
|
|
||||||
<thead class="bg-gray-100">
|
|
||||||
<tr>
|
|
||||||
<th class="border border-gray-300 px-6 py-3 text-left font-semibold text-gray-900">Feature</th>
|
|
||||||
<th class="border border-gray-300 px-6 py-3 text-left font-semibold text-gray-900">Statisch</th>
|
|
||||||
<th class="border border-gray-300 px-6 py-3 text-left font-semibold text-gray-900">Dynamisch</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr class="bg-white">
|
|
||||||
<td class="border border-gray-300 px-6 py-4 font-medium text-gray-900">Editierbar</td>
|
|
||||||
<td class="border border-gray-300 px-6 py-4 text-gray-700">Nein</td>
|
|
||||||
<td class="border border-gray-300 px-6 py-4 text-gray-700">Ja</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="bg-gray-50">
|
|
||||||
<td class="border border-gray-300 px-6 py-4 font-medium text-gray-900">Analytics</td>
|
|
||||||
<td class="border border-gray-300 px-6 py-4 text-gray-700">Nein</td>
|
|
||||||
<td class="border border-gray-300 px-6 py-4 text-gray-700">Ja</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="bg-white">
|
|
||||||
<td class="border border-gray-300 px-6 py-4 font-medium text-gray-900">Kosten</td>
|
|
||||||
<td class="border border-gray-300 px-6 py-4 text-gray-700">Kostenlos</td>
|
|
||||||
<td class="border border-gray-300 px-6 py-4 text-gray-700">Abo erforderlich</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="bg-gray-50">
|
|
||||||
<td class="border border-gray-300 px-6 py-4 font-medium text-gray-900">Größe</td>
|
|
||||||
<td class="border border-gray-300 px-6 py-4 text-gray-700">Größer bei langen URLs</td>
|
|
||||||
<td class="border border-gray-300 px-6 py-4 text-gray-700">Immer kompakt</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="bg-white">
|
|
||||||
<td class="border border-gray-300 px-6 py-4 font-medium text-gray-900">Gültigkeitsdauer</td>
|
|
||||||
<td class="border border-gray-300 px-6 py-4 text-gray-700">Für immer</td>
|
|
||||||
<td class="border border-gray-300 px-6 py-4 text-gray-700">Abhängig vom Abo</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>Wann sollten Sie welchen QR-Code-Typ verwenden?</h2>
|
|
||||||
<h3>Verwenden Sie statische QR-Codes für:</h3>
|
|
||||||
<p>• Visitenkarten mit festen Kontaktdaten</p>
|
|
||||||
<p>• WLAN-Passwörter</p>
|
|
||||||
<p>• Produktverpackungen mit permanenten URLs</p>
|
|
||||||
<p>• Anwendungen, bei denen Sie keine Analytics benötigen</p>
|
|
||||||
|
|
||||||
<h3>Verwenden Sie dynamische QR-Codes für:</h3>
|
|
||||||
<p>• Marketing-Kampagnen mit wechselnden Angeboten</p>
|
|
||||||
<p>• Event-Tickets mit aktualisierbaren Informationen</p>
|
|
||||||
<p>• Print-Anzeigen, bei denen Sie die Landingpage optimieren möchten</p>
|
|
||||||
<p>• Jede Anwendung, bei der Sie Scan-Statistiken tracken möchten</p>
|
|
||||||
|
|
||||||
<h2>Fazit</h2>
|
|
||||||
<p>Statische QR-Codes sind ideal für permanente, unveränderliche Inhalte ohne Tracking-Bedarf. Dynamische QR-Codes bieten Flexibilität, Analytics und Marketing-Power – perfekt für professionelle Kampagnen. Wählen Sie basierend auf Ihren Anforderungen: Langfristige Stabilität oder Marketing-Flexibilität?</p>
|
|
||||||
</div>`,
|
|
||||||
},
|
|
||||||
'qr-codes-im-restaurant': {
|
|
||||||
slug: 'qr-codes-im-restaurant',
|
|
||||||
title: 'QR-Codes im Restaurant: Digitale Speisekarten & kontaktloses Bestellen',
|
|
||||||
excerpt: 'Erfahren Sie, wie Restaurants QR-Codes für digitale Menüs, kontaktlose Bestellungen und verbessertes Gästeerlebnis einsetzen.',
|
|
||||||
date: 'October 14, 2025',
|
|
||||||
datePublished: '2025-10-14T09:00:00Z',
|
|
||||||
dateModified: '2025-10-14T09:00:00Z',
|
|
||||||
readTime: '7 Min',
|
|
||||||
category: 'Anwendungsfälle',
|
|
||||||
image: 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=1200&q=80',
|
|
||||||
imageAlt: 'Restaurant-Tisch mit QR-Code für digitale Speisekarte',
|
|
||||||
author: 'QR Master Team',
|
|
||||||
authorUrl: 'https://www.qrmaster.com/about',
|
|
||||||
content: `<div class="blog-content">
|
|
||||||
<h2>Warum QR-Codes für Restaurants?</h2>
|
|
||||||
<p>Seit der Pandemie haben QR-Codes die Gastronomie revolutioniert. Sie ermöglichen kontaktloses Bestellen, reduzieren Druckkosten für Speisekarten und verbessern das Gästeerlebnis durch interaktive Funktionen. Moderne Gäste erwarten digitale Lösungen – QR-Codes liefern genau das.</p>
|
|
||||||
|
|
||||||
<h2>Digitale Speisekarten mit QR-Codes</h2>
|
|
||||||
<h3>Vorteile für Restaurants</h3>
|
|
||||||
<p><strong>Kosten sparen:</strong> Keine teuren Speisekarten-Neudrucke bei Preisänderungen oder neuen Gerichten.</p>
|
|
||||||
<p><strong>Aktualisierungen in Echtzeit:</strong> Passen Sie Ihr Menü sofort an – täglich wechselnde Angebote, Verfügbarkeiten oder Allergiehinweise.</p>
|
|
||||||
<p><strong>Mehrsprachigkeit:</strong> Bieten Sie Ihre Speisekarte automatisch in mehreren Sprachen an.</p>
|
|
||||||
<p><strong>Hygiene:</strong> Gäste scannen mit ihrem eigenen Smartphone – keine gemeinsam genutzten Speisekarten mehr.</p>
|
|
||||||
|
|
||||||
<h3>Vorteile für Gäste</h3>
|
|
||||||
<p>• Sofortiger Zugriff auf die Speisekarte ohne Warten</p>
|
|
||||||
<p>• Hochauflösende Bilder der Gerichte</p>
|
|
||||||
<p>• Detaillierte Nährwertangaben und Allergiehinweise</p>
|
|
||||||
<p>• Personalisierte Empfehlungen</p>
|
|
||||||
|
|
||||||
<h2>Kontaktloses Bestellen via QR-Code</h2>
|
|
||||||
<p>QR-Codes ermöglichen es Gästen, direkt vom Tisch aus zu bestellen – ohne auf Kellner zu warten. Das System ist einfach: Gast scannt Code → wählt Gerichte aus → bestätigt Bestellung → zahlt digital.</p>
|
|
||||||
|
|
||||||
<h3>Vorteile des kontaktlosen Bestellens</h3>
|
|
||||||
<p><strong>Schnellerer Service:</strong> Bestellungen gehen direkt in die Küche, ohne Zwischenschritte.</p>
|
|
||||||
<p><strong>Weniger Fehler:</strong> Gäste geben ihre Bestellung selbst ein – keine Missverständnisse mehr.</p>
|
|
||||||
<p><strong>Upselling-Möglichkeiten:</strong> Zeigen Sie automatisch passende Empfehlungen oder Zusatzprodukte an.</p>
|
|
||||||
<p><strong>Höhere Tischrotation:</strong> Weniger Wartezeiten bedeuten mehr bediente Gäste pro Abend.</p>
|
|
||||||
|
|
||||||
<h2>QR-Code-Platzierung im Restaurant</h2>
|
|
||||||
<h3>Auf dem Tisch</h3>
|
|
||||||
<p>Der klassische Ansatz: QR-Codes auf Tischaufstellern, Tischsets oder direkt auf dem Tisch. Ideal für Restaurants mit festem Sitzplatz.</p>
|
|
||||||
|
|
||||||
<h3>An der Wand oder am Eingang</h3>
|
|
||||||
<p>Für Schnellrestaurants oder Cafés: Gäste scannen beim Betreten und bestellen an der Theke oder am Tisch.</p>
|
|
||||||
|
|
||||||
<h3>Auf Rechnungen und Flyern</h3>
|
|
||||||
<p>Nutzen Sie QR-Codes auf Rechnungen für Online-Bewertungen, Treueprogramme oder Social-Media-Verlinkungen.</p>
|
|
||||||
|
|
||||||
<h2>Best Practices für Restaurant-QR-Codes</h2>
|
|
||||||
<h3>1. Design: Markenkonform und ansprechend</h3>
|
|
||||||
<p>Gestalten Sie QR-Codes in Ihren Markenfarben mit Ihrem Logo. Ein professionelles Design erhöht die Scan-Rate.</p>
|
|
||||||
|
|
||||||
<h3>2. Klare Anweisungen</h3>
|
|
||||||
<p>Fügen Sie einen Call-to-Action hinzu: "Scannen für Speisekarte" oder "Hier bestellen". Nicht jeder Gast ist mit QR-Codes vertraut.</p>
|
|
||||||
|
|
||||||
<h3>3. Mobile Optimierung</h3>
|
|
||||||
<p>Ihre digitale Speisekarte MUSS mobilfreundlich sein. Große Schriftarten, einfache Navigation, schnelle Ladezeiten.</p>
|
|
||||||
|
|
||||||
<h3>4. Testen Sie regelmäßig</h3>
|
|
||||||
<p>Prüfen Sie wöchentlich, ob alle QR-Codes funktionieren und zur richtigen Seite führen.</p>
|
|
||||||
|
|
||||||
<h2>Analytics: Messen Sie den Erfolg</h2>
|
|
||||||
<p>Mit dynamischen QR-Codes erhalten Sie wertvolle Insights:</p>
|
|
||||||
<p>• Wie viele Gäste scannen den QR-Code?</p>
|
|
||||||
<p>• Welche Gerichte werden am häufigsten angesehen?</p>
|
|
||||||
<p>• Zu welchen Uhrzeiten ist die Nachfrage am höchsten?</p>
|
|
||||||
<p>• Welche Tische haben die höchste Scan-Rate?</p>
|
|
||||||
|
|
||||||
<h2>Praxisbeispiel: Pizza-Restaurant "Bella Italia"</h2>
|
|
||||||
<p>Das fiktive Restaurant "Bella Italia" führte QR-Code-Bestellungen ein und erzielte innerhalb von 3 Monaten:</p>
|
|
||||||
<p>• 40% schnellere Bestellabwicklung</p>
|
|
||||||
<p>• 25% höherer Umsatz durch Upselling-Vorschläge</p>
|
|
||||||
<p>• 90% der Gäste bevorzugen QR-Bestellungen gegenüber klassischer Bedienung</p>
|
|
||||||
<p>• 50% Kosteneinsparung bei Speisekarten-Druck</p>
|
|
||||||
|
|
||||||
<h2>Häufige Fragen (FAQ)</h2>
|
|
||||||
<h3>Was, wenn Gäste kein Smartphone haben?</h3>
|
|
||||||
<p>Halten Sie einige gedruckte Speisekarten als Backup bereit – besonders für ältere Gäste.</p>
|
|
||||||
|
|
||||||
<h3>Wie sicher ist das Bezahlen via QR-Code?</h3>
|
|
||||||
<p>Nutzen Sie etablierte Payment-Anbieter wie Stripe oder PayPal, die höchste Sicherheitsstandards erfüllen.</p>
|
|
||||||
|
|
||||||
<h3>Brauche ich technisches Know-how?</h3>
|
|
||||||
<p>Nein! Plattformen wie QR Master ermöglichen die Erstellung und Verwaltung von Restaurant-QR-Codes ohne Programmierkenntnisse.</p>
|
|
||||||
|
|
||||||
<h2>Fazit</h2>
|
|
||||||
<p>QR-Codes sind die Zukunft der Gastronomie. Sie verbessern das Gästeerlebnis, sparen Kosten und steigern den Umsatz. Egal ob kleines Café oder gehobenes Restaurant – QR-Codes lohnen sich für jeden Betrieb. Starten Sie noch heute mit Ihrer digitalen Transformation!</p>
|
|
||||||
</div>`,
|
|
||||||
},
|
|
||||||
'qr-code-tracking-guide-2025': {
|
'qr-code-tracking-guide-2025': {
|
||||||
slug: 'qr-code-tracking-guide-2025',
|
slug: 'qr-code-tracking-guide-2025',
|
||||||
title: 'QR Code Tracking: Complete Guide 2025 (Free Tools & Best Practices)',
|
title: 'QR Code Tracking: Complete Guide 2025 (Free Tools & Best Practices)',
|
||||||
|
|
@ -868,7 +681,7 @@ app.get('/qr/:id', async (req, res) => {
|
||||||
imageAlt: 'Two QR codes side by side showing static and dynamic comparison',
|
imageAlt: 'Two QR codes side by side showing static and dynamic comparison',
|
||||||
author: 'QR Master Team',
|
author: 'QR Master Team',
|
||||||
authorUrl: 'https://www.qrmaster.com/about',
|
authorUrl: 'https://www.qrmaster.com/about',
|
||||||
answer: 'Static QR codes encode data directly and cannot be edited after creation, while dynamic QR codes contain a short redirect URL that can be updated anytime. Dynamic QR codes also provide tracking analytics, making them ideal for marketing campaigns. Static QR codes work forever without subscriptions, perfect for permanent content like WiFi passwords or fixed URLs.',
|
answer: 'Static QR codes encode data directly and cannot be edited after creation, while dynamic QR codes contain a short redirect URL that can be updated anytime. Dynamic QR codes also provide tracking analytics, making them ideal for marketing campaigns. Static QR codes work forever without subscriptions, perfect for permanent content like contact cards or fixed URLs.',
|
||||||
content: `<div class="blog-content">
|
content: `<div class="blog-content">
|
||||||
<p>Choosing between static and dynamic QR codes is one of the most important decisions when implementing a QR code strategy. According to <a href="https://en.wikipedia.org/wiki/QR_code" target="_blank" rel="noopener">Wikipedia</a>, QR codes were invented in 1994 by Masahiro Hara at Denso Wave for automotive part tracking. Today, QR codes have evolved into sophisticated marketing tools, with dynamic QR codes offering features unimaginable in their original static form.</p>
|
<p>Choosing between static and dynamic QR codes is one of the most important decisions when implementing a QR code strategy. According to <a href="https://en.wikipedia.org/wiki/QR_code" target="_blank" rel="noopener">Wikipedia</a>, QR codes were invented in 1994 by Masahiro Hara at Denso Wave for automotive part tracking. Today, QR codes have evolved into sophisticated marketing tools, with dynamic QR codes offering features unimaginable in their original static form.</p>
|
||||||
|
|
||||||
|
|
@ -879,14 +692,14 @@ app.get('/qr/:id', async (req, res) => {
|
||||||
<p>A static QR code directly encodes your data into the QR code pattern itself. When you create a static QR code for a URL, that URL is permanently embedded in the black-and-white squares. The QR code reader decodes the pattern and accesses the content directly—no intermediate server, no redirect, no tracking.</p>
|
<p>A static QR code directly encodes your data into the QR code pattern itself. When you create a static QR code for a URL, that URL is permanently embedded in the black-and-white squares. The QR code reader decodes the pattern and accesses the content directly—no intermediate server, no redirect, no tracking.</p>
|
||||||
|
|
||||||
<h3>How Static QR Codes Work</h3>
|
<h3>How Static QR Codes Work</h3>
|
||||||
<p>Think of a static QR code like printing a phone number on a business card. The phone number is the final information—there's no lookup service or translation layer. When someone scans the QR code, their device reads the encoded data and immediately processes it (opens the URL, displays the text, connects to WiFi, etc.).</p>
|
<p>Think of a static QR code like printing a phone number on a business card. The phone number is the final information—there's no lookup service or translation layer. When someone scans the QR code, their device reads the encoded data and immediately processes it (opens the URL, displays the text, opens a location in maps, etc.).</p>
|
||||||
|
|
||||||
<p><strong>Example:</strong> If you create a static QR code for <code>https://www.yourwebsite.com/summer-sale-2025</code>, that exact URL is encoded into the QR code pattern. The QR code scanner extracts this URL and opens it directly.</p>
|
<p><strong>Example:</strong> If you create a static QR code for <code>https://www.yourwebsite.com/summer-sale-2025</code>, that exact URL is encoded into the QR code pattern. The QR code scanner extracts this URL and opens it directly.</p>
|
||||||
|
|
||||||
<h3>Common Uses for Static QR Codes</h3>
|
<h3>Common Uses for Static QR Codes</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>WiFi passwords:</strong> Encode network credentials for guest access</li>
|
<li><strong>Contact cards (vCard):</strong> Share permanent contact information on business cards</li>
|
||||||
<li><strong>Business card vCards:</strong> Share permanent contact information</li>
|
<li><strong>Location links:</strong> Direct links to Google Maps locations for offices or stores</li>
|
||||||
<li><strong>App store links:</strong> Fixed URLs that never change</li>
|
<li><strong>App store links:</strong> Fixed URLs that never change</li>
|
||||||
<li><strong>Bitcoin wallet addresses:</strong> Cryptocurrency payment addresses</li>
|
<li><strong>Bitcoin wallet addresses:</strong> Cryptocurrency payment addresses</li>
|
||||||
<li><strong>Fixed website URLs:</strong> Company homepage, about page, etc.</li>
|
<li><strong>Fixed website URLs:</strong> Company homepage, about page, etc.</li>
|
||||||
|
|
@ -898,7 +711,7 @@ app.get('/qr/:id', async (req, res) => {
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>✅ Works forever:</strong> No dependency on external servers or subscriptions. Once created, it functions permanently.</li>
|
<li><strong>✅ Works forever:</strong> No dependency on external servers or subscriptions. Once created, it functions permanently.</li>
|
||||||
<li><strong>✅ Faster scanning:</strong> No redirect delay—scanner goes directly to content (typically 100-300ms faster than dynamic).</li>
|
<li><strong>✅ Faster scanning:</strong> No redirect delay—scanner goes directly to content (typically 100-300ms faster than dynamic).</li>
|
||||||
<li><strong>✅ Works offline:</strong> For content types like WiFi credentials or vCards, no internet connection needed.</li>
|
<li><strong>✅ Works offline:</strong> For content types like vCards or location data, no internet connection needed for initial scan.</li>
|
||||||
<li><strong>✅ Completely free:</strong> No ongoing costs or subscriptions required.</li>
|
<li><strong>✅ Completely free:</strong> No ongoing costs or subscriptions required.</li>
|
||||||
<li><strong>✅ Privacy-friendly:</strong> No tracking, no data collection, no third-party involvement.</li>
|
<li><strong>✅ Privacy-friendly:</strong> No tracking, no data collection, no third-party involvement.</li>
|
||||||
<li><strong>✅ Simple:</strong> What you encode is what you get—no complexity.</li>
|
<li><strong>✅ Simple:</strong> What you encode is what you get—no complexity.</li>
|
||||||
|
|
@ -1077,9 +890,9 @@ Tracking ✓ | Editable ✓ | Analytics ✓
|
||||||
|
|
||||||
<h3>When Static QR Codes Excel</h3>
|
<h3>When Static QR Codes Excel</h3>
|
||||||
|
|
||||||
<h4>Scenario 1: Coffee Shop WiFi Password</h4>
|
<h4>Scenario 1: Business Card Contact Information</h4>
|
||||||
<p><strong>Situation:</strong> Coffee shop wants customers to easily connect to WiFi.</p>
|
<p><strong>Situation:</strong> Professional wants to share their contact details easily at networking events.</p>
|
||||||
<p><strong>Why Static:</strong> WiFi credentials never change. No tracking needed. QR code works forever even if you stop paying for QR service. Privacy-friendly (no data collection).</p>
|
<p><strong>Why Static:</strong> Contact information rarely changes. No tracking needed for personal cards. QR code works forever even if you stop paying for QR service. Privacy-friendly (no data collection).</p>
|
||||||
<p><strong>Cost Savings:</strong> $0 forever vs $5-15/month for dynamic QR service = $60-180/year saved.</p>
|
<p><strong>Cost Savings:</strong> $0 forever vs $5-15/month for dynamic QR service = $60-180/year saved.</p>
|
||||||
|
|
||||||
<h4>Scenario 2: Book Back Cover</h4>
|
<h4>Scenario 2: Book Back Cover</h4>
|
||||||
|
|
@ -1111,7 +924,7 @@ Tracking ✓ | Editable ✓ | Analytics ✓
|
||||||
|
|
||||||
<h3>1. Content Never Changes</h3>
|
<h3>1. Content Never Changes</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>WiFi password that's permanent</li>
|
<li>Contact information (vCard) that remains constant</li>
|
||||||
<li>App store download link (Apple App Store / Google Play URLs are stable)</li>
|
<li>App store download link (Apple App Store / Google Play URLs are stable)</li>
|
||||||
<li>Company homepage that's been the same for years</li>
|
<li>Company homepage that's been the same for years</li>
|
||||||
<li>Historical information (museum exhibits, memorial plaques)</li>
|
<li>Historical information (museum exhibits, memorial plaques)</li>
|
||||||
|
|
@ -1135,8 +948,8 @@ Tracking ✓ | Editable ✓ | Analytics ✓
|
||||||
|
|
||||||
<h3>4. Offline Content</h3>
|
<h3>4. Offline Content</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>WiFi credentials (works without internet)</li>
|
|
||||||
<li>vCard contact information (stored locally on device)</li>
|
<li>vCard contact information (stored locally on device)</li>
|
||||||
|
<li>Location coordinates (opens maps app directly)</li>
|
||||||
<li>Plain text messages or instructions</li>
|
<li>Plain text messages or instructions</li>
|
||||||
<li>SMS or phone number links</li>
|
<li>SMS or phone number links</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,10 @@ 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 } 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';
|
||||||
|
|
||||||
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;
|
||||||
|
|
@ -84,11 +85,17 @@ const blogPosts = [
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function BlogPage() {
|
export default function BlogPage() {
|
||||||
|
const breadcrumbItems: BreadcrumbItem[] = [
|
||||||
|
{ name: 'Home', url: '/' },
|
||||||
|
{ name: 'Blog', url: '/blog' },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SeoJsonLd data={websiteSchema()} />
|
<SeoJsonLd data={[websiteSchema(), breadcrumbSchema(breadcrumbItems)]} />
|
||||||
<div className="py-20 bg-gradient-to-b from-gray-50 to-white">
|
<div className="py-20 bg-gradient-to-b from-gray-50 to-white">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
|
<Breadcrumbs items={breadcrumbItems} />
|
||||||
<div className="text-center mb-16">
|
<div className="text-center mb-16">
|
||||||
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-6">
|
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-6">
|
||||||
QR Code Insights
|
QR Code Insights
|
||||||
|
|
|
||||||
|
|
@ -3,19 +3,77 @@ 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 Breadcrumbs, { BreadcrumbItem } from '@/components/Breadcrumbs';
|
||||||
|
import { breadcrumbSchema } from '@/lib/schema';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Bulk QR Code Generator - Create 1000s of QR Codes from Excel | QR Master',
|
title: 'Bulk QR Code Generator - Create 1000s of QR Codes from Excel | QR Master',
|
||||||
description: 'Generate hundreds of QR codes at once from CSV or Excel files. Perfect for products, events, inventory management. Free bulk QR code generator with custom branding.',
|
description: 'Generate hundreds of QR codes at once from CSV or Excel files. Create URLs, vCards, locations, phone numbers, and text QR codes in bulk. Perfect for products, events, inventory management.',
|
||||||
keywords: 'bulk qr code generator, batch qr code, qr code from excel, csv qr code generator, mass qr code generation, bulk qr codes free',
|
keywords: 'bulk qr code generator, batch qr code, qr code from excel, csv qr code generator, mass qr code generation, bulk vcard qr code, bulk qr codes free',
|
||||||
|
alternates: {
|
||||||
|
canonical: 'https://www.qrmaster.com/bulk-qr-code-generator',
|
||||||
|
languages: {
|
||||||
|
'x-default': 'https://www.qrmaster.com/bulk-qr-code-generator',
|
||||||
|
en: 'https://www.qrmaster.com/bulk-qr-code-generator',
|
||||||
|
},
|
||||||
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: 'Bulk QR Code Generator - Create 1000s of QR Codes from Excel',
|
title: 'Bulk QR Code Generator - Create 1000s of QR Codes from Excel',
|
||||||
description: 'Generate hundreds of QR codes at once from CSV or Excel files. Perfect for products, events, and inventory.',
|
description: 'Generate hundreds of QR codes at once from CSV or Excel files. Perfect for products, events, and inventory.',
|
||||||
|
url: 'https://www.qrmaster.com/bulk-qr-code-generator',
|
||||||
type: 'website',
|
type: 'website',
|
||||||
},
|
},
|
||||||
|
twitter: {
|
||||||
|
title: 'Bulk QR Code Generator - Create 1000s of QR Codes from Excel',
|
||||||
|
description: 'Generate hundreds of QR codes at once from CSV or Excel files. Perfect for products, events, and inventory.',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function BulkQRCodeGeneratorPage() {
|
export default function BulkQRCodeGeneratorPage() {
|
||||||
|
const qrCodeTypes = [
|
||||||
|
{
|
||||||
|
type: 'URL',
|
||||||
|
icon: '🌐',
|
||||||
|
title: 'Website Links',
|
||||||
|
description: 'Generate QR codes for websites, landing pages, and online content',
|
||||||
|
format: 'https://example.com/product',
|
||||||
|
example: 'Product Page,URL,https://example.com/product',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'VCARD',
|
||||||
|
icon: '👤',
|
||||||
|
title: 'Contact Cards',
|
||||||
|
description: 'Create vCard QR codes with contact information',
|
||||||
|
format: 'FirstName,LastName,Email,Phone,Organization,Title',
|
||||||
|
example: 'John Doe,VCARD,John,Doe,john@example.com,+1234567890,Company Inc,CEO',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'GEO',
|
||||||
|
icon: '📍',
|
||||||
|
title: 'Locations',
|
||||||
|
description: 'Generate location QR codes with GPS coordinates',
|
||||||
|
format: 'latitude,longitude,label',
|
||||||
|
example: 'Office Location,GEO,37.7749,-122.4194,Main Office',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'PHONE',
|
||||||
|
icon: '📞',
|
||||||
|
title: 'Phone Numbers',
|
||||||
|
description: 'Create QR codes that dial phone numbers',
|
||||||
|
format: '+1234567890',
|
||||||
|
example: 'Support Hotline,PHONE,+1234567890',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'TEXT',
|
||||||
|
icon: '📝',
|
||||||
|
title: 'Plain Text',
|
||||||
|
description: 'Generate QR codes with any text content',
|
||||||
|
format: 'Your text content here',
|
||||||
|
example: 'Serial Number,TEXT,SN-12345-ABCDE',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const bulkFeatures = [
|
const bulkFeatures = [
|
||||||
{
|
{
|
||||||
icon: '📊',
|
icon: '📊',
|
||||||
|
|
@ -110,11 +168,132 @@ export default function BulkQRCodeGeneratorPage() {
|
||||||
{ column: 'tags', description: 'Comma-separated tags', required: false },
|
{ column: 'tags', description: 'Comma-separated tags', required: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const softwareSchema = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'SoftwareApplication',
|
||||||
|
'@id': 'https://www.qrmaster.com/bulk-qr-code-generator#software',
|
||||||
|
name: 'QR Master - Bulk QR Code Generator',
|
||||||
|
applicationCategory: 'BusinessApplication',
|
||||||
|
operatingSystem: 'Web Browser',
|
||||||
|
offers: {
|
||||||
|
'@type': 'Offer',
|
||||||
|
price: '0',
|
||||||
|
priceCurrency: 'USD',
|
||||||
|
availability: 'https://schema.org/InStock',
|
||||||
|
},
|
||||||
|
aggregateRating: {
|
||||||
|
'@type': 'AggregateRating',
|
||||||
|
ratingValue: '4.8',
|
||||||
|
ratingCount: '980',
|
||||||
|
},
|
||||||
|
description: 'Generate hundreds of QR codes at once from CSV or Excel files. Perfect for products, events, inventory management with custom branding.',
|
||||||
|
featureList: [
|
||||||
|
'Excel and CSV file import',
|
||||||
|
'Generate up to 1000 QR codes at once',
|
||||||
|
'Unified branding and design',
|
||||||
|
'Batch download as ZIP',
|
||||||
|
'Individual tracking per code',
|
||||||
|
'Bulk update capabilities',
|
||||||
|
'Custom filenames',
|
||||||
|
'High-resolution exports',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const howToSchema = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'HowTo',
|
||||||
|
'@id': 'https://www.qrmaster.com/bulk-qr-code-generator#howto',
|
||||||
|
name: 'How to Generate Bulk QR Codes from Excel',
|
||||||
|
description: 'Learn how to create hundreds of QR codes from an Excel or CSV file',
|
||||||
|
totalTime: 'PT10M',
|
||||||
|
step: [
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 1,
|
||||||
|
name: 'Prepare Excel File',
|
||||||
|
text: 'Create an Excel or CSV file with columns for name, URL, and any custom data you need',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 2,
|
||||||
|
name: 'Upload File',
|
||||||
|
text: 'Log into QR Master and upload your file to the bulk generator',
|
||||||
|
url: 'https://www.qrmaster.com/bulk-creation',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 3,
|
||||||
|
name: 'Map Columns',
|
||||||
|
text: 'Map your file columns to QR code fields (name, URL, description, etc.)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 4,
|
||||||
|
name: 'Customize Design',
|
||||||
|
text: 'Apply your logo, brand colors, and design settings to all QR codes at once',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 5,
|
||||||
|
name: 'Generate and Download',
|
||||||
|
text: 'Click generate and download all QR codes as a ZIP file with custom filenames',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const faqSchema = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
'@id': 'https://www.qrmaster.com/bulk-qr-code-generator#faq',
|
||||||
|
mainEntity: [
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'How many QR codes can I generate at once?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'With QR Master, you can generate up to 1000 QR codes at once from a CSV or Excel file. For larger volumes, contact our enterprise team.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'What file formats are supported?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'QR Master supports CSV (.csv), Excel (.xlsx, .xls), and other spreadsheet formats. Simply ensure your file has columns for name and destination URL.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Can I apply my branding to all QR codes?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes, you can apply your logo, brand colors, and custom design to all QR codes in your bulk generation. All codes will have consistent branding.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Are bulk generated QR codes trackable?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes, each QR code generated in bulk is individually trackable. You can see scans, locations, and analytics for every single code in your dashboard.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const breadcrumbItems: BreadcrumbItem[] = [
|
||||||
|
{ name: 'Home', url: '/' },
|
||||||
|
{ name: 'Bulk QR Code Generator', url: '/bulk-qr-code-generator' },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<SeoJsonLd data={[softwareSchema, howToSchema, faqSchema, breadcrumbSchema(breadcrumbItems)]} />
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-white">
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="relative overflow-hidden bg-gradient-to-br from-green-50 via-white to-blue-50 py-20">
|
<section className="relative overflow-hidden bg-gradient-to-br from-green-50 via-white to-blue-50 py-20">
|
||||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||||
|
<Breadcrumbs items={breadcrumbItems} />
|
||||||
<div className="grid lg:grid-cols-2 gap-12 items-center">
|
<div className="grid lg:grid-cols-2 gap-12 items-center">
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="inline-flex items-center space-x-2 bg-green-100 text-green-800 px-4 py-2 rounded-full text-sm font-semibold">
|
<div className="inline-flex items-center space-x-2 bg-green-100 text-green-800 px-4 py-2 rounded-full text-sm font-semibold">
|
||||||
|
|
@ -127,13 +306,13 @@ export default function BulkQRCodeGeneratorPage() {
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="text-xl text-gray-600 leading-relaxed">
|
<p className="text-xl text-gray-600 leading-relaxed">
|
||||||
Create hundreds or thousands of QR codes from Excel or CSV files. Perfect for products, events, inventory, and marketing campaigns. Fast, efficient, and with your branding.
|
Create hundreds or thousands of QR codes from Excel or CSV files. Generate URLs, vCards, locations, phone numbers, and text QR codes in bulk. Perfect for products, events, inventory, and marketing campaigns.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{[
|
{[
|
||||||
'Upload Excel or CSV files',
|
'Upload Excel or CSV files',
|
||||||
'Generate up to 1000 QR codes',
|
'Generate URLs, vCards, locations & more',
|
||||||
'Custom branding on all codes',
|
'Custom branding on all codes',
|
||||||
'Download as organized ZIP',
|
'Download as organized ZIP',
|
||||||
].map((feature, index) => (
|
].map((feature, index) => (
|
||||||
|
|
@ -195,6 +374,100 @@ export default function BulkQRCodeGeneratorPage() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Supported QR Code Types */}
|
||||||
|
<section className="py-20">
|
||||||
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||||
|
<div className="text-center mb-16">
|
||||||
|
<h2 className="text-4xl font-bold text-gray-900 mb-4">
|
||||||
|
Supported QR Code Types
|
||||||
|
</h2>
|
||||||
|
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||||
|
Generate multiple types of QR codes from your CSV or Excel file. Each type has its own format requirements.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{qrCodeTypes.map((qrType, index) => (
|
||||||
|
<Card key={index} className="p-6 hover:shadow-lg transition-shadow">
|
||||||
|
<div className="flex items-center space-x-3 mb-4">
|
||||||
|
<div className="text-3xl">{qrType.icon}</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">
|
||||||
|
{qrType.title}
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs font-mono text-gray-500">{qrType.type}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600 mb-4 text-sm">
|
||||||
|
{qrType.description}
|
||||||
|
</p>
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3 mb-3">
|
||||||
|
<p className="text-xs font-semibold text-gray-700 mb-1">Format:</p>
|
||||||
|
<code className="text-xs text-gray-900 break-all">{qrType.format}</code>
|
||||||
|
</div>
|
||||||
|
<div className="bg-blue-50 rounded-lg p-3">
|
||||||
|
<p className="text-xs font-semibold text-blue-700 mb-1">CSV Example:</p>
|
||||||
|
<code className="text-xs text-blue-900 break-all">{qrType.example}</code>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 bg-gradient-to-r from-blue-50 to-purple-50 rounded-lg p-8">
|
||||||
|
<h3 className="text-xl font-bold text-gray-900 mb-4 text-center">
|
||||||
|
📥 CSV/Excel File Format
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 text-center mb-6">
|
||||||
|
Your file must have at least these three columns: <code className="bg-white px-2 py-1 rounded">title</code>, <code className="bg-white px-2 py-1 rounded">contentType</code>, and <code className="bg-white px-2 py-1 rounded">content</code>
|
||||||
|
</p>
|
||||||
|
<div className="bg-white rounded-lg p-6 shadow-sm overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b-2 border-gray-300">
|
||||||
|
<th className="text-left py-2 px-3 font-semibold text-gray-700">title</th>
|
||||||
|
<th className="text-left py-2 px-3 font-semibold text-gray-700">contentType</th>
|
||||||
|
<th className="text-left py-2 px-3 font-semibold text-gray-700">content</th>
|
||||||
|
<th className="text-left py-2 px-3 font-semibold text-gray-700">tags</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="font-mono text-xs">
|
||||||
|
<tr className="border-b border-gray-200">
|
||||||
|
<td className="py-2 px-3">Product Page</td>
|
||||||
|
<td className="py-2 px-3">URL</td>
|
||||||
|
<td className="py-2 px-3">https://example.com/product</td>
|
||||||
|
<td className="py-2 px-3">product,shop</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-gray-200">
|
||||||
|
<td className="py-2 px-3">John Doe</td>
|
||||||
|
<td className="py-2 px-3">VCARD</td>
|
||||||
|
<td className="py-2 px-3">John,Doe,john@example.com,+1234567890,Company,CEO</td>
|
||||||
|
<td className="py-2 px-3">contact</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-gray-200">
|
||||||
|
<td className="py-2 px-3">Office Location</td>
|
||||||
|
<td className="py-2 px-3">GEO</td>
|
||||||
|
<td className="py-2 px-3">37.7749,-122.4194,Main Office</td>
|
||||||
|
<td className="py-2 px-3">location</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-gray-200">
|
||||||
|
<td className="py-2 px-3">Support Hotline</td>
|
||||||
|
<td className="py-2 px-3">PHONE</td>
|
||||||
|
<td className="py-2 px-3">+1234567890</td>
|
||||||
|
<td className="py-2 px-3">support</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="py-2 px-3">Serial Number</td>
|
||||||
|
<td className="py-2 px-3">TEXT</td>
|
||||||
|
<td className="py-2 px-3">SN-12345-ABCDE</td>
|
||||||
|
<td className="py-2 px-3">product,serial</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Features */}
|
{/* Features */}
|
||||||
<section className="py-20 bg-gray-50">
|
<section className="py-20 bg-gray-50">
|
||||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||||
|
|
@ -308,7 +581,7 @@ export default function BulkQRCodeGeneratorPage() {
|
||||||
<Card className="p-6 bg-blue-50 border border-blue-200">
|
<Card className="p-6 bg-blue-50 border border-blue-200">
|
||||||
<h4 className="font-semibold text-gray-900 mb-2">Example CSV:</h4>
|
<h4 className="font-semibold text-gray-900 mb-2">Example CSV:</h4>
|
||||||
<pre className="bg-white p-4 rounded border border-blue-200 overflow-x-auto text-sm font-mono">
|
<pre className="bg-white p-4 rounded border border-blue-200 overflow-x-auto text-sm font-mono">
|
||||||
{`name,url,description,tags
|
{`name,url,description,tags
|
||||||
Product A,https://example.com/product-a,Premium Widget,electronics,featured
|
Product A,https://example.com/product-a,Premium Widget,electronics,featured
|
||||||
Product B,https://example.com/product-b,Standard Widget,electronics
|
Product B,https://example.com/product-b,Standard Widget,electronics
|
||||||
Product C,https://example.com/product-c,Budget Widget,electronics,sale`}
|
Product C,https://example.com/product-c,Budget Widget,electronics,sale`}
|
||||||
|
|
@ -383,5 +656,6 @@ Product C,https://example.com/product-c,Budget Widget,electronics,sale`}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,31 @@ 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 Breadcrumbs, { BreadcrumbItem } from '@/components/Breadcrumbs';
|
||||||
|
import { breadcrumbSchema } from '@/lib/schema';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Dynamic QR Code Generator - Edit QR Codes Anytime | QR Master',
|
title: 'Dynamic QR Code Generator - Edit QR Codes Anytime | QR Master',
|
||||||
description: 'Create dynamic QR codes that can be edited after printing. Change destination URL, track scans, and update content without reprinting. Free dynamic QR code generator.',
|
description: 'Create dynamic QR codes that can be edited after printing. Change destination URL, track scans, and update content without reprinting. Free dynamic QR code generator.',
|
||||||
keywords: 'dynamic qr code generator, editable qr code, dynamic qr code, free dynamic qr code, qr code generator dynamic, best dynamic qr code generator',
|
keywords: 'dynamic qr code generator, editable qr code, dynamic qr code, free dynamic qr code, qr code generator dynamic, best dynamic qr code generator',
|
||||||
|
alternates: {
|
||||||
|
canonical: 'https://www.qrmaster.com/dynamic-qr-code-generator',
|
||||||
|
languages: {
|
||||||
|
'x-default': 'https://www.qrmaster.com/dynamic-qr-code-generator',
|
||||||
|
en: 'https://www.qrmaster.com/dynamic-qr-code-generator',
|
||||||
|
},
|
||||||
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: 'Dynamic QR Code Generator - Edit QR Codes Anytime | QR Master',
|
title: 'Dynamic QR Code Generator - Edit QR Codes Anytime | QR Master',
|
||||||
description: 'Create dynamic QR codes that can be edited after printing. Change URLs, track scans, and update content anytime.',
|
description: 'Create dynamic QR codes that can be edited after printing. Change URLs, track scans, and update content anytime.',
|
||||||
|
url: 'https://www.qrmaster.com/dynamic-qr-code-generator',
|
||||||
type: 'website',
|
type: 'website',
|
||||||
},
|
},
|
||||||
|
twitter: {
|
||||||
|
title: 'Dynamic QR Code Generator - Edit QR Codes Anytime | QR Master',
|
||||||
|
description: 'Create dynamic QR codes that can be edited after printing. Change URLs, track scans, and update content anytime.',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DynamicQRCodeGeneratorPage() {
|
export default function DynamicQRCodeGeneratorPage() {
|
||||||
|
|
@ -114,11 +129,120 @@ export default function DynamicQRCodeGeneratorPage() {
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const softwareSchema = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'SoftwareApplication',
|
||||||
|
'@id': 'https://www.qrmaster.com/dynamic-qr-code-generator#software',
|
||||||
|
name: 'QR Master - Dynamic QR Code Generator',
|
||||||
|
applicationCategory: 'BusinessApplication',
|
||||||
|
operatingSystem: 'Web Browser',
|
||||||
|
offers: {
|
||||||
|
'@type': 'Offer',
|
||||||
|
price: '0',
|
||||||
|
priceCurrency: 'USD',
|
||||||
|
availability: 'https://schema.org/InStock',
|
||||||
|
},
|
||||||
|
aggregateRating: {
|
||||||
|
'@type': 'AggregateRating',
|
||||||
|
ratingValue: '4.9',
|
||||||
|
ratingCount: '2150',
|
||||||
|
},
|
||||||
|
description: 'Create dynamic QR codes that can be edited after printing. Change destination URLs, track scans, and update content without reprinting.',
|
||||||
|
featureList: [
|
||||||
|
'Edit QR codes after printing',
|
||||||
|
'Real-time scan tracking',
|
||||||
|
'A/B testing capabilities',
|
||||||
|
'Custom branding and design',
|
||||||
|
'Geo-targeting options',
|
||||||
|
'Scheduled content updates',
|
||||||
|
'Password protection',
|
||||||
|
'Expiration dates',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const howToSchema = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'HowTo',
|
||||||
|
'@id': 'https://www.qrmaster.com/dynamic-qr-code-generator#howto',
|
||||||
|
name: 'How to Create a Dynamic QR Code',
|
||||||
|
description: 'Learn how to create editable QR codes that can be updated after printing',
|
||||||
|
totalTime: 'PT3M',
|
||||||
|
step: [
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 1,
|
||||||
|
name: 'Sign Up Free',
|
||||||
|
text: 'Create a free QR Master account to start generating dynamic QR codes',
|
||||||
|
url: 'https://www.qrmaster.com/signup',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 2,
|
||||||
|
name: 'Generate QR Code',
|
||||||
|
text: 'Enter your destination URL and customize the design with your branding',
|
||||||
|
url: 'https://www.qrmaster.com/create',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 3,
|
||||||
|
name: 'Download and Print',
|
||||||
|
text: 'Download your QR code in high resolution and add it to your marketing materials',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 4,
|
||||||
|
name: 'Update Anytime',
|
||||||
|
text: 'Log into your dashboard to change the destination URL whenever needed - no reprinting required',
|
||||||
|
url: 'https://www.qrmaster.com/dashboard',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const faqSchema = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
'@id': 'https://www.qrmaster.com/dynamic-qr-code-generator#faq',
|
||||||
|
mainEntity: [
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'What is a dynamic QR code?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'A dynamic QR code is an editable QR code that redirects through a short URL, allowing you to change the destination without reprinting the code. Unlike static QR codes, dynamic codes can be tracked and updated anytime.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Can I edit a QR code after printing?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes, with dynamic QR codes you can edit the destination URL anytime after printing. The QR code image stays the same, but the content it points to can be changed from your dashboard.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Is dynamic QR code generator free?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Yes, QR Master offers a free plan for creating dynamic QR codes with basic tracking features. Premium plans include advanced analytics and customization options.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const breadcrumbItems: BreadcrumbItem[] = [
|
||||||
|
{ name: 'Home', url: '/' },
|
||||||
|
{ name: 'Dynamic QR Code Generator', url: '/dynamic-qr-code-generator' },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<SeoJsonLd data={[softwareSchema, howToSchema, faqSchema, breadcrumbSchema(breadcrumbItems)]} />
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-white">
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="relative overflow-hidden bg-gradient-to-br from-purple-50 via-white to-blue-50 py-20">
|
<section className="relative overflow-hidden bg-gradient-to-br from-purple-50 via-white to-blue-50 py-20">
|
||||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||||
|
<Breadcrumbs items={breadcrumbItems} />
|
||||||
<div className="grid lg:grid-cols-2 gap-12 items-center">
|
<div className="grid lg:grid-cols-2 gap-12 items-center">
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="inline-flex items-center space-x-2 bg-purple-100 text-purple-800 px-4 py-2 rounded-full text-sm font-semibold">
|
<div className="inline-flex items-center space-x-2 bg-purple-100 text-purple-800 px-4 py-2 rounded-full text-sm font-semibold">
|
||||||
|
|
@ -389,5 +513,6 @@ export default function DynamicQRCodeGeneratorPage() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import React, { useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import CookieBanner from '@/components/CookieBanner';
|
|
||||||
import en from '@/i18n/en.json';
|
import en from '@/i18n/en.json';
|
||||||
|
|
||||||
export default function MarketingLayout({
|
export default function MarketingLayout({
|
||||||
|
|
@ -33,7 +32,7 @@ export default function MarketingLayout({
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<Link href="/" className="flex items-center space-x-2">
|
<Link href="/" className="flex items-center space-x-2">
|
||||||
<img src="/logo.svg" alt="QR Master" className="w-8 h-8" />
|
<img src="/favicon.svg" alt="QR Master" className="w-8 h-8" />
|
||||||
<span className="text-xl font-bold text-gray-900">QR Master</span>
|
<span className="text-xl font-bold text-gray-900">QR Master</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
|
@ -111,7 +110,7 @@ export default function MarketingLayout({
|
||||||
<div className="grid md:grid-cols-4 gap-8">
|
<div className="grid md:grid-cols-4 gap-8">
|
||||||
<div>
|
<div>
|
||||||
<Link href="/" className="flex items-center space-x-2 mb-4 hover:opacity-80 transition-opacity">
|
<Link href="/" className="flex items-center space-x-2 mb-4 hover:opacity-80 transition-opacity">
|
||||||
<img src="/favicon.svg" alt="QR Master" className="w-10 h-10" />
|
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
|
||||||
<span className="text-xl font-bold">QR Master</span>
|
<span className="text-xl font-bold">QR Master</span>
|
||||||
</Link>
|
</Link>
|
||||||
<p className="text-gray-400">
|
<p className="text-gray-400">
|
||||||
|
|
@ -138,14 +137,6 @@ export default function MarketingLayout({
|
||||||
<li><Link href="/signup" className="hover:text-white">Get Started</Link></li>
|
<li><Link href="/signup" className="hover:text-white">Get Started</Link></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold mb-4">Legal</h3>
|
|
||||||
<ul className="space-y-2 text-gray-400">
|
|
||||||
<li><Link href="/privacy" className="hover:text-white">Privacy Policy</Link></li>
|
|
||||||
<li><Link href="/terms" className="hover:text-white">Terms of Service</Link></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-gray-800 mt-8 pt-8 text-center text-gray-400">
|
<div className="border-t border-gray-800 mt-8 pt-8 text-center text-gray-400">
|
||||||
|
|
@ -153,9 +144,6 @@ export default function MarketingLayout({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
{/* Cookie Banner */}
|
|
||||||
<CookieBanner />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -3,16 +3,31 @@ 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 Breadcrumbs, { BreadcrumbItem } from '@/components/Breadcrumbs';
|
||||||
|
import { breadcrumbSchema } from '@/lib/schema';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'QR Code Tracking & Analytics - Track Every Scan | QR Master',
|
title: 'QR Code Tracking & Analytics - Track Every Scan | QR Master',
|
||||||
description: 'Track QR code scans with real-time analytics. Monitor location, device, time, and user behavior. Free QR code tracking software with detailed reports.',
|
description: 'Track QR code scans with real-time analytics. Monitor location, device, time, and user behavior. Free QR code tracking software with detailed reports.',
|
||||||
keywords: 'qr code tracking, qr code analytics, track qr scans, qr code statistics, free qr tracking, qr code monitoring',
|
keywords: 'qr code tracking, qr code analytics, track qr scans, qr code statistics, free qr tracking, qr code monitoring',
|
||||||
|
alternates: {
|
||||||
|
canonical: 'https://www.qrmaster.com/qr-code-tracking',
|
||||||
|
languages: {
|
||||||
|
'x-default': 'https://www.qrmaster.com/qr-code-tracking',
|
||||||
|
en: 'https://www.qrmaster.com/qr-code-tracking',
|
||||||
|
},
|
||||||
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: 'QR Code Tracking & Analytics - Track Every Scan | QR Master',
|
title: 'QR Code Tracking & Analytics - Track Every Scan | QR Master',
|
||||||
description: 'Track QR code scans with real-time analytics. Monitor location, device, time, and user behavior.',
|
description: 'Track QR code scans with real-time analytics. Monitor location, device, time, and user behavior.',
|
||||||
|
url: 'https://www.qrmaster.com/qr-code-tracking',
|
||||||
type: 'website',
|
type: 'website',
|
||||||
},
|
},
|
||||||
|
twitter: {
|
||||||
|
title: 'QR Code Tracking & Analytics - Track Every Scan | QR Master',
|
||||||
|
description: 'Track QR code scans with real-time analytics. Monitor location, device, time, and user behavior.',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function QRCodeTrackingPage() {
|
export default function QRCodeTrackingPage() {
|
||||||
|
|
@ -82,11 +97,87 @@ export default function QRCodeTrackingPage() {
|
||||||
{ feature: 'API Access', free: false, qrMaster: true },
|
{ feature: 'API Access', free: false, qrMaster: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const softwareSchema = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'SoftwareApplication',
|
||||||
|
'@id': 'https://www.qrmaster.com/qr-code-tracking#software',
|
||||||
|
name: 'QR Master - QR Code Tracking & Analytics',
|
||||||
|
applicationCategory: 'BusinessApplication',
|
||||||
|
operatingSystem: 'Web Browser, iOS, Android',
|
||||||
|
offers: {
|
||||||
|
'@type': 'Offer',
|
||||||
|
price: '0',
|
||||||
|
priceCurrency: 'USD',
|
||||||
|
availability: 'https://schema.org/InStock',
|
||||||
|
},
|
||||||
|
aggregateRating: {
|
||||||
|
'@type': 'AggregateRating',
|
||||||
|
ratingValue: '4.8',
|
||||||
|
ratingCount: '1250',
|
||||||
|
},
|
||||||
|
description: 'Track QR code scans with real-time analytics. Monitor location, device, time, and user behavior with our free QR code tracking software.',
|
||||||
|
features: [
|
||||||
|
'Real-time analytics dashboard',
|
||||||
|
'Location tracking by country and city',
|
||||||
|
'Device detection (iOS, Android, Desktop)',
|
||||||
|
'Time-based scan reports',
|
||||||
|
'Unique vs total scan tracking',
|
||||||
|
'Campaign performance metrics',
|
||||||
|
'Unlimited scans',
|
||||||
|
'Export detailed reports',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const howToSchema = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'HowTo',
|
||||||
|
'@id': 'https://www.qrmaster.com/qr-code-tracking#howto',
|
||||||
|
name: 'How to Track QR Code Scans',
|
||||||
|
description: 'Learn how to track and analyze QR code scans with real-time analytics',
|
||||||
|
totalTime: 'PT5M',
|
||||||
|
step: [
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 1,
|
||||||
|
name: 'Create QR Code',
|
||||||
|
text: 'Sign up for free and create a dynamic QR code with tracking enabled',
|
||||||
|
url: 'https://www.qrmaster.com/signup',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 2,
|
||||||
|
name: 'Deploy QR Code',
|
||||||
|
text: 'Download and place your QR code on marketing materials, products, or digital platforms',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 3,
|
||||||
|
name: 'Monitor Analytics',
|
||||||
|
text: 'View real-time scan data including location, device, and time patterns in your dashboard',
|
||||||
|
url: 'https://www.qrmaster.com/analytics',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'HowToStep',
|
||||||
|
position: 4,
|
||||||
|
name: 'Optimize Campaigns',
|
||||||
|
text: 'Use insights to optimize placement, timing, and targeting of your QR code campaigns',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const breadcrumbItems: BreadcrumbItem[] = [
|
||||||
|
{ name: 'Home', url: '/' },
|
||||||
|
{ name: 'QR Code Tracking', url: '/qr-code-tracking' },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<SeoJsonLd data={[softwareSchema, howToSchema, breadcrumbSchema(breadcrumbItems)]} />
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-white">
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="relative overflow-hidden bg-gradient-to-br from-blue-50 via-white to-purple-50 py-20">
|
<section className="relative overflow-hidden bg-gradient-to-br from-blue-50 via-white to-purple-50 py-20">
|
||||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||||
|
<Breadcrumbs items={breadcrumbItems} />
|
||||||
<div className="grid lg:grid-cols-2 gap-12 items-center">
|
<div className="grid lg:grid-cols-2 gap-12 items-center">
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="inline-flex items-center space-x-2 bg-blue-100 text-blue-800 px-4 py-2 rounded-full text-sm font-semibold">
|
<div className="inline-flex items-center space-x-2 bg-blue-100 text-blue-800 px-4 py-2 rounded-full text-sm font-semibold">
|
||||||
|
|
@ -302,5 +393,6 @@ export default function QRCodeTrackingPage() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
@ -71,16 +74,82 @@ export async function GET(request: NextRequest) {
|
||||||
|
|
||||||
const userInfo = await userInfoResponse.json();
|
const userInfo = await userInfoResponse.json();
|
||||||
|
|
||||||
// Here you would:
|
// Check if user exists in database
|
||||||
// 1. Check if user exists in database
|
let user = await db.user.findUnique({
|
||||||
// 2. Create user if they don't exist
|
where: { email: userInfo.email },
|
||||||
// 3. Create session cookie
|
});
|
||||||
// 4. Redirect to dashboard
|
|
||||||
|
|
||||||
// For now, just redirect to login with error message
|
// Create user if they don't exist
|
||||||
return NextResponse.redirect(
|
if (!user) {
|
||||||
`${process.env.NEXT_PUBLIC_APP_URL}/login?error=google-not-fully-configured`
|
user = await db.user.create({
|
||||||
);
|
data: {
|
||||||
|
email: userInfo.email,
|
||||||
|
name: userInfo.name || userInfo.email.split('@')[0],
|
||||||
|
image: userInfo.picture,
|
||||||
|
emailVerified: new Date(), // Google already verified the email
|
||||||
|
password: null, // OAuth users don't need a password
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create Account entry for the OAuth provider
|
||||||
|
await db.account.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
type: 'oauth',
|
||||||
|
provider: 'google',
|
||||||
|
providerAccountId: userInfo.sub || userInfo.id,
|
||||||
|
access_token: tokens.access_token,
|
||||||
|
refresh_token: tokens.refresh_token,
|
||||||
|
expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null,
|
||||||
|
token_type: tokens.token_type,
|
||||||
|
scope: tokens.scope,
|
||||||
|
id_token: tokens.id_token,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Update existing account tokens
|
||||||
|
const existingAccount = await db.account.findUnique({
|
||||||
|
where: {
|
||||||
|
provider_providerAccountId: {
|
||||||
|
provider: 'google',
|
||||||
|
providerAccountId: userInfo.sub || userInfo.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingAccount) {
|
||||||
|
await db.account.update({
|
||||||
|
where: { id: existingAccount.id },
|
||||||
|
data: {
|
||||||
|
access_token: tokens.access_token,
|
||||||
|
refresh_token: tokens.refresh_token,
|
||||||
|
expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Create Account entry if it doesn't exist
|
||||||
|
await db.account.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
type: 'oauth',
|
||||||
|
provider: 'google',
|
||||||
|
providerAccountId: userInfo.sub || userInfo.id,
|
||||||
|
access_token: tokens.access_token,
|
||||||
|
refresh_token: tokens.refresh_token,
|
||||||
|
expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null,
|
||||||
|
token_type: tokens.token_type,
|
||||||
|
scope: tokens.scope,
|
||||||
|
id_token: tokens.id_token,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set authentication cookie
|
||||||
|
cookies().set('userId', user.id, getAuthCookieOptions());
|
||||||
|
|
||||||
|
// Redirect to dashboard
|
||||||
|
return NextResponse.redirect(`${process.env.NEXT_PUBLIC_APP_URL}/dashboard`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Google OAuth error:', error);
|
console.error('Google OAuth error:', error);
|
||||||
return NextResponse.redirect(
|
return NextResponse.redirect(
|
||||||
|
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { getServerSession } from 'next-auth';
|
|
||||||
import { authOptions } from '@/lib/auth';
|
|
||||||
import { db } from '@/lib/db';
|
|
||||||
import { generateSlug } from '@/lib/hash';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
|
||||||
|
|
||||||
const bulkCreateSchema = z.object({
|
|
||||||
qrCodes: z.array(z.object({
|
|
||||||
title: z.string(),
|
|
||||||
contentType: z.string(),
|
|
||||||
content: z.string(),
|
|
||||||
tags: z.string().optional(),
|
|
||||||
type: z.enum(['STATIC', 'DYNAMIC']).optional(),
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rate Limiting (user-based)
|
|
||||||
const clientId = session.user.id || getClientIdentifier(request);
|
|
||||||
const rateLimitResult = rateLimit(clientId, RateLimits.BULK_CREATE);
|
|
||||||
|
|
||||||
if (!rateLimitResult.success) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: 'Too many requests. Please try again later.',
|
|
||||||
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 429,
|
|
||||||
headers: {
|
|
||||||
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
|
||||||
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
|
||||||
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const { qrCodes } = bulkCreateSchema.parse(body);
|
|
||||||
|
|
||||||
// Limit bulk creation to 1000 items
|
|
||||||
if (qrCodes.length > 1000) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Maximum 1000 QR codes per bulk upload' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transform and create QR codes
|
|
||||||
const createData = qrCodes.map(qr => {
|
|
||||||
// Parse content based on type
|
|
||||||
let content: any = { url: qr.content };
|
|
||||||
|
|
||||||
if (qr.contentType === 'URL') {
|
|
||||||
content = { url: qr.content };
|
|
||||||
} else if (qr.contentType === 'PHONE') {
|
|
||||||
content = { phone: qr.content };
|
|
||||||
} else if (qr.contentType === 'EMAIL') {
|
|
||||||
const [email, subject] = qr.content.split('?subject=');
|
|
||||||
content = { email, subject };
|
|
||||||
} else if (qr.contentType === 'TEXT') {
|
|
||||||
content = { text: qr.content };
|
|
||||||
} else if (qr.contentType === 'WIFI') {
|
|
||||||
// Parse format: "NetworkName:password"
|
|
||||||
const [ssid, password] = qr.content.split(':');
|
|
||||||
content = { ssid, password, security: 'WPA' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
userId: session.user.id!,
|
|
||||||
title: qr.title,
|
|
||||||
type: qr.type || 'DYNAMIC',
|
|
||||||
contentType: qr.contentType as any,
|
|
||||||
content,
|
|
||||||
tags: qr.tags ? qr.tags.split(',').map(t => t.trim()) : [],
|
|
||||||
slug: generateSlug(qr.title),
|
|
||||||
status: 'ACTIVE' as const,
|
|
||||||
style: {
|
|
||||||
foregroundColor: '#000000',
|
|
||||||
backgroundColor: '#FFFFFF',
|
|
||||||
cornerStyle: 'square',
|
|
||||||
size: 200,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Batch create
|
|
||||||
const created = await db.qRCode.createMany({
|
|
||||||
data: createData,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
count: created.count,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof z.ZodError) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Invalid input', details: error.errors },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error('Bulk upload error:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Internal server error' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -151,18 +151,29 @@ export async function POST(request: NextRequest) {
|
||||||
case 'PHONE':
|
case 'PHONE':
|
||||||
qrContent = `tel:${body.content.phone}`;
|
qrContent = `tel:${body.content.phone}`;
|
||||||
break;
|
break;
|
||||||
case 'EMAIL':
|
|
||||||
qrContent = `mailto:${body.content.email}${body.content.subject ? `?subject=${encodeURIComponent(body.content.subject)}` : ''}`;
|
|
||||||
break;
|
|
||||||
case 'SMS':
|
case 'SMS':
|
||||||
qrContent = `sms:${body.content.phone}${body.content.message ? `?body=${encodeURIComponent(body.content.message)}` : ''}`;
|
qrContent = `sms:${body.content.phone}${body.content.message ? `?body=${encodeURIComponent(body.content.message)}` : ''}`;
|
||||||
break;
|
break;
|
||||||
|
case 'VCARD':
|
||||||
|
qrContent = `BEGIN:VCARD
|
||||||
|
VERSION:3.0
|
||||||
|
FN:${body.content.firstName || ''} ${body.content.lastName || ''}
|
||||||
|
N:${body.content.lastName || ''};${body.content.firstName || ''};;;
|
||||||
|
${body.content.organization ? `ORG:${body.content.organization}` : ''}
|
||||||
|
${body.content.title ? `TITLE:${body.content.title}` : ''}
|
||||||
|
${body.content.email ? `EMAIL:${body.content.email}` : ''}
|
||||||
|
${body.content.phone ? `TEL:${body.content.phone}` : ''}
|
||||||
|
END:VCARD`;
|
||||||
|
break;
|
||||||
|
case 'GEO':
|
||||||
|
const lat = body.content.latitude || 0;
|
||||||
|
const lon = body.content.longitude || 0;
|
||||||
|
const label = body.content.label ? `?q=${encodeURIComponent(body.content.label)}` : '';
|
||||||
|
qrContent = `geo:${lat},${lon}${label}`;
|
||||||
|
break;
|
||||||
case 'TEXT':
|
case 'TEXT':
|
||||||
qrContent = body.content.text;
|
qrContent = body.content.text;
|
||||||
break;
|
break;
|
||||||
case 'WIFI':
|
|
||||||
qrContent = `WIFI:T:${body.content.security || 'WPA'};S:${body.content.ssid};P:${body.content.password || ''};H:false;;`;
|
|
||||||
break;
|
|
||||||
case 'WHATSAPP':
|
case 'WHATSAPP':
|
||||||
qrContent = `https://wa.me/${body.content.phone}${body.content.message ? `?text=${encodeURIComponent(body.content.message)}` : ''}`;
|
qrContent = `https://wa.me/${body.content.phone}${body.content.message ? `?text=${encodeURIComponent(body.content.message)}` : ''}`;
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -23,18 +23,29 @@ export async function POST(request: NextRequest) {
|
||||||
case 'PHONE':
|
case 'PHONE':
|
||||||
qrContent = `tel:${content.phone}`;
|
qrContent = `tel:${content.phone}`;
|
||||||
break;
|
break;
|
||||||
case 'EMAIL':
|
|
||||||
qrContent = `mailto:${content.email}${content.subject ? `?subject=${encodeURIComponent(content.subject)}` : ''}`;
|
|
||||||
break;
|
|
||||||
case 'SMS':
|
case 'SMS':
|
||||||
qrContent = `sms:${content.phone}${content.message ? `?body=${encodeURIComponent(content.message)}` : ''}`;
|
qrContent = `sms:${content.phone}${content.message ? `?body=${encodeURIComponent(content.message)}` : ''}`;
|
||||||
break;
|
break;
|
||||||
|
case 'VCARD':
|
||||||
|
qrContent = `BEGIN:VCARD
|
||||||
|
VERSION:3.0
|
||||||
|
FN:${content.firstName || ''} ${content.lastName || ''}
|
||||||
|
N:${content.lastName || ''};${content.firstName || ''};;;
|
||||||
|
${content.organization ? `ORG:${content.organization}` : ''}
|
||||||
|
${content.title ? `TITLE:${content.title}` : ''}
|
||||||
|
${content.email ? `EMAIL:${content.email}` : ''}
|
||||||
|
${content.phone ? `TEL:${content.phone}` : ''}
|
||||||
|
END:VCARD`;
|
||||||
|
break;
|
||||||
|
case 'GEO':
|
||||||
|
const lat = content.latitude || 0;
|
||||||
|
const lon = content.longitude || 0;
|
||||||
|
const label = content.label ? `?q=${encodeURIComponent(content.label)}` : '';
|
||||||
|
qrContent = `geo:${lat},${lon}${label}`;
|
||||||
|
break;
|
||||||
case 'TEXT':
|
case 'TEXT':
|
||||||
qrContent = content.text;
|
qrContent = content.text;
|
||||||
break;
|
break;
|
||||||
case 'WIFI':
|
|
||||||
qrContent = `WIFI:T:${content.security || 'WPA'};S:${content.ssid};P:${content.password || ''};H:false;;`;
|
|
||||||
break;
|
|
||||||
case 'WHATSAPP':
|
case 'WHATSAPP':
|
||||||
qrContent = `https://wa.me/${content.phone}${content.message ? `?text=${encodeURIComponent(content.message)}` : ''}`;
|
qrContent = `https://wa.me/${content.phone}${content.message ? `?text=${encodeURIComponent(content.message)}` : ''}`;
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,13 @@
|
||||||
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';
|
||||||
import { headers } from 'next/headers';
|
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { slug: string } }
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { slug } = 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({
|
||||||
|
|
@ -43,22 +42,27 @@ export async function GET(
|
||||||
case 'PHONE':
|
case 'PHONE':
|
||||||
destination = `tel:${content.phone}`;
|
destination = `tel:${content.phone}`;
|
||||||
break;
|
break;
|
||||||
case 'EMAIL':
|
|
||||||
destination = `mailto:${content.email}${content.subject ? `?subject=${encodeURIComponent(content.subject)}` : ''}`;
|
|
||||||
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':
|
||||||
|
// For vCard, redirect to display page
|
||||||
|
const baseUrlVcard = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050';
|
||||||
|
destination = `${baseUrlVcard}/vcard?firstName=${encodeURIComponent(content.firstName || '')}&lastName=${encodeURIComponent(content.lastName || '')}&email=${encodeURIComponent(content.email || '')}&phone=${encodeURIComponent(content.phone || '')}&organization=${encodeURIComponent(content.organization || '')}&title=${encodeURIComponent(content.title || '')}`;
|
||||||
|
break;
|
||||||
|
case 'GEO':
|
||||||
|
// For location, redirect to Google Maps (works on desktop and mobile)
|
||||||
|
const lat = content.latitude || 0;
|
||||||
|
const lon = content.longitude || 0;
|
||||||
|
destination = `https://maps.google.com/?q=${lat},${lon}`;
|
||||||
|
break;
|
||||||
case 'TEXT':
|
case 'TEXT':
|
||||||
// For plain text, redirect to a display page
|
// For plain text, redirect to a display page
|
||||||
destination = `/display?text=${encodeURIComponent(content.text || '')}`;
|
const baseUrlText = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050';
|
||||||
break;
|
destination = `${baseUrlText}/display?text=${encodeURIComponent(content.text || '')}`;
|
||||||
case 'WIFI':
|
|
||||||
// For WiFi, show a connection page
|
|
||||||
destination = `/wifi?ssid=${encodeURIComponent(content.ssid || '')}&security=${content.security || 'WPA'}`;
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
destination = 'https://example.com';
|
destination = 'https://example.com';
|
||||||
|
|
@ -92,15 +96,14 @@ export async function GET(
|
||||||
|
|
||||||
async function trackScan(qrId: string, request: NextRequest) {
|
async function trackScan(qrId: string, request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const headersList = headers();
|
const userAgent = request.headers.get('user-agent') || '';
|
||||||
const userAgent = headersList.get('user-agent') || '';
|
const referer = request.headers.get('referer') || '';
|
||||||
const referer = headersList.get('referer') || '';
|
const ip = request.headers.get('x-forwarded-for') ||
|
||||||
const ip = headersList.get('x-forwarded-for') ||
|
request.headers.get('x-real-ip') ||
|
||||||
headersList.get('x-real-ip') ||
|
|
||||||
'unknown';
|
'unknown';
|
||||||
|
|
||||||
// Check DNT header
|
// Check DNT header
|
||||||
const dnt = headersList.get('dnt');
|
const dnt = request.headers.get('dnt');
|
||||||
if (dnt === '1') {
|
if (dnt === '1') {
|
||||||
// Respect Do Not Track - only increment counter
|
// Respect Do Not Track - only increment counter
|
||||||
await db.qRScan.create({
|
await db.qRScan.create({
|
||||||
|
|
@ -130,8 +133,8 @@ async function trackScan(qrId: string, request: NextRequest) {
|
||||||
else if (/ios|iphone|ipad/i.test(userAgent)) os = 'iOS';
|
else if (/ios|iphone|ipad/i.test(userAgent)) os = 'iOS';
|
||||||
|
|
||||||
// Get country from header (Vercel/Cloudflare provide this)
|
// Get country from header (Vercel/Cloudflare provide this)
|
||||||
const country = headersList.get('x-vercel-ip-country') ||
|
const country = request.headers.get('x-vercel-ip-country') ||
|
||||||
headersList.get('cf-ipcountry') ||
|
request.headers.get('cf-ipcountry') ||
|
||||||
'unknown';
|
'unknown';
|
||||||
|
|
||||||
// Extract UTM parameters
|
// Extract UTM parameters
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,274 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function VCardPage() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [firstName, setFirstName] = useState('');
|
||||||
|
const [lastName, setLastName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [organization, setOrganization] = useState('');
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [hasAutoDownloaded, setHasAutoDownloaded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const firstNameParam = searchParams.get('firstName');
|
||||||
|
const lastNameParam = searchParams.get('lastName');
|
||||||
|
const emailParam = searchParams.get('email');
|
||||||
|
const phoneParam = searchParams.get('phone');
|
||||||
|
const organizationParam = searchParams.get('organization');
|
||||||
|
const titleParam = searchParams.get('title');
|
||||||
|
|
||||||
|
if (firstNameParam) setFirstName(firstNameParam);
|
||||||
|
if (lastNameParam) setLastName(lastNameParam);
|
||||||
|
if (emailParam) setEmail(emailParam);
|
||||||
|
if (phoneParam) setPhone(phoneParam);
|
||||||
|
if (organizationParam) setOrganization(organizationParam);
|
||||||
|
if (titleParam) setTitle(titleParam);
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
// Auto-download after 500ms (only once)
|
||||||
|
useEffect(() => {
|
||||||
|
if ((firstName || lastName) && !hasAutoDownloaded) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
handleSaveContact();
|
||||||
|
setHasAutoDownloaded(true);
|
||||||
|
}, 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [firstName, lastName, hasAutoDownloaded]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const handleSaveContact = () => {
|
||||||
|
// Generate vCard format
|
||||||
|
const vCard = `BEGIN:VCARD
|
||||||
|
VERSION:3.0
|
||||||
|
FN:${firstName} ${lastName}
|
||||||
|
N:${lastName};${firstName};;;
|
||||||
|
${organization ? `ORG:${organization}` : ''}
|
||||||
|
${title ? `TITLE:${title}` : ''}
|
||||||
|
${email ? `EMAIL:${email}` : ''}
|
||||||
|
${phone ? `TEL:${phone}` : ''}
|
||||||
|
END:VCARD`;
|
||||||
|
|
||||||
|
// Create a blob and download
|
||||||
|
const blob = new Blob([vCard], { type: 'text/vcard;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `${firstName}_${lastName}.vcf`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show loading or error state
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||||
|
backgroundColor: '#ffffff'
|
||||||
|
}}>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '48px', marginBottom: '16px' }}>👤</div>
|
||||||
|
<p style={{ color: '#666' }}>Loading contact...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!firstName && !lastName) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||||
|
padding: '20px',
|
||||||
|
backgroundColor: '#ffffff'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
maxWidth: '420px',
|
||||||
|
width: '100%',
|
||||||
|
padding: '48px 32px',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '64px',
|
||||||
|
marginBottom: '24px',
|
||||||
|
opacity: 0.3
|
||||||
|
}}>👤</div>
|
||||||
|
<h1 style={{
|
||||||
|
fontSize: '22px',
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: '12px',
|
||||||
|
color: '#1a1a1a'
|
||||||
|
}}>No Contact Found</h1>
|
||||||
|
<p style={{
|
||||||
|
color: '#666',
|
||||||
|
fontSize: '15px',
|
||||||
|
lineHeight: '1.6'
|
||||||
|
}}>This QR code doesn't contain any contact information.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||||
|
padding: '20px',
|
||||||
|
backgroundColor: '#ffffff'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
maxWidth: '420px',
|
||||||
|
width: '100%',
|
||||||
|
padding: '48px 32px'
|
||||||
|
}}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: '32px' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '80px',
|
||||||
|
height: '80px',
|
||||||
|
margin: '0 auto 20px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: '#f0f0f0',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '36px'
|
||||||
|
}}>👤</div>
|
||||||
|
|
||||||
|
<h1 style={{
|
||||||
|
fontSize: '28px',
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: '8px',
|
||||||
|
color: '#1a1a1a',
|
||||||
|
letterSpacing: '-0.02em'
|
||||||
|
}}>
|
||||||
|
{firstName} {lastName}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{(title || organization) && (
|
||||||
|
<p style={{
|
||||||
|
color: '#666',
|
||||||
|
fontSize: '16px',
|
||||||
|
marginTop: '4px'
|
||||||
|
}}>
|
||||||
|
{title && organization && `${title} at ${organization}`}
|
||||||
|
{title && !organization && title}
|
||||||
|
{!title && organization && organization}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact Details */}
|
||||||
|
<div style={{ marginBottom: '32px' }}>
|
||||||
|
{email && (
|
||||||
|
<div style={{
|
||||||
|
padding: '16px 20px',
|
||||||
|
backgroundColor: '#f8f8f8',
|
||||||
|
borderRadius: '12px',
|
||||||
|
marginBottom: '12px',
|
||||||
|
border: '1px solid #f0f0f0'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#888',
|
||||||
|
marginBottom: '6px',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}>Email</div>
|
||||||
|
<a href={`mailto:${email}`} style={{
|
||||||
|
fontSize: '15px',
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#2563eb',
|
||||||
|
textDecoration: 'none',
|
||||||
|
wordBreak: 'break-all'
|
||||||
|
}}>{email}</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phone && (
|
||||||
|
<div style={{
|
||||||
|
padding: '16px 20px',
|
||||||
|
backgroundColor: '#f8f8f8',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: '1px solid #f0f0f0'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#888',
|
||||||
|
marginBottom: '6px',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}>Phone</div>
|
||||||
|
<a href={`tel:${phone}`} style={{
|
||||||
|
fontSize: '15px',
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#2563eb',
|
||||||
|
textDecoration: 'none'
|
||||||
|
}}>{phone}</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleSaveContact}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '16px',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: 'white',
|
||||||
|
backgroundColor: '#2563eb',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
boxShadow: '0 2px 8px rgba(37, 99, 235, 0.2)'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = '#1d4ed8';
|
||||||
|
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 4px 12px rgba(37, 99, 235, 0.3)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = '#2563eb';
|
||||||
|
e.currentTarget.style.transform = 'translateY(0)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 2px 8px rgba(37, 99, 235, 0.2)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save to Contacts
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: '13px',
|
||||||
|
color: '#999',
|
||||||
|
marginTop: '16px',
|
||||||
|
lineHeight: '1.5'
|
||||||
|
}}>
|
||||||
|
Add this contact to your address book
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -45,8 +45,22 @@ export const QRCodeCard: React.FC<QRCodeCardProps> = ({
|
||||||
qrUrl = qr.content.url;
|
qrUrl = qr.content.url;
|
||||||
} else if (qr.contentType === 'PHONE' && qr.content?.phone) {
|
} else if (qr.contentType === 'PHONE' && qr.content?.phone) {
|
||||||
qrUrl = `tel:${qr.content.phone}`;
|
qrUrl = `tel:${qr.content.phone}`;
|
||||||
} else if (qr.contentType === 'EMAIL' && qr.content?.email) {
|
} else if (qr.contentType === 'VCARD') {
|
||||||
qrUrl = `mailto:${qr.content.email}`;
|
// VCARD content needs to be formatted properly
|
||||||
|
qrUrl = `BEGIN:VCARD
|
||||||
|
VERSION:3.0
|
||||||
|
FN:${qr.content.firstName || ''} ${qr.content.lastName || ''}
|
||||||
|
N:${qr.content.lastName || ''};${qr.content.firstName || ''};;;
|
||||||
|
${qr.content.organization ? `ORG:${qr.content.organization}` : ''}
|
||||||
|
${qr.content.title ? `TITLE:${qr.content.title}` : ''}
|
||||||
|
${qr.content.email ? `EMAIL:${qr.content.email}` : ''}
|
||||||
|
${qr.content.phone ? `TEL:${qr.content.phone}` : ''}
|
||||||
|
END:VCARD`;
|
||||||
|
} else if (qr.contentType === 'GEO' && qr.content) {
|
||||||
|
const lat = qr.content.latitude || 0;
|
||||||
|
const lon = qr.content.longitude || 0;
|
||||||
|
const label = qr.content.label ? `?q=${encodeURIComponent(qr.content.label)}` : '';
|
||||||
|
qrUrl = `geo:${lat},${lon}${label}`;
|
||||||
} else if (qr.contentType === 'TEXT' && qr.content?.text) {
|
} else if (qr.contentType === 'TEXT' && qr.content?.text) {
|
||||||
qrUrl = qr.content.text;
|
qrUrl = qr.content.text;
|
||||||
} else if (qr.content?.qrContent) {
|
} else if (qr.content?.qrContent) {
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,13 @@ interface HeroProps {
|
||||||
export const Hero: React.FC<HeroProps> = ({ t }) => {
|
export const Hero: React.FC<HeroProps> = ({ t }) => {
|
||||||
const templateCards = [
|
const templateCards = [
|
||||||
{ title: 'URL/Website', color: 'bg-blue-100', icon: '🌐' },
|
{ title: 'URL/Website', color: 'bg-blue-100', icon: '🌐' },
|
||||||
{ title: 'WiFi', color: 'bg-purple-100', icon: '📶' },
|
{ title: 'Contact Card', color: 'bg-purple-100', icon: '👤' },
|
||||||
{ title: 'Email', color: 'bg-green-100', icon: '📧' },
|
{ title: 'Location', color: 'bg-green-100', icon: '📍' },
|
||||||
{ title: 'Phone Number', color: 'bg-pink-100', icon: '📞' },
|
{ title: 'Phone Number', color: 'bg-pink-100', icon: '📞' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative overflow-hidden bg-gradient-to-br from-blue-50 via-white to-purple-50 py-20">
|
<section className="relative overflow-hidden bg-gradient-to-br from-blue-50 via-white to-purple-50 pt-12 pb-20">
|
||||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||||
<div className="grid lg:grid-cols-2 gap-12 items-center">
|
<div className="grid lg:grid-cols-2 gap-12 items-center">
|
||||||
{/* Left Content */}
|
{/* Left Content */}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
|
@ -77,6 +78,7 @@ export const Pricing: React.FC<PricingProps> = ({ t }) => {
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<Link href="/signup">
|
||||||
<Button
|
<Button
|
||||||
variant={plan.popular ? 'primary' : 'outline'}
|
variant={plan.popular ? 'primary' : 'outline'}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
|
@ -84,16 +86,11 @@ export const Pricing: React.FC<PricingProps> = ({ t }) => {
|
||||||
>
|
>
|
||||||
Get Started
|
Get Started
|
||||||
</Button>
|
</Button>
|
||||||
|
</Link>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center mt-8">
|
|
||||||
<a href="/#pricing" className="text-primary-600 hover:text-primary-700 font-medium">
|
|
||||||
View Full Pricing Details →
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,9 @@ export const TemplateCards: React.FC<TemplateCardsProps> = ({ t }) => {
|
||||||
iconBg: 'bg-blue-100',
|
iconBg: 'bg-blue-100',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'wifi',
|
key: 'vcard',
|
||||||
title: t.templates.wifi,
|
title: t.templates.vcard,
|
||||||
icon: '📶',
|
icon: '👤',
|
||||||
color: 'bg-purple-50 border-purple-200',
|
color: 'bg-purple-50 border-purple-200',
|
||||||
iconBg: 'bg-purple-100',
|
iconBg: 'bg-purple-100',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,17 @@ interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
({ className, type, label, error, ...props }, ref) => {
|
({ className, type, label, error, onInvalid, ...props }, ref) => {
|
||||||
|
// Default English validation message
|
||||||
|
const handleInvalid = (e: React.InvalidEvent<HTMLInputElement>) => {
|
||||||
|
e.target.setCustomValidity('Please fill out this field.');
|
||||||
|
if (onInvalid) onInvalid(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInput = (e: React.FormEvent<HTMLInputElement>) => {
|
||||||
|
e.currentTarget.setCustomValidity('');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{label && (
|
{label && (
|
||||||
|
|
@ -23,6 +33,8 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
onInvalid={handleInvalid}
|
||||||
|
onInput={handleInput}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
{error && (
|
{error && (
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@
|
||||||
"title": "Mit einer Vorlage beginnen",
|
"title": "Mit einer Vorlage beginnen",
|
||||||
"restaurant": "Restaurant-Menü",
|
"restaurant": "Restaurant-Menü",
|
||||||
"business": "Visitenkarte",
|
"business": "Visitenkarte",
|
||||||
"wifi": "WLAN-Zugang",
|
"vcard": "Kontaktkarte",
|
||||||
"event": "Event-Ticket",
|
"event": "Event-Ticket",
|
||||||
"use_template": "Vorlage verwenden →"
|
"use_template": "Vorlage verwenden →"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@
|
||||||
"title": "Start with a Template",
|
"title": "Start with a Template",
|
||||||
"restaurant": "Restaurant Menu",
|
"restaurant": "Restaurant Menu",
|
||||||
"business": "Business Card",
|
"business": "Business Card",
|
||||||
"wifi": "Wi-Fi Access",
|
"vcard": "Contact Card",
|
||||||
"event": "Event Ticket",
|
"event": "Event Ticket",
|
||||||
"use_template": "Use template →"
|
"use_template": "Use template →"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -22,15 +22,17 @@ const qrContentSchema = z.object({
|
||||||
url: z.string().url().optional(),
|
url: z.string().url().optional(),
|
||||||
phone: z.string().optional(),
|
phone: z.string().optional(),
|
||||||
email: z.string().email().optional(),
|
email: z.string().email().optional(),
|
||||||
subject: z.string().optional(),
|
|
||||||
message: z.string().optional(),
|
message: z.string().optional(),
|
||||||
text: z.string().optional(),
|
text: z.string().optional(),
|
||||||
ssid: z.string().optional(),
|
// VCARD fields
|
||||||
password: z.string().optional(),
|
|
||||||
security: z.enum(['WPA', 'WEP', 'nopass']).optional(),
|
|
||||||
firstName: z.string().optional(),
|
firstName: z.string().optional(),
|
||||||
lastName: z.string().optional(),
|
lastName: z.string().optional(),
|
||||||
organization: z.string().optional(),
|
organization: z.string().optional(),
|
||||||
|
title: z.string().optional(),
|
||||||
|
// GEO fields
|
||||||
|
latitude: z.number().optional(),
|
||||||
|
longitude: z.number().optional(),
|
||||||
|
label: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const qrStyleSchema = z.object({
|
const qrStyleSchema = z.object({
|
||||||
|
|
@ -128,25 +130,26 @@ export function getQRContent(qr: any): string {
|
||||||
return content.url || '';
|
return content.url || '';
|
||||||
case 'PHONE':
|
case 'PHONE':
|
||||||
return `tel:${content.phone || ''}`;
|
return `tel:${content.phone || ''}`;
|
||||||
case 'EMAIL':
|
|
||||||
const subject = content.subject ? `?subject=${encodeURIComponent(content.subject)}` : '';
|
|
||||||
return `mailto:${content.email || ''}${subject}`;
|
|
||||||
case 'SMS':
|
case 'SMS':
|
||||||
const message = content.message ? `?body=${encodeURIComponent(content.message)}` : '';
|
const message = content.message ? `?body=${encodeURIComponent(content.message)}` : '';
|
||||||
return `sms:${content.phone || ''}${message}`;
|
return `sms:${content.phone || ''}${message}`;
|
||||||
case 'WHATSAPP':
|
case 'WHATSAPP':
|
||||||
const whatsappMessage = content.message ? `?text=${encodeURIComponent(content.message)}` : '';
|
const whatsappMessage = content.message ? `?text=${encodeURIComponent(content.message)}` : '';
|
||||||
return `https://wa.me/${content.phone || ''}${whatsappMessage}`;
|
return `https://wa.me/${content.phone || ''}${whatsappMessage}`;
|
||||||
case 'WIFI':
|
|
||||||
return `WIFI:T:${content.security || 'WPA'};S:${content.ssid || ''};P:${content.password || ''};;`;
|
|
||||||
case 'VCARD':
|
case 'VCARD':
|
||||||
return `BEGIN:VCARD
|
return `BEGIN:VCARD
|
||||||
VERSION:3.0
|
VERSION:3.0
|
||||||
FN:${content.firstName || ''} ${content.lastName || ''}
|
FN:${content.firstName || ''} ${content.lastName || ''}
|
||||||
ORG:${content.organization || ''}
|
ORG:${content.organization || ''}
|
||||||
|
TITLE:${content.title || ''}
|
||||||
EMAIL:${content.email || ''}
|
EMAIL:${content.email || ''}
|
||||||
TEL:${content.phone || ''}
|
TEL:${content.phone || ''}
|
||||||
END:VCARD`;
|
END:VCARD`;
|
||||||
|
case 'GEO':
|
||||||
|
const lat = content.latitude || 0;
|
||||||
|
const lon = content.longitude || 0;
|
||||||
|
const label = content.label ? `?q=${encodeURIComponent(content.label)}` : '';
|
||||||
|
return `geo:${lat},${lon}${label}`;
|
||||||
case 'TEXT':
|
case 'TEXT':
|
||||||
return content.text || '';
|
return content.text || '';
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
|
|
@ -35,10 +35,10 @@ export const STRIPE_PLANS = {
|
||||||
currency: 'EUR',
|
currency: 'EUR',
|
||||||
interval: 'month',
|
interval: 'month',
|
||||||
features: [
|
features: [
|
||||||
'50 QR-Codes',
|
'50 QR Codes',
|
||||||
'Branding (Farben)',
|
'Branding (Colors)',
|
||||||
'Detaillierte Analytics (Datum, Gerät, Stadt)',
|
'Detailed Analytics (Date, Device, City)',
|
||||||
'CSV-Export',
|
'CSV Export',
|
||||||
'SVG/PNG Download',
|
'SVG/PNG Download',
|
||||||
],
|
],
|
||||||
limits: {
|
limits: {
|
||||||
|
|
@ -57,9 +57,9 @@ export const STRIPE_PLANS = {
|
||||||
interval: 'month',
|
interval: 'month',
|
||||||
features: [
|
features: [
|
||||||
'500 QR-Codes',
|
'500 QR-Codes',
|
||||||
'Alles von Pro',
|
'Everything from Pro',
|
||||||
'Bulk QR-Generierung (bis 1,000)',
|
'Bulk QR Generation (up to 1,000)',
|
||||||
'Prioritäts-Support',
|
'Priority Support',
|
||||||
],
|
],
|
||||||
limits: {
|
limits: {
|
||||||
dynamicQRCodes: 500,
|
dynamicQRCodes: 500,
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ export const createQRSchema = z.object({
|
||||||
|
|
||||||
isStatic: z.boolean().optional(),
|
isStatic: z.boolean().optional(),
|
||||||
|
|
||||||
contentType: z.enum(['URL', 'WIFI', 'EMAIL', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT'], {
|
contentType: z.enum(['URL', 'VCARD', 'GEO', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT'], {
|
||||||
errorMap: () => ({ message: 'Invalid content type' })
|
errorMap: () => ({ message: 'Invalid content type' })
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
@ -60,7 +60,7 @@ export const bulkQRSchema = z.object({
|
||||||
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', 'WIFI', 'EMAIL', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT']),
|
contentType: z.enum(['URL', 'VCARD', 'GEO', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT']),
|
||||||
})
|
})
|
||||||
).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'),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue