Initial commit - QR Master application

This commit is contained in:
Timo Knuth 2025-10-13 20:19:18 +02:00
commit 5262f9e78f
96 changed files with 18902 additions and 0 deletions

View File

@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(docker-compose:*)",
"Bash(docker container prune:*)"
],
"deny": [],
"ask": []
}
}

55
.dockerignore Normal file
View File

@ -0,0 +1,55 @@
# Dependencies
node_modules
npm-debug.log
yarn-error.log
pnpm-debug.log
# Testing
coverage
.nyc_output
# Next.js
.next
out
dist
build
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDEs
.vscode
.idea
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Git
.git
.gitignore
# Docker
Dockerfile
docker-compose*.yml
.dockerignore
# Misc
README.md
.prettierrc
.eslintrc.json
*.md
# Logs
logs
*.log
# Prisma
prisma/migrations

14
.editorconfig Normal file
View File

@ -0,0 +1,14 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.{js,jsx,ts,tsx,json,css,scss,md}]
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false

12
.env.example Normal file
View File

@ -0,0 +1,12 @@
NODE_ENV=production
PORT=3000
DATABASE_URL=postgresql://postgres:postgres@db:5432/qrmaster?schema=public
DIRECT_URL=postgresql://postgres:postgres@db:5432/qrmaster?schema=public
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=CHANGE_ME
NEXT_PUBLIC_APP_URL=http://localhost:3000
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
REDIS_URL=redis://redis:6379
IP_SALT=CHANGE_ME_SALT
ENABLE_DEMO=true

26
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,26 @@
name: CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Generate Prisma Client
run: npx prisma generate
- name: Build application
run: npm run build
- name: Run linter
run: npm run lint

48
.gitignore vendored Normal file
View File

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

2
.npmrc Normal file
View File

@ -0,0 +1,2 @@
registry=https://registry.npmjs.org/
legacy-peer-deps=true

8
.prettierrc Normal file
View File

@ -0,0 +1,8 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false
}

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"codium.codeCompletion.enable": false
}

187
CHANGELOG.md Normal file
View File

@ -0,0 +1,187 @@
# Changelog - PostgreSQL Migration
## [2.0.0] - 2024-10-13
### 🎉 Major Changes - Supabase to PostgreSQL Migration
#### Removed
- ❌ **Supabase dependency** - Removed all Supabase-specific configurations
- ❌ **DIRECT_URL** - Removed connection pooling URL (Supabase-specific)
- ❌ **External database dependency** - Now fully self-hosted
#### Added
- ✅ **PostgreSQL 16 in Docker** - Local PostgreSQL database with Docker support
- ✅ **Redis 7** - Caching and rate limiting with Redis
- ✅ **Adminer** - Database management UI (http://localhost:8080)
- ✅ **Docker Compose setups** - Both development and production configurations
- ✅ **Database initialization** - Automated database setup with extensions
- ✅ **Complete documentation** - Multiple guides for setup and migration
- ✅ **Setup scripts** - Automated setup for both Linux/Mac and Windows
- ✅ **npm scripts** - Convenient Docker commands via npm
#### Modified Files
- 📝 `prisma/schema.prisma` - Removed directUrl field
- 📝 `src/lib/env.ts` - Removed DIRECT_URL, updated DATABASE_URL default
- 📝 `docker-compose.yml` - Complete rewrite with PostgreSQL, Redis, and networking
- 📝 `Dockerfile` - Enhanced with proper PostgreSQL support
- 📝 `package.json` - Added Docker scripts and tsx dependency
- 📝 `README.md` - Updated with new setup instructions
#### New Files
- 📄 `docker-compose.dev.yml` - Development environment (database only)
- 📄 `docker/init-db.sh` - PostgreSQL initialization script
- 📄 `docker/README.md` - Docker-specific documentation
- 📄 `DOCKER_SETUP.md` - Comprehensive Docker setup guide
- 📄 `MIGRATION_FROM_SUPABASE.md` - Step-by-step migration guide
- 📄 `env.example` - Environment variable template
- 📄 `.dockerignore` - Docker build optimization
- 📄 `scripts/setup.sh` - Quick setup script (Linux/Mac)
- 📄 `scripts/setup.ps1` - Quick setup script (Windows)
- 📄 `CHANGELOG.md` - This file
### 📦 Docker Services
#### PostgreSQL Database
- **Image**: postgres:16-alpine
- **Port**: 5432
- **Features**:
- Health checks
- Volume persistence
- UTF-8 encoding
- Extensions: uuid-ossp, pg_trgm
#### Redis Cache
- **Image**: redis:7-alpine
- **Port**: 6379
- **Features**:
- AOF persistence
- 256MB max memory with LRU eviction
- Health checks
#### Next.js Application
- **Port**: 3000
- **Features**:
- Multi-stage build
- Production optimization
- Health checks
- Automatic Prisma generation
#### Adminer (Development)
- **Port**: 8080
- **Features**:
- Database management UI
- Optional (dev profile)
- Pre-configured for PostgreSQL
### 🚀 Quick Start
#### Development Mode
```bash
npm run docker:dev # Start database
npm run db:migrate # Run migrations
npm run dev # Start app
```
#### Production Mode
```bash
npm run docker:prod # Start all services
```
### 📚 Documentation
- **README.md** - Main documentation with quick start
- **DOCKER_SETUP.md** - Complete Docker guide with troubleshooting
- **MIGRATION_FROM_SUPABASE.md** - Migration guide from Supabase
- **docker/README.md** - Docker commands and operations
- **env.example** - Environment variable reference
### 🔧 New npm Scripts
```bash
# Docker commands
npm run docker:dev # Start development services
npm run docker:dev:stop # Stop development services
npm run docker:prod # Start production stack
npm run docker:stop # Stop all services
npm run docker:logs # View all logs
npm run docker:db # PostgreSQL CLI
npm run docker:redis # Redis CLI
npm run docker:backup # Backup database
```
### 🔐 Environment Variables
#### Required
- `DATABASE_URL` - PostgreSQL connection string
- `NEXTAUTH_SECRET` - NextAuth.js secret (generate with openssl)
- `NEXTAUTH_URL` - Application URL
- `IP_SALT` - Salt for IP hashing (generate with openssl)
#### Optional
- `GOOGLE_CLIENT_ID` - Google OAuth client ID
- `GOOGLE_CLIENT_SECRET` - Google OAuth secret
- `REDIS_URL` - Redis connection string
- `ENABLE_DEMO` - Enable demo mode
### 🎯 Benefits
1. **Full Control** - Own your data and infrastructure
2. **No Vendor Lock-in** - Standard PostgreSQL
3. **Lower Latency** - Local network speed
4. **Cost Effective** - No monthly database fees
5. **Privacy** - Data stays on your infrastructure
6. **Development** - Easy local testing
7. **Offline Capable** - Works without internet
### 🔄 Migration Path
1. Backup Supabase data
2. Update codebase
3. Start local PostgreSQL
4. Restore data or run migrations
5. Update environment variables
6. Deploy
See [MIGRATION_FROM_SUPABASE.md](MIGRATION_FROM_SUPABASE.md) for detailed steps.
### ⚠️ Breaking Changes
- `DIRECT_URL` environment variable removed
- Database now requires Docker or local PostgreSQL
- Supabase-specific features removed
### 📊 Performance Improvements
- Local database reduces latency
- Redis caching improves response times
- Connection pooling via Prisma
- Optimized Docker images
### 🐛 Bug Fixes
- Fixed database connection handling
- Improved error messages
- Better health checks
### 🔜 Future Enhancements
- [ ] PostgreSQL replication for HA
- [ ] Redis Sentinel for failover
- [ ] Automated backup scripts
- [ ] Monitoring and alerting
- [ ] Database performance tuning
- [ ] Multi-region deployment
### 📝 Notes
- Default PostgreSQL password should be changed in production
- Always backup data before migration
- Review security settings before deployment
- Set up automated backups in production
---
**Migration completed successfully!** 🎉
For support, see documentation or open an issue on GitHub.

461
DOCKER_SETUP.md Normal file
View File

@ -0,0 +1,461 @@
# 🐳 Docker Setup Guide for QR Master
Complete guide for setting up and running QR Master with Docker and PostgreSQL.
## Prerequisites
- Docker Desktop (Windows/Mac) or Docker Engine (Linux)
- Docker Compose V2
- Git
- Node.js 18+ (for local development)
## 🚀 Getting Started
### Option 1: Development Mode (Recommended for Development)
Run only the database services in Docker and the Next.js app on your host machine:
1. **Clone the repository**
```bash
git clone <your-repo-url>
cd QRMASTER
```
2. **Install dependencies**
```bash
npm install
```
3. **Set up environment variables**
```bash
cp env.example .env
```
Edit `.env` and update the values, especially:
- `NEXTAUTH_SECRET` (generate with: `openssl rand -base64 32`)
- `IP_SALT` (generate with: `openssl rand -base64 32`)
4. **Start database services**
```bash
npm run docker:dev
```
This starts PostgreSQL, Redis, and Adminer.
5. **Run database migrations**
```bash
npm run db:migrate
```
6. **Seed the database (optional)**
```bash
npm run db:seed
```
7. **Start the development server**
```bash
npm run dev
```
8. **Access the application**
- **App**: http://localhost:3050
- **Database UI (Adminer)**: http://localhost:8080
- System: PostgreSQL
- Server: db
- Username: postgres
- Password: postgres
- Database: qrmaster
### Option 2: Full Production Mode
Run everything in Docker containers:
1. **Clone and configure**
```bash
git clone <your-repo-url>
cd QRMASTER
cp env.example .env
```
2. **Update environment variables in `.env`**
Make sure to set strong secrets in production!
3. **Build and start all services**
```bash
npm run docker:prod
```
4. **Run migrations inside the container**
```bash
docker-compose exec web npx prisma migrate deploy
```
5. **Access the application**
- **App**: http://localhost:3050
## 📦 What Gets Installed
### Services
1. **PostgreSQL 16** - Main database
- Port: 5432
- Database: qrmaster
- User: postgres
- Password: postgres (change in production!)
2. **Redis 7** - Caching and rate limiting
- Port: 6379
- Max memory: 256MB with LRU eviction
- Persistence: AOF enabled
3. **Next.js App** - The QR Master application
- Port: 3000
- Built with production optimizations
4. **Adminer** - Database management UI (dev only)
- Port: 8080
- Lightweight alternative to pgAdmin
## 🗄️ Database Management
### Prisma Commands
```bash
# Generate Prisma Client
npm run db:generate
# Create a new migration
npm run db:migrate
# Deploy migrations (production)
npm run db:deploy
# Seed the database
npm run db:seed
# Open Prisma Studio
npm run db:studio
```
### Direct PostgreSQL Access
```bash
# Connect to PostgreSQL
docker-compose exec db psql -U postgres -d qrmaster
# Backup database
docker-compose exec db pg_dump -U postgres qrmaster > backup_$(date +%Y%m%d).sql
# Restore database
docker-compose exec -T db psql -U postgres qrmaster < backup.sql
```
## 🔧 Docker Commands
### Starting Services
```bash
# Development mode (database only)
npm run docker:dev
# or
docker-compose -f docker-compose.dev.yml up -d
# Production mode (full stack)
npm run docker:prod
# or
docker-compose up -d --build
# Production with database UI
docker-compose --profile dev up -d
```
### Stopping Services
```bash
# Stop all services
npm run docker:stop
# or
docker-compose down
# Stop and remove volumes (⚠️ deletes data!)
docker-compose down -v
```
### Viewing Logs
```bash
# All services
docker-compose logs -f
# Specific service
docker-compose logs -f web
docker-compose logs -f db
docker-compose logs -f redis
```
### Rebuilding
```bash
# Rebuild the web application
docker-compose build web
# Rebuild without cache
docker-compose build --no-cache web
# Rebuild and restart
docker-compose up -d --build web
```
## 🌍 Environment Variables
### Required Variables
```env
# Database (automatically set for Docker)
DATABASE_URL=postgresql://postgres:postgres@db:5432/qrmaster?schema=public
# NextAuth
NEXTAUTH_URL=http://localhost:3050
NEXTAUTH_SECRET=<generate-with-openssl-rand-base64-32>
# Security
IP_SALT=<generate-with-openssl-rand-base64-32>
```
### Optional Variables
```env
# OAuth (Google)
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Redis (automatically set for Docker)
REDIS_URL=redis://redis:6379
# Features
ENABLE_DEMO=false
```
### Generating Secrets
```bash
# On Linux/Mac
openssl rand -base64 32
# On Windows (PowerShell)
[Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Maximum 256 }))
```
## 🔍 Health Checks
All services include health checks:
```bash
# Check status of all services
docker-compose ps
# Check database health
docker-compose exec db pg_isready -U postgres
# Check Redis health
docker-compose exec redis redis-cli ping
# Check web app health
curl http://localhost:3050
```
## 🐛 Troubleshooting
### Database Connection Failed
```bash
# Check database is running
docker-compose ps db
# Check database logs
docker-compose logs db
# Restart database
docker-compose restart db
# Test connection
docker-compose exec db psql -U postgres -d qrmaster -c "SELECT version();"
```
### Port Already in Use
```bash
# Windows - find process using port
netstat -ano | findstr :3050
# Linux/Mac - find process using port
lsof -i :3050
# Kill the process or change the port in docker-compose.yml
```
### Migration Errors
```bash
# Reset the database (⚠️ deletes all data!)
docker-compose exec web npx prisma migrate reset
# Or manually
docker-compose down -v
docker-compose up -d db redis
npm run db:migrate
```
### Container Won't Start
```bash
# Remove all containers and volumes
docker-compose down -v
# Remove dangling images
docker image prune
# Rebuild from scratch
docker-compose build --no-cache
docker-compose up -d
```
### Prisma Client Not Generated
```bash
# Generate Prisma Client
npm run db:generate
# Or in Docker
docker-compose exec web npx prisma generate
```
## 🔐 Production Checklist
Before deploying to production:
- [ ] Change PostgreSQL password
- [ ] Generate strong `NEXTAUTH_SECRET`
- [ ] Generate strong `IP_SALT`
- [ ] Set proper `NEXTAUTH_URL` (your domain)
- [ ] Configure OAuth credentials (if using)
- [ ] Set up database backups
- [ ] Configure Redis persistence
- [ ] Set up monitoring and logging
- [ ] Enable HTTPS/SSL
- [ ] Review and adjust rate limits
- [ ] Set up a reverse proxy (nginx/Traefik)
- [ ] Configure firewall rules
- [ ] Set up automated database backups
## 📊 Monitoring
### Resource Usage
```bash
# View resource usage
docker stats
# View specific container
docker stats qrmaster-web qrmaster-db qrmaster-redis
```
### Database Size
```bash
# Check database size
docker-compose exec db psql -U postgres -d qrmaster -c "
SELECT
pg_size_pretty(pg_database_size('qrmaster')) as db_size,
pg_size_pretty(pg_total_relation_size('\"QRCode\"')) as qrcode_table_size;
"
```
### Redis Info
```bash
# Get Redis info
docker-compose exec redis redis-cli info
# Get memory usage
docker-compose exec redis redis-cli info memory
```
## 🔄 Backup and Recovery
### Automated Backups
Create a backup script `backup.sh`:
```bash
#!/bin/bash
BACKUP_DIR="./backups"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
mkdir -p $BACKUP_DIR
# Backup database
docker-compose exec -T db pg_dump -U postgres qrmaster > "$BACKUP_DIR/qrmaster_$TIMESTAMP.sql"
# Backup Redis
docker-compose exec redis redis-cli BGSAVE
echo "Backup completed: $BACKUP_DIR/qrmaster_$TIMESTAMP.sql"
```
### Restore from Backup
```bash
# Stop the web service
docker-compose stop web
# Restore database
cat backup_20241013.sql | docker-compose exec -T db psql -U postgres qrmaster
# Restart
docker-compose start web
```
## 🚀 Performance Tips
1. **Increase PostgreSQL shared buffers** (in production):
Edit `docker-compose.yml`:
```yaml
db:
command: postgres -c shared_buffers=256MB -c max_connections=100
```
2. **Enable Redis persistence**:
Already configured with AOF in docker-compose.yml
3. **Use connection pooling**:
Prisma already includes connection pooling
4. **Monitor slow queries**:
```bash
docker-compose exec db psql -U postgres -d qrmaster -c "
SELECT query, mean_exec_time, calls
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;"
```
## 📚 Additional Resources
- [Docker Documentation](https://docs.docker.com/)
- [PostgreSQL Documentation](https://www.postgresql.org/docs/)
- [Redis Documentation](https://redis.io/documentation)
- [Prisma Documentation](https://www.prisma.io/docs/)
- [Next.js Documentation](https://nextjs.org/docs)
## 🆘 Getting Help
If you encounter issues:
1. Check the logs: `docker-compose logs -f`
2. Check service health: `docker-compose ps`
3. Review this guide
4. Check the `docker/README.md` for more details
---
**Happy coding! 🎉**

57
Dockerfile Normal file
View File

@ -0,0 +1,57 @@
# ---- deps ----
FROM node:20-alpine AS deps
# Install OpenSSL for Prisma
RUN apk add --no-cache openssl
WORKDIR /app
COPY package.json package-lock.json* pnpm-lock.yaml* yarn.lock* .npmrc* ./
# Copy prisma schema for postinstall script
COPY prisma ./prisma
RUN \
if [ -f pnpm-lock.yaml ]; then \
npm i -g pnpm && pnpm i --frozen-lockfile; \
elif [ -f yarn.lock ]; then \
yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then \
npm ci; \
else \
npm install --legacy-peer-deps; \
fi
# ---- builder ----
FROM node:20-alpine AS builder
# Install OpenSSL for Prisma
RUN apk add --no-cache openssl
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
# Add build-time environment variables with defaults
ENV DATABASE_URL="postgresql://postgres:postgres@db:5432/qrmaster?schema=public"
ENV NEXTAUTH_URL="http://localhost:3000"
ENV NEXTAUTH_SECRET="build-time-secret"
ENV IP_SALT="build-time-salt"
RUN npx prisma generate
RUN npm run build
# ---- runner ----
FROM node:20-alpine AS runner
# Install OpenSSL for Prisma runtime
RUN apk add --no-cache openssl
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 QR Master
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

315
MIGRATION_FROM_SUPABASE.md Normal file
View File

@ -0,0 +1,315 @@
# Migration Guide: From Supabase to Local PostgreSQL
This guide helps you migrate your QR Master application from Supabase to a local PostgreSQL database with Docker.
## What Changed
### ✅ Removed
- Supabase connection pooling (`DIRECT_URL` environment variable)
- Supabase-specific configurations
- External database dependency
### ✨ Added
- Local PostgreSQL 16 database in Docker
- Redis cache for better performance
- Adminer database management UI
- Complete Docker setup with docker-compose
- Database initialization scripts
- Development and production Docker configurations
## Migration Steps
### 1. Backup Your Supabase Database (IMPORTANT!)
Before making any changes, backup your existing data:
```bash
# If you have access to Supabase CLI
supabase db dump > backup_$(date +%Y%m%d).sql
# Or use pg_dump directly with your Supabase credentials
pg_dump "postgresql://postgres:[PASSWORD]@[PROJECT_REF].supabase.co:5432/postgres" > backup.sql
```
### 2. Update Your Codebase
Pull the latest changes or update these files:
#### Updated Files:
- ✏️ `prisma/schema.prisma` - Removed `directUrl` field
- ✏️ `src/lib/env.ts` - Removed `DIRECT_URL` variable
- ✏️ `docker-compose.yml` - Updated with PostgreSQL setup
- ✏️ `Dockerfile` - Enhanced with PostgreSQL support
- ✏️ `package.json` - Added Docker scripts and tsx
#### New Files:
- 📄 `docker-compose.dev.yml` - Development setup
- 📄 `docker/init-db.sh` - Database initialization
- 📄 `docker/README.md` - Docker documentation
- 📄 `DOCKER_SETUP.md` - Complete Docker guide
- 📄 `env.example` - Environment template
- 📄 `.dockerignore` - Docker build optimization
### 3. Set Up Environment Variables
1. Remove Supabase-specific variables:
```bash
# Remove these from .env:
# DIRECT_URL=...
# SUPABASE_URL=...
# SUPABASE_ANON_KEY=...
```
2. Update database connection:
```bash
# For Docker (default):
DATABASE_URL=postgresql://postgres:postgres@db:5432/qrmaster?schema=public
# For local development (without Docker):
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/qrmaster?schema=public
```
3. Copy from template:
```bash
cp env.example .env
```
4. Generate secure secrets:
```bash
# Linux/Mac
openssl rand -base64 32 # Use for NEXTAUTH_SECRET
openssl rand -base64 32 # Use for IP_SALT
# Windows PowerShell
[Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Maximum 256 }))
```
### 4. Start Local PostgreSQL
#### Option A: Development Mode (Recommended)
```bash
# Start database only (run app on host)
npm run docker:dev
# Wait for database to be ready
docker-compose -f docker-compose.dev.yml logs -f db
```
#### Option B: Full Docker
```bash
# Start all services
npm run docker:prod
# Wait for all services to be ready
docker-compose logs -f
```
### 5. Restore Your Data
#### Option 1: Using Prisma Migrations (Clean Start)
```bash
# Generate Prisma client
npm run db:generate
# Run migrations
npm run db:migrate
# Seed with demo data
npm run db:seed
```
#### Option 2: Restore from Backup (Preserve Data)
```bash
# Restore your Supabase backup
cat backup.sql | docker-compose exec -T db psql -U postgres qrmaster
# Or if running locally
psql -U postgres -d qrmaster < backup.sql
# Then run migrations to update schema
npm run db:deploy
```
### 6. Verify Migration
1. **Check Database Connection:**
```bash
# Connect to database
npm run docker:db
# Or manually
docker-compose exec db psql -U postgres -d qrmaster
# Run test query
SELECT COUNT(*) FROM "User";
SELECT COUNT(*) FROM "QRCode";
```
2. **Access Adminer (Database UI):**
- URL: http://localhost:8080
- System: PostgreSQL
- Server: db
- Username: postgres
- Password: postgres
- Database: qrmaster
3. **Test Your Application:**
```bash
# Start the app (if using dev mode)
npm run dev
# Access: http://localhost:3050
```
### 7. Update Your Deployment
#### For Docker Production:
```bash
# Build and deploy
docker-compose up -d --build
# Run migrations
docker-compose exec web npx prisma migrate deploy
# Check logs
docker-compose logs -f web
```
#### For Other Platforms (Vercel, Railway, etc.):
Update your environment variables in the platform's dashboard:
- Remove: `DIRECT_URL`
- Update: `DATABASE_URL` to your new PostgreSQL connection string
## Troubleshooting
### Issue: Connection Refused
```bash
# Check if database is running
docker-compose ps
# Check database logs
docker-compose logs db
# Restart database
docker-compose restart db
```
### Issue: Migration Errors
```bash
# Reset migrations (⚠️ deletes data!)
npm run db:migrate reset
# Or manually reset
docker-compose down -v
docker-compose up -d db
npm run db:migrate
```
### Issue: Prisma Client Not Generated
```bash
# Regenerate Prisma client
npm run db:generate
# Or
npx prisma generate
```
### Issue: Data Not Migrated
```bash
# Check if backup was restored correctly
docker-compose exec db psql -U postgres -d qrmaster -c "
SELECT
schemaname,
tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;
"
```
## Differences: Supabase vs Local PostgreSQL
| Feature | Supabase | Local PostgreSQL |
|---------|----------|------------------|
| Hosting | Cloud (managed) | Self-hosted (Docker) |
| Connection Pooling | Built-in (Supavisor) | Prisma built-in |
| Database UI | Supabase Studio | Adminer (included) |
| Backups | Automatic | Manual (or scripted) |
| Cost | Free tier + paid | Free (infrastructure cost only) |
| Latency | Internet dependent | Local network |
| Setup | Account required | Docker only |
| Scaling | Automatic | Manual |
## Benefits of Local PostgreSQL
**Full Control**: Own your data and infrastructure
**No Vendor Lock-in**: Standard PostgreSQL
**Lower Latency**: Local network speed
**Cost**: No monthly fees
**Privacy**: Data stays on your infrastructure
**Development**: Easy local testing
**Offline**: Works without internet
## Next Steps
1. ✅ Verify all data migrated correctly
2. ✅ Test all application features
3. ✅ Update your CI/CD pipelines
4. ✅ Set up automated backups:
```bash
# Create backup script
cat > backup.sh << 'EOF'
#!/bin/bash
BACKUP_DIR="./backups"
mkdir -p $BACKUP_DIR
docker-compose exec -T db pg_dump -U postgres qrmaster > "$BACKUP_DIR/backup_$(date +%Y%m%d_%H%M%S).sql"
EOF
chmod +x backup.sh
# Run daily backups (cron example)
# 0 2 * * * /path/to/backup.sh
```
5. ✅ Monitor your application
6. ✅ Update documentation
## Rollback Plan
If you need to rollback to Supabase:
1. Keep your Supabase project active during testing
2. Keep your backup files safe
3. To rollback, simply change `DATABASE_URL` back to Supabase
4. Add back `DIRECT_URL` to `prisma/schema.prisma`:
```prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}
```
## Support
For issues:
1. Check [DOCKER_SETUP.md](DOCKER_SETUP.md) for detailed Docker help
2. Check [docker/README.md](docker/README.md) for Docker commands
3. Review logs: `docker-compose logs -f`
4. Open an issue on GitHub
---
🎉 **Congratulations!** You've successfully migrated from Supabase to local PostgreSQL!

290
README.md Normal file
View File

@ -0,0 +1,290 @@
# 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.
## Features
- 🎨 **Custom QR Codes** - Create static and dynamic QR codes with full customization
- 📊 **Advanced Analytics** - Track scans, locations, devices, and user behavior
- 🔄 **Dynamic Content** - Edit QR code destinations anytime without reprinting
- 📦 **Bulk Operations** - Import CSV/Excel files to create multiple QR codes at once
- 🔌 **Integrations** - Connect with Zapier, Airtable, and Google Sheets
- 🌍 **Multi-language** - Support for English and German (i18n)
- 🔒 **Privacy-First** - Respects user privacy with hashed IPs and DNT headers
- 📱 **Responsive Design** - Works perfectly on all devices
## Tech Stack
- **Frontend**: Next.js 14 (App Router), TypeScript, Tailwind CSS
- **Backend**: Next.js API Routes, Prisma ORM
- **Database**: PostgreSQL
- **Cache**: Redis (optional)
- **Auth**: NextAuth.js (Credentials + Google OAuth)
- **QR Generation**: qrcode library
- **Charts**: Chart.js with react-chartjs-2
- **i18n**: i18next
## Quick Start
### Prerequisites
- Node.js 18+
- Docker and Docker Compose V2
- Git
### Installation
#### Option 1: Development Mode (Recommended)
Run database in Docker, app on host machine:
1. Clone the repository:
```bash
git clone https://github.com/yourusername/qr-master.git
cd qr-master
```
2. Install dependencies:
```bash
npm install
```
3. Copy and configure environment:
```bash
cp env.example .env
```
Edit `.env` and set:
- `NEXTAUTH_SECRET` (generate: `openssl rand -base64 32`)
- `IP_SALT` (generate: `openssl rand -base64 32`)
- (Optional) Google OAuth credentials
4. Start database services:
```bash
npm run docker:dev
```
5. Run migrations and seed:
```bash
npm run db:migrate
npm run db:seed
```
6. Start development server:
```bash
npm run dev
```
7. Access the application:
- **App**: http://localhost:3050
- **Database UI**: http://localhost:8080 (Adminer)
- **Database**: localhost:5432
- **Redis**: localhost:6379
#### Option 2: Full Docker (Production)
Run everything in Docker:
1. Clone and setup:
```bash
git clone https://github.com/yourusername/qr-master.git
cd qr-master
cp env.example .env
# Edit .env with your configuration
```
2. Build and start:
```bash
npm run docker:prod
```
3. Run migrations:
```bash
docker-compose exec web npx prisma migrate deploy
```
4. Access at http://localhost:3050
📚 **For detailed Docker setup, see [DOCKER_SETUP.md](DOCKER_SETUP.md)**
## Demo Account
Use these credentials to test the application:
- **Email**: demo@qrmaster.com
- **Password**: demo123
## Development
### Available Scripts
```bash
# Development
npm run dev # Start Next.js dev server
npm run build # Build for production
npm run start # Start production server
# Database
npm run db:generate # Generate Prisma Client
npm run db:migrate # Run migrations (dev)
npm run db:deploy # Deploy migrations (prod)
npm run db:seed # Seed database
npm run db:studio # Open Prisma Studio
# Docker
npm run docker:dev # Start DB & Redis only
npm run docker:dev:stop # Stop dev services
npm run docker:prod # Start full stack
npm run docker:stop # Stop all services
npm run docker:logs # View logs
npm run docker:db # PostgreSQL CLI
npm run docker:redis # Redis CLI
```
### Local Development (without Docker)
1. Install dependencies:
```bash
npm install
```
2. Set up PostgreSQL and Redis locally
3. Configure `.env` with local database URL:
```env
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/qrmaster?schema=public
```
4. Run migrations:
```bash
npm run db:migrate
npm run db:seed
```
5. Start dev server:
```bash
npm run dev
```
### Project Structure
```
qr-master/
├── src/
│ ├── app/ # Next.js app router pages
│ ├── components/ # React components
│ ├── lib/ # Utility functions and configurations
│ ├── hooks/ # Custom React hooks
│ ├── styles/ # Global styles
│ └── i18n/ # Translation files
├── prisma/ # Database schema and migrations
├── docker/ # Docker initialization scripts
│ ├── init-db.sh # PostgreSQL initialization
│ └── README.md # Docker documentation
├── public/ # Static assets
├── docker-compose.yml # Production Docker setup
├── docker-compose.dev.yml # Development Docker setup
├── Dockerfile # Container definition
├── DOCKER_SETUP.md # Complete Docker guide
└── env.example # Environment template
```
## API Endpoints
### Authentication
- `POST /api/auth/signin` - Sign in with credentials
- `POST /api/auth/signout` - Sign out
- `GET /api/auth/session` - Get current session
### QR Codes
- `GET /api/qrs` - List all QR codes
- `POST /api/qrs` - Create a new QR code
- `GET /api/qrs/[id]` - Get QR code details
- `PATCH /api/qrs/[id]` - Update QR code
- `DELETE /api/qrs/[id]` - Delete QR code
### Analytics
- `GET /api/analytics/summary` - Get analytics summary
### Bulk Operations
- `POST /api/bulk` - Import QR codes from CSV/Excel
### Public Redirect
- `GET /r/[slug]` - Redirect and track QR code scan
## Environment Variables
| Variable | Description | Required | Default |
|----------|-------------|----------|---------|
| `DATABASE_URL` | PostgreSQL connection string | Yes | `postgresql://postgres:postgres@localhost:5432/qrmaster?schema=public` |
| `NEXTAUTH_URL` | Application URL | Yes | `http://localhost:3050` |
| `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` |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | No | - |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | No | - |
| `REDIS_URL` | Redis connection string | No | `redis://redis:6379` |
| `ENABLE_DEMO` | Enable demo mode | No | `false` |
**Note**: Copy `env.example` to `.env` and update the values before starting.
## Security
- IP addresses are hashed with salt before storage
- Respects Do Not Track (DNT) headers
- CORS protection enabled
- Rate limiting on API endpoints
- Secure session management with NextAuth.js
## Deployment
### Docker (Recommended for Self-Hosting)
The application includes production-ready Docker configuration with PostgreSQL and Redis:
```bash
# Build and start all services
docker-compose up -d --build
# Run migrations
docker-compose exec web npx prisma migrate deploy
# View logs
docker-compose logs -f
```
For detailed deployment instructions, see [DOCKER_SETUP.md](DOCKER_SETUP.md).
### Vercel
1. Push your code to GitHub
2. Import the project in Vercel
3. Add a PostgreSQL database (Vercel Postgres, Supabase, or other)
4. Add environment variables in Vercel dashboard
5. Deploy
**Note**: For Vercel deployment, you'll need to set up a PostgreSQL database separately.
## Contributing
1. Fork the repository
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## Support
For support, email support@qrmaster.com or open an issue on GitHub.
## Acknowledgments
- Next.js team for the amazing framework
- Vercel for hosting and deployment
- All open-source contributors
---
Built with ❤️ by QR Master Team

301
SETUP_COMPLETE.md Normal file
View File

@ -0,0 +1,301 @@
# ✅ Setup Complete - PostgreSQL Migration
## 🎉 What Was Done
Your QR Master application has been successfully migrated from Supabase to a local PostgreSQL database with Docker!
### ✅ Completed Tasks
1. **Removed Supabase Dependencies**
- ❌ Removed `DIRECT_URL` from Prisma schema
- ❌ Removed `DIRECT_URL` from environment validation
- ❌ Cleaned up all Supabase-specific configurations
2. **Created Docker Infrastructure**
- ✅ Production Docker Compose (`docker-compose.yml`)
- ✅ Development Docker Compose (`docker-compose.dev.yml`)
- ✅ Optimized Dockerfile for Next.js
- ✅ PostgreSQL 16 Alpine with persistence
- ✅ Redis 7 Alpine with AOF persistence
- ✅ Adminer database UI (optional)
- ✅ Custom bridge network for services
3. **Database Setup**
- ✅ PostgreSQL initialization script
- ✅ UUID and pg_trgm extensions
- ✅ Health checks for all services
- ✅ Volume persistence
4. **Documentation**
- ✅ Updated README.md
- ✅ Created DOCKER_SETUP.md (comprehensive guide)
- ✅ Created MIGRATION_FROM_SUPABASE.md
- ✅ Created docker/README.md
- ✅ Created CHANGELOG.md
- ✅ Created env.example template
5. **Developer Tools**
- ✅ Setup script for Linux/Mac (`scripts/setup.sh`)
- ✅ Setup script for Windows (`scripts/setup.ps1`)
- ✅ npm Docker scripts
- ✅ .dockerignore for optimization
6. **Environment Configuration**
- ✅ Created env.example template
- ✅ Updated environment validation
- ✅ Simplified configuration
## 🚀 How to Get Started
### Option 1: Quick Setup (Recommended)
#### Windows:
```powershell
cd scripts
.\setup.ps1
```
#### Linux/Mac:
```bash
chmod +x scripts/setup.sh
./scripts/setup.sh
```
### Option 2: Manual Setup
#### Development Mode (Database in Docker, App on Host)
```bash
# 1. Copy environment file
cp env.example .env
# 2. Edit .env and set NEXTAUTH_SECRET and IP_SALT
# Generate with: openssl rand -base64 32
# 3. Install dependencies
npm install
# 4. Start database services
npm run docker:dev
# 5. Run migrations
npm run db:migrate
# 6. Seed database
npm run db:seed
# 7. Start development server
npm run dev
```
#### Production Mode (Full Stack in Docker)
```bash
# 1. Copy and configure environment
cp env.example .env
# Edit .env with your settings
# 2. Build and start
npm run docker:prod
# 3. Run migrations
docker-compose exec web npx prisma migrate deploy
# 4. Access at http://localhost:3050
```
## 📍 Access Points
After setup, you can access:
- **🌐 Application**: http://localhost:3050
- **🗄️ Database UI (Adminer)**: http://localhost:8080
- System: PostgreSQL
- Server: db
- Username: postgres
- Password: postgres
- Database: qrmaster
- **💾 PostgreSQL**: localhost:5432
- **🔴 Redis**: localhost:6379
## 📦 What's Included
### Docker Services
| Service | Image | Port | Purpose |
|---------|-------|------|---------|
| web | Next.js (custom) | 3050 | Application |
| db | postgres:16-alpine | 5432 | Database |
| redis | redis:7-alpine | 6379 | Cache |
| adminer | adminer:latest | 8080 | DB UI |
### File Structure
```
QRMASTER/
├── docker/
│ ├── init-db.sh # PostgreSQL initialization
│ └── README.md # Docker commands
├── scripts/
│ ├── setup.sh # Quick setup (Linux/Mac)
│ └── setup.ps1 # Quick setup (Windows)
├── src/ # Application code
├── prisma/
│ └── schema.prisma # Updated schema (no directUrl)
├── docker-compose.yml # Production setup
├── docker-compose.dev.yml # Development setup
├── Dockerfile # Application container
├── env.example # Environment template
├── .dockerignore # Docker build optimization
├── DOCKER_SETUP.md # Complete Docker guide
├── MIGRATION_FROM_SUPABASE.md # Migration guide
├── CHANGELOG.md # What changed
└── README.md # Updated main docs
```
## 🛠️ Useful Commands
### Development
```bash
npm run dev # Start dev server
npm run docker:dev # Start database only
npm run docker:dev:stop # Stop database
```
### Database
```bash
npm run db:migrate # Run migrations
npm run db:seed # Seed database
npm run db:studio # Open Prisma Studio
npm run docker:db # PostgreSQL CLI
```
### Docker
```bash
npm run docker:prod # Start all services
npm run docker:stop # Stop all services
npm run docker:logs # View logs
npm run docker:backup # Backup database
```
### Management
```bash
docker-compose ps # Check status
docker-compose logs -f # Follow logs
docker-compose restart web # Restart app
docker-compose exec db psql -U postgres -d qrmaster # DB CLI
```
## 📚 Documentation
- **[README.md](README.md)** - Main documentation with quick start
- **[DOCKER_SETUP.md](DOCKER_SETUP.md)** - Complete Docker guide with troubleshooting
- **[MIGRATION_FROM_SUPABASE.md](MIGRATION_FROM_SUPABASE.md)** - Migration guide from Supabase
- **[docker/README.md](docker/README.md)** - Docker commands and operations
- **[CHANGELOG.md](CHANGELOG.md)** - What changed in this version
## 🔐 Security Checklist
Before deploying to production:
- [ ] Change PostgreSQL password in docker-compose.yml
- [ ] Set strong NEXTAUTH_SECRET (generate with `openssl rand -base64 32`)
- [ ] Set strong IP_SALT (generate with `openssl rand -base64 32`)
- [ ] Update NEXTAUTH_URL to your domain
- [ ] Enable HTTPS/SSL
- [ ] Set up firewall rules
- [ ] Configure automated backups
- [ ] Review and test all environment variables
## 🎯 Next Steps
1. **Test the Application**
```bash
npm run docker:dev
npm run dev
# Visit http://localhost:3050
```
2. **Review Configuration**
- Check `.env` file
- Verify database connection
- Test authentication
3. **Set Up Backups**
```bash
# Manual backup
npm run docker:backup
# Or create automated backup script
# See DOCKER_SETUP.md for examples
```
4. **Customize**
- Update database passwords
- Configure OAuth providers
- Adjust resource limits
- Set up monitoring
## 🆘 Need Help?
### Common Issues
**Database won't start:**
```bash
docker-compose logs db
docker-compose restart db
```
**Port already in use:**
```bash
# Windows
netstat -ano | findstr :3050
# Change port in docker-compose.yml if needed
```
**Prisma errors:**
```bash
npm run db:generate
npm run db:migrate
```
### Resources
- **DOCKER_SETUP.md** - Comprehensive troubleshooting
- **docker/README.md** - Common Docker commands
- **MIGRATION_FROM_SUPABASE.md** - Migration help
### Support
1. Check the documentation files
2. Review logs: `docker-compose logs -f`
3. Check service health: `docker-compose ps`
4. Open an issue on GitHub
## ✨ Features
Your application now has:
- ✅ **Self-hosted PostgreSQL** - Full control over your data
- ✅ **Redis caching** - Improved performance
- ✅ **Docker Compose** - Easy deployment
- ✅ **Health checks** - Automatic monitoring
- ✅ **Data persistence** - Volumes for data safety
- ✅ **Database UI** - Adminer for easy management
- ✅ **Development mode** - Run only what you need
- ✅ **Production ready** - Optimized Docker builds
- ✅ **Complete docs** - Multiple guides and references
## 🎊 Success!
You're now ready to develop and deploy QR Master with your own PostgreSQL database!
**Demo Credentials:**
- Email: demo@qrmaster.com
- Password: demo123
---
**Happy coding!** 🚀
Need more help? Check the documentation or run the setup scripts.

66
docker-compose.dev.yml Normal file
View File

@ -0,0 +1,66 @@
services:
# PostgreSQL Database
db:
image: postgres:16-alpine
container_name: qrmaster-db-dev
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: qrmaster
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=en_US.utf8"
ports:
- "5435:5432"
volumes:
- dbdata_dev:/var/lib/postgresql/data
- ./docker/init-db.sh:/docker-entrypoint-initdb.d/init-db.sh
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U postgres -d qrmaster" ]
interval: 5s
timeout: 5s
retries: 10
networks:
- qrmaster-network
# Redis Cache
redis:
image: redis:7-alpine
container_name: qrmaster-redis-dev
restart: unless-stopped
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
ports:
- "6379:6379"
volumes:
- redisdata_dev:/data
healthcheck:
test: [ "CMD", "redis-cli", "ping" ]
interval: 5s
timeout: 3s
retries: 5
networks:
- qrmaster-network
# Adminer - Database Management UI
adminer:
image: adminer:latest
container_name: qrmaster-adminer-dev
restart: unless-stopped
ports:
- "8081:8080"
environment:
ADMINER_DEFAULT_SERVER: db
ADMINER_DESIGN: pepa-linha
depends_on:
- db
networks:
- qrmaster-network
volumes:
dbdata_dev:
driver: local
redisdata_dev:
driver: local
networks:
qrmaster-network:
driver: bridge

96
docker-compose.yml Normal file
View File

@ -0,0 +1,96 @@
services:
# PostgreSQL Database
db:
image: postgres:16-alpine
container_name: qrmaster-db
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: qrmaster
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=en_US.utf8"
ports:
- "5432:5432"
volumes:
- dbdata:/var/lib/postgresql/data
- ./docker/init-db.sh:/docker-entrypoint-initdb.d/init-db.sh
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U postgres -d qrmaster" ]
interval: 5s
timeout: 5s
retries: 10
networks:
- qrmaster-network
# Redis Cache
redis:
image: redis:7-alpine
container_name: qrmaster-redis
restart: unless-stopped
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
ports:
- "6379:6379"
volumes:
- redisdata:/data
healthcheck:
test: [ "CMD", "redis-cli", "ping" ]
interval: 5s
timeout: 3s
retries: 5
networks:
- qrmaster-network
# Next.js Application
web:
build:
context: .
dockerfile: Dockerfile
container_name: qrmaster-web
restart: unless-stopped
ports:
- "3050:3000"
environment:
NODE_ENV: production
DATABASE_URL: postgresql://postgres:postgres@db:5432/qrmaster?schema=public
REDIS_URL: redis://redis:6379
NEXTAUTH_URL: http://localhost:3050
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-your-secret-key-change-in-production}
IP_SALT: ${IP_SALT:-your-salt-change-in-production}
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: [ "CMD", "node", "-e", "require('http').get('http://localhost:3000',()=>process.exit(0)).on('error',()=>process.exit(1))" ]
interval: 10s
timeout: 3s
retries: 10
networks:
- qrmaster-network
# Adminer - Database Management UI (Optional)
adminer:
image: adminer:latest
container_name: qrmaster-adminer
restart: unless-stopped
ports:
- "8080:8080"
environment:
ADMINER_DEFAULT_SERVER: db
depends_on:
- db
networks:
- qrmaster-network
profiles:
- dev
volumes:
dbdata:
driver: local
redisdata:
driver: local
networks:
qrmaster-network:
driver: bridge

276
docker/README.md Normal file
View File

@ -0,0 +1,276 @@
# Docker Setup for QR Master
This directory contains Docker configuration files for running QR Master with PostgreSQL database.
## 🚀 Quick Start
### Development (Database Only)
For local development where you run Next.js on your host machine:
```bash
# Start PostgreSQL and Redis
docker-compose -f docker-compose.dev.yml up -d
# Run database migrations
npm run db:migrate
# Start the development server
npm run dev
```
Access:
- **Application**: http://localhost:3050
- **Database**: localhost:5432
- **Redis**: localhost:6379
- **Adminer (DB UI)**: http://localhost:8080
### Production (Full Stack)
To run the entire application in Docker:
```bash
# Build and start all services
docker-compose up -d --build
# Run database migrations
docker-compose exec web npx prisma migrate deploy
# (Optional) Seed the database
docker-compose exec web npm run db:seed
```
Access:
- **Application**: http://localhost:3050
- **Database**: localhost:5432
- **Redis**: localhost:6379
- **Adminer (DB UI)**: http://localhost:8080 (only with --profile dev)
To include Adminer in production mode:
```bash
docker-compose --profile dev up -d
```
## 📦 Services
### PostgreSQL (db)
- **Image**: postgres:16-alpine
- **Port**: 5432
- **Database**: qrmaster
- **User**: postgres
- **Password**: postgres (change in production!)
### Redis (redis)
- **Image**: redis:7-alpine
- **Port**: 6379
- **Max Memory**: 256MB with LRU eviction policy
- **Persistence**: AOF enabled
### Next.js Application (web)
- **Port**: 3050
- **Environment**: Production
- **Health Check**: HTTP GET on localhost:3050
### Adminer (adminer)
- **Image**: adminer:latest
- **Port**: 8080
- **Purpose**: Database management UI
- **Profile**: dev (optional in production)
## 🗄️ Database Management
### Migrations
```bash
# Create a new migration
npm run db:migrate
# Deploy migrations in Docker
docker-compose exec web npx prisma migrate deploy
# Reset database (caution!)
docker-compose exec web npx prisma migrate reset
```
### Prisma Studio
```bash
# On host machine
npm run db:studio
# Or in Docker
docker-compose exec web npx prisma studio
```
### Backup and Restore
```bash
# Backup
docker-compose exec db pg_dump -U postgres qrmaster > backup.sql
# Restore
docker-compose exec -T db psql -U postgres qrmaster < backup.sql
```
## 🔧 Useful Commands
### View Logs
```bash
# All services
docker-compose logs -f
# Specific service
docker-compose logs -f web
docker-compose logs -f db
docker-compose logs -f redis
```
### Shell Access
```bash
# Next.js container
docker-compose exec web sh
# PostgreSQL container
docker-compose exec db psql -U postgres -d qrmaster
# Redis container
docker-compose exec redis redis-cli
```
### Stop and Clean
```bash
# Stop all services
docker-compose down
# Stop and remove volumes (deletes data!)
docker-compose down -v
# Stop and remove everything including images
docker-compose down -v --rmi all
```
## 🔐 Environment Variables
Create a `.env` file in the root directory (copy from `env.example`):
```bash
cp env.example .env
```
Required variables:
- `DATABASE_URL`: PostgreSQL connection string
- `NEXTAUTH_SECRET`: Secret for NextAuth.js
- `NEXTAUTH_URL`: Application URL
- `IP_SALT`: Salt for hashing IP addresses
- `REDIS_URL`: Redis connection string
## 🌐 Network Architecture
All services run on a custom bridge network `qrmaster-network` which allows:
- Service discovery by container name
- Network isolation from other Docker projects
- Internal DNS resolution
## 📊 Volumes
### Persistent Data
- `dbdata`: PostgreSQL data
- `redisdata`: Redis data
### Volume Management
```bash
# List volumes
docker volume ls
# Inspect volume
docker volume inspect qrmaster_dbdata
# Remove all unused volumes
docker volume prune
```
## 🐛 Troubleshooting
### Database Connection Issues
```bash
# Check if database is ready
docker-compose exec db pg_isready -U postgres
# Check database logs
docker-compose logs db
# Restart database
docker-compose restart db
```
### Application Won't Start
```bash
# Check health status
docker-compose ps
# View application logs
docker-compose logs web
# Rebuild the application
docker-compose up -d --build web
```
### Port Already in Use
If ports 3050, 5432, 6379, or 8080 are already in use:
```bash
# Find process using port
# Windows
netstat -ano | findstr :3050
# Linux/Mac
lsof -i :3050
# Kill process or change port in docker-compose.yml
```
## 🔄 Updates and Maintenance
### Update Dependencies
```bash
# Update Node packages
npm update
# Rebuild Docker images
docker-compose build --no-cache
```
### Update Docker Images
```bash
# Pull latest images
docker-compose pull
# Restart with new images
docker-compose up -d
```
## 📝 Notes
- **Development**: Use `docker-compose.dev.yml` to run only the database and Redis
- **Production**: Use `docker-compose.yml` to run the full stack
- **Security**: Always change default passwords in production
- **Backups**: Implement regular database backups in production
- **Scaling**: For production, consider using PostgreSQL replication and Redis Sentinel
## 🆘 Support
For more information, see:
- [Docker Documentation](https://docs.docker.com/)
- [PostgreSQL Documentation](https://www.postgresql.org/docs/)
- [Prisma Documentation](https://www.prisma.io/docs/)
- [Next.js Documentation](https://nextjs.org/docs)

26
docker/init-db.sh Normal file
View File

@ -0,0 +1,26 @@
#!/bin/bash
set -e
# This script runs when the PostgreSQL container is first created
# It ensures the database is properly initialized
echo "🚀 Initializing QR Master database..."
# Create the database if it doesn't exist (already created by POSTGRES_DB)
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
-- Enable required extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
-- Grant privileges
GRANT ALL PRIVILEGES ON DATABASE qrmaster TO postgres;
-- Set timezone
ALTER DATABASE qrmaster SET timezone TO 'UTC';
EOSQL
echo "✅ Database initialization complete!"
echo "📊 Database: $POSTGRES_DB"
echo "👤 User: $POSTGRES_USER"
echo "🌐 Ready to accept connections on port 5432"

28
env.example Normal file
View File

@ -0,0 +1,28 @@
# Environment Configuration
NODE_ENV=development
PORT=3000
# Database Configuration (PostgreSQL)
# For local development (without Docker):
# DATABASE_URL=postgresql://postgres:postgres@localhost:5432/qrmaster?schema=public
# For Docker Compose:
DATABASE_URL=postgresql://postgres:postgres@db:5432/qrmaster?schema=public
# NextAuth Configuration
NEXTAUTH_URL=http://localhost:3050
NEXTAUTH_SECRET=your-secret-key-here-change-in-production
# OAuth Providers (Optional)
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Redis Configuration (Optional - for rate limiting and caching)
REDIS_URL=redis://redis:6379
# Security
# Used for hashing IP addresses in analytics
IP_SALT=your-ip-salt-here-change-in-production
# Features
ENABLE_DEMO=false

12
next.config.mjs Normal file
View File

@ -0,0 +1,12 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
images: {
unoptimized: true
},
experimental: {
serverComponentsExternalPackages: ['@prisma/client', 'bcryptjs']
}
}
export default nextConfig

7926
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

71
package.json Normal file
View File

@ -0,0 +1,71 @@
{
"name": "qr-master",
"version": "1.0.0",
"description": "Create custom QR codes in seconds",
"private": true,
"scripts": {
"dev": "next dev -p 3050",
"build": "prisma generate && next build",
"start": "next start",
"lint": "next lint",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:deploy": "prisma migrate deploy",
"db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio",
"postinstall": "prisma generate",
"docker:dev": "docker compose -f docker-compose.dev.yml up -d",
"docker:dev:stop": "docker compose -f docker-compose.dev.yml down",
"docker:dev:clean": "docker compose -f docker-compose.dev.yml down --remove-orphans && docker container prune -f",
"docker:prod": "docker compose up -d --build",
"docker:stop": "docker compose down",
"docker:logs": "docker compose logs -f",
"docker:db": "docker compose exec db psql -U postgres -d qrmaster",
"docker:redis": "docker compose exec redis redis-cli",
"docker:backup": "docker compose exec db pg_dump -U postgres qrmaster > backup_$(date +%Y%m%d).sql"
},
"dependencies": {
"@auth/prisma-adapter": "^1.0.12",
"@prisma/client": "^5.7.0",
"bcryptjs": "^2.4.3",
"chart.js": "^4.4.0",
"clsx": "^2.0.0",
"dayjs": "^1.11.10",
"i18next": "^23.7.6",
"ioredis": "^5.3.2",
"next": "14.0.4",
"next-auth": "^4.24.5",
"papaparse": "^5.4.1",
"qrcode": "^1.5.3",
"qrcode.react": "^3.1.0",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-i18next": "^13.5.0",
"sharp": "^0.33.1",
"tailwind-merge": "^2.2.0",
"xlsx": "^0.18.5",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20.10.5",
"@types/papaparse": "^5.3.14",
"@types/qrcode": "^1.5.5",
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"autoprefixer": "^10.4.16",
"eslint": "^8.56.0",
"eslint-config-next": "14.0.4",
"postcss": "^8.4.32",
"prettier": "^3.1.1",
"prisma": "^5.7.0",
"tailwindcss": "^3.3.6",
"tsx": "^4.7.0",
"typescript": "^5.3.3"
},
"engines": {
"node": ">=18.0.0"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

135
prisma/schema.prisma Normal file
View File

@ -0,0 +1,135 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String?
password String?
image String?
emailVerified DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
qrCodes QRCode[]
integrations Integration[]
accounts Account[]
sessions Session[]
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
model QRCode {
id String @id @default(cuid())
userId String
title String
type QRType @default(DYNAMIC)
contentType ContentType @default(URL)
content Json
tags String[]
status QRStatus @default(ACTIVE)
style Json
slug String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
scans QRScan[]
@@index([userId, createdAt])
}
enum QRType {
STATIC
DYNAMIC
}
enum ContentType {
URL
WIFI
VCARD
PHONE
EMAIL
SMS
TEXT
WHATSAPP
}
enum QRStatus {
ACTIVE
PAUSED
}
model QRScan {
id String @id @default(cuid())
qrId String
ts DateTime @default(now())
ipHash String
userAgent String?
device String?
os String?
country String?
referrer String?
utmSource String?
utmMedium String?
utmCampaign String?
isUnique Boolean @default(false)
qr QRCode @relation(fields: [qrId], references: [id], onDelete: Cascade)
@@index([qrId, ts])
}
model Integration {
id String @id @default(cuid())
userId String
provider String
status String @default("inactive")
config Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}

108
prisma/seed.ts Normal file
View File

@ -0,0 +1,108 @@
import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
async function main() {
// Create demo user
const hashedPassword = await bcrypt.hash('demo123', 12);
const user = await prisma.user.upsert({
where: { email: 'demo@qrmaster.com' },
update: {},
create: {
email: 'demo@qrmaster.com',
name: 'Demo User',
password: hashedPassword,
},
});
console.log('Created demo user:', user.email);
// Create demo QR codes
const qrCodes = [
{
title: 'Support Phone',
contentType: 'PHONE' as const,
content: { phone: '+1-555-0123' },
tags: ['support', 'contact'],
slug: 'support-phone-demo',
},
{
title: 'Event Details',
contentType: 'URL' as const,
content: { url: 'https://example.com/event-2025' },
tags: ['event', 'conference'],
slug: 'event-details-demo',
},
{
title: 'Product Demo',
contentType: 'URL' as const,
content: { url: 'https://example.com/product-demo' },
tags: ['product', 'demo'],
slug: 'product-demo-qr',
},
{
title: 'Company Website',
contentType: 'URL' as const,
content: { url: 'https://company.example.com' },
tags: ['website', 'company'],
slug: 'company-website-qr',
},
{
title: 'Contact Email',
contentType: 'EMAIL' as const,
content: { email: 'contact@company.com', subject: 'Inquiry' },
tags: ['contact', 'email'],
slug: 'contact-email-qr',
},
{
title: 'Event Details',
contentType: 'URL' as const,
content: { url: 'https://example.com/event-duplicate' },
tags: ['event', 'duplicate'],
slug: 'event-details-dup',
},
];
const baseDate = new Date('2025-08-07T10:00:00Z');
for (let i = 0; i < qrCodes.length; i++) {
const qrData = qrCodes[i];
const createdAt = new Date(baseDate.getTime() + i * 60000); // 1 minute apart
await prisma.qRCode.upsert({
where: { slug: qrData.slug },
update: {},
create: {
userId: user.id,
title: qrData.title,
type: 'DYNAMIC',
contentType: qrData.contentType,
content: qrData.content,
tags: qrData.tags,
status: 'ACTIVE',
style: {
foregroundColor: '#000000',
backgroundColor: '#FFFFFF',
cornerStyle: 'square',
size: 200,
},
slug: qrData.slug,
createdAt,
updatedAt: createdAt,
},
});
}
console.log('Created 6 demo QR codes');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

21
public/logo.svg Normal file
View File

@ -0,0 +1,21 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="8" fill="#2563EB"/>
<rect x="6" y="6" width="4" height="4" fill="white"/>
<rect x="6" y="12" width="4" height="4" fill="white"/>
<rect x="6" y="18" width="4" height="4" fill="white"/>
<rect x="12" y="6" width="4" height="4" fill="white"/>
<rect x="12" y="18" width="4" height="4" fill="white"/>
<rect x="18" y="6" width="4" height="4" fill="white"/>
<rect x="18" y="12" width="4" height="4" fill="white"/>
<rect x="18" y="18" width="4" height="4" fill="white"/>
<rect x="24" y="6" width="2" height="2" fill="white"/>
<rect x="24" y="10" width="2" height="2" fill="white"/>
<rect x="24" y="14" width="2" height="2" fill="white"/>
<rect x="24" y="18" width="2" height="2" fill="white"/>
<rect x="24" y="22" width="2" height="2" fill="white"/>
<rect x="6" y="24" width="2" height="2" fill="white"/>
<rect x="10" y="24" width="2" height="2" fill="white"/>
<rect x="14" y="24" width="2" height="2" fill="white"/>
<rect x="18" y="24" width="2" height="2" fill="white"/>
<rect x="22" y="24" width="2" height="2" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

137
scripts/setup.ps1 Normal file
View File

@ -0,0 +1,137 @@
# QR Master - Quick Setup Script (PowerShell for Windows)
# This script automates the initial setup process
Write-Host "🚀 QR Master - Quick Setup" -ForegroundColor Cyan
Write-Host "================================" -ForegroundColor Cyan
Write-Host ""
# Check if Docker is installed
try {
docker --version | Out-Null
Write-Host "✓ Docker is installed" -ForegroundColor Green
} catch {
Write-Host "❌ Docker is not installed. Please install Docker Desktop first." -ForegroundColor Red
exit 1
}
# Check if Docker Compose is installed
try {
docker-compose --version | Out-Null
Write-Host "✓ Docker Compose is installed" -ForegroundColor Green
} catch {
Write-Host "❌ Docker Compose is not installed. Please install Docker Desktop first." -ForegroundColor Red
exit 1
}
Write-Host ""
# Check if .env exists
if (-Not (Test-Path .env)) {
Write-Host "📝 Creating .env file from template..." -ForegroundColor Yellow
Copy-Item env.example .env
# Generate secrets
$NEXTAUTH_SECRET = [Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Maximum 256 }))
$IP_SALT = [Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Maximum 256 }))
# Update .env with generated secrets
(Get-Content .env) -replace 'NEXTAUTH_SECRET=.*', "NEXTAUTH_SECRET=$NEXTAUTH_SECRET" | Set-Content .env
(Get-Content .env) -replace 'IP_SALT=.*', "IP_SALT=$IP_SALT" | Set-Content .env
Write-Host "✓ Generated secure secrets" -ForegroundColor Green
} else {
Write-Host "✓ .env file already exists" -ForegroundColor Green
}
Write-Host ""
# Ask user what mode they want
Write-Host "Choose setup mode:"
Write-Host "1) Development (database only in Docker, app on host)"
Write-Host "2) Production (full stack in Docker)"
$choice = Read-Host "Enter choice [1-2]"
Write-Host ""
switch ($choice) {
"1" {
Write-Host "🔧 Setting up development environment..." -ForegroundColor Cyan
Write-Host ""
# Start database services
Write-Host "Starting PostgreSQL and Redis..."
docker-compose -f docker-compose.dev.yml up -d
# Wait for database to be ready
Write-Host "Waiting for database to be ready..."
Start-Sleep -Seconds 5
# Install dependencies
Write-Host "Installing dependencies..."
npm install
# Run migrations
Write-Host "Running database migrations..."
npm run db:migrate
# Seed database
Write-Host "Seeding database with demo data..."
npm run db:seed
Write-Host ""
Write-Host "✅ Development environment ready!" -ForegroundColor Green
Write-Host ""
Write-Host "To start the application:"
Write-Host " npm run dev"
Write-Host ""
Write-Host "Access points:"
Write-Host " - App: http://localhost:3050"
Write-Host " - Database UI: http://localhost:8080"
Write-Host " - Database: localhost:5432"
Write-Host " - Redis: localhost:6379"
}
"2" {
Write-Host "🚀 Setting up production environment..." -ForegroundColor Cyan
Write-Host ""
# Build and start all services
Write-Host "Building and starting all services..."
docker-compose up -d --build
# Wait for services to be ready
Write-Host "Waiting for services to be ready..."
Start-Sleep -Seconds 10
# Run migrations
Write-Host "Running database migrations..."
docker-compose exec web npx prisma migrate deploy
# Seed database
Write-Host "Seeding database with demo data..."
docker-compose exec web npm run db:seed
Write-Host ""
Write-Host "✅ Production environment ready!" -ForegroundColor Green
Write-Host ""
Write-Host "Access points:"
Write-Host " - App: http://localhost:3050"
Write-Host " - Database: localhost:5432"
Write-Host " - Redis: localhost:6379"
Write-Host ""
Write-Host "To view logs:"
Write-Host " docker-compose logs -f"
}
default {
Write-Host "❌ Invalid choice. Exiting." -ForegroundColor Red
exit 1
}
}
Write-Host ""
Write-Host "📚 Documentation:"
Write-Host " - Quick start: README.md"
Write-Host " - Docker guide: DOCKER_SETUP.md"
Write-Host " - Migration guide: MIGRATION_FROM_SUPABASE.md"
Write-Host ""
Write-Host "🎉 Setup complete! Happy coding!" -ForegroundColor Green

148
scripts/setup.sh Normal file
View File

@ -0,0 +1,148 @@
#!/bin/bash
# QR Master - Quick Setup Script
# This script automates the initial setup process
set -e
echo "🚀 QR Master - Quick Setup"
echo "================================"
echo ""
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Check if Docker is installed
if ! command -v docker &> /dev/null; then
echo -e "${RED}❌ Docker is not installed. Please install Docker first.${NC}"
exit 1
fi
# Check if Docker Compose is installed
if ! command -v docker-compose &> /dev/null; then
echo -e "${RED}❌ Docker Compose is not installed. Please install Docker Compose first.${NC}"
exit 1
fi
echo -e "${GREEN}${NC} Docker is installed"
echo -e "${GREEN}${NC} Docker Compose is installed"
echo ""
# Check if .env exists
if [ ! -f .env ]; then
echo "📝 Creating .env file from template..."
cp env.example .env
# Generate secrets
if command -v openssl &> /dev/null; then
NEXTAUTH_SECRET=$(openssl rand -base64 32)
IP_SALT=$(openssl rand -base64 32)
# Update .env with generated secrets
sed -i.bak "s|NEXTAUTH_SECRET=.*|NEXTAUTH_SECRET=$NEXTAUTH_SECRET|" .env
sed -i.bak "s|IP_SALT=.*|IP_SALT=$IP_SALT|" .env
rm .env.bak 2>/dev/null || true
echo -e "${GREEN}${NC} Generated secure secrets"
else
echo -e "${YELLOW}${NC} OpenSSL not found. Please manually update NEXTAUTH_SECRET and IP_SALT in .env"
fi
else
echo -e "${GREEN}${NC} .env file already exists"
fi
echo ""
# Ask user what mode they want
echo "Choose setup mode:"
echo "1) Development (database only in Docker, app on host)"
echo "2) Production (full stack in Docker)"
read -p "Enter choice [1-2]: " choice
echo ""
case $choice in
1)
echo "🔧 Setting up development environment..."
echo ""
# Start database services
echo "Starting PostgreSQL and Redis..."
docker-compose -f docker-compose.dev.yml up -d
# Wait for database to be ready
echo "Waiting for database to be ready..."
sleep 5
# Install dependencies
echo "Installing dependencies..."
npm install
# Run migrations
echo "Running database migrations..."
npm run db:migrate
# Seed database
echo "Seeding database with demo data..."
npm run db:seed
echo ""
echo -e "${GREEN}✅ Development environment ready!${NC}"
echo ""
echo "To start the application:"
echo " npm run dev"
echo ""
echo "Access points:"
echo " - App: http://localhost:3050"
echo " - Database UI: http://localhost:8080"
echo " - Database: localhost:5432"
echo " - Redis: localhost:6379"
;;
2)
echo "🚀 Setting up production environment..."
echo ""
# Build and start all services
echo "Building and starting all services..."
docker-compose up -d --build
# Wait for services to be ready
echo "Waiting for services to be ready..."
sleep 10
# Run migrations
echo "Running database migrations..."
docker-compose exec web npx prisma migrate deploy
# Seed database
echo "Seeding database with demo data..."
docker-compose exec web npm run db:seed
echo ""
echo -e "${GREEN}✅ Production environment ready!${NC}"
echo ""
echo "Access points:"
echo " - App: http://localhost:3050"
echo " - Database: localhost:5432"
echo " - Redis: localhost:6379"
echo ""
echo "To view logs:"
echo " docker-compose logs -f"
;;
*)
echo -e "${RED}Invalid choice. Exiting.${NC}"
exit 1
;;
esac
echo ""
echo "📚 Documentation:"
echo " - Quick start: README.md"
echo " - Docker guide: DOCKER_SETUP.md"
echo " - Migration guide: MIGRATION_FROM_SUPABASE.md"
echo ""
echo "🎉 Setup complete! Happy coding!"

View File

@ -0,0 +1,406 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Table } from '@/components/ui/Table';
import { useTranslation } from '@/hooks/useTranslation';
import { Line, Bar, Doughnut } from 'react-chartjs-2';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
ArcElement,
Title,
Tooltip,
Legend,
Filler,
} from 'chart.js';
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
ArcElement,
Title,
Tooltip,
Legend,
Filler
);
export default function AnalyticsPage() {
const { t } = useTranslation();
const [timeRange, setTimeRange] = useState('7');
const [loading, setLoading] = useState(true);
const [analyticsData, setAnalyticsData] = useState<any>(null);
useEffect(() => {
fetchAnalytics();
}, [timeRange]);
const fetchAnalytics = async () => {
try {
const response = await fetch('/api/analytics/summary');
if (response.ok) {
const data = await response.json();
setAnalyticsData(data);
} else {
// Set empty data if not authorized
setAnalyticsData({
summary: {
totalScans: 0,
uniqueScans: 0,
avgScansPerQR: 0,
mobilePercentage: 0,
topCountry: 'N/A',
topCountryPercentage: 0,
},
deviceStats: {},
countryStats: [],
dailyScans: {},
qrPerformance: [],
});
}
} catch (error) {
console.error('Error fetching analytics:', error);
setAnalyticsData({
summary: {
totalScans: 0,
uniqueScans: 0,
avgScansPerQR: 0,
mobilePercentage: 0,
topCountry: 'N/A',
topCountryPercentage: 0,
},
deviceStats: {},
countryStats: [],
dailyScans: {},
qrPerformance: [],
});
} finally {
setLoading(false);
}
};
const exportReport = () => {
// Create CSV data
const csvData = [
['QR Master Analytics Report'],
['Generated:', new Date().toLocaleString()],
[''],
['Summary'],
['Total Scans', analyticsData?.summary.totalScans || 0],
['Unique Scans', analyticsData?.summary.uniqueScans || 0],
['Average Scans per QR', analyticsData?.summary.avgScansPerQR || 0],
['Mobile Usage %', analyticsData?.summary.mobilePercentage || 0],
[''],
['Top QR Codes'],
['Title', 'Type', 'Total Scans', 'Unique Scans', 'Conversion %'],
...(analyticsData?.qrPerformance || []).map((qr: any) => [
qr.title,
qr.type,
qr.totalScans,
qr.uniqueScans,
qr.conversion,
]),
];
// Convert to CSV string
const csv = csvData.map(row => row.join(',')).join('\n');
// Download
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `qr-analytics-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
};
// Prepare chart data
const last7Days = Array.from({ length: 7 }, (_, i) => {
const date = new Date();
date.setDate(date.getDate() - (6 - i));
return date.toISOString().split('T')[0];
});
const scanChartData = {
labels: last7Days.map(date => {
const d = new Date(date);
return d.toLocaleDateString('en', { month: 'short', day: 'numeric' });
}),
datasets: [
{
label: 'Scans',
data: last7Days.map(date => analyticsData?.dailyScans[date] || 0),
borderColor: 'rgb(37, 99, 235)',
backgroundColor: 'rgba(37, 99, 235, 0.1)',
tension: 0.4,
fill: true,
},
],
};
const deviceChartData = {
labels: ['Desktop', 'Mobile', 'Tablet'],
datasets: [
{
data: [
analyticsData?.deviceStats.desktop || 0,
analyticsData?.deviceStats.mobile || 0,
analyticsData?.deviceStats.tablet || 0,
],
backgroundColor: [
'rgba(37, 99, 235, 0.8)',
'rgba(34, 197, 94, 0.8)',
'rgba(249, 115, 22, 0.8)',
],
},
],
};
if (loading) {
return (
<div className="space-y-8">
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/4 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-1/2 mb-8"></div>
<div className="grid md:grid-cols-4 gap-6 mb-8">
{[1, 2, 3, 4].map(i => (
<div key={i} className="h-32 bg-gray-200 rounded"></div>
))}
</div>
</div>
</div>
);
}
return (
<div className="space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">{t('analytics.title')}</h1>
<p className="text-gray-600 mt-2">{t('analytics.subtitle')}</p>
</div>
<Button onClick={exportReport}>Export Report</Button>
</div>
{/* Time Range Selector */}
<div className="flex space-x-2">
{['7', '30', '90'].map((days) => (
<button
key={days}
onClick={() => setTimeRange(days)}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
timeRange === days
? 'bg-primary-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{days} Days
</button>
))}
</div>
{/* KPI Cards */}
<div className="grid md:grid-cols-4 gap-6">
<Card>
<CardContent className="p-6">
<p className="text-sm text-gray-600 mb-1">Total Scans</p>
<p className="text-2xl font-bold text-gray-900">
{analyticsData?.summary.totalScans.toLocaleString() || '0'}
</p>
<p className={`text-sm mt-2 ${analyticsData?.summary.totalScans > 0 ? 'text-green-600' : 'text-gray-500'}`}>
{analyticsData?.summary.totalScans > 0 ? '+12.5%' : 'No data'} from last period
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<p className="text-sm text-gray-600 mb-1">Avg Scans/QR</p>
<p className="text-2xl font-bold text-gray-900">
{analyticsData?.summary.avgScansPerQR || '0'}
</p>
<p className={`text-sm mt-2 ${analyticsData?.summary.avgScansPerQR > 0 ? 'text-green-600' : 'text-gray-500'}`}>
{analyticsData?.summary.avgScansPerQR > 0 ? '+8.3%' : 'No data'} from last period
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<p className="text-sm text-gray-600 mb-1">Mobile Usage</p>
<p className="text-2xl font-bold text-gray-900">
{analyticsData?.summary.mobilePercentage || '0'}%
</p>
<p className="text-sm mt-2 text-gray-500">
{analyticsData?.summary.mobilePercentage > 0 ? 'Of total scans' : 'No mobile scans'}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<p className="text-sm text-gray-600 mb-1">Top Country</p>
<p className="text-2xl font-bold text-gray-900">
{analyticsData?.summary.topCountry || 'N/A'}
</p>
<p className="text-sm mt-2 text-gray-500">
{analyticsData?.summary.topCountryPercentage || '0'}% of total
</p>
</CardContent>
</Card>
</div>
{/* Charts */}
<div className="grid lg:grid-cols-2 gap-8">
{/* Scans Over Time */}
<Card>
<CardHeader>
<CardTitle>Scans Over Time</CardTitle>
</CardHeader>
<CardContent>
<div className="h-64">
<Line
data={scanChartData}
options={{
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
},
scales: {
y: {
beginAtZero: true,
ticks: {
precision: 0,
},
},
},
}}
/>
</div>
</CardContent>
</Card>
{/* Device Types */}
<Card>
<CardHeader>
<CardTitle>Device Types</CardTitle>
</CardHeader>
<CardContent>
<div className="h-64 flex items-center justify-center">
{analyticsData?.summary.totalScans > 0 ? (
<Doughnut
data={deviceChartData}
options={{
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
},
},
}}
/>
) : (
<p className="text-gray-500">No scan data available</p>
)}
</div>
</CardContent>
</Card>
</div>
{/* Top Countries Table */}
<Card>
<CardHeader>
<CardTitle>Top Countries</CardTitle>
</CardHeader>
<CardContent>
{analyticsData?.countryStats.length > 0 ? (
<Table>
<thead>
<tr>
<th>Country</th>
<th>Scans</th>
<th>Percentage</th>
<th>Trend</th>
</tr>
</thead>
<tbody>
{analyticsData.countryStats.map((country: any, index: number) => (
<tr key={index}>
<td>{country.country}</td>
<td>{country.count.toLocaleString()}</td>
<td>{country.percentage}%</td>
<td>
<Badge variant="success"></Badge>
</td>
</tr>
))}
</tbody>
</Table>
) : (
<p className="text-gray-500 text-center py-8">No country data available yet</p>
)}
</CardContent>
</Card>
{/* QR Code Performance Table */}
<Card>
<CardHeader>
<CardTitle>QR Code Performance</CardTitle>
</CardHeader>
<CardContent>
{analyticsData?.qrPerformance.length > 0 ? (
<Table>
<thead>
<tr>
<th>QR Code</th>
<th>Type</th>
<th>Total Scans</th>
<th>Unique Scans</th>
<th>Conversion</th>
<th>Trend</th>
</tr>
</thead>
<tbody>
{analyticsData.qrPerformance.map((qr: any) => (
<tr key={qr.id}>
<td className="font-medium">{qr.title}</td>
<td>
<Badge variant={qr.type === 'DYNAMIC' ? 'info' : 'default'}>
{qr.type}
</Badge>
</td>
<td>{qr.totalScans.toLocaleString()}</td>
<td>{qr.uniqueScans.toLocaleString()}</td>
<td>{qr.conversion}%</td>
<td>
<Badge variant={qr.totalScans > 0 ? 'success' : 'default'}>
{qr.totalScans > 0 ? '↑' : '—'}
</Badge>
</td>
</tr>
))}
</tbody>
</Table>
) : (
<p className="text-gray-500 text-center py-8">
No QR codes created yet. Create your first QR code to see analytics!
</p>
)}
</CardContent>
</Card>
</div>
);
}

410
src/app/(app)/bulk/page.tsx Normal file
View File

@ -0,0 +1,410 @@
'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>
);
}

View File

@ -0,0 +1,527 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { QRCodeSVG } from 'qrcode.react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { calculateContrast } from '@/lib/utils';
import { useTranslation } from '@/hooks/useTranslation';
export default function CreatePage() {
const router = useRouter();
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
// Form state
const [title, setTitle] = useState('');
const [contentType, setContentType] = useState('URL');
const [content, setContent] = useState<any>({ url: '' });
const [isDynamic, setIsDynamic] = useState(true);
const [tags, setTags] = useState('');
// Style state
const [foregroundColor, setForegroundColor] = useState('#000000');
const [backgroundColor, setBackgroundColor] = useState('#FFFFFF');
const [cornerStyle, setCornerStyle] = useState('square');
const [size, setSize] = useState(200);
// QR preview
const [qrDataUrl, setQrDataUrl] = useState('');
const contrast = calculateContrast(foregroundColor, backgroundColor);
const hasGoodContrast = contrast >= 4.5;
const contentTypes = [
{ value: 'URL', label: 'URL / Website' },
{ value: 'WIFI', label: 'WiFi Network' },
{ value: 'VCARD', label: 'Contact Card' },
{ value: 'PHONE', label: 'Phone Number' },
{ value: 'EMAIL', label: 'Email' },
{ value: 'SMS', label: 'SMS' },
{ value: 'TEXT', label: 'Plain Text' },
{ value: 'WHATSAPP', label: 'WhatsApp' },
];
// Get QR content based on content type
const getQRContent = () => {
switch (contentType) {
case 'URL':
return content.url || 'https://example.com';
case 'PHONE':
return `tel:${content.phone || '+1234567890'}`;
case 'EMAIL':
return `mailto:${content.email || 'email@example.com'}${content.subject ? `?subject=${encodeURIComponent(content.subject)}` : ''}`;
case 'SMS':
return `sms:${content.phone || '+1234567890'}${content.message ? `?body=${encodeURIComponent(content.message)}` : ''}`;
case 'WIFI':
return `WIFI:T:${content.security || 'WPA'};S:${content.ssid || 'NetworkName'};P:${content.password || ''};H:false;;`;
case 'TEXT':
return content.text || 'Sample text';
case 'WHATSAPP':
return `https://wa.me/${content.phone || '+1234567890'}${content.message ? `?text=${encodeURIComponent(content.message)}` : ''}`;
default:
return 'https://example.com';
}
};
const qrContent = getQRContent();
const downloadQR = async (format: 'svg' | 'png') => {
try {
// Get the content based on content type
let qrContent = '';
switch (contentType) {
case 'URL':
qrContent = content.url || '';
break;
case 'PHONE':
qrContent = `tel:${content.phone || ''}`;
break;
case 'EMAIL':
qrContent = `mailto:${content.email || ''}${content.subject ? `?subject=${encodeURIComponent(content.subject)}` : ''}`;
break;
case 'TEXT':
qrContent = content.text || '';
break;
default:
qrContent = content.url || '';
}
if (!qrContent) return;
const QRCode = (await import('qrcode')).default;
if (format === 'svg') {
const svg = await QRCode.toString(qrContent, {
type: 'svg',
width: size,
margin: 2,
color: {
dark: foregroundColor,
light: backgroundColor,
},
});
const blob = new Blob([svg], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `qrcode-${title || 'download'}.svg`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} else {
const a = document.createElement('a');
a.href = qrDataUrl;
a.download = `qrcode-${title || 'download'}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
} catch (err) {
console.error('Error downloading QR code:', err);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const qrData = {
title,
contentType,
content,
isStatic: !isDynamic, // Add this flag
tags: tags.split(',').map(t => t.trim()).filter(Boolean),
style: {
foregroundColor,
backgroundColor,
cornerStyle,
size,
},
};
console.log('SENDING QR DATA:', qrData);
const response = await fetch('/api/qrs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(qrData),
});
const responseData = await response.json();
console.log('RESPONSE DATA:', responseData);
if (response.ok) {
// Show what was saved
alert(`QR Code saved!\n\nType: ${responseData.type}\nContent: ${JSON.stringify(responseData.content, null, 2)}`);
router.push('/dashboard');
router.refresh(); // Force refresh to get new data
} else {
console.error('Error creating QR code:', responseData);
alert('Error creating QR code: ' + (responseData.error || 'Unknown error'));
}
} catch (error) {
console.error('Error creating QR code:', error);
alert('Error creating QR code');
} finally {
setLoading(false);
}
};
const renderContentFields = () => {
switch (contentType) {
case 'URL':
return (
<Input
label="URL"
value={content.url || ''}
onChange={(e) => setContent({ url: e.target.value })}
placeholder="https://example.com"
required
/>
);
case 'PHONE':
return (
<Input
label="Phone Number"
value={content.phone || ''}
onChange={(e) => setContent({ phone: e.target.value })}
placeholder="+1234567890"
required
/>
);
case 'EMAIL':
return (
<>
<Input
label="Email Address"
type="email"
value={content.email || ''}
onChange={(e) => setContent({ ...content, email: e.target.value })}
placeholder="contact@example.com"
required
/>
<Input
label="Subject (optional)"
value={content.subject || ''}
onChange={(e) => setContent({ ...content, subject: e.target.value })}
placeholder="Email subject"
/>
</>
);
case 'WIFI':
return (
<>
<Input
label="Network Name (SSID)"
value={content.ssid || ''}
onChange={(e) => setContent({ ...content, ssid: e.target.value })}
required
/>
<Input
label="Password"
type="password"
value={content.password || ''}
onChange={(e) => setContent({ ...content, password: e.target.value })}
/>
<Select
label="Security"
value={content.security || 'WPA'}
onChange={(e) => setContent({ ...content, security: e.target.value })}
options={[
{ value: 'WPA', label: 'WPA/WPA2' },
{ value: 'WEP', label: 'WEP' },
{ value: 'nopass', label: 'No Password' },
]}
/>
</>
);
case 'TEXT':
return (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Text</label>
<textarea
value={content.text || ''}
onChange={(e) => setContent({ text: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
rows={4}
placeholder="Enter your text here..."
required
/>
</div>
);
default:
return null;
}
};
return (
<div className="max-w-6xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">{t('create.title')}</h1>
</div>
<form onSubmit={handleSubmit}>
<div className="grid lg:grid-cols-3 gap-8">
{/* Left: Form */}
<div className="lg:col-span-2 space-y-6">
{/* Content Section */}
<Card>
<CardHeader>
<CardTitle>{t('create.content')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Input
label="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="My QR Code"
required
/>
<Select
label="Content Type"
value={contentType}
onChange={(e) => setContentType(e.target.value)}
options={contentTypes}
/>
{renderContentFields()}
<Input
label="Tags (comma-separated)"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="marketing, campaign, 2024"
/>
</CardContent>
</Card>
{/* QR Type Section */}
<Card>
<CardHeader>
<CardTitle>{t('create.type')}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center space-x-4">
<label className="flex items-center cursor-pointer">
<input
type="radio"
checked={isDynamic}
onChange={() => setIsDynamic(true)}
className="mr-2"
/>
<span className="font-medium">Dynamic</span>
<Badge variant="info" className="ml-2">Recommended</Badge>
</label>
<label className="flex items-center cursor-pointer">
<input
type="radio"
checked={!isDynamic}
onChange={() => setIsDynamic(false)}
className="mr-2"
/>
<span className="font-medium">Static (Direct URL)</span>
</label>
</div>
<p className="text-sm text-gray-600 mt-2">
{isDynamic
? '✅ Dynamic: Track scans, edit URL later, view analytics. QR contains tracking link.'
: '⚡ Static: Direct to URL, no tracking, cannot edit. QR contains actual URL.'}
</p>
{isDynamic && (
<div className="mt-3 p-3 bg-blue-50 rounded-lg">
<p className="text-sm text-blue-900">
<strong>Note:</strong> Dynamic QR codes route through your server for tracking.
In production, deploy your app to get a public URL instead of localhost.
</p>
</div>
)}
</CardContent>
</Card>
{/* Style Section */}
<Card>
<CardHeader>
<CardTitle>{t('create.style')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Foreground Color
</label>
<div className="flex items-center space-x-2">
<input
type="color"
value={foregroundColor}
onChange={(e) => setForegroundColor(e.target.value)}
className="w-12 h-10 rounded border border-gray-300"
/>
<Input
value={foregroundColor}
onChange={(e) => setForegroundColor(e.target.value)}
className="flex-1"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Background Color
</label>
<div className="flex items-center space-x-2">
<input
type="color"
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
className="w-12 h-10 rounded border border-gray-300"
/>
<Input
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
className="flex-1"
/>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<Select
label="Corner Style"
value={cornerStyle}
onChange={(e) => setCornerStyle(e.target.value)}
options={[
{ value: 'square', label: 'Square' },
{ value: 'rounded', label: 'Rounded' },
]}
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Size: {size}px
</label>
<input
type="range"
min="100"
max="400"
value={size}
onChange={(e) => setSize(Number(e.target.value))}
className="w-full"
/>
</div>
</div>
<div className="flex items-center justify-between">
<Badge variant={hasGoodContrast ? 'success' : 'warning'}>
{hasGoodContrast ? 'Good contrast' : 'Low contrast'}
</Badge>
<span className="text-sm text-gray-500">
Contrast ratio: {contrast.toFixed(1)}:1
</span>
</div>
</CardContent>
</Card>
</div>
{/* Right: Preview */}
<div className="lg:col-span-1">
<Card className="sticky top-6">
<CardHeader>
<CardTitle>{t('create.preview')}</CardTitle>
</CardHeader>
<CardContent className="text-center">
<div id="create-qr-preview" className="flex justify-center mb-4">
{qrContent ? (
<div className={cornerStyle === 'rounded' ? 'rounded-lg overflow-hidden' : ''}>
<QRCodeSVG
value={qrContent}
size={200}
fgColor={foregroundColor}
bgColor={backgroundColor}
level="M"
/>
</div>
) : (
<div className="w-[200px] h-[200px] bg-gray-100 rounded flex items-center justify-center text-gray-500">
Enter content
</div>
)}
</div>
<div className="space-y-3">
<Button
variant="outline"
className="w-full"
type="button"
onClick={() => {
const svg = document.querySelector('#create-qr-preview svg');
if (!svg) return;
const svgData = new XMLSerializer().serializeToString(svg);
const blob = new Blob([svgData], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title || 'qrcode'}.svg`;
a.click();
URL.revokeObjectURL(url);
}}
disabled={!qrContent}
>
Download SVG
</Button>
<Button
variant="outline"
className="w-full"
type="button"
onClick={() => {
const svg = document.querySelector('#create-qr-preview svg');
if (!svg) return;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
const svgData = new XMLSerializer().serializeToString(svg);
const blob = new Blob([svgData], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
img.onload = () => {
canvas.width = 200;
canvas.height = 200;
ctx?.drawImage(img, 0, 0);
canvas.toBlob((blob) => {
if (blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title || 'qrcode'}.png`;
a.click();
URL.revokeObjectURL(url);
}
});
};
img.src = url;
}}
disabled={!qrContent}
>
Download PNG
</Button>
<Button type="submit" className="w-full" loading={loading}>
Save QR Code
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,258 @@
'use client';
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { StatsGrid } from '@/components/dashboard/StatsGrid';
import { QRCodeCard } from '@/components/dashboard/QRCodeCard';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { useTranslation } from '@/hooks/useTranslation';
interface QRCodeData {
id: string;
title: string;
type: 'STATIC' | 'DYNAMIC';
contentType: string;
content?: any;
slug: string;
status: 'ACTIVE' | 'PAUSED';
createdAt: string;
scans: number;
}
export default function DashboardPage() {
const { t } = useTranslation();
const [qrCodes, setQrCodes] = useState<QRCodeData[]>([]);
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState({
totalScans: 0,
activeQRCodes: 0,
conversionRate: 0,
});
const mockQRCodes = [
{
id: '1',
title: 'Support Phone',
type: 'DYNAMIC' as const,
contentType: 'PHONE',
slug: 'support-phone-demo',
status: 'ACTIVE' as const,
createdAt: '2025-08-07T10:00:00Z',
scans: 0,
},
{
id: '2',
title: 'Event Details',
type: 'DYNAMIC' as const,
contentType: 'URL',
slug: 'event-details-demo',
status: 'ACTIVE' as const,
createdAt: '2025-08-07T10:01:00Z',
scans: 0,
},
{
id: '3',
title: 'Product Demo',
type: 'DYNAMIC' as const,
contentType: 'URL',
slug: 'product-demo-qr',
status: 'ACTIVE' as const,
createdAt: '2025-08-07T10:02:00Z',
scans: 0,
},
{
id: '4',
title: 'Company Website',
type: 'DYNAMIC' as const,
contentType: 'URL',
slug: 'company-website-qr',
status: 'ACTIVE' as const,
createdAt: '2025-08-07T10:03:00Z',
scans: 0,
},
{
id: '5',
title: 'Contact Email',
type: 'DYNAMIC' as const,
contentType: 'EMAIL',
slug: 'contact-email-qr',
status: 'ACTIVE' as const,
createdAt: '2025-08-07T10:04:00Z',
scans: 0,
},
{
id: '6',
title: 'Event Details',
type: 'DYNAMIC' as const,
contentType: 'URL',
slug: 'event-details-dup',
status: 'ACTIVE' as const,
createdAt: '2025-08-07T10:05:00Z',
scans: 0,
},
];
const blogPosts = [
{
title: 'QR-Codes im Restaurant: Die digitale Revolution der Speisekarte',
excerpt: 'Erfahren Sie, wie QR-Codes die Gastronomie revolutionieren...',
readTime: '5 Min',
slug: 'qr-codes-im-restaurant',
},
{
title: 'Dynamische vs. Statische QR-Codes: Was ist der Unterschied?',
excerpt: 'Ein umfassender Vergleich zwischen dynamischen und statischen QR-Codes...',
readTime: '3 Min',
slug: 'dynamische-vs-statische-qr-codes',
},
{
title: 'QR-Code Marketing-Strategien für 2024',
excerpt: 'Die besten Marketing-Strategien mit QR-Codes für Ihr Unternehmen...',
readTime: '7 Min',
slug: 'qr-code-marketing-strategien',
},
];
useEffect(() => {
// Load real QR codes from API
const fetchData = async () => {
try {
const response = await fetch('/api/qrs');
if (response.ok) {
const data = await response.json();
setQrCodes(data);
// Calculate real stats
const totalScans = data.reduce((sum: number, qr: QRCodeData) => sum + (qr.scans || 0), 0);
const activeQRCodes = data.filter((qr: QRCodeData) => qr.status === 'ACTIVE').length;
const conversionRate = activeQRCodes > 0 ? Math.round((totalScans / (activeQRCodes * 100)) * 100) : 0;
setStats({
totalScans,
activeQRCodes,
conversionRate: Math.min(conversionRate, 100), // Cap at 100%
});
} else {
// If not logged in, show zeros
setQrCodes([]);
setStats({
totalScans: 0,
activeQRCodes: 0,
conversionRate: 0,
});
}
} catch (error) {
console.error('Error fetching data:', error);
setQrCodes([]);
setStats({
totalScans: 0,
activeQRCodes: 0,
conversionRate: 0,
});
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const handleEdit = (id: string) => {
console.log('Edit QR:', id);
};
const handleDuplicate = (id: string) => {
console.log('Duplicate QR:', id);
};
const handlePause = (id: string) => {
console.log('Pause QR:', id);
};
const handleDelete = (id: string) => {
console.log('Delete QR:', id);
};
return (
<div className="space-y-8">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-gray-900">{t('dashboard.title')}</h1>
<p className="text-gray-600 mt-2">{t('dashboard.subtitle')}</p>
</div>
{/* Stats Grid */}
<StatsGrid stats={stats} />
{/* Recent QR Codes */}
<div>
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-gray-900">{t('dashboard.recent_codes')}</h2>
<Link href="/create">
<Button>Create New QR Code</Button>
</Link>
</div>
{loading ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Card key={i}>
<CardContent className="p-4">
<div className="animate-pulse">
<div className="h-4 bg-gray-200 rounded w-3/4 mb-3"></div>
<div className="h-24 bg-gray-200 rounded mb-3"></div>
<div className="space-y-2">
<div className="h-3 bg-gray-200 rounded"></div>
<div className="h-3 bg-gray-200 rounded"></div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
) : (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{qrCodes.map((qr) => (
<QRCodeCard
key={qr.id}
qr={qr}
onEdit={handleEdit}
onDuplicate={handleDuplicate}
onPause={handlePause}
onDelete={handleDelete}
/>
))}
</div>
)}
</div>
{/* Blog & Resources */}
<div>
<h2 className="text-xl font-semibold text-gray-900 mb-6">{t('dashboard.blog_resources')}</h2>
<div className="grid md:grid-cols-3 gap-6">
{blogPosts.map((post) => (
<Card key={post.slug} hover>
<CardHeader>
<div className="flex items-center justify-between mb-2">
<Badge variant="info">{post.readTime}</Badge>
</div>
<CardTitle className="text-lg">{post.title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-600 text-sm">{post.excerpt}</p>
<Link
href={`/blog/${post.slug}`}
className="text-primary-600 hover:text-primary-700 text-sm font-medium mt-3 inline-block"
>
Read more
</Link>
</CardContent>
</Card>
))}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,392 @@
'use client';
import React, { useState } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Input } from '@/components/ui/Input';
import { Dialog } from '@/components/ui/Dialog';
import { useTranslation } from '@/hooks/useTranslation';
interface Integration {
id: string;
name: string;
description: string;
icon: string;
status: 'active' | 'inactive' | 'coming_soon';
category: string;
features: string[];
}
export default function IntegrationsPage() {
const { t } = useTranslation();
const [selectedIntegration, setSelectedIntegration] = useState<Integration | null>(null);
const [showSetupDialog, setShowSetupDialog] = useState(false);
const [apiKey, setApiKey] = useState('');
const [webhookUrl, setWebhookUrl] = useState('');
const integrations: Integration[] = [
{
id: 'zapier',
name: 'Zapier',
description: 'Connect QR Master with 5,000+ apps',
icon: '⚡',
status: 'active',
category: 'Automation',
features: [
'Trigger actions when QR codes are scanned',
'Create QR codes from other apps',
'Update QR destinations automatically',
'Sync analytics to spreadsheets',
],
},
{
id: 'airtable',
name: 'Airtable',
description: 'Sync QR codes with your Airtable bases',
icon: '📊',
status: 'inactive',
category: 'Database',
features: [
'Two-way sync with Airtable',
'Bulk import from bases',
'Auto-update QR content',
'Analytics dashboard integration',
],
},
{
id: 'google-sheets',
name: 'Google Sheets',
description: 'Manage QR codes from spreadsheets',
icon: '📈',
status: 'inactive',
category: 'Spreadsheet',
features: [
'Import QR codes from sheets',
'Export analytics data',
'Real-time sync',
'Collaborative QR management',
],
},
{
id: 'slack',
name: 'Slack',
description: 'Get QR scan notifications in Slack',
icon: '💬',
status: 'coming_soon',
category: 'Communication',
features: [
'Real-time scan notifications',
'Daily analytics summaries',
'Team collaboration',
'Custom alert rules',
],
},
{
id: 'webhook',
name: 'Webhooks',
description: 'Send data to any URL',
icon: '🔗',
status: 'active',
category: 'Developer',
features: [
'Custom webhook endpoints',
'Real-time event streaming',
'Retry logic',
'Event filtering',
],
},
{
id: 'api',
name: 'REST API',
description: 'Full programmatic access',
icon: '🔧',
status: 'active',
category: 'Developer',
features: [
'Complete CRUD operations',
'Bulk operations',
'Analytics API',
'Rate limiting: 1000 req/hour',
],
},
];
const stats = {
totalQRCodes: 234,
activeIntegrations: 2,
syncStatus: 'Synced',
availableServices: 6,
};
const handleActivate = (integration: Integration) => {
setSelectedIntegration(integration);
setShowSetupDialog(true);
};
const handleTestConnection = async () => {
// Simulate API test
await new Promise(resolve => setTimeout(resolve, 1000));
alert('Connection successful!');
};
const handleSaveIntegration = () => {
setShowSetupDialog(false);
// Update integration status
};
return (
<div className="space-y-8">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-gray-900">{t('integrations.title')}</h1>
<p className="text-gray-600 mt-2">{t('integrations.subtitle')}</p>
</div>
{/* Stats */}
<div className="grid md:grid-cols-4 gap-6">
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 mb-1">QR Codes Total</p>
<p className="text-2xl font-bold text-gray-900">{stats.totalQRCodes}</p>
</div>
<div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
</svg>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 mb-1">Active Integrations</p>
<p className="text-2xl font-bold text-gray-900">{stats.activeIntegrations}</p>
</div>
<div className="w-12 h-12 bg-success-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-success-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>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 mb-1">Sync Status</p>
<p className="text-2xl font-bold text-gray-900">{stats.syncStatus}</p>
</div>
<div className="w-12 h-12 bg-info-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-info-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 mb-1">Available Services</p>
<p className="text-2xl font-bold text-gray-900">{stats.availableServices}</p>
</div>
<div className="w-12 h-12 bg-warning-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-warning-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z" />
</svg>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Integration Cards */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{integrations.map((integration) => (
<Card key={integration.id} className="hover:shadow-lg transition-shadow">
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-center space-x-3">
<div className="text-3xl">{integration.icon}</div>
<div>
<CardTitle className="text-lg">{integration.name}</CardTitle>
<Badge
variant={
integration.status === 'active' ? 'success' :
integration.status === 'coming_soon' ? 'warning' :
'default'
}
className="mt-1"
>
{integration.status === 'active' ? 'Active' :
integration.status === 'coming_soon' ? 'Coming Soon' :
'Inactive'}
</Badge>
</div>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-600 mb-4">{integration.description}</p>
<div className="space-y-2 mb-4">
{integration.features.slice(0, 3).map((feature, index) => (
<div key={index} className="flex items-start space-x-2">
<svg className="w-4 h-4 text-success-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-sm text-gray-700">{feature}</span>
</div>
))}
</div>
{integration.status === 'active' ? (
<Button variant="outline" className="w-full">
Configure
</Button>
) : integration.status === 'coming_soon' ? (
<Button variant="outline" className="w-full" disabled>
Coming Soon
</Button>
) : (
<Button className="w-full" onClick={() => handleActivate(integration)}>
Activate & Configure
</Button>
)}
</CardContent>
</Card>
))}
</div>
{/* Setup Dialog */}
{showSetupDialog && selectedIntegration && (
<Dialog
open={showSetupDialog}
onOpenChange={setShowSetupDialog}
>
<div className="bg-white rounded-xl shadow-lg border border-gray-200 p-6 max-w-lg mx-auto">
<h2 className="text-lg font-semibold mb-4">Setup {selectedIntegration.name}</h2>
<div className="space-y-4">
{selectedIntegration.id === 'zapier' && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Webhook URL
</label>
<Input
value="https://hooks.zapier.com/hooks/catch/123456/abcdef/"
readOnly
className="font-mono text-sm"
/>
<p className="text-sm text-gray-500 mt-1">
Copy this URL to your Zapier trigger
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Events to Send
</label>
<div className="space-y-2">
<label className="flex items-center">
<input type="checkbox" className="mr-2" defaultChecked />
<span className="text-sm">QR Code Scanned</span>
</label>
<label className="flex items-center">
<input type="checkbox" className="mr-2" defaultChecked />
<span className="text-sm">QR Code Created</span>
</label>
<label className="flex items-center">
<input type="checkbox" className="mr-2" />
<span className="text-sm">QR Code Updated</span>
</label>
</div>
</div>
<div className="p-4 bg-gray-50 rounded-lg">
<h4 className="font-medium text-gray-900 mb-2">Sample Payload</h4>
<pre className="text-xs text-gray-600 overflow-x-auto">
{`{
"event": "qr_scanned",
"qr_id": "abc123",
"title": "Product Page",
"timestamp": "2024-01-01T12:00:00Z",
"location": "United States",
"device": "mobile"
}`}
</pre>
</div>
</>
)}
{selectedIntegration.id === 'airtable' && (
<>
<Input
label="API Key"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="key..."
/>
<Input
label="Base ID"
value=""
placeholder="app..."
/>
<Input
label="Table Name"
value=""
placeholder="QR Codes"
/>
<Button variant="outline" onClick={handleTestConnection}>
Test Connection
</Button>
</>
)}
{selectedIntegration.id === 'google-sheets' && (
<>
<div className="text-center p-6">
<Button>
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Connect Google Account
</Button>
</div>
<Input
label="Spreadsheet URL"
value=""
placeholder="https://docs.google.com/spreadsheets/..."
/>
</>
)}
<div className="flex justify-end space-x-3 pt-4">
<Button variant="outline" onClick={() => setShowSetupDialog(false)}>
Cancel
</Button>
<Button onClick={handleSaveIntegration}>
Save Integration
</Button>
</div>
</div>
</div>
</Dialog>
)}
</div>
);
}

159
src/app/(app)/layout.tsx Normal file
View File

@ -0,0 +1,159 @@
'use client';
import React, { useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { signOut } from 'next-auth/react';
import { Button } from '@/components/ui/Button';
import { Dropdown, DropdownItem } from '@/components/ui/Dropdown';
import { useTranslation } from '@/hooks/useTranslation';
export default function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
const { t, locale, setLocale } = useTranslation();
const [sidebarOpen, setSidebarOpen] = useState(false);
const navigation = [
{
name: 'Dashboard',
href: '/dashboard',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
),
},
{
name: 'Create QR',
href: '/create',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
),
},
{
name: 'Analytics',
href: '/analytics',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
},
];
return (
<div className="min-h-screen bg-gray-50">
{/* Mobile sidebar backdrop */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={`fixed top-0 left-0 z-50 h-full w-64 bg-white border-r border-gray-200 transform transition-transform lg:translate-x-0 ${
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<Link href="/" className="flex items-center space-x-2">
<img src="/logo.svg" alt="QR Master" className="w-8 h-8" />
<span className="text-xl font-bold text-gray-900">QR Master</span>
</Link>
<button
className="lg:hidden"
onClick={() => setSidebarOpen(false)}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<nav className="p-4 space-y-1">
{navigation.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.name}
href={item.href}
className={`flex items-center space-x-3 px-3 py-2 rounded-lg transition-colors ${
isActive
? 'bg-primary-50 text-primary-600'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
{item.icon}
<span className="font-medium">{item.name}</span>
</Link>
);
})}
</nav>
</aside>
{/* Main content */}
<div className="lg:ml-64">
{/* Top bar */}
<header className="bg-white border-b border-gray-200">
<div className="flex items-center justify-between px-4 py-3">
<button
className="lg:hidden"
onClick={() => setSidebarOpen(true)}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<div className="flex items-center space-x-4 ml-auto">
{/* Language Switcher */}
<button
onClick={() => setLocale(locale === 'en' ? 'de' : 'en')}
className="text-gray-600 hover:text-gray-900 font-medium"
>
{locale === 'en' ? '🇩🇪 DE' : '🇬🇧 EN'}
</button>
{/* User Menu */}
<Dropdown
align="right"
trigger={
<button className="flex items-center space-x-2 text-gray-700 hover:text-gray-900">
<div className="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center">
<span className="text-sm font-medium text-primary-600">
U
</span>
</div>
<span className="hidden md:block font-medium">
User
</span>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
}
>
<DropdownItem onClick={() => signOut()}>
Sign Out
</DropdownItem>
</Dropdown>
</div>
</div>
</header>
{/* Page content */}
<main className="p-6">
{children}
</main>
</div>
</div>
);
}

View File

@ -0,0 +1,453 @@
'use client';
import React, { useState } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { useTranslation } from '@/hooks/useTranslation';
export default function SettingsPage() {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState('profile');
const [loading, setLoading] = useState(false);
// Form states
const [profile, setProfile] = useState({
name: '',
email: '',
company: '',
phone: '',
});
// Load user data from localStorage
React.useEffect(() => {
const userStr = localStorage.getItem('user');
if (userStr) {
const user = JSON.parse(userStr);
setProfile({
name: user.name || '',
email: user.email || '',
company: user.company || '',
phone: user.phone || '',
});
}
}, []);
const [apiKey, setApiKey] = useState('');
const [showApiKey, setShowApiKey] = useState(false);
const tabs = [
{ id: 'profile', label: 'Profile', icon: '👤' },
{ id: 'billing', label: 'Billing', icon: '💳' },
{ id: 'team', label: 'Team & Roles', icon: '👥' },
{ id: 'api', label: 'API Keys', icon: '🔑' },
{ id: 'workspace', label: 'Workspace', icon: '🏢' },
];
const generateApiKey = () => {
const key = 'qrm_' + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
setApiKey(key);
setShowApiKey(true);
};
const handleSaveProfile = async () => {
setLoading(true);
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
setLoading(false);
};
return (
<div className="max-w-6xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">{t('settings.title')}</h1>
<p className="text-gray-600 mt-2">{t('settings.subtitle')}</p>
</div>
<div className="grid lg:grid-cols-4 gap-8">
{/* Sidebar */}
<div className="lg:col-span-1">
<Card>
<CardContent className="p-2">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`w-full text-left px-4 py-3 rounded-lg flex items-center space-x-3 transition-colors ${
activeTab === tab.id
? 'bg-primary-50 text-primary-600'
: 'hover:bg-gray-50 text-gray-700'
}`}
>
<span className="text-xl">{tab.icon}</span>
<span className="font-medium">{tab.label}</span>
</button>
))}
</CardContent>
</Card>
</div>
{/* Content */}
<div className="lg:col-span-3">
{/* Profile Tab */}
{activeTab === 'profile' && (
<Card>
<CardHeader>
<CardTitle>Profile Information</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center space-x-4">
<div className="w-20 h-20 bg-primary-100 rounded-full flex items-center justify-center">
<span className="text-2xl font-bold text-primary-600">JD</span>
</div>
<div>
<Button variant="outline" size="sm">Change Photo</Button>
<p className="text-sm text-gray-500 mt-1">JPG, PNG or GIF. Max 2MB</p>
</div>
</div>
<div className="grid md:grid-cols-2 gap-4">
<Input
label="Full Name"
value={profile.name}
onChange={(e) => setProfile({ ...profile, name: e.target.value })}
/>
<Input
label="Email"
type="email"
value={profile.email}
onChange={(e) => setProfile({ ...profile, email: e.target.value })}
/>
<Input
label="Company"
value={profile.company}
onChange={(e) => setProfile({ ...profile, company: e.target.value })}
/>
<Input
label="Phone"
value={profile.phone}
onChange={(e) => setProfile({ ...profile, phone: e.target.value })}
/>
</div>
<div className="flex justify-end">
<Button onClick={handleSaveProfile} loading={loading}>
Save Changes
</Button>
</div>
</CardContent>
</Card>
)}
{/* Billing Tab */}
{activeTab === 'billing' && (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Current Plan</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between mb-6">
<div>
<div className="flex items-center space-x-2">
<h3 className="text-2xl font-bold text-gray-900">Pro Plan</h3>
<Badge variant="success">Active</Badge>
</div>
<p className="text-gray-600 mt-1">9/month Renews on Jan 1, 2025</p>
</div>
<Button variant="outline">Change Plan</Button>
</div>
<div className="grid md:grid-cols-3 gap-4">
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm text-gray-600 mb-1">QR Codes</p>
<p className="text-xl font-bold text-gray-900">234 / 500</p>
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
<div className="bg-primary-600 h-2 rounded-full" style={{ width: '46.8%' }}></div>
</div>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm text-gray-600 mb-1">Scans</p>
<p className="text-xl font-bold text-gray-900">45,678 / 100,000</p>
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
<div className="bg-primary-600 h-2 rounded-full" style={{ width: '45.7%' }}></div>
</div>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm text-gray-600 mb-1">API Calls</p>
<p className="text-xl font-bold text-gray-900">12,345 / 50,000</p>
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
<div className="bg-primary-600 h-2 rounded-full" style={{ width: '24.7%' }}></div>
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Payment Method</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center space-x-4">
<div className="w-12 h-8 bg-gradient-to-r from-blue-600 to-blue-400 rounded flex items-center justify-center">
<span className="text-white text-xs font-bold">VISA</span>
</div>
<div>
<p className="font-medium text-gray-900"> 4242</p>
<p className="text-sm text-gray-500">Expires 12/25</p>
</div>
</div>
<Button variant="outline" size="sm">Update</Button>
</div>
<Button variant="outline" className="w-full mt-4">
Add Payment Method
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Billing History</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{[
{ date: 'Dec 1, 2024', amount: '€9.00', status: 'Paid' },
{ date: 'Nov 1, 2024', amount: '€9.00', status: 'Paid' },
{ date: 'Oct 1, 2024', amount: '€9.00', status: 'Paid' },
].map((invoice, index) => (
<div key={index} className="flex items-center justify-between py-3 border-b last:border-0">
<div>
<p className="font-medium text-gray-900">{invoice.date}</p>
<p className="text-sm text-gray-500">Pro Plan Monthly</p>
</div>
<div className="flex items-center space-x-3">
<Badge variant="success">{invoice.status}</Badge>
<span className="font-medium text-gray-900">{invoice.amount}</span>
<Button variant="outline" size="sm">
<svg className="w-4 h-4" 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>
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
)}
{/* Team Tab */}
{activeTab === 'team' && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Team Members</CardTitle>
<Button size="sm">Invite Member</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{[
{ name: 'John Doe', email: 'john@example.com', role: 'Owner', status: 'Active' },
{ name: 'Jane Smith', email: 'jane@example.com', role: 'Admin', status: 'Active' },
{ name: 'Bob Johnson', email: 'bob@example.com', role: 'Editor', status: 'Active' },
{ name: 'Alice Brown', email: 'alice@example.com', role: 'Viewer', status: 'Pending' },
].map((member, index) => (
<div key={index} className="flex items-center justify-between py-3 border-b last:border-0">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center">
<span className="text-sm font-medium text-gray-600">
{member.name.split(' ').map(n => n[0]).join('')}
</span>
</div>
<div>
<p className="font-medium text-gray-900">{member.name}</p>
<p className="text-sm text-gray-500">{member.email}</p>
</div>
</div>
<div className="flex items-center space-x-3">
<Badge variant={member.status === 'Active' ? 'success' : 'warning'}>
{member.status}
</Badge>
<select className="px-3 py-1 border rounded-lg text-sm">
<option value="owner" selected={member.role === 'Owner'}>Owner</option>
<option value="admin" selected={member.role === 'Admin'}>Admin</option>
<option value="editor" selected={member.role === 'Editor'}>Editor</option>
<option value="viewer" selected={member.role === 'Viewer'}>Viewer</option>
</select>
{member.role !== 'Owner' && (
<Button variant="outline" size="sm">Remove</Button>
)}
</div>
</div>
))}
</div>
<div className="mt-6 p-4 bg-info-50 rounded-lg">
<p className="text-sm text-info-900">
<strong>Team Seats:</strong> 4 of 5 used
</p>
<p className="text-sm text-info-700 mt-1">
Upgrade to Business plan for unlimited team members
</p>
</div>
</CardContent>
</Card>
)}
{/* API Keys Tab */}
{activeTab === 'api' && (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>API Access</CardTitle>
</CardHeader>
<CardContent>
<div className="mb-6">
<p className="text-gray-600 mb-4">
Use API keys to integrate QR Master with your applications. Keep your keys secure and never share them publicly.
</p>
<div className="p-4 bg-warning-50 rounded-lg">
<p className="text-sm text-warning-900">
<strong> Warning:</strong> API keys provide full access to your account. Treat them like passwords.
</p>
</div>
</div>
{!apiKey ? (
<Button onClick={generateApiKey}>Generate New API Key</Button>
) : (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Your API Key</label>
<div className="flex space-x-2">
<Input
type={showApiKey ? 'text' : 'password'}
value={apiKey}
readOnly
className="font-mono"
/>
<Button
variant="outline"
onClick={() => setShowApiKey(!showApiKey)}
>
{showApiKey ? 'Hide' : 'Show'}
</Button>
<Button
variant="outline"
onClick={() => navigator.clipboard.writeText(apiKey)}
>
Copy
</Button>
</div>
<p className="text-sm text-gray-500 mt-2">
This key will only be shown once. Store it securely.
</p>
</div>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>API Documentation</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<h4 className="font-medium text-gray-900 mb-2">Base URL</h4>
<code className="block p-3 bg-gray-100 rounded text-sm">
https://api.qrmaster.com/v1
</code>
</div>
<div>
<h4 className="font-medium text-gray-900 mb-2">Authentication</h4>
<code className="block p-3 bg-gray-100 rounded text-sm">
Authorization: Bearer YOUR_API_KEY
</code>
</div>
<div>
<h4 className="font-medium text-gray-900 mb-2">Example Request</h4>
<pre className="p-3 bg-gray-100 rounded text-sm overflow-x-auto">
{`curl -X POST https://api.qrmaster.com/v1/qr-codes \\
-H "Authorization: Bearer YOUR_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{"title":"My QR","content":"https://example.com"}'`}
</pre>
</div>
<Button variant="outline">View Full Documentation</Button>
</div>
</CardContent>
</Card>
</div>
)}
{/* Workspace Tab */}
{activeTab === 'workspace' && (
<Card>
<CardHeader>
<CardTitle>Workspace Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Workspace Name</label>
<Input value="Acme Corp" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Workspace URL</label>
<div className="flex">
<span className="inline-flex items-center px-3 rounded-l-lg border border-r-0 border-gray-300 bg-gray-50 text-gray-500 text-sm">
qrmaster.com/
</span>
<Input value="acme-corp" className="rounded-l-none" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Default QR Settings</label>
<div className="space-y-3">
<label className="flex items-center">
<input type="checkbox" className="mr-2" defaultChecked />
<span className="text-sm text-gray-700">Auto-generate slugs for dynamic QR codes</span>
</label>
<label className="flex items-center">
<input type="checkbox" className="mr-2" defaultChecked />
<span className="text-sm text-gray-700">Track scan analytics by default</span>
</label>
<label className="flex items-center">
<input type="checkbox" className="mr-2" />
<span className="text-sm text-gray-700">Require approval for new QR codes</span>
</label>
</div>
</div>
<div className="pt-6 border-t">
<h4 className="text-lg font-medium text-gray-900 mb-4">Danger Zone</h4>
<div className="p-4 border border-red-200 rounded-lg bg-red-50">
<p className="text-sm text-red-900 mb-3">
Deleting your workspace will permanently remove all QR codes, analytics data, and team members.
</p>
<Button variant="outline" className="border-red-300 text-red-600 hover:bg-red-100">
Delete Workspace
</Button>
</div>
</div>
<div className="flex justify-end">
<Button>Save Changes</Button>
</div>
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
}

158
src/app/(app)/test/page.tsx Normal file
View File

@ -0,0 +1,158 @@
'use client';
import React, { useState } from 'react';
import { QRCodeSVG } from 'qrcode.react';
import { Button } from '@/components/ui/Button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
export default function TestPage() {
const [testResults, setTestResults] = useState<any>({});
const [loading, setLoading] = useState(false);
const runTest = async () => {
setLoading(true);
const results: any = {};
try {
// Step 1: Create a STATIC QR code
console.log('Creating STATIC QR code...');
const createResponse = await fetch('/api/qrs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: 'Test Static QR',
contentType: 'URL',
content: { url: 'https://google.com' },
isStatic: true,
tags: [],
style: {
foregroundColor: '#000000',
backgroundColor: '#FFFFFF',
cornerStyle: 'square',
size: 200,
},
}),
});
const createdQR = await createResponse.json();
results.created = createdQR;
console.log('Created QR:', createdQR);
// Step 2: Fetch all QR codes
console.log('Fetching QR codes...');
const fetchResponse = await fetch('/api/qrs');
const allQRs = await fetchResponse.json();
results.fetched = allQRs;
console.log('Fetched QRs:', allQRs);
// Step 3: Check debug endpoint
console.log('Checking debug endpoint...');
const debugResponse = await fetch('/api/debug');
const debugData = await debugResponse.json();
results.debug = debugData;
console.log('Debug data:', debugData);
} catch (error) {
results.error = String(error);
console.error('Test error:', error);
}
setTestResults(results);
setLoading(false);
};
const getQRValue = (qr: any) => {
// Check for qrContent field
if (qr?.content?.qrContent) {
return qr.content.qrContent;
}
// Check for direct URL
if (qr?.content?.url) {
return qr.content.url;
}
// Fallback to redirect
return `http://localhost:3001/r/${qr?.slug || 'unknown'}`;
};
return (
<div className="max-w-4xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-6">QR Code Test Page</h1>
<Card className="mb-6">
<CardHeader>
<CardTitle>Test Static QR Code Creation</CardTitle>
</CardHeader>
<CardContent>
<Button onClick={runTest} loading={loading}>
Run Test
</Button>
{testResults.created && (
<div className="mt-6">
<h3 className="font-semibold mb-2">Created QR Code:</h3>
<pre className="bg-gray-100 p-3 rounded text-xs overflow-auto">
{JSON.stringify(testResults.created, null, 2)}
</pre>
<div className="mt-4">
<h4 className="font-semibold mb-2">QR Code Preview:</h4>
<div className="bg-gray-50 p-4 rounded">
<QRCodeSVG
value={getQRValue(testResults.created)}
size={200}
/>
<p className="mt-2 text-sm text-gray-600">
QR Value: {getQRValue(testResults.created)}
</p>
</div>
</div>
</div>
)}
{testResults.fetched && (
<div className="mt-6">
<h3 className="font-semibold mb-2">All QR Codes:</h3>
<pre className="bg-gray-100 p-3 rounded text-xs overflow-auto max-h-64">
{JSON.stringify(testResults.fetched, null, 2)}
</pre>
</div>
)}
{testResults.debug && (
<div className="mt-6">
<h3 className="font-semibold mb-2">Debug Data:</h3>
<pre className="bg-gray-100 p-3 rounded text-xs overflow-auto max-h-64">
{JSON.stringify(testResults.debug, null, 2)}
</pre>
</div>
)}
{testResults.error && (
<div className="mt-6 p-4 bg-red-50 text-red-600 rounded">
Error: {testResults.error}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Manual QR Tests</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h3 className="font-semibold mb-2">Direct URL QR (Should go to Google):</h3>
<QRCodeSVG value="https://google.com" size={150} />
<p className="text-sm text-gray-600 mt-1">Value: https://google.com</p>
</div>
<div>
<h3 className="font-semibold mb-2">Redirect QR (Goes through localhost):</h3>
<QRCodeSVG value="http://localhost:3001/r/test-slug" size={150} />
<p className="text-sm text-gray-600 mt-1">Value: http://localhost:3001/r/test-slug</p>
</div>
</CardContent>
</Card>
</div>
);
}

11
src/app/(auth)/layout.tsx Normal file
View File

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

View File

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

View File

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

View File

@ -0,0 +1,157 @@
'use client';
import React from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
const blogContent = {
'qr-codes-im-restaurant': {
title: 'QR-Codes im Restaurant: Die digitale Revolution der Speisekarte',
date: '2024-01-15',
readTime: '5 Min',
category: 'Gastronomie',
content: `
<p>Die Gastronomie hat sich in den letzten Jahren stark digitalisiert, und QR-Codes spielen dabei eine zentrale Rolle. Von kontaktlosen Speisekarten bis hin zu digitalen Zahlungssystemen QR-Codes revolutionieren die Art und Weise, wie Restaurants mit ihren Gästen interagieren.</p>
<h2>Vorteile für Restaurants</h2>
<ul>
<li>Kostenersparnis durch digitale Speisekarten</li>
<li>Einfache Aktualisierung von Preisen und Angeboten</li>
<li>Hygienische, kontaktlose Lösung</li>
<li>Mehrsprachige Menüs ohne zusätzliche Druckkosten</li>
</ul>
<h2>Vorteile für Gäste</h2>
<ul>
<li>Schneller Zugriff auf aktuelle Informationen</li>
<li>Detaillierte Produktbeschreibungen und Allergeninformationen</li>
<li>Einfache Bestellung und Bezahlung</li>
<li>Personalisierte Empfehlungen</li>
</ul>
<p>Die Implementierung von QR-Codes in Ihrem Restaurant ist einfacher als Sie denken. Mit QR Master können Sie in wenigen Minuten professionelle QR-Codes erstellen, die perfekt zu Ihrem Branding passen.</p>
`,
},
'dynamische-vs-statische-qr-codes': {
title: 'Dynamische vs. Statische QR-Codes: Was ist der Unterschied?',
date: '2024-01-10',
readTime: '3 Min',
category: 'Grundlagen',
content: `
<p>Bei der Erstellung von QR-Codes stehen Sie vor der Wahl zwischen statischen und dynamischen Codes. Beide haben ihre Vor- und Nachteile, und die richtige Wahl hängt von Ihrem spezifischen Anwendungsfall ab.</p>
<h2>Statische QR-Codes</h2>
<p>Statische QR-Codes enthalten die Informationen direkt im Code selbst. Einmal erstellt, können sie nicht mehr geändert werden.</p>
<ul>
<li>Kostenlos und unbegrenzt nutzbar</li>
<li>Funktionieren für immer ohne Server</li>
<li>Ideal für permanente Informationen</li>
<li>Keine Tracking-Möglichkeiten</li>
</ul>
<h2>Dynamische QR-Codes</h2>
<p>Dynamische QR-Codes verweisen auf eine URL, die Sie jederzeit ändern können.</p>
<ul>
<li>Inhalt kann nachträglich geändert werden</li>
<li>Detaillierte Scan-Statistiken</li>
<li>Kürzere, sauberere QR-Codes</li>
<li>Perfekt für Marketing-Kampagnen</li>
</ul>
<p>Mit QR Master können Sie beide Arten von QR-Codes erstellen und verwalten. Unsere Plattform bietet Ihnen die Flexibilität, die Sie für Ihre Projekte benötigen.</p>
`,
},
'qr-code-marketing-strategien': {
title: 'QR-Code Marketing-Strategien für 2024',
date: '2024-01-05',
readTime: '7 Min',
category: 'Marketing',
content: `
<p>QR-Codes sind zu einem unverzichtbaren Werkzeug im modernen Marketing geworden. Hier sind die effektivsten Strategien für 2024.</p>
<h2>1. Personalisierte Kundenerlebnisse</h2>
<p>Nutzen Sie dynamische QR-Codes, um personalisierte Landingpages basierend auf Standort, Zeit oder Kundenverhalten zu erstellen.</p>
<h2>2. Social Media Integration</h2>
<p>Verbinden Sie QR-Codes mit Ihren Social-Media-Kampagnen für nahtlose Cross-Channel-Erlebnisse.</p>
<h2>3. Event-Marketing</h2>
<p>Von Tickets bis zu Networking QR-Codes machen Events interaktiver und messbar.</p>
<h2>4. Loyalty-Programme</h2>
<p>Digitale Treuekarten und Rabattaktionen lassen sich perfekt mit QR-Codes umsetzen.</p>
<h2>5. Analytics und Optimierung</h2>
<p>Nutzen Sie die Tracking-Funktionen, um Ihre Kampagnen kontinuierlich zu verbessern.</p>
<p>Mit QR Master haben Sie alle Tools, die Sie für erfolgreiches QR-Code-Marketing benötigen. Starten Sie noch heute mit Ihrer ersten Kampagne!</p>
`,
},
};
export default function BlogPostPage() {
const params = useParams();
const slug = params?.slug as string;
const post = blogContent[slug as keyof typeof blogContent];
if (!post) {
return (
<div className="py-20">
<div className="container mx-auto px-4 text-center">
<h1 className="text-4xl font-bold text-gray-900 mb-4">Post not found</h1>
<p className="text-xl text-gray-600 mb-8">The blog post you're looking for doesn't exist.</p>
<Link href="/blog">
<Button>Back to Blog</Button>
</Link>
</div>
</div>
);
}
return (
<div className="py-20">
<div className="container mx-auto px-4">
<div className="max-w-4xl mx-auto">
<Link href="/blog" className="inline-flex items-center text-primary-600 hover:text-primary-700 mb-8">
<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="M15 19l-7-7 7-7" />
</svg>
Back to Blog
</Link>
<article>
<header className="mb-8">
<div className="flex items-center space-x-4 mb-4">
<Badge variant="info">{post.category}</Badge>
<span className="text-gray-500">{post.readTime}</span>
<span className="text-gray-500">{post.date}</span>
</div>
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-4">
{post.title}
</h1>
</header>
<div
className="prose prose-lg max-w-none"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
<div className="mt-12 p-8 bg-primary-50 rounded-xl text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-4">
Ready to create your QR codes?
</h2>
<p className="text-gray-600 mb-6">
Start creating professional QR codes with advanced tracking and analytics.
</p>
<Link href="/dashboard">
<Button size="lg">Get Started Free</Button>
</Link>
</div>
</article>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,76 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
const blogPosts = [
{
slug: 'qr-codes-im-restaurant',
title: 'QR-Codes im Restaurant: Die digitale Revolution der Speisekarte',
excerpt: 'Erfahren Sie, wie QR-Codes die Gastronomie revolutionieren und welche Vorteile sie für Restaurants und Gäste bieten.',
date: '2024-01-15',
readTime: '5 Min',
category: 'Gastronomie',
image: '🍽️',
},
{
slug: 'dynamische-vs-statische-qr-codes',
title: 'Dynamische vs. Statische QR-Codes: Was ist der Unterschied?',
excerpt: 'Ein umfassender Vergleich zwischen dynamischen und statischen QR-Codes und wann Sie welchen Typ verwenden sollten.',
date: '2024-01-10',
readTime: '3 Min',
category: 'Grundlagen',
image: '📊',
},
{
slug: 'qr-code-marketing-strategien',
title: 'QR-Code Marketing-Strategien für 2024',
excerpt: 'Die besten Marketing-Strategien mit QR-Codes für Ihr Unternehmen im Jahr 2024.',
date: '2024-01-05',
readTime: '7 Min',
category: 'Marketing',
image: '📈',
},
];
export default function BlogPage() {
return (
<div className="py-20">
<div className="container mx-auto px-4">
<div className="text-center mb-12">
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-4">
Blog & Resources
</h1>
<p className="text-xl text-gray-600">
Learn about QR codes, best practices, and industry insights
</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8 max-w-6xl mx-auto">
{blogPosts.map((post) => (
<Link key={post.slug} href={`/blog/${post.slug}`}>
<Card hover className="h-full">
<CardHeader>
<div className="text-4xl mb-4 text-center bg-gray-100 rounded-lg py-8">
{post.image}
</div>
<div className="flex items-center justify-between mb-2">
<Badge variant="info">{post.category}</Badge>
<span className="text-sm text-gray-500">{post.readTime}</span>
</div>
<CardTitle className="text-xl">{post.title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-600 mb-4">{post.excerpt}</p>
<p className="text-sm text-gray-500">{post.date}</p>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,15 @@
'use client';
import React from 'react';
import { FAQ } from '@/components/marketing/FAQ';
import { useTranslation } from '@/hooks/useTranslation';
export default function FAQPage() {
const { t } = useTranslation();
return (
<div className="py-20">
<FAQ t={t} />
</div>
);
}

View File

@ -0,0 +1,165 @@
'use client';
import React, { useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Button } from '@/components/ui/Button';
import { useTranslation } from '@/hooks/useTranslation';
export default function MarketingLayout({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
const { t, locale, setLocale } = useTranslation();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const navigation = [
{ name: t('nav.features'), href: '/#features' },
{ name: t('nav.pricing'), href: '/pricing' },
{ name: t('nav.faq'), href: '/faq' },
{ name: t('nav.blog'), href: '/blog' },
];
return (
<div className="min-h-screen bg-white">
{/* Header */}
<header className="sticky top-0 z-50 bg-white border-b border-gray-200">
<nav className="container mx-auto px-4 py-4">
<div className="flex items-center justify-between">
{/* Logo */}
<Link href="/" className="flex items-center space-x-2">
<img src="/logo.svg" alt="QR Master" className="w-8 h-8" />
<span className="text-xl font-bold text-gray-900">QR Master</span>
</Link>
{/* Desktop Navigation */}
<div className="hidden md:flex items-center space-x-8">
{navigation.map((item) => (
<Link
key={item.name}
href={item.href}
className="text-gray-600 hover:text-gray-900 font-medium transition-colors"
>
{item.name}
</Link>
))}
</div>
{/* Right Actions */}
<div className="hidden md:flex items-center space-x-4">
{/* Language Switcher */}
<button
onClick={() => setLocale(locale === 'en' ? 'de' : 'en')}
className="text-gray-600 hover:text-gray-900 font-medium"
>
{locale === 'en' ? '🇩🇪 DE' : '🇬🇧 EN'}
</button>
<Link href="/login">
<Button variant="outline">{t('nav.login')}</Button>
</Link>
<Link href="/dashboard">
<Button>{t('nav.dashboard')}</Button>
</Link>
</div>
{/* Mobile Menu Button */}
<button
className="md:hidden"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{mobileMenuOpen ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
)}
</svg>
</button>
</div>
{/* Mobile Menu */}
{mobileMenuOpen && (
<div className="md:hidden mt-4 pb-4 border-t border-gray-200 pt-4">
<div className="flex flex-col space-y-4">
{navigation.map((item) => (
<Link
key={item.name}
href={item.href}
className="text-gray-600 hover:text-gray-900 font-medium"
onClick={() => setMobileMenuOpen(false)}
>
{item.name}
</Link>
))}
<Link href="/login" onClick={() => setMobileMenuOpen(false)}>
<Button variant="outline" className="w-full">{t('nav.login')}</Button>
</Link>
<Link href="/dashboard" onClick={() => setMobileMenuOpen(false)}>
<Button className="w-full">{t('nav.dashboard')}</Button>
</Link>
</div>
</div>
)}
</nav>
</header>
{/* Main Content */}
<main>{children}</main>
{/* Footer */}
<footer className="bg-gray-900 text-white py-12 mt-20">
<div className="container mx-auto px-4">
<div className="grid md:grid-cols-4 gap-8">
<div>
<div className="flex items-center space-x-2 mb-4">
<img src="/logo.svg" alt="QR Master" className="w-8 h-8 brightness-0 invert" />
<span className="text-xl font-bold">QR Master</span>
</div>
<p className="text-gray-400">
Create custom QR codes in seconds with advanced tracking and analytics.
</p>
</div>
<div>
<h3 className="font-semibold mb-4">Product</h3>
<ul className="space-y-2 text-gray-400">
<li><Link href="/#features" className="hover:text-white">Features</Link></li>
<li><Link href="/pricing" className="hover:text-white">Pricing</Link></li>
<li><Link href="/faq" className="hover:text-white">FAQ</Link></li>
<li><Link href="/blog" className="hover:text-white">Blog</Link></li>
</ul>
</div>
<div>
<h3 className="font-semibold mb-4">Company</h3>
<ul className="space-y-2 text-gray-400">
<li><a href="#" className="hover:text-white">About</a></li>
<li><a href="#" className="hover:text-white">Careers</a></li>
<li><a href="#" className="hover:text-white">Contact</a></li>
<li><a href="#" className="hover:text-white">Partners</a></li>
</ul>
</div>
<div>
<h3 className="font-semibold mb-4">Legal</h3>
<ul className="space-y-2 text-gray-400">
<li><a href="#" className="hover:text-white">Privacy Policy</a></li>
<li><a href="#" className="hover:text-white">Terms of Service</a></li>
<li><a href="#" className="hover:text-white">Cookie Policy</a></li>
<li><a href="#" className="hover:text-white">GDPR</a></li>
</ul>
</div>
</div>
<div className="border-t border-gray-800 mt-8 pt-8 text-center text-gray-400">
<p>&copy; 2024 QR Master. All rights reserved.</p>
</div>
</div>
</footer>
</div>
);
}

View File

@ -0,0 +1,77 @@
'use client';
import React from 'react';
import { Hero } from '@/components/marketing/Hero';
import { StatsStrip } from '@/components/marketing/StatsStrip';
import { TemplateCards } from '@/components/marketing/TemplateCards';
import { InstantGenerator } from '@/components/marketing/InstantGenerator';
import { StaticVsDynamic } from '@/components/marketing/StaticVsDynamic';
import { Features } from '@/components/marketing/Features';
import { Pricing } from '@/components/marketing/Pricing';
import { FAQ } from '@/components/marketing/FAQ';
import { Button } from '@/components/ui/Button';
import { useTranslation } from '@/hooks/useTranslation';
export default function HomePage() {
const { t } = useTranslation();
const industries = [
'Restaurant Chain',
'Tech Startup',
'Real Estate',
'Event Agency',
'Retail Store',
'Healthcare',
];
return (
<>
<Hero t={t} />
<StatsStrip t={t} />
{/* Industry Buttons */}
<section className="py-8">
<div className="container mx-auto px-4">
<div className="flex flex-wrap justify-center gap-3">
{industries.map((industry) => (
<Button key={industry} variant="outline" size="sm">
{industry}
</Button>
))}
</div>
</div>
</section>
<TemplateCards t={t} />
<InstantGenerator t={t} />
<StaticVsDynamic t={t} />
<Features t={t} />
{/* Pricing Teaser */}
<section className="py-16 bg-primary-50">
<div className="container mx-auto px-4 text-center">
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
Ready to get started?
</h2>
<p className="text-xl text-gray-600 mb-8">
Choose the perfect plan for your needs
</p>
<Button size="lg">View Pricing Plans</Button>
</div>
</section>
{/* FAQ Teaser */}
<section className="py-16">
<div className="container mx-auto px-4 text-center">
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
Have questions?
</h2>
<p className="text-xl text-gray-600 mb-8">
Check out our frequently asked questions
</p>
<Button variant="outline" size="lg">View FAQ</Button>
</div>
</section>
</>
);
}

View File

@ -0,0 +1,15 @@
'use client';
import React from 'react';
import { Pricing } from '@/components/marketing/Pricing';
import { useTranslation } from '@/hooks/useTranslation';
export default function PricingPage() {
const { t } = useTranslation();
return (
<div className="py-20">
<Pricing t={t} />
</div>
);
}

View File

@ -0,0 +1,110 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
export async function GET(request: NextRequest) {
try {
const userId = cookies().get('userId')?.value;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Get user's QR codes
const qrCodes = await db.qRCode.findMany({
where: { userId },
include: {
scans: true,
},
});
// Calculate stats
const totalScans = qrCodes.reduce((sum, qr) => sum + qr.scans.length, 0);
const uniqueScans = qrCodes.reduce((sum, qr) =>
sum + qr.scans.filter(s => s.isUnique).length, 0
);
// Device stats
const deviceStats = qrCodes.flatMap(qr => qr.scans)
.reduce((acc, scan) => {
const device = scan.device || 'unknown';
acc[device] = (acc[device] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const mobileScans = (deviceStats.mobile || 0) + (deviceStats.tablet || 0);
const mobilePercentage = totalScans > 0
? Math.round((mobileScans / totalScans) * 100)
: 0;
// Country stats
const countryStats = qrCodes.flatMap(qr => qr.scans)
.reduce((acc, scan) => {
const country = scan.country || 'Unknown';
acc[country] = (acc[country] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const topCountry = Object.entries(countryStats)
.sort(([,a], [,b]) => b - a)[0];
// Time-based stats (last 30 days)
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const recentScans = qrCodes.flatMap(qr => qr.scans)
.filter(scan => new Date(scan.ts) > thirtyDaysAgo);
// Daily scan counts for chart
const dailyScans = recentScans.reduce((acc, scan) => {
const date = new Date(scan.ts).toISOString().split('T')[0];
acc[date] = (acc[date] || 0) + 1;
return acc;
}, {} as Record<string, number>);
// QR performance
const qrPerformance = qrCodes.map(qr => ({
id: qr.id,
title: qr.title,
type: qr.type,
totalScans: qr.scans.length,
uniqueScans: qr.scans.filter(s => s.isUnique).length,
conversion: qr.scans.length > 0
? Math.round((qr.scans.filter(s => s.isUnique).length / qr.scans.length) * 100)
: 0,
})).sort((a, b) => b.totalScans - a.totalScans);
return NextResponse.json({
summary: {
totalScans,
uniqueScans,
avgScansPerQR: qrCodes.length > 0
? Math.round(totalScans / qrCodes.length)
: 0,
mobilePercentage,
topCountry: topCountry ? topCountry[0] : 'N/A',
topCountryPercentage: topCountry && totalScans > 0
? Math.round((topCountry[1] / totalScans) * 100)
: 0,
},
deviceStats,
countryStats: Object.entries(countryStats)
.sort(([,a], [,b]) => b - a)
.slice(0, 10)
.map(([country, count]) => ({
country,
count,
percentage: totalScans > 0
? Math.round((count / totalScans) * 100)
: 0,
})),
dailyScans,
qrPerformance: qrPerformance.slice(0, 10),
});
} catch (error) {
console.error('Error fetching analytics:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,6 @@
import NextAuth from 'next-auth';
import { authOptions } from '@/lib/auth';
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

View File

@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from 'next/server';
import bcrypt from 'bcryptjs';
import { db } from '@/lib/db';
import { z } from 'zod';
const signupSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
password: z.string().min(8),
});
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { name, email, password } = signupSchema.parse(body);
// Check if user already exists
const existingUser = await db.user.findUnique({
where: { email },
});
if (existingUser) {
return NextResponse.json(
{ error: 'User already exists' },
{ status: 400 }
);
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 12);
// Create user
const user = await db.user.create({
data: {
name,
email,
password: hashedPassword,
},
});
return NextResponse.json({
id: user.id,
name: user.name,
email: user.email,
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid input', details: error.errors },
{ status: 400 }
);
}
console.error('Signup error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import bcrypt from 'bcryptjs';
import { cookies } from 'next/headers';
export async function POST(request: NextRequest) {
try {
const { email, password } = await request.json();
// Find user
const user = await db.user.findUnique({
where: { email },
});
if (!user) {
// Create user if doesn't exist (for demo)
const hashedPassword = await bcrypt.hash(password, 12);
const newUser = await db.user.create({
data: {
email,
name: email.split('@')[0],
password: hashedPassword,
},
});
// Set cookie
cookies().set('userId', newUser.id, {
httpOnly: true,
secure: false,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7 days
});
return NextResponse.json({
success: true,
user: { id: newUser.id, email: newUser.email, name: newUser.name }
});
}
// For demo/development: Accept any password for existing users
// In production, you would check: const isValid = await bcrypt.compare(password, user.password || '');
const isValid = true; // DEMO MODE - accepts any password
// Set cookie
cookies().set('userId', user.id, {
httpOnly: true,
secure: false,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7 days
});
return NextResponse.json({
success: true,
user: { id: user.id, email: user.email, name: user.name }
});
} catch (error) {
console.error('Login error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

97
src/app/api/bulk/route.ts Normal file
View File

@ -0,0 +1,97 @@
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';
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 });
}
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 }
);
}
}

View File

@ -0,0 +1,152 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { db } from '@/lib/db';
import { z } from 'zod';
const updateQRSchema = z.object({
title: z.string().min(1).optional(),
content: z.any().optional(),
tags: z.array(z.string()).optional(),
style: z.any().optional(),
status: z.enum(['ACTIVE', 'PAUSED']).optional(),
});
// GET /api/qrs/[id] - Get a single QR code
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const qrCode = await db.qRCode.findFirst({
where: {
id: params.id,
userId: session.user.id,
},
include: {
scans: {
orderBy: { ts: 'desc' },
take: 100,
},
},
});
if (!qrCode) {
return NextResponse.json({ error: 'QR code not found' }, { status: 404 });
}
return NextResponse.json(qrCode);
} catch (error) {
console.error('Error fetching QR code:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
// PATCH /api/qrs/[id] - Update a QR code
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const data = updateQRSchema.parse(body);
// Check ownership
const existing = await db.qRCode.findFirst({
where: {
id: params.id,
userId: session.user.id,
},
});
if (!existing) {
return NextResponse.json({ error: 'QR code not found' }, { status: 404 });
}
// Static QR codes cannot be edited
if (existing.type === 'STATIC' && data.content) {
return NextResponse.json(
{ error: 'Static QR codes cannot be edited' },
{ status: 400 }
);
}
// Update QR code
const updated = await db.qRCode.update({
where: { id: params.id },
data: {
...(data.title && { title: data.title }),
...(data.content && { content: data.content }),
...(data.tags && { tags: data.tags }),
...(data.style && { style: data.style }),
...(data.status && { status: data.status }),
},
});
return NextResponse.json(updated);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid input', details: error.errors },
{ status: 400 }
);
}
console.error('Error updating QR code:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
// DELETE /api/qrs/[id] - Delete a QR code
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Check ownership
const existing = await db.qRCode.findFirst({
where: {
id: params.id,
userId: session.user.id,
},
});
if (!existing) {
return NextResponse.json({ error: 'QR code not found' }, { status: 404 });
}
// Delete QR code (cascades to scans)
await db.qRCode.delete({
where: { id: params.id },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting QR code:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

137
src/app/api/qrs/route.ts Normal file
View File

@ -0,0 +1,137 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { generateSlug } from '@/lib/hash';
// GET /api/qrs - List user's QR codes
export async function GET(request: NextRequest) {
try {
const userId = cookies().get('userId')?.value;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const qrCodes = await db.qRCode.findMany({
where: { userId },
include: {
_count: {
select: { scans: true },
},
},
orderBy: { createdAt: 'desc' },
});
// Transform the data
const transformed = qrCodes.map(qr => ({
...qr,
scans: qr._count.scans,
_count: undefined,
}));
return NextResponse.json(transformed);
} catch (error) {
console.error('Error fetching QR codes:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
// POST /api/qrs - Create a new QR code
export async function POST(request: NextRequest) {
try {
const userId = cookies().get('userId')?.value;
console.log('POST /api/qrs - userId from cookie:', userId);
if (!userId) {
return NextResponse.json({ error: 'Unauthorized - no userId cookie' }, { status: 401 });
}
// Check if user exists
const userExists = await db.user.findUnique({
where: { id: userId }
});
console.log('User exists:', !!userExists);
if (!userExists) {
return NextResponse.json({ error: `User not found: ${userId}` }, { status: 404 });
}
const body = await request.json();
console.log('Request body:', body);
// Check if this is a static QR request
const isStatic = body.isStatic === true;
let enrichedContent = body.content;
// For STATIC QR codes, calculate what the QR should contain
if (isStatic) {
let qrContent = '';
switch (body.contentType) {
case 'URL':
qrContent = body.content.url;
break;
case 'PHONE':
qrContent = `tel:${body.content.phone}`;
break;
case 'EMAIL':
qrContent = `mailto:${body.content.email}${body.content.subject ? `?subject=${encodeURIComponent(body.content.subject)}` : ''}`;
break;
case 'SMS':
qrContent = `sms:${body.content.phone}${body.content.message ? `?body=${encodeURIComponent(body.content.message)}` : ''}`;
break;
case 'TEXT':
qrContent = body.content.text;
break;
case 'WIFI':
qrContent = `WIFI:T:${body.content.security || 'WPA'};S:${body.content.ssid};P:${body.content.password || ''};H:false;;`;
break;
case 'WHATSAPP':
qrContent = `https://wa.me/${body.content.phone}${body.content.message ? `?text=${encodeURIComponent(body.content.message)}` : ''}`;
break;
default:
qrContent = body.content.url || 'https://example.com';
}
// Add qrContent to the content object
enrichedContent = {
...body.content,
qrContent // This is what the QR code should actually contain
};
}
// Generate slug for the QR code
const slug = generateSlug(body.title);
// Create QR code
const qrCode = await db.qRCode.create({
data: {
userId,
title: body.title,
type: isStatic ? 'STATIC' : 'DYNAMIC',
contentType: body.contentType,
content: enrichedContent,
tags: body.tags || [],
style: body.style || {
foregroundColor: '#000000',
backgroundColor: '#FFFFFF',
cornerStyle: 'square',
size: 200,
},
slug,
status: 'ACTIVE',
},
});
return NextResponse.json(qrCode);
} catch (error) {
console.error('Error creating QR code:', error);
return NextResponse.json(
{ error: 'Internal server error', details: String(error) },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { generateSlug } from '@/lib/hash';
// POST /api/qrs/static - Create a STATIC QR code that contains the direct URL
export async function POST(request: NextRequest) {
try {
const userId = cookies().get('userId')?.value;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { title, contentType, content, tags, style } = body;
// Generate the actual QR content based on type
let qrContent = '';
switch (contentType) {
case 'URL':
qrContent = content.url;
break;
case 'PHONE':
qrContent = `tel:${content.phone}`;
break;
case 'EMAIL':
qrContent = `mailto:${content.email}${content.subject ? `?subject=${encodeURIComponent(content.subject)}` : ''}`;
break;
case 'SMS':
qrContent = `sms:${content.phone}${content.message ? `?body=${encodeURIComponent(content.message)}` : ''}`;
break;
case 'TEXT':
qrContent = content.text;
break;
case 'WIFI':
qrContent = `WIFI:T:${content.security || 'WPA'};S:${content.ssid};P:${content.password || ''};H:false;;`;
break;
case 'WHATSAPP':
qrContent = `https://wa.me/${content.phone}${content.message ? `?text=${encodeURIComponent(content.message)}` : ''}`;
break;
default:
qrContent = content.url || 'https://example.com';
}
// Store the QR content in a special field
const enrichedContent = {
...content,
qrContent // This is what the QR code should actually contain
};
// Generate slug
const slug = generateSlug(title);
// Create QR code
const qrCode = await db.qRCode.create({
data: {
userId,
title,
type: 'STATIC',
contentType,
content: enrichedContent,
tags: tags || [],
style: style || {
foregroundColor: '#000000',
backgroundColor: '#FFFFFF',
cornerStyle: 'square',
size: 200,
},
slug,
status: 'ACTIVE',
},
});
return NextResponse.json(qrCode);
} catch (error) {
console.error('Error creating static QR code:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

42
src/app/layout.tsx Normal file
View File

@ -0,0 +1,42 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import '@/styles/globals.css';
import { ToastContainer } from '@/components/ui/Toast';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'QR Master - Create Custom QR Codes in Seconds',
description: 'Generate static and dynamic QR codes with advanced tracking, professional templates, and seamless integrations.',
keywords: 'QR code, QR generator, dynamic QR, QR tracking, QR analytics',
openGraph: {
title: 'QR Master - Create Custom QR Codes in Seconds',
description: 'Generate static and dynamic QR codes with advanced tracking, professional templates, and seamless integrations.',
url: 'https://qrmaster.com',
siteName: 'QR Master',
images: [
{
url: '/og-image.png',
width: 1200,
height: 630,
},
],
locale: 'en_US',
type: 'website',
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
{children}
<ToastContainer />
</body>
</html>
);
}

7
src/app/providers.tsx Normal file
View File

@ -0,0 +1,7 @@
'use client';
import { SessionProvider } from 'next-auth/react';
export function Providers({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}

179
src/app/r/[slug]/route.ts Normal file
View File

@ -0,0 +1,179 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { hashIP } from '@/lib/hash';
import { headers } from 'next/headers';
export async function GET(
request: NextRequest,
{ params }: { params: { slug: string } }
) {
try {
const { slug } = params;
// Fetch QR code by slug
const qrCode = await db.qRCode.findUnique({
where: { slug },
select: {
id: true,
status: true,
content: true,
contentType: true,
},
});
if (!qrCode) {
return new NextResponse('QR Code not found', { status: 404 });
}
if (qrCode.status === 'PAUSED') {
return new NextResponse('QR Code is paused', { status: 404 });
}
// Track scan (fire and forget)
trackScan(qrCode.id, request).catch(console.error);
// Determine destination URL
let destination = '';
const content = qrCode.content as any;
switch (qrCode.contentType) {
case 'URL':
destination = content.url || 'https://example.com';
break;
case 'PHONE':
destination = `tel:${content.phone}`;
break;
case 'EMAIL':
destination = `mailto:${content.email}${content.subject ? `?subject=${encodeURIComponent(content.subject)}` : ''}`;
break;
case 'SMS':
destination = `sms:${content.phone}${content.message ? `?body=${encodeURIComponent(content.message)}` : ''}`;
break;
case 'WHATSAPP':
destination = `https://wa.me/${content.phone}${content.message ? `?text=${encodeURIComponent(content.message)}` : ''}`;
break;
case 'TEXT':
// For plain text, redirect to a display page
destination = `/display?text=${encodeURIComponent(content.text || '')}`;
break;
case 'WIFI':
// For WiFi, show a connection page
destination = `/wifi?ssid=${encodeURIComponent(content.ssid || '')}&security=${content.security || 'WPA'}`;
break;
default:
destination = 'https://example.com';
}
// Preserve UTM parameters
const searchParams = request.nextUrl.searchParams;
const utmParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
const preservedParams = new URLSearchParams();
utmParams.forEach(param => {
const value = searchParams.get(param);
if (value) {
preservedParams.set(param, value);
}
});
// Add preserved params to destination
if (preservedParams.toString() && destination.startsWith('http')) {
const separator = destination.includes('?') ? '&' : '?';
destination = `${destination}${separator}${preservedParams.toString()}`;
}
// Return 307 redirect (temporary redirect that preserves method)
return NextResponse.redirect(destination, { status: 307 });
} catch (error) {
console.error('QR redirect error:', error);
return new NextResponse('Internal server error', { status: 500 });
}
}
async function trackScan(qrId: string, request: NextRequest) {
try {
const headersList = headers();
const userAgent = headersList.get('user-agent') || '';
const referer = headersList.get('referer') || '';
const ip = headersList.get('x-forwarded-for') ||
headersList.get('x-real-ip') ||
'unknown';
// Check DNT header
const dnt = headersList.get('dnt');
if (dnt === '1') {
// Respect Do Not Track - only increment counter
await db.qRScan.create({
data: {
qrId,
ipHash: 'dnt',
isUnique: false,
},
});
return;
}
// Hash IP for privacy
const ipHash = hashIP(ip);
// Parse user agent for device info
const isMobile = /mobile|android|iphone/i.test(userAgent);
const isTablet = /tablet|ipad/i.test(userAgent);
const device = isTablet ? 'tablet' : isMobile ? 'mobile' : 'desktop';
// Detect OS
let os = 'unknown';
if (/windows/i.test(userAgent)) os = 'Windows';
else if (/mac/i.test(userAgent)) os = 'macOS';
else if (/linux/i.test(userAgent)) os = 'Linux';
else if (/android/i.test(userAgent)) os = 'Android';
else if (/ios|iphone|ipad/i.test(userAgent)) os = 'iOS';
// Get country from header (Vercel/Cloudflare provide this)
const country = headersList.get('x-vercel-ip-country') ||
headersList.get('cf-ipcountry') ||
'unknown';
// Extract UTM parameters
const searchParams = request.nextUrl.searchParams;
const utmSource = searchParams.get('utm_source');
const utmMedium = searchParams.get('utm_medium');
const utmCampaign = searchParams.get('utm_campaign');
// Check if this is a unique scan (first scan from this IP today)
const today = new Date();
today.setHours(0, 0, 0, 0);
const existingScan = await db.qRScan.findFirst({
where: {
qrId,
ipHash,
ts: {
gte: today,
},
},
});
const isUnique = !existingScan;
// Create scan record
await db.qRScan.create({
data: {
qrId,
ipHash,
userAgent: userAgent.substring(0, 255),
device,
os,
country,
referrer: referer.substring(0, 255),
utmSource,
utmMedium,
utmCampaign,
isUnique,
},
});
} catch (error) {
console.error('Error tracking scan:', error);
// Don't throw - this is fire and forget
}
}

View File

@ -0,0 +1,186 @@
'use client';
import React from 'react';
import { QRCodeSVG } from 'qrcode.react';
import { Card, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Dropdown, DropdownItem } from '@/components/ui/Dropdown';
import { formatDate } from '@/lib/utils';
interface QRCodeCardProps {
qr: {
id: string;
title: string;
type: 'STATIC' | 'DYNAMIC';
contentType: string;
content?: any;
slug: string;
status: 'ACTIVE' | 'PAUSED';
createdAt: string;
scans?: number;
};
onEdit: (id: string) => void;
onDuplicate: (id: string) => void;
onPause: (id: string) => void;
onDelete: (id: string) => void;
}
export const QRCodeCard: React.FC<QRCodeCardProps> = ({
qr,
onEdit,
onDuplicate,
onPause,
onDelete,
}) => {
// For dynamic QR codes, use the redirect URL for tracking
// For static QR codes, use the direct URL from content
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || (typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3050');
// Get the QR URL based on type
let qrUrl = '';
// SIMPLE FIX: For STATIC QR codes, ALWAYS use the direct content
if (qr.type === 'STATIC') {
// Extract the actual URL/content based on contentType
if (qr.contentType === 'URL' && qr.content?.url) {
qrUrl = qr.content.url;
} else if (qr.contentType === 'PHONE' && qr.content?.phone) {
qrUrl = `tel:${qr.content.phone}`;
} else if (qr.contentType === 'EMAIL' && qr.content?.email) {
qrUrl = `mailto:${qr.content.email}`;
} else if (qr.contentType === 'TEXT' && qr.content?.text) {
qrUrl = qr.content.text;
} else if (qr.content?.qrContent) {
// Fallback to qrContent if it exists
qrUrl = qr.content.qrContent;
} else {
// Last resort fallback
qrUrl = `${baseUrl}/r/${qr.slug}`;
}
console.log(`STATIC QR [${qr.title}]: ${qrUrl}`);
} else {
// DYNAMIC QR codes always use redirect for tracking
qrUrl = `${baseUrl}/r/${qr.slug}`;
console.log(`DYNAMIC QR [${qr.title}]: ${qrUrl}`);
}
const downloadQR = (format: 'png' | 'svg') => {
const svg = document.querySelector(`#qr-${qr.id} svg`);
if (!svg) return;
if (format === 'svg') {
const svgData = new XMLSerializer().serializeToString(svg);
const blob = new Blob([svgData], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.svg`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} else {
// Convert SVG to PNG
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
const svgData = new XMLSerializer().serializeToString(svg);
const blob = new Blob([svgData], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
img.onload = () => {
canvas.width = 300;
canvas.height = 300;
ctx?.drawImage(img, 0, 0, 300, 300);
canvas.toBlob((blob) => {
if (blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
});
URL.revokeObjectURL(url);
};
img.src = url;
}
};
return (
<Card hover>
<CardContent className="p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<h3 className="font-semibold text-gray-900 mb-1">{qr.title}</h3>
<div className="flex items-center space-x-2">
<Badge variant={qr.type === 'DYNAMIC' ? 'info' : 'default'}>
{qr.type}
</Badge>
<Badge variant={qr.status === 'ACTIVE' ? 'success' : 'warning'}>
{qr.status}
</Badge>
</div>
</div>
<Dropdown
align="right"
trigger={
<button className="p-1 hover:bg-gray-100 rounded">
<svg className="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
</svg>
</button>
}
>
<DropdownItem onClick={() => downloadQR('png')}>Download PNG</DropdownItem>
<DropdownItem onClick={() => downloadQR('svg')}>Download SVG</DropdownItem>
<DropdownItem onClick={() => onEdit(qr.id)}>Edit</DropdownItem>
<DropdownItem onClick={() => onDuplicate(qr.id)}>Duplicate</DropdownItem>
<DropdownItem onClick={() => onPause(qr.id)}>
{qr.status === 'ACTIVE' ? 'Pause' : 'Resume'}
</DropdownItem>
<DropdownItem onClick={() => onDelete(qr.id)} className="text-red-600">
Delete
</DropdownItem>
</Dropdown>
</div>
<div id={`qr-${qr.id}`} className="flex items-center justify-center bg-gray-50 rounded-lg p-4 mb-3">
<QRCodeSVG
value={qrUrl}
size={96}
fgColor="#000000"
bgColor="#FFFFFF"
level="M"
/>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-gray-500">Type:</span>
<span className="text-gray-900">{qr.contentType}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-500">Scans:</span>
<span className="text-gray-900">{qr.scans || 0}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-500">Created:</span>
<span className="text-gray-900">{formatDate(qr.createdAt)}</span>
</div>
{qr.type === 'DYNAMIC' && (
<div className="pt-2 border-t">
<p className="text-xs text-gray-500">
📊 Dynamic QR: Tracks scans via {baseUrl}/r/{qr.slug}
</p>
</div>
)}
</div>
</CardContent>
</Card>
);
};

View File

@ -0,0 +1,82 @@
'use client';
import React from 'react';
import { Card, CardContent } from '@/components/ui/Card';
import { formatNumber } from '@/lib/utils';
interface StatsGridProps {
stats: {
totalScans: number;
activeQRCodes: number;
conversionRate: number;
};
}
export const StatsGrid: React.FC<StatsGridProps> = ({ stats }) => {
// Only show growth if there are actual scans
const showGrowth = stats.totalScans > 0;
const cards = [
{
title: 'Total Scans',
value: formatNumber(stats.totalScans),
change: showGrowth ? '+12%' : 'No data yet',
changeType: showGrowth ? 'positive' : 'neutral' as 'positive' | 'negative' | 'neutral',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
),
},
{
title: 'Active QR Codes',
value: stats.activeQRCodes.toString(),
change: stats.activeQRCodes > 0 ? `${stats.activeQRCodes} active` : 'Create your first',
changeType: stats.activeQRCodes > 0 ? 'positive' : 'neutral' as 'positive' | 'negative' | 'neutral',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
</svg>
),
},
{
title: 'Conversion Rate',
value: `${stats.conversionRate}%`,
change: stats.totalScans > 0 ? `${stats.conversionRate}% rate` : 'No scans yet',
changeType: stats.conversionRate > 0 ? 'positive' : 'neutral' as 'positive' | 'negative' | 'neutral',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
),
},
];
return (
<div className="grid md:grid-cols-3 gap-6">
{cards.map((card, index) => (
<Card key={index}>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 mb-1">{card.title}</p>
<p className="text-2xl font-bold text-gray-900">{card.value}</p>
<p className={`text-sm mt-2 ${
card.changeType === 'positive' ? 'text-success-600' :
card.changeType === 'negative' ? 'text-red-600' :
'text-gray-500'
}`}>
{card.changeType === 'neutral' ? card.change : `${card.change} from last month`}
</p>
</div>
<div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center text-primary-600">
{card.icon}
</div>
</div>
</CardContent>
</Card>
))}
</div>
);
};

View File

@ -0,0 +1,157 @@
'use client';
import React, { useEffect, useState } from 'react';
import QRCode from 'qrcode';
import { Badge } from '@/components/ui/Badge';
import { calculateContrast } from '@/lib/utils';
interface QRPreviewProps {
content: string;
style: {
foregroundColor: string;
backgroundColor: string;
cornerStyle: 'square' | 'rounded';
size: number;
};
}
export const QRPreview: React.FC<QRPreviewProps> = ({ content, style }) => {
const [qrDataUrl, setQrDataUrl] = useState<string>('');
const [error, setError] = useState<string>('');
const contrast = calculateContrast(style.foregroundColor, style.backgroundColor);
const hasGoodContrast = contrast >= 4.5;
useEffect(() => {
const generateQR = async () => {
try {
if (!content) {
setQrDataUrl('');
return;
}
const options = {
width: style.size,
margin: 2,
color: {
dark: style.foregroundColor,
light: style.backgroundColor,
},
errorCorrectionLevel: 'M' as const,
};
const dataUrl = await QRCode.toDataURL(content, options);
setQrDataUrl(dataUrl);
setError('');
} catch (err) {
console.error('Error generating QR code:', err);
setError('Failed to generate QR code');
}
};
generateQR();
}, [content, style]);
const downloadQR = async (format: 'svg' | 'png') => {
if (!content) return;
try {
if (format === 'svg') {
const svg = await QRCode.toString(content, {
type: 'svg',
width: style.size,
margin: 2,
color: {
dark: style.foregroundColor,
light: style.backgroundColor,
},
});
const blob = new Blob([svg], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'qrcode.svg';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} else {
// For PNG, use the canvas
const canvas = document.createElement('canvas');
await QRCode.toCanvas(canvas, content, {
width: style.size,
margin: 2,
color: {
dark: style.foregroundColor,
light: style.backgroundColor,
},
});
canvas.toBlob((blob) => {
if (blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'qrcode.png';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
});
}
} catch (err) {
console.error('Error downloading QR code:', err);
}
};
return (
<div className="space-y-4">
<div className="flex justify-center">
{error ? (
<div className="w-[200px] h-[200px] bg-gray-100 rounded-lg flex items-center justify-center text-gray-500">
{error}
</div>
) : qrDataUrl ? (
<img
src={qrDataUrl}
alt="QR Code Preview"
className={`border-2 border-gray-200 ${style.cornerStyle === 'rounded' ? 'rounded-lg' : ''}`}
style={{ width: Math.min(style.size, 300), height: Math.min(style.size, 300) }}
/>
) : (
<div className="w-[200px] h-[200px] bg-gray-100 rounded-lg flex items-center justify-center text-gray-500">
Enter content to generate QR code
</div>
)}
</div>
<div className="flex items-center justify-between">
<Badge variant={hasGoodContrast ? 'success' : 'warning'}>
{hasGoodContrast ? 'Good contrast' : 'Low contrast'}
</Badge>
<span className="text-sm text-gray-500">
Contrast: {contrast.toFixed(1)}:1
</span>
</div>
<div className="space-y-2">
<button
onClick={() => downloadQR('svg')}
disabled={!content || !qrDataUrl}
className="w-full px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Download SVG
</button>
<button
onClick={() => downloadQR('png')}
disabled={!content || !qrDataUrl}
className="w-full px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Download PNG
</button>
</div>
</div>
);
};

View File

@ -0,0 +1,63 @@
'use client';
import React, { useState } from 'react';
import { Card } from '@/components/ui/Card';
interface FAQProps {
t: any; // i18n translation function
}
export const FAQ: React.FC<FAQProps> = ({ t }) => {
const [openIndex, setOpenIndex] = useState<number | null>(null);
const questions = [
'account',
'static_vs_dynamic',
'forever',
'file_type',
'password',
'analytics',
'privacy',
'bulk',
];
return (
<section className="py-16 bg-gray-50">
<div className="container mx-auto px-4">
<div className="text-center mb-12">
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
{t('faq.title')}
</h2>
</div>
<div className="max-w-3xl mx-auto space-y-4">
{questions.map((key, index) => (
<Card key={key} className="cursor-pointer" onClick={() => setOpenIndex(openIndex === index ? null : index)}>
<div className="p-6">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">
{t(`faq.questions.${key}.question`)}
</h3>
<svg
className={`w-5 h-5 text-gray-500 transition-transform ${openIndex === index ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
{openIndex === index && (
<div className="mt-4 text-gray-600">
{t(`faq.questions.${key}.answer`)}
</div>
)}
</div>
</Card>
))}
</div>
</div>
</section>
);
};

View File

@ -0,0 +1,97 @@
'use client';
import React from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
interface FeaturesProps {
t: any; // i18n translation function
}
export const Features: React.FC<FeaturesProps> = ({ t }) => {
const features = [
{
key: 'analytics',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
color: 'text-blue-600 bg-blue-100',
},
{
key: 'customization',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
),
color: 'text-purple-600 bg-purple-100',
},
{
key: 'bulk',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
),
color: 'text-green-600 bg-green-100',
},
{
key: 'integrations',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z" />
</svg>
),
color: 'text-orange-600 bg-orange-100',
},
{
key: 'api',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
),
color: 'text-indigo-600 bg-indigo-100',
},
{
key: 'support',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192l-3.536 3.536M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-5 0a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
),
color: 'text-red-600 bg-red-100',
},
];
return (
<section className="py-16 bg-gray-50">
<div className="container mx-auto px-4">
<div className="text-center mb-12">
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
{t('features.title')}
</h2>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto">
{features.map((feature) => (
<Card key={feature.key} hover>
<CardHeader>
<div className={`w-12 h-12 rounded-lg ${feature.color} flex items-center justify-center mb-4`}>
{feature.icon}
</div>
<CardTitle>{t(`features.${feature.key}.title`)}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-600">
{t(`features.${feature.key}.description`)}
</p>
</CardContent>
</Card>
))}
</div>
</div>
</section>
);
};

View File

@ -0,0 +1,83 @@
'use client';
import React from 'react';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Card } from '@/components/ui/Card';
interface HeroProps {
t: any; // i18n translation function
}
export const Hero: React.FC<HeroProps> = ({ t }) => {
const templateCards = [
{ title: 'Restaurant Menu', color: 'bg-pink-100', icon: '🍽️' },
{ title: 'Business Card', color: 'bg-blue-100', icon: '💼' },
{ title: 'Event Tickets', color: 'bg-green-100', icon: '🎫' },
{ title: 'WiFi Access', color: 'bg-purple-100', icon: '📶' },
];
return (
<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">
<div className="grid lg:grid-cols-2 gap-12 items-center">
{/* Left Content */}
<div className="space-y-8">
<Badge variant="info" className="inline-flex items-center space-x-2">
<span>{t('hero.badge')}</span>
</Badge>
<div className="space-y-6">
<h1 className="text-5xl lg:text-6xl font-bold text-gray-900 leading-tight">
{t('hero.title')}
</h1>
<p className="text-xl text-gray-600 leading-relaxed">
{t('hero.subtitle')}
</p>
<div className="space-y-3">
{t('hero.features', { returnObjects: true }).map((feature: string, index: number) => (
<div key={index} className="flex items-center space-x-3">
<div className="flex-shrink-0 w-5 h-5 bg-success-500 rounded-full flex items-center justify-center">
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
<span className="text-gray-700">{feature}</span>
</div>
))}
</div>
<div className="flex flex-col sm:flex-row gap-4">
<Button size="lg" className="text-lg px-8 py-4">
{t('hero.cta_primary')}
</Button>
<Button variant="outline" size="lg" className="text-lg px-8 py-4">
{t('hero.cta_secondary')}
</Button>
</div>
</div>
</div>
{/* Right Preview Widget */}
<div className="relative">
<div className="grid grid-cols-2 gap-4">
{templateCards.map((card, index) => (
<Card key={index} className={`${card.color} border-0 p-6 text-center hover:scale-105 transition-transform`}>
<div className="text-3xl mb-2">{card.icon}</div>
<h3 className="font-semibold text-gray-800">{card.title}</h3>
</Card>
))}
</div>
{/* Floating Badge */}
<div className="absolute -top-4 -right-4 bg-success-500 text-white px-4 py-2 rounded-full text-sm font-semibold shadow-lg">
{t('hero.engagement_badge')}
</div>
</div>
</div>
</div>
</section>
);
};

View File

@ -0,0 +1,220 @@
'use client';
import React, { useState } from 'react';
import { QRCodeSVG } from 'qrcode.react';
import { Card } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { calculateContrast } from '@/lib/utils';
interface InstantGeneratorProps {
t: any; // i18n translation function
}
export const InstantGenerator: React.FC<InstantGeneratorProps> = ({ t }) => {
const [url, setUrl] = useState('https://example.com');
const [foregroundColor, setForegroundColor] = useState('#000000');
const [backgroundColor, setBackgroundColor] = useState('#FFFFFF');
const [cornerStyle, setCornerStyle] = useState('square');
const [size, setSize] = useState(200);
const contrast = calculateContrast(foregroundColor, backgroundColor);
const hasGoodContrast = contrast >= 4.5;
const downloadQR = (format: 'svg' | 'png') => {
const svg = document.querySelector('#instant-qr-preview svg');
if (!svg || !url) return;
if (format === 'svg') {
const svgData = new XMLSerializer().serializeToString(svg);
const blob = new Blob([svgData], { type: 'image/svg+xml' });
const downloadUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = 'qrcode.svg';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(downloadUrl);
} else {
// Convert SVG to PNG using Canvas
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
const svgData = new XMLSerializer().serializeToString(svg);
const blob = new Blob([svgData], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
img.onload = () => {
canvas.width = size;
canvas.height = size;
if (ctx) {
ctx.fillStyle = backgroundColor;
ctx.fillRect(0, 0, size, size);
ctx.drawImage(img, 0, 0, size, size);
}
canvas.toBlob((blob) => {
if (blob) {
const downloadUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = 'qrcode.png';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(downloadUrl);
}
});
URL.revokeObjectURL(url);
};
img.src = url;
}
};
return (
<section className="py-16 bg-gray-50">
<div className="container mx-auto px-4">
<div className="text-center mb-12">
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
{t('generator.title')}
</h2>
</div>
<div className="grid lg:grid-cols-2 gap-12 max-w-6xl mx-auto">
{/* Left Form */}
<Card className="space-y-6">
<Input
label="URL"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={t('generator.url_placeholder')}
/>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('generator.foreground')}
</label>
<div className="flex items-center space-x-2">
<input
type="color"
value={foregroundColor}
onChange={(e) => setForegroundColor(e.target.value)}
className="w-12 h-10 rounded border border-gray-300"
/>
<Input
value={foregroundColor}
onChange={(e) => setForegroundColor(e.target.value)}
className="flex-1"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('generator.background')}
</label>
<div className="flex items-center space-x-2">
<input
type="color"
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
className="w-12 h-10 rounded border border-gray-300"
/>
<Input
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
className="flex-1"
/>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('generator.corners')}
</label>
<select
value={cornerStyle}
onChange={(e) => setCornerStyle(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="square">Square</option>
<option value="rounded">Rounded</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('generator.size')}
</label>
<input
type="range"
min="100"
max="400"
value={size}
onChange={(e) => setSize(Number(e.target.value))}
className="w-full"
/>
<div className="text-sm text-gray-500 text-center mt-1">{size}px</div>
</div>
</div>
<div className="flex items-center justify-between">
<Badge variant={hasGoodContrast ? 'success' : 'warning'}>
{hasGoodContrast ? t('generator.contrast_good') : 'Low contrast'}
</Badge>
<div className="text-sm text-gray-500">
Contrast: {contrast.toFixed(1)}:1
</div>
</div>
<div className="flex space-x-3">
<Button variant="outline" className="flex-1" onClick={() => downloadQR('svg')}>
{t('generator.download_svg')}
</Button>
<Button variant="outline" className="flex-1" onClick={() => downloadQR('png')}>
{t('generator.download_png')}
</Button>
</div>
<Button className="w-full">
{t('generator.save_track')}
</Button>
</Card>
{/* Right Preview */}
<div className="flex flex-col items-center justify-center">
<Card className="text-center p-8">
<h3 className="text-lg font-semibold mb-4">{t('generator.live_preview')}</h3>
<div id="instant-qr-preview" className="flex justify-center mb-4">
{url ? (
<div className={`${cornerStyle === 'rounded' ? 'rounded-lg overflow-hidden' : ''}`}>
<QRCodeSVG
value={url}
size={Math.min(size, 200)}
fgColor={foregroundColor}
bgColor={backgroundColor}
level="M"
/>
</div>
) : (
<div
className="bg-gray-200 flex items-center justify-center text-gray-500"
style={{ width: 200, height: 200 }}
>
Enter URL
</div>
)}
</div>
<div className="text-sm text-gray-600 mb-2">URL</div>
<div className="text-xs text-gray-500">{t('generator.demo_note')}</div>
</Card>
</div>
</div>
</div>
</section>
);
};

View File

@ -0,0 +1,94 @@
'use client';
import React from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
interface PricingProps {
t: any; // i18n translation function
}
export const Pricing: React.FC<PricingProps> = ({ t }) => {
const plans = [
{
key: 'free',
popular: false,
},
{
key: 'pro',
popular: true,
},
{
key: 'business',
popular: false,
},
];
return (
<section className="py-16">
<div className="container mx-auto px-4">
<div className="text-center mb-12">
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
{t('pricing.title')}
</h2>
<p className="text-xl text-gray-600">
{t('pricing.subtitle')}
</p>
</div>
<div className="grid md:grid-cols-3 gap-8 max-w-5xl mx-auto">
{plans.map((plan) => (
<Card
key={plan.key}
className={plan.popular ? 'border-primary-500 shadow-xl relative' : ''}
>
{plan.popular && (
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
<Badge variant="info" className="px-3 py-1">
{t(`pricing.${plan.key}.badge`)}
</Badge>
</div>
)}
<CardHeader className="text-center pb-8">
<CardTitle className="text-2xl mb-4">
{t(`pricing.${plan.key}.title`)}
</CardTitle>
<div className="flex items-baseline justify-center">
<span className="text-4xl font-bold">
{t(`pricing.${plan.key}.price`)}
</span>
<span className="text-gray-600 ml-2">
{t(`pricing.${plan.key}.period`)}
</span>
</div>
</CardHeader>
<CardContent className="space-y-4">
<ul className="space-y-3">
{t(`pricing.${plan.key}.features`, { returnObjects: true }).map((feature: string, index: number) => (
<li key={index} className="flex items-start space-x-3">
<svg className="w-5 h-5 text-success-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
<span className="text-gray-700">{feature}</span>
</li>
))}
</ul>
<Button
variant={plan.popular ? 'primary' : 'outline'}
className="w-full"
size="lg"
>
Get Started
</Button>
</CardContent>
</Card>
))}
</div>
</div>
</section>
);
};

View File

@ -0,0 +1,69 @@
'use client';
import React from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
interface StaticVsDynamicProps {
t: any; // i18n translation function
}
export const StaticVsDynamic: React.FC<StaticVsDynamicProps> = ({ t }) => {
return (
<section className="py-16">
<div className="container mx-auto px-4">
<div className="grid lg:grid-cols-2 gap-8 max-w-6xl mx-auto">
{/* Static QR Codes */}
<Card className="relative">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-2xl">{t('static_vs_dynamic.static.title')}</CardTitle>
<Badge variant="success">{t('static_vs_dynamic.static.subtitle')}</Badge>
</div>
<p className="text-gray-600">{t('static_vs_dynamic.static.description')}</p>
</CardHeader>
<CardContent>
<ul className="space-y-3">
{t('static_vs_dynamic.static.features', { returnObjects: true }).map((feature: string, index: number) => (
<li key={index} className="flex items-center space-x-3">
<div className="flex-shrink-0 w-5 h-5 bg-gray-400 rounded-full flex items-center justify-center">
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
<span className="text-gray-700">{feature}</span>
</li>
))}
</ul>
</CardContent>
</Card>
{/* Dynamic QR Codes */}
<Card className="relative border-primary-200 bg-primary-50">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-2xl">{t('static_vs_dynamic.dynamic.title')}</CardTitle>
<Badge variant="info">{t('static_vs_dynamic.dynamic.subtitle')}</Badge>
</div>
<p className="text-gray-600">{t('static_vs_dynamic.dynamic.description')}</p>
</CardHeader>
<CardContent>
<ul className="space-y-3">
{t('static_vs_dynamic.dynamic.features', { returnObjects: true }).map((feature: string, index: number) => (
<li key={index} className="flex items-center space-x-3">
<div className="flex-shrink-0 w-5 h-5 bg-primary-500 rounded-full flex items-center justify-center">
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
<span className="text-gray-700">{feature}</span>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</div>
</section>
);
};

View File

@ -0,0 +1,35 @@
'use client';
import React from 'react';
interface StatsStripProps {
t: any; // i18n translation function
}
export const StatsStrip: React.FC<StatsStripProps> = ({ t }) => {
const stats = [
{ key: 'users', value: '10,000+', label: t('trust.users') },
{ key: 'codes', value: '500,000+', label: t('trust.codes') },
{ key: 'scans', value: '50M+', label: t('trust.scans') },
{ key: 'countries', value: '120+', label: t('trust.countries') },
];
return (
<section className="py-16 bg-gray-50">
<div className="container mx-auto px-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
{stats.map((stat, index) => (
<div key={stat.key} className="text-center">
<div className="text-3xl lg:text-4xl font-bold text-primary-600 mb-2">
{stat.value}
</div>
<div className="text-gray-600 font-medium">
{stat.label}
</div>
</div>
))}
</div>
</div>
</section>
);
};

View File

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

View File

@ -0,0 +1,32 @@
import React from 'react';
import { cn } from '@/lib/utils';
interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: 'default' | 'success' | 'warning' | 'info' | 'error';
}
export const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
({ className, variant = 'default', ...props }, ref) => {
const variants = {
default: 'bg-gray-100 text-gray-800',
success: 'bg-success-100 text-success-800',
warning: 'bg-warning-100 text-warning-800',
info: 'bg-info-100 text-info-800',
error: 'bg-red-100 text-red-800',
};
return (
<div
ref={ref}
className={cn(
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
variants[variant],
className
)}
{...props}
/>
);
}
);
Badge.displayName = 'Badge';

View File

@ -0,0 +1,46 @@
import React from 'react';
import { cn } from '@/lib/utils';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'primary', size = 'md', loading, children, disabled, ...props }, ref) => {
const baseClasses = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
const variants = {
primary: 'bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-500',
outline: 'border border-gray-300 text-gray-700 hover:bg-gray-50 focus:ring-gray-500',
ghost: 'text-gray-700 hover:bg-gray-100 focus:ring-gray-500',
};
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base',
};
return (
<button
ref={ref}
className={cn(baseClasses, variants[variant], sizes[size], className)}
disabled={disabled || loading}
{...props}
>
{loading && (
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
)}
{children}
</button>
);
}
);
Button.displayName = 'Button';

View File

@ -0,0 +1,94 @@
import React from 'react';
import { cn } from '@/lib/utils';
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
hover?: boolean;
}
export const Card = React.forwardRef<HTMLDivElement, CardProps>(
({ className, hover = false, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(
'bg-white rounded-xl shadow-sm border border-gray-200 p-6',
hover && 'transition-all duration-200 hover:shadow-md hover:border-gray-300',
className
)}
{...props}
/>
);
}
);
Card.displayName = 'Card';
export const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
return (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 pb-4', className)}
{...props}
/>
);
}
);
CardHeader.displayName = 'CardHeader';
export const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => {
return (
<h3
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
);
}
);
CardTitle.displayName = 'CardTitle';
export const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => {
return (
<p
ref={ref}
className={cn('text-sm text-gray-600', className)}
{...props}
/>
);
}
);
CardDescription.displayName = 'CardDescription';
export const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
return (
<div
ref={ref}
className={cn('pt-0', className)}
{...props}
/>
);
}
);
CardContent.displayName = 'CardContent';
export const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
return (
<div
ref={ref}
className={cn('flex items-center pt-4', className)}
{...props}
/>
);
}
);
CardFooter.displayName = 'CardFooter';

View File

@ -0,0 +1,92 @@
import React from 'react';
import { cn } from '@/lib/utils';
interface DialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
}
export const Dialog: React.FC<DialogProps> = ({ open, onOpenChange, children }) => {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="fixed inset-0 bg-black/50"
onClick={() => onOpenChange(false)}
/>
<div className="relative z-50 w-full max-w-lg mx-4">
{children}
</div>
</div>
);
};
interface DialogContentProps extends React.HTMLAttributes<HTMLDivElement> {}
export const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'bg-white rounded-xl shadow-lg border border-gray-200 p-6',
className
)}
{...props}
/>
)
);
DialogContent.displayName = 'DialogContent';
interface DialogHeaderProps extends React.HTMLAttributes<HTMLDivElement> {}
export const DialogHeader = React.forwardRef<HTMLDivElement, DialogHeaderProps>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)}
{...props}
/>
)
);
DialogHeader.displayName = 'DialogHeader';
interface DialogTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {}
export const DialogTitle = React.forwardRef<HTMLHeadingElement, DialogTitleProps>(
({ className, ...props }, ref) => (
<h2
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
)
);
DialogTitle.displayName = 'DialogTitle';
interface DialogDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> {}
export const DialogDescription = React.forwardRef<HTMLParagraphElement, DialogDescriptionProps>(
({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-gray-600', className)}
{...props}
/>
)
);
DialogDescription.displayName = 'DialogDescription';
interface DialogFooterProps extends React.HTMLAttributes<HTMLDivElement> {}
export const DialogFooter = React.forwardRef<HTMLDivElement, DialogFooterProps>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 pt-4', className)}
{...props}
/>
)
);
DialogFooter.displayName = 'DialogFooter';

View File

@ -0,0 +1,63 @@
import React, { useState, useRef, useEffect } from 'react';
import { cn } from '@/lib/utils';
interface DropdownProps {
trigger: React.ReactNode;
children: React.ReactNode;
align?: 'left' | 'right';
}
export const Dropdown: React.FC<DropdownProps> = ({ trigger, children, align = 'left' }) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<div className="relative" ref={dropdownRef}>
<div onClick={() => setIsOpen(!isOpen)}>
{trigger}
</div>
{isOpen && (
<div
className={cn(
'absolute top-full mt-1 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50',
align === 'right' ? 'right-0' : 'left-0'
)}
>
{children}
</div>
)}
</div>
);
};
interface DropdownItemProps extends React.HTMLAttributes<HTMLDivElement> {
icon?: React.ReactNode;
}
export const DropdownItem = React.forwardRef<HTMLDivElement, DropdownItemProps>(
({ className, icon, children, ...props }, ref) => (
<div
ref={ref}
className={cn(
'flex items-center px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer',
className
)}
{...props}
>
{icon && <span className="mr-2">{icon}</span>}
{children}
</div>
)
);
DropdownItem.displayName = 'DropdownItem';

View File

@ -0,0 +1,36 @@
import React from 'react';
import { cn } from '@/lib/utils';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
}
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, label, error, ...props }, ref) => {
return (
<div className="space-y-1">
{label && (
<label className="block text-sm font-medium text-gray-700">
{label}
</label>
)}
<input
type={type}
className={cn(
'flex h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
error && 'border-red-500 focus-visible:ring-red-500',
className
)}
ref={ref}
{...props}
/>
{error && (
<p className="text-sm text-red-600">{error}</p>
)}
</div>
);
}
);
Input.displayName = 'Input';

View File

@ -0,0 +1,54 @@
'use client';
import React from 'react';
import { QRCodeSVG } from 'qrcode.react';
interface QRCodeProps {
value: string;
size?: number;
fgColor?: string;
bgColor?: string;
level?: 'L' | 'M' | 'Q' | 'H';
includeMargin?: boolean;
imageSettings?: {
src: string;
height: number;
width: number;
excavate: boolean;
};
}
export const QRCode: React.FC<QRCodeProps> = ({
value,
size = 128,
fgColor = '#000000',
bgColor = '#FFFFFF',
level = 'M',
includeMargin = false,
imageSettings,
}) => {
if (!value) {
return (
<div
className="bg-gray-200 flex items-center justify-center text-gray-500"
style={{ width: size, height: size }}
>
No data
</div>
);
}
return (
<QRCodeSVG
value={value}
size={size}
fgColor={fgColor}
bgColor={bgColor}
level={level}
includeMargin={includeMargin}
imageSettings={imageSettings}
/>
);
};
export default QRCode;

View File

@ -0,0 +1,42 @@
import React from 'react';
import { cn } from '@/lib/utils';
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
error?: string;
options: { value: string; label: string }[];
}
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
({ className, label, error, options, ...props }, ref) => {
return (
<div className="space-y-1">
{label && (
<label className="block text-sm font-medium text-gray-700">
{label}
</label>
)}
<select
className={cn(
'flex h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
error && 'border-red-500 focus-visible:ring-red-500',
className
)}
ref={ref}
{...props}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{error && (
<p className="text-sm text-red-600">{error}</p>
)}
</div>
);
}
);
Select.displayName = 'Select';

105
src/components/ui/Table.tsx Normal file
View File

@ -0,0 +1,105 @@
import React from 'react';
import { cn } from '@/lib/utils';
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
)
);
Table.displayName = 'Table';
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
)
);
TableHeader.displayName = 'TableHeader';
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
)
);
TableBody.displayName = 'TableBody';
const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn('border-t bg-gray-50/50 font-medium [&>tr]:last:border-b-0', className)}
{...props}
/>
)
);
TableFooter.displayName = 'TableFooter';
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
'border-b transition-colors hover:bg-gray-50/50 data-[state=selected]:bg-gray-50',
className
)}
{...props}
/>
)
);
TableRow.displayName = 'TableRow';
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'h-12 px-4 text-left align-middle font-medium text-gray-500 [&:has([role=checkbox])]:pr-0',
className
)}
{...props}
/>
)
);
TableHead.displayName = 'TableHead';
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<td
ref={ref}
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
{...props}
/>
)
);
TableCell.displayName = 'TableCell';
const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn('mt-4 text-sm text-gray-500', className)}
{...props}
/>
)
);
TableCaption.displayName = 'TableCaption';
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

140
src/components/ui/Toast.tsx Normal file
View File

@ -0,0 +1,140 @@
'use client';
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
export interface ToastProps {
id: string;
message: string;
type?: 'success' | 'error' | 'info' | 'warning';
duration?: number;
onClose?: () => void;
}
export const Toast: React.FC<ToastProps> = ({
id,
message,
type = 'info',
duration = 3000,
onClose,
}) => {
const [isVisible, setIsVisible] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setIsVisible(false);
setTimeout(() => onClose?.(), 300);
}, duration);
return () => clearTimeout(timer);
}, [duration, onClose]);
const icons = {
success: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
),
error: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
),
warning: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
),
info: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
};
const colors = {
success: 'bg-success-50 text-success-900 border-success-200',
error: 'bg-red-50 text-red-900 border-red-200',
warning: 'bg-warning-50 text-warning-900 border-warning-200',
info: 'bg-info-50 text-info-900 border-info-200',
};
return (
<div
className={`
flex items-center space-x-3 px-4 py-3 rounded-lg border shadow-lg
${colors[type]}
transition-all duration-300 transform
${isVisible ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'}
`}
>
<div className="flex-shrink-0">{icons[type]}</div>
<p className="text-sm font-medium">{message}</p>
<button
onClick={() => {
setIsVisible(false);
setTimeout(() => onClose?.(), 300);
}}
className="ml-auto flex-shrink-0 hover:opacity-70"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
);
};
// Toast Container
export const ToastContainer: React.FC = () => {
const [toasts, setToasts] = useState<ToastProps[]>([]);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
const handleToast = (event: CustomEvent<Omit<ToastProps, 'id'>>) => {
const newToast: ToastProps = {
...event.detail,
id: Date.now().toString(),
};
setToasts(prev => [...prev, newToast]);
};
window.addEventListener('toast' as any, handleToast);
return () => window.removeEventListener('toast' as any, handleToast);
}, []);
const removeToast = (id: string) => {
setToasts(prev => prev.filter(toast => toast.id !== id));
};
if (!isMounted) return null;
return createPortal(
<div className="fixed top-4 right-4 z-50 space-y-2">
{toasts.map(toast => (
<Toast
key={toast.id}
{...toast}
onClose={() => removeToast(toast.id)}
/>
))}
</div>,
document.body
);
};
// Helper function to show toast
export const showToast = (
message: string,
type: 'success' | 'error' | 'info' | 'warning' = 'info',
duration = 3000
) => {
if (typeof window !== 'undefined') {
const event = new CustomEvent('toast', {
detail: { message, type, duration },
});
window.dispatchEvent(event);
}
};

View File

@ -0,0 +1,59 @@
'use client';
import { useState, useEffect } from 'react';
import en from '@/i18n/en.json';
import de from '@/i18n/de.json';
type Locale = 'en' | 'de';
const translations = {
en,
de,
};
export function useTranslation() {
const [locale, setLocale] = useState<Locale>('en');
useEffect(() => {
// Check localStorage for saved locale
const savedLocale = localStorage.getItem('locale') as Locale;
if (savedLocale && (savedLocale === 'en' || savedLocale === 'de')) {
setLocale(savedLocale);
}
}, []);
const changeLocale = (newLocale: Locale) => {
setLocale(newLocale);
localStorage.setItem('locale', newLocale);
};
const t = (key: string, options?: { returnObjects?: boolean }) => {
const keys = key.split('.');
let value: any = translations[locale];
for (const k of keys) {
if (value && typeof value === 'object' && k in value) {
value = value[k];
} else {
// Fallback to English if key not found
value = translations.en;
for (const k of keys) {
if (value && typeof value === 'object' && k in value) {
value = value[k];
} else {
return key; // Return key if not found
}
}
break;
}
}
return value;
};
return {
t,
locale,
setLocale: changeLocale,
};
}

308
src/i18n/de.json Normal file
View File

@ -0,0 +1,308 @@
{
"nav": {
"features": "Funktionen",
"pricing": "Preise",
"faq": "FAQ",
"blog": "Blog",
"login": "Anmelden",
"dashboard": "Dashboard"
},
"hero": {
"badge": "🚀 Der beliebteste QR-Code-Generator im Internet",
"title": "Erstelle individuelle QR-Codes in Sekunden",
"subtitle": "Generiere statische und dynamische QR-Codes mit erweiterten Tracking-Funktionen, professionellen Vorlagen und nahtlosen Integrationen.",
"features": [
"Keine Kreditkarte zum Starten erforderlich",
"QR-Codes für immer kostenlos erstellen",
"Erweiterte Verfolgung und Analytik",
"Professionelle Vorlagen inklusive"
],
"cta_primary": "QR-Code kostenlos erstellen",
"cta_secondary": "Demo ansehen",
"engagement_badge": "+47% Engagement-Steigerung"
},
"trust": {
"users": "10.000+ Aktive Nutzer",
"codes": "500.000+ QR-Codes erstellt",
"scans": "50M+ Scans verfolgt",
"countries": "120+ Länder"
},
"industries": {
"restaurant": "Restaurant-Kette",
"tech": "Tech-Startup",
"realestate": "Immobilien",
"events": "Event-Agentur",
"retail": "Einzelhandel",
"healthcare": "Gesundheitswesen"
},
"templates": {
"title": "Mit einer Vorlage beginnen",
"restaurant": "Restaurant-Menü",
"business": "Visitenkarte",
"wifi": "WLAN-Zugang",
"event": "Event-Ticket",
"use_template": "Vorlage verwenden →"
},
"generator": {
"title": "Sofortiger QR-Code-Generator",
"url_placeholder": "Geben Sie hier Ihre URL ein...",
"foreground": "Vordergrund",
"background": "Hintergrund",
"corners": "Ecken",
"size": "Größe",
"contrast_good": "Guter Kontrast",
"download_svg": "SVG herunterladen",
"download_png": "PNG herunterladen",
"save_track": "Speichern & Verfolgen",
"live_preview": "Live-Vorschau",
"demo_note": "Dies ist ein Demo-QR-Code"
},
"static_vs_dynamic": {
"static": {
"title": "Statische QR-Codes",
"subtitle": "Immer kostenlos",
"description": "Perfekt für permanente Inhalte, die sich nie ändern",
"features": [
"Inhalt kann nicht bearbeitet werden",
"Keine Scan-Verfolgung",
"Funktioniert für immer",
"Kein Konto erforderlich"
]
},
"dynamic": {
"title": "Dynamische QR-Codes",
"subtitle": "Empfohlen",
"description": "Volle Kontrolle mit Tracking- und Bearbeitungsfunktionen",
"features": [
"Inhalt jederzeit bearbeiten",
"Erweiterte Analytik",
"Individuelles Branding",
"Bulk-Operationen"
]
}
},
"features": {
"title": "Alles was Sie brauchen, um professionelle QR-Codes zu erstellen",
"analytics": {
"title": "Erweiterte Analytik",
"description": "Verfolgen Sie Scans, Standorte, Geräte und Nutzerverhalten mit detaillierten Einblicken."
},
"customization": {
"title": "Vollständige Anpassung",
"description": "Branden Sie Ihre QR-Codes mit individuellen Farben, Logos und Styling-Optionen."
},
"bulk": {
"title": "Bulk-Operationen",
"description": "Erstellen Sie hunderte von QR-Codes auf einmal mit CSV-Import und Batch-Verarbeitung."
},
"integrations": {
"title": "Integrationen",
"description": "Verbinden Sie sich mit Zapier, Airtable, Google Sheets und weiteren beliebten Tools."
},
"api": {
"title": "Entwickler-API",
"description": "Integrieren Sie QR-Code-Generierung in Ihre Anwendungen mit unserer REST-API."
},
"support": {
"title": "24/7 Support",
"description": "Erhalten Sie Hilfe, wenn Sie sie brauchen, mit unserem dedizierten Kundensupport-Team."
}
},
"pricing": {
"title": "Einfache, transparente Preise",
"subtitle": "Wählen Sie den Plan, der zu Ihnen passt",
"free": {
"title": "Kostenlos",
"price": "€0",
"period": "für immer",
"features": [
"5 dynamische QR-Codes",
"Unbegrenzte statische QR-Codes",
"Basis-Analytik",
"Standard-Vorlagen"
]
},
"pro": {
"title": "Pro",
"price": "€9",
"period": "pro Monat",
"badge": "Beliebteste",
"features": [
"Unbegrenzte QR-Codes",
"Erweiterte Analytik",
"Individuelles Branding",
"Bulk-Operationen",
"API-Zugang",
"Prioritäts-Support"
]
},
"business": {
"title": "Business",
"price": "€49",
"period": "pro Monat",
"features": [
"Alles aus Pro",
"Team-Zusammenarbeit",
"White-Label-Lösung",
"Erweiterte Integrationen",
"Individuelle Domains",
"Dedizierter Support"
]
}
},
"faq": {
"title": "Häufig gestellte Fragen",
"questions": {
"account": {
"question": "Benötige ich ein Konto, um QR-Codes zu erstellen?",
"answer": "Für statische QR-Codes ist kein Konto erforderlich. Dynamische QR-Codes mit Tracking- und Bearbeitungsfunktionen erfordern jedoch ein kostenloses Konto."
},
"static_vs_dynamic": {
"question": "Was ist der Unterschied zwischen statischen und dynamischen QR-Codes?",
"answer": "Statische QR-Codes enthalten feste Inhalte, die nicht geändert werden können. Dynamische QR-Codes können jederzeit bearbeitet werden und bieten detaillierte Analytik."
},
"forever": {
"question": "Funktionieren meine QR-Codes für immer?",
"answer": "Statische QR-Codes funktionieren für immer, da der Inhalt direkt eingebettet ist. Dynamische QR-Codes funktionieren, solange Ihr Konto aktiv ist."
},
"file_type": {
"question": "Welchen Dateityp sollte ich zum Drucken verwenden?",
"answer": "Für Druckmaterialien empfehlen wir das SVG-Format für Skalierbarkeit oder hochauflösendes PNG (300+ DPI) für beste Qualität."
},
"password": {
"question": "Kann ich einen QR-Code mit einem Passwort schützen?",
"answer": "Ja, Pro- und Business-Pläne beinhalten Passwortschutz und Zugriffskontrollfunktionen für Ihre QR-Codes."
},
"analytics": {
"question": "Wie funktioniert die Analytik?",
"answer": "Wir verfolgen Scans, Standorte, Geräte und Referrer unter Beachtung der Privatsphäre der Nutzer. Keine persönlichen Daten werden gespeichert."
},
"privacy": {
"question": "Verfolgen Sie persönliche Daten?",
"answer": "Wir respektieren die Privatsphäre und sammeln nur anonyme Nutzungsdaten. IP-Adressen werden gehasht und wir respektieren Do-Not-Track-Header."
},
"bulk": {
"question": "Kann ich Codes in großen Mengen mit meinen eigenen Daten erstellen?",
"answer": "Ja, Sie können CSV- oder Excel-Dateien hochladen, um mehrere QR-Codes auf einmal mit individueller Datenzuordnung zu erstellen."
}
}
},
"dashboard": {
"title": "Dashboard",
"subtitle": "Verwalten Sie Ihre QR-Codes und verfolgen Sie Ihre Performance",
"stats": {
"total_scans": "Gesamte Scans",
"active_codes": "Aktive QR-Codes",
"conversion_rate": "Konversionsrate"
},
"recent_codes": "Aktuelle QR-Codes",
"blog_resources": "Blog & Ressourcen",
"menu": {
"edit": "Bearbeiten",
"duplicate": "Duplizieren",
"pause": "Pausieren",
"delete": "Löschen"
}
},
"create": {
"title": "Dynamische QR-Codes erstellen",
"content": "Inhalt",
"type": "QR-Code-Typ",
"style": "Stil & Branding",
"preview": "Live-Vorschau"
},
"analytics": {
"title": "Analytik",
"ranges": {
"7d": "7 Tage",
"30d": "30 Tage",
"90d": "90 Tage"
},
"kpis": {
"total_scans": "Gesamte Scans",
"avg_scans": "Ø Scans/QR",
"mobile_usage": "Mobile Nutzung %",
"top_country": "Top Land"
},
"charts": {
"scans_over_time": "Scans über Zeit",
"device_types": "Gerätetypen",
"top_countries": "Top Länder"
},
"table": {
"qr_code": "QR-Code",
"type": "Typ",
"scans": "Scans",
"performance": "Performance",
"created": "Erstellt",
"status": "Status"
}
},
"bulk": {
"title": "Bulk-Upload",
"steps": {
"upload": "Upload",
"preview": "Vorschau",
"complete": "Abschließen"
},
"drag_drop": "Datei hier hinziehen oder klicken zum Durchsuchen",
"supported_formats": "Unterstützte Formate: .csv, .xls, .xlsx"
},
"integrations": {
"title": "Integrationen",
"metrics": {
"total_codes": "QR-Codes Gesamt",
"active_integrations": "Aktive Integrationen",
"sync_status": "Sync-Status",
"available_services": "Verfügbare Services"
},
"zapier": {
"title": "Zapier",
"description": "Automatisieren Sie QR-Code-Erstellung mit 5000+ Apps",
"features": [
"Trigger bei neuen QR-Codes",
"Codes aus anderen Apps erstellen",
"Scan-Daten synchronisieren"
]
},
"airtable": {
"title": "Airtable",
"description": "Synchronisieren Sie QR-Codes mit Ihren Airtable-Basen",
"features": [
"Bidirektionale Synchronisation",
"Individuelle Feldzuordnung",
"Echtzeit-Updates"
]
},
"sheets": {
"title": "Google Sheets",
"description": "Exportieren Sie Daten automatisch zu Google Sheets",
"features": [
"Automatisierte Exporte",
"Individuelle Vorlagen",
"Geplante Updates"
]
},
"activate": "Aktivieren & Konfigurieren"
},
"settings": {
"title": "Einstellungen",
"tabs": {
"profile": "Profil",
"billing": "Abrechnung",
"team": "Team & Rollen",
"api": "API-Schlüssel",
"workspace": "Arbeitsbereich"
}
},
"common": {
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"edit": "Bearbeiten",
"create": "Erstellen",
"loading": "Lädt...",
"error": "Ein Fehler ist aufgetreten",
"success": "Erfolgreich!"
}
}

308
src/i18n/en.json Normal file
View File

@ -0,0 +1,308 @@
{
"nav": {
"features": "Features",
"pricing": "Pricing",
"faq": "FAQ",
"blog": "Blog",
"login": "Login",
"dashboard": "Dashboard"
},
"hero": {
"badge": "🚀 The Internet's Favorite QR Code Creator",
"title": "Create Custom QR Codes in Seconds",
"subtitle": "Generate static and dynamic QR codes with advanced tracking, professional templates, and seamless integrations.",
"features": [
"No credit card required to start",
"Create QR codes free forever",
"Advanced tracking and analytics",
"Professional templates included"
],
"cta_primary": "Make a QR Code Free",
"cta_secondary": "Watch Demo",
"engagement_badge": "+47% Engagement Up"
},
"trust": {
"users": "10,000+ Active Users",
"codes": "500,000+ QR Codes Created",
"scans": "50M+ Scans Tracked",
"countries": "120+ Countries"
},
"industries": {
"restaurant": "Restaurant Chain",
"tech": "Tech Startup",
"realestate": "Real Estate",
"events": "Event Agency",
"retail": "Retail Store",
"healthcare": "Healthcare"
},
"templates": {
"title": "Start with a Template",
"restaurant": "Restaurant Menu",
"business": "Business Card",
"wifi": "Wi-Fi Access",
"event": "Event Ticket",
"use_template": "Use template →"
},
"generator": {
"title": "Instant QR Code Generator",
"url_placeholder": "Enter your URL here...",
"foreground": "Foreground",
"background": "Background",
"corners": "Corners",
"size": "Size",
"contrast_good": "Good contrast",
"download_svg": "Download SVG",
"download_png": "Download PNG",
"save_track": "Save & Track",
"live_preview": "Live Preview",
"demo_note": "This is a demo QR code"
},
"static_vs_dynamic": {
"static": {
"title": "Static QR Codes",
"subtitle": "Always Free",
"description": "Perfect for permanent content that never changes",
"features": [
"Content cannot be edited",
"No scan tracking",
"Works forever",
"No account required"
]
},
"dynamic": {
"title": "Dynamic QR Codes",
"subtitle": "Recommended",
"description": "Full control with tracking and editing capabilities",
"features": [
"Edit content anytime",
"Advanced analytics",
"Custom branding",
"Bulk operations"
]
}
},
"features": {
"title": "Everything you need to create professional QR codes",
"analytics": {
"title": "Advanced Analytics",
"description": "Track scans, locations, devices, and user behavior with detailed insights."
},
"customization": {
"title": "Full Customization",
"description": "Brand your QR codes with custom colors, logos, and styling options."
},
"bulk": {
"title": "Bulk Operations",
"description": "Create hundreds of QR codes at once with CSV import and batch processing."
},
"integrations": {
"title": "Integrations",
"description": "Connect with Zapier, Airtable, Google Sheets, and more popular tools."
},
"api": {
"title": "Developer API",
"description": "Integrate QR code generation into your applications with our REST API."
},
"support": {
"title": "24/7 Support",
"description": "Get help when you need it with our dedicated customer support team."
}
},
"pricing": {
"title": "Simple, transparent pricing",
"subtitle": "Choose the plan that's right for you",
"free": {
"title": "Free",
"price": "€0",
"period": "forever",
"features": [
"5 dynamic QR codes",
"Unlimited static QR codes",
"Basic analytics",
"Standard templates"
]
},
"pro": {
"title": "Pro",
"price": "€9",
"period": "per month",
"badge": "Most Popular",
"features": [
"Unlimited QR codes",
"Advanced analytics",
"Custom branding",
"Bulk operations",
"API access",
"Priority support"
]
},
"business": {
"title": "Business",
"price": "€49",
"period": "per month",
"features": [
"Everything in Pro",
"Team collaboration",
"White-label solution",
"Advanced integrations",
"Custom domains",
"Dedicated support"
]
}
},
"faq": {
"title": "Frequently Asked Questions",
"questions": {
"account": {
"question": "Do I need an account to create QR codes?",
"answer": "No account is required for static QR codes. However, dynamic QR codes with tracking and editing capabilities require a free account."
},
"static_vs_dynamic": {
"question": "What's the difference between static and dynamic QR codes?",
"answer": "Static QR codes contain fixed content that cannot be changed. Dynamic QR codes can be edited anytime and provide detailed analytics."
},
"forever": {
"question": "Will my QR codes work forever?",
"answer": "Static QR codes work forever as the content is embedded directly. Dynamic QR codes work as long as your account is active."
},
"file_type": {
"question": "What file type should I use for printing?",
"answer": "For print materials, we recommend SVG format for scalability or high-resolution PNG (300+ DPI) for best quality."
},
"password": {
"question": "Can I password-protect a QR code?",
"answer": "Yes, Pro and Business plans include password protection and access control features for your QR codes."
},
"analytics": {
"question": "How do analytics work?",
"answer": "We track scans, locations, devices, and referrers while respecting user privacy. No personal data is stored."
},
"privacy": {
"question": "Do you track personal data?",
"answer": "We respect privacy and only collect anonymous usage data. IP addresses are hashed and we honor Do Not Track headers."
},
"bulk": {
"question": "Can I bulk-create codes with my own data?",
"answer": "Yes, you can upload CSV or Excel files to create multiple QR codes at once with custom data mapping."
}
}
},
"dashboard": {
"title": "Dashboard",
"subtitle": "Manage your QR codes and track performance",
"stats": {
"total_scans": "Total Scans",
"active_codes": "Active QR Codes",
"conversion_rate": "Conversion Rate"
},
"recent_codes": "Recent QR Codes",
"blog_resources": "Blog & Resources",
"menu": {
"edit": "Edit",
"duplicate": "Duplicate",
"pause": "Pause",
"delete": "Delete"
}
},
"create": {
"title": "Create Dynamic QR Codes",
"content": "Content",
"type": "QR Code Type",
"style": "Style & Branding",
"preview": "Live Preview"
},
"analytics": {
"title": "Analytics",
"ranges": {
"7d": "7 Days",
"30d": "30 Days",
"90d": "90 Days"
},
"kpis": {
"total_scans": "Total Scans",
"avg_scans": "Avg Scans/QR",
"mobile_usage": "Mobile Usage %",
"top_country": "Top Country"
},
"charts": {
"scans_over_time": "Scans Over Time",
"device_types": "Device Types",
"top_countries": "Top Countries"
},
"table": {
"qr_code": "QR Code",
"type": "Type",
"scans": "Scans",
"performance": "Performance",
"created": "Created",
"status": "Status"
}
},
"bulk": {
"title": "Bulk Upload",
"steps": {
"upload": "Upload",
"preview": "Preview",
"complete": "Complete"
},
"drag_drop": "Drag & drop your file here, or click to browse",
"supported_formats": "Supported formats: .csv, .xls, .xlsx"
},
"integrations": {
"title": "Integrations",
"metrics": {
"total_codes": "QR Codes Total",
"active_integrations": "Active Integrations",
"sync_status": "Sync Status",
"available_services": "Available Services"
},
"zapier": {
"title": "Zapier",
"description": "Automate QR code creation with 5000+ apps",
"features": [
"Trigger on new QR codes",
"Create codes from other apps",
"Sync scan data"
]
},
"airtable": {
"title": "Airtable",
"description": "Sync QR codes with your Airtable bases",
"features": [
"Two-way sync",
"Custom field mapping",
"Real-time updates"
]
},
"sheets": {
"title": "Google Sheets",
"description": "Export data to Google Sheets automatically",
"features": [
"Automated exports",
"Custom templates",
"Scheduled updates"
]
},
"activate": "Activate & Configure"
},
"settings": {
"title": "Settings",
"tabs": {
"profile": "Profile",
"billing": "Billing",
"team": "Team & Roles",
"api": "API Keys",
"workspace": "Workspace"
}
},
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"create": "Create",
"loading": "Loading...",
"error": "An error occurred",
"success": "Success!"
}
}

77
src/lib/auth.ts Normal file
View File

@ -0,0 +1,77 @@
import { NextAuthOptions } from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import GoogleProvider from 'next-auth/providers/google';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { db } from './db';
import { comparePassword } from './hash';
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(db) as any,
session: {
strategy: 'jwt',
},
providers: [
CredentialsProvider({
name: 'credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
const user = await db.user.findUnique({
where: { email: credentials.email },
});
if (!user || !user.password) {
return null;
}
const isPasswordValid = await comparePassword(
credentials.password,
user.password
);
if (!isPasswordValid) {
return null;
}
return {
id: user.id,
email: user.email,
name: user.name,
};
},
}),
...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
? [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
]
: []),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
}
return token;
},
async session({ session, token }) {
if (session?.user) {
session.user.id = token.id as string;
}
return session;
},
},
pages: {
signIn: '/login',
error: '/login',
},
secret: process.env.NEXTAUTH_SECRET,
};

80
src/lib/charts.ts Normal file
View File

@ -0,0 +1,80 @@
import { ChartConfiguration } from 'chart.js';
export const defaultChartOptions: Partial<ChartConfiguration['options']> = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
},
scales: {
x: {
grid: {
display: false,
},
},
y: {
grid: {
color: '#f3f4f6',
},
beginAtZero: true,
},
},
};
export function createLineChartConfig(labels: string[], data: number[], label: string): ChartConfiguration {
return {
type: 'line',
data: {
labels,
datasets: [
{
label,
data,
borderColor: '#2563eb',
backgroundColor: 'rgba(37, 99, 235, 0.1)',
fill: true,
tension: 0.4,
},
],
},
options: {
...defaultChartOptions,
plugins: {
...(defaultChartOptions?.plugins || {}),
tooltip: {
mode: 'index',
intersect: false,
},
},
},
};
}
export function createBarChartConfig(labels: string[], data: number[], label: string): ChartConfiguration {
return {
type: 'bar',
data: {
labels,
datasets: [
{
label,
data,
backgroundColor: '#2563eb',
borderRadius: 4,
},
],
},
options: {
...defaultChartOptions,
plugins: {
...(defaultChartOptions?.plugins || {}),
tooltip: {
mode: 'index',
intersect: false,
},
},
},
};
}

13
src/lib/db.ts Normal file
View File

@ -0,0 +1,13 @@
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const db =
globalForPrisma.prisma ??
new PrismaClient({
log: ['query'],
});
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;

27
src/lib/env.ts Normal file
View File

@ -0,0 +1,27 @@
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.string().default('3000'),
DATABASE_URL: z.string().default('postgresql://postgres:postgres@localhost:5432/qrmaster?schema=public'),
NEXTAUTH_URL: z.string().default('http://localhost:3050'),
NEXTAUTH_SECRET: z.string().default('development-secret-change-in-production'),
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
REDIS_URL: z.string().optional(),
IP_SALT: z.string().default('development-salt-change-in-production'),
ENABLE_DEMO: z.string().default('false'),
});
// During build, we might not have all env vars, so we'll use defaults
const isBuildTime = process.env.NEXT_PHASE === 'phase-production-build' || !process.env.DATABASE_URL;
export const env = isBuildTime
? envSchema.parse({
...process.env,
DATABASE_URL: process.env.DATABASE_URL || 'postgresql://postgres:postgres@db:5432/qrmaster?schema=public',
NEXTAUTH_URL: process.env.NEXTAUTH_URL || 'http://localhost:3050',
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET || 'development-secret-change-in-production',
IP_SALT: process.env.IP_SALT || 'development-salt-change-in-production',
})
: envSchema.parse(process.env);

50
src/lib/geo.ts Normal file
View File

@ -0,0 +1,50 @@
export function getCountryFromHeaders(headers: Headers): string | null {
// Try Vercel's country header first
const vercelCountry = headers.get('x-vercel-ip-country');
if (vercelCountry) {
return vercelCountry;
}
// Try Cloudflare's country header
const cfCountry = headers.get('cf-ipcountry');
if (cfCountry && cfCountry !== 'XX') {
return cfCountry;
}
// Fallback to other common headers
const country = headers.get('x-country-code') || headers.get('x-forwarded-country');
return country || null;
}
export function parseUserAgent(userAgent: string | null): { device: string | null; os: string | null } {
if (!userAgent) {
return { device: null, os: null };
}
let device: string | null = null;
let os: string | null = null;
// Detect device
if (/Mobile|Android|iPhone|iPad/.test(userAgent)) {
device = 'mobile';
} else if (/Tablet|iPad/.test(userAgent)) {
device = 'tablet';
} else {
device = 'desktop';
}
// Detect OS
if (/Windows/.test(userAgent)) {
os = 'Windows';
} else if (/Mac OS X|macOS/.test(userAgent)) {
os = 'macOS';
} else if (/Linux/.test(userAgent)) {
os = 'Linux';
} else if (/Android/.test(userAgent)) {
os = 'Android';
} else if (/iOS|iPhone|iPad/.test(userAgent)) {
os = 'iOS';
}
return { device, os };
}

49
src/lib/hash.ts Normal file
View File

@ -0,0 +1,49 @@
import crypto from 'crypto';
import { env } from './env';
/**
* Hash an IP address for privacy
* Uses a salt from environment variables to ensure consistent hashing
*/
export function hashIP(ip: string): string {
const salt = env.IP_SALT || 'default-salt-change-in-production';
return crypto
.createHash('sha256')
.update(ip + salt)
.digest('hex')
.substring(0, 16); // Use first 16 chars for storage efficiency
}
/**
* Generate a random slug for QR codes
*/
export function generateSlug(title?: string): string {
const base = title
? title.toLowerCase().replace(/[^a-z0-9]+/g, '-').substring(0, 20)
: 'qr';
const random = Math.random().toString(36).substring(2, 8);
return `${base}-${random}`;
}
/**
* Generate a secure API key
*/
export function generateApiKey(): string {
return 'qrm_' + crypto.randomBytes(32).toString('hex');
}
/**
* Hash a password (for comparison with bcrypt hashed passwords)
*/
export async function hashPassword(password: string): Promise<string> {
const bcrypt = await import('bcryptjs');
return bcrypt.hash(password, 12);
}
/**
* Compare a plain password with a hashed password
*/
export async function comparePassword(password: string, hash: string): Promise<boolean> {
const bcrypt = await import('bcryptjs');
return bcrypt.compare(password, hash);
}

224
src/lib/qr.ts Normal file
View File

@ -0,0 +1,224 @@
import { z } from 'zod';
import QRCode from 'qrcode';
import { db } from './db';
import { generateSlug, hashIP } from './hash';
import { getCountryFromHeaders, parseUserAgent } from './geo';
import { ContentType, QRType, QRStatus } from '@prisma/client';
import Redis from 'ioredis';
import { env } from './env';
// Redis client (optional)
let redis: Redis | null = null;
if (env.REDIS_URL) {
try {
redis = new Redis(env.REDIS_URL);
} catch (error) {
console.warn('Redis connection failed, falling back to direct DB writes');
}
}
// Validation schemas
const qrContentSchema = z.object({
url: z.string().url().optional(),
phone: z.string().optional(),
email: z.string().email().optional(),
subject: z.string().optional(),
message: z.string().optional(),
text: z.string().optional(),
ssid: z.string().optional(),
password: z.string().optional(),
security: z.enum(['WPA', 'WEP', 'nopass']).optional(),
firstName: z.string().optional(),
lastName: z.string().optional(),
organization: z.string().optional(),
});
const qrStyleSchema = z.object({
foregroundColor: z.string().default('#000000'),
backgroundColor: z.string().default('#FFFFFF'),
cornerStyle: z.enum(['square', 'rounded']).default('square'),
size: z.number().min(100).max(1000).default(200),
});
const createQRSchema = z.object({
title: z.string().min(1).max(100),
type: z.nativeEnum(QRType).default(QRType.DYNAMIC),
contentType: z.nativeEnum(ContentType).default(ContentType.URL),
content: qrContentSchema,
tags: z.array(z.string()).default([]),
style: qrStyleSchema.default({}),
});
export async function createQR(userId: string, data: z.infer<typeof createQRSchema>) {
const validated = createQRSchema.parse(data);
const slug = generateSlug(validated.title);
const qrCode = await db.qRCode.create({
data: {
userId,
title: validated.title,
type: validated.type,
contentType: validated.contentType,
content: validated.content,
tags: validated.tags,
style: validated.style,
slug,
status: QRStatus.ACTIVE,
},
});
return qrCode;
}
export async function updateQR(id: string, userId: string, data: Partial<z.infer<typeof createQRSchema>>) {
const qrCode = await db.qRCode.findFirst({
where: { id, userId },
});
if (!qrCode) {
throw new Error('QR Code not found');
}
const updateData: any = {};
if (data.title) updateData.title = data.title;
if (data.content) updateData.content = data.content;
if (data.tags) updateData.tags = data.tags;
if (data.style) updateData.style = data.style;
return db.qRCode.update({
where: { id },
data: updateData,
});
}
export async function generateQRCodeSVG(content: string, style: any = {}): Promise<string> {
const options = {
type: 'svg' as const,
width: style.size || 200,
color: {
dark: style.foregroundColor || '#000000',
light: style.backgroundColor || '#FFFFFF',
},
margin: 2,
};
return QRCode.toString(content, options);
}
export async function generateQRCodePNG(content: string, style: any = {}): Promise<Buffer> {
const options = {
width: style.size || 200,
color: {
dark: style.foregroundColor || '#000000',
light: style.backgroundColor || '#FFFFFF',
},
margin: 2,
};
return QRCode.toBuffer(content, options);
}
export function getQRContent(qr: any): string {
const { contentType, content } = qr;
switch (contentType) {
case 'URL':
return content.url || '';
case 'PHONE':
return `tel:${content.phone || ''}`;
case 'EMAIL':
const subject = content.subject ? `?subject=${encodeURIComponent(content.subject)}` : '';
return `mailto:${content.email || ''}${subject}`;
case 'SMS':
const message = content.message ? `?body=${encodeURIComponent(content.message)}` : '';
return `sms:${content.phone || ''}${message}`;
case 'WHATSAPP':
const whatsappMessage = content.message ? `?text=${encodeURIComponent(content.message)}` : '';
return `https://wa.me/${content.phone || ''}${whatsappMessage}`;
case 'WIFI':
return `WIFI:T:${content.security || 'WPA'};S:${content.ssid || ''};P:${content.password || ''};;`;
case 'VCARD':
return `BEGIN:VCARD
VERSION:3.0
FN:${content.firstName || ''} ${content.lastName || ''}
ORG:${content.organization || ''}
EMAIL:${content.email || ''}
TEL:${content.phone || ''}
END:VCARD`;
case 'TEXT':
return content.text || '';
default:
return content.url || '';
}
}
export async function trackScan(qrId: string, request: Request) {
const headers = request.headers;
const ip = headers.get('x-forwarded-for') || headers.get('x-real-ip') || '127.0.0.1';
const userAgent = headers.get('user-agent');
const referrer = headers.get('referer');
const dnt = headers.get('dnt');
// Respect Do Not Track
if (dnt === '1') {
// Only increment aggregate counter, skip detailed tracking
return;
}
const ipHash = hashIP(ip);
const country = getCountryFromHeaders(headers);
const { device, os } = parseUserAgent(userAgent);
// Parse UTM parameters from referrer
let utmSource: string | null = null;
let utmMedium: string | null = null;
let utmCampaign: string | null = null;
if (referrer) {
try {
const url = new URL(referrer);
utmSource = url.searchParams.get('utm_source');
utmMedium = url.searchParams.get('utm_medium');
utmCampaign = url.searchParams.get('utm_campaign');
} catch (e) {
// Invalid referrer URL
}
}
// Check if this is a unique scan (same IP hash within 24 hours)
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const existingScan = await db.qRScan.findFirst({
where: {
qrId,
ipHash,
ts: { gte: dayAgo },
},
});
const isUnique = !existingScan;
const scanData = {
qrId,
ipHash,
userAgent,
device,
os,
country,
referrer,
utmSource,
utmMedium,
utmCampaign,
isUnique,
};
// Fire-and-forget tracking
if (redis) {
// Queue to Redis for background processing
redis.lpush('qr_scans', JSON.stringify(scanData)).catch(console.error);
} else {
// Direct database write
db.qRScan.create({ data: scanData }).catch(console.error);
}
}

67
src/lib/utils.ts Normal file
View File

@ -0,0 +1,67 @@
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
type ClassValue = string | number | null | undefined | boolean | ClassValue[] | { [key: string]: any };
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatNumber(num: number): string {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
}
if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toString();
}
export function formatDate(date: Date | string): string {
const d = new Date(date);
return d.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
export function formatDateTime(date: Date | string): string {
const d = new Date(date);
return d.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
export function calculateContrast(hex1: string, hex2: string): number {
// Convert hex to RGB
const getRGB = (hex: string) => {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return [r, g, b];
};
// Calculate relative luminance
const getLuminance = (rgb: number[]) => {
const [r, g, b] = rgb.map(c => {
c = c / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const rgb1 = getRGB(hex1);
const rgb2 = getRGB(hex2);
const lum1 = getLuminance(rgb1);
const lum2 = getLuminance(rgb2);
const brightest = Math.max(lum1, lum2);
const darkest = Math.min(lum1, lum2);
return (brightest + 0.05) / (darkest + 0.05);
}

52
src/middleware.ts Normal file
View File

@ -0,0 +1,52 @@
import { withAuth } from 'next-auth/middleware';
import { NextResponse } from 'next/server';
export default withAuth(
function middleware(req) {
return NextResponse.next();
},
{
callbacks: {
authorized: ({ req, token }) => {
// Public routes that don't require authentication
const publicPaths = [
'/',
'/pricing',
'/faq',
'/blog',
'/login',
'/signup',
'/api/auth',
];
const path = req.nextUrl.pathname;
// Allow public paths
if (publicPaths.some(p => path.startsWith(p))) {
return true;
}
// Allow redirect routes
if (path.startsWith('/r/')) {
return true;
}
// Require authentication for all other routes
return !!token;
},
},
}
);
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - public folder
*/
'/((?!_next/static|_next/image|favicon.ico|logo.svg|og-image.png).*)',
],
};

148
src/styles/globals.css Normal file
View File

@ -0,0 +1,148 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
}
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
}
a {
color: inherit;
text-decoration: none;
}
@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* Focus styles for accessibility */
.focus-ring {
@apply focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2;
}
/* Card styles */
.card {
@apply bg-white rounded-xl shadow-sm border border-gray-200 p-6;
}
.card-hover {
@apply transition-all duration-200 hover:shadow-md hover:border-gray-300;
}
/* Button styles */
.btn-primary {
@apply bg-primary-600 text-white px-4 py-2 rounded-lg font-medium hover:bg-primary-700 focus-ring transition-colors;
}
.btn-secondary {
@apply bg-gray-100 text-gray-900 px-4 py-2 rounded-lg font-medium hover:bg-gray-200 focus-ring transition-colors;
}
.btn-outline {
@apply border border-gray-300 text-gray-700 px-4 py-2 rounded-lg font-medium hover:bg-gray-50 focus-ring transition-colors;
}
/* Input styles */
.input {
@apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent;
}
/* Badge styles */
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.badge-success {
@apply bg-success-100 text-success-800;
}
.badge-warning {
@apply bg-warning-100 text-warning-800;
}
.badge-info {
@apply bg-info-100 text-info-800;
}
.badge-gray {
@apply bg-gray-100 text-gray-800;
}
/* Loading spinner */
.spinner {
@apply animate-spin rounded-full border-2 border-gray-300 border-t-primary-600;
}
/* Skeleton loading */
.skeleton {
@apply animate-pulse bg-gray-200 rounded;
}
/* Gradient backgrounds */
.gradient-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.gradient-success {
background: linear-gradient(135deg, #84fab0 0%, #8fd3f4 100%);
}
/* Chart container */
.chart-container {
position: relative;
height: 300px;
width: 100%;
}

13
src/types/next-auth.d.ts vendored Normal file
View File

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

69
tailwind.config.js Normal file
View File

@ -0,0 +1,69 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
success: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
800: '#166534',
900: '#14532d',
},
warning: {
50: '#fff7ed',
100: '#ffedd5',
200: '#fed7aa',
300: '#fdba74',
400: '#fb923c',
500: '#f97316',
600: '#ea580c',
700: '#c2410c',
800: '#9a3412',
900: '#7c2d12',
},
info: {
50: '#faf5ff',
100: '#f3e8ff',
200: '#e9d5ff',
300: '#d8b4fe',
400: '#c084fc',
500: '#a855f7',
600: '#8b5cf6',
700: '#7c3aed',
800: '#6b21a8',
900: '#581c87',
}
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
borderRadius: {
'xl': '14px',
}
},
},
plugins: [],
}

28
tsconfig.json Normal file
View File

@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "es6"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

11
vercel.json Normal file
View File

@ -0,0 +1,11 @@
{
"buildCommand": "prisma generate && next build",
"outputDirectory": ".next",
"framework": "nextjs",
"installCommand": "npm install",
"build": {
"env": {
"NODE_OPTIONS": "--max-old-space-size=4096"
}
}
}