Initial commit - QR Master application
This commit is contained in:
commit
5262f9e78f
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(docker-compose:*)",
|
||||||
|
"Bash(docker container prune:*)"
|
||||||
|
],
|
||||||
|
"deny": [],
|
||||||
|
"ask": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
registry=https://registry.npmjs.org/
|
||||||
|
legacy-peer-deps=true
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 80,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"codium.codeCompletion.enable": false
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
@ -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! 🎉**
|
||||||
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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!
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
images: {
|
||||||
|
unoptimized: true
|
||||||
|
},
|
||||||
|
experimental: {
|
||||||
|
serverComponentsExternalPackages: ['@prisma/client', 'bcryptjs']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default nextConfig
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
|
@ -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 |
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -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!"
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>© 2024 QR Master. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { SessionProvider } from 'next-auth/react';
|
||||||
|
|
||||||
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
|
return <SessionProvider>{children}</SessionProvider>;
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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).*)',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -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%;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { DefaultSession } from 'next-auth';
|
||||||
|
|
||||||
|
declare module 'next-auth' {
|
||||||
|
interface Session {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
} & DefaultSession['user'];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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: [],
|
||||||
|
}
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue