From 30c1e57eab234c40abb7820a23a0d12ac44151b6 Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Sun, 25 Jan 2026 14:59:25 +0100 Subject: [PATCH] Shema --- docker-compose.yml | 242 +- next-sitemap.config.js | 42 - package-lock.json | 43 - package.json | 1 - src/app/(main)/(app)/AppLayout.tsx | 506 ++-- src/app/(main)/(app)/analytics/page.tsx | 1186 +++++----- src/app/(main)/(app)/create/page.tsx | 2052 ++++++++--------- src/app/(main)/(app)/layout.tsx | 64 +- src/app/(main)/(app)/qr/[id]/edit/page.tsx | 918 ++++---- .../(main)/(app)/qr/[id]/feedback/page.tsx | 392 ++-- src/app/(main)/(app)/qr/[id]/page.tsx | 574 ++--- src/app/(main)/(auth)/layout.tsx | 48 +- src/app/(main)/(auth)/login/ClientPage.tsx | 328 +-- src/app/(main)/(auth)/login/LoginClient.tsx | 374 +-- src/app/(main)/(auth)/login/page.tsx | 20 +- src/app/(main)/(auth)/reset-password/page.tsx | 416 ++-- src/app/(main)/(auth)/signup/ClientPage.tsx | 370 +-- src/app/(main)/(auth)/signup/SignupClient.tsx | 416 ++-- src/app/(main)/(auth)/signup/page.tsx | 26 +- .../(main)/(marketing)/MarketingLayout.tsx | 578 ++--- .../(main)/(marketing)/blog/[slug]/page.tsx | 314 +-- src/app/(main)/(marketing)/blog/page.tsx | 364 +-- .../bulk-qr-code-generator/page.tsx | 1328 ++++++----- .../custom-qr-code-generator/page.tsx | 1322 +++++------ .../dynamic-qr-code-generator/page.tsx | 1036 +++++---- src/app/(main)/(marketing)/error.tsx | 238 +- .../(main)/(marketing)/faq/ContactSupport.tsx | 44 +- src/app/(main)/(marketing)/faq/page.tsx | 284 +-- src/app/(main)/(marketing)/layout.tsx | 136 +- .../(marketing)/manage-qr-codes/page.tsx | 1484 ++++++------ .../newsletter/NewsletterClient.tsx | 1508 ++++++------ .../(main)/(marketing)/newsletter/page.tsx | 38 +- src/app/(main)/(marketing)/not-found.tsx | 126 +- src/app/(main)/(marketing)/page.tsx | 146 +- .../(marketing)/pricing/PricingClient.tsx | 538 ++--- src/app/(main)/(marketing)/pricing/page.tsx | 88 +- .../(marketing)/privacy/PrivacyEmailLink.tsx | 26 +- src/app/(main)/(marketing)/privacy/page.tsx | 264 +-- .../(marketing)/qr-code-tracking/page.tsx | 796 ++++--- .../(marketing)/reprint-calculator/page.tsx | 234 +- .../BarcodeGeneratorClient.tsx | 914 ++++---- .../tools/barcode-generator/BarcodeGuide.tsx | 642 +++--- .../tools/barcode-generator/page.tsx | 606 ++--- .../call-qr-code-generator/PhoneGenerator.tsx | 494 ++-- .../tools/call-qr-code-generator/page.tsx | 668 +++--- .../tools/crypto-qr-code/CryptoGenerator.tsx | 748 +++--- .../(marketing)/tools/crypto-qr-code/page.tsx | 670 +++--- .../tools/email-qr-code/EmailGenerator.tsx | 592 ++--- .../(marketing)/tools/email-qr-code/page.tsx | 538 ++--- .../tools/event-qr-code/EventGenerator.tsx | 660 +++--- .../(marketing)/tools/event-qr-code/page.tsx | 650 +++--- .../facebook-qr-code/FacebookGenerator.tsx | 494 ++-- .../tools/facebook-qr-code/page.tsx | 674 +++--- .../GeolocationGenerator.tsx | 584 ++--- .../tools/geolocation-qr-code/page.tsx | 664 +++--- .../instagram-qr-code/InstagramGenerator.tsx | 504 ++-- .../tools/instagram-qr-code/page.tsx | 650 +++--- src/app/(main)/(marketing)/tools/layout.tsx | 60 +- .../tools/paypal-qr-code/PayPalGenerator.tsx | 684 +++--- .../(marketing)/tools/paypal-qr-code/page.tsx | 662 +++--- .../tools/sms-qr-code/SMSGenerator.tsx | 532 ++--- .../(marketing)/tools/sms-qr-code/page.tsx | 582 ++--- .../tools/teams-qr-code/TeamsGenerator.tsx | 636 ++--- .../(marketing)/tools/teams-qr-code/page.tsx | 616 ++--- .../tools/text-qr-code/TextGenerator.tsx | 490 ++-- .../(marketing)/tools/text-qr-code/page.tsx | 648 +++--- .../tools/tiktok-qr-code/TikTokGenerator.tsx | 504 ++-- .../(marketing)/tools/tiktok-qr-code/page.tsx | 666 +++--- .../twitter-qr-code/TwitterGenerator.tsx | 504 ++-- .../tools/twitter-qr-code/page.tsx | 670 +++--- .../tools/url-qr-code/URLGenerator.tsx | 490 ++-- .../(marketing)/tools/url-qr-code/page.tsx | 564 ++--- .../tools/vcard-qr-code/VCardGenerator.tsx | 696 +++--- .../(marketing)/tools/vcard-qr-code/page.tsx | 592 ++--- .../whatsapp-qr-code/WhatsAppGenerator.tsx | 534 ++--- .../tools/whatsapp-qr-code/page.tsx | 670 +++--- .../tools/wifi-qr-code/WiFiGenerator.tsx | 614 ++--- .../(marketing)/tools/wifi-qr-code/page.tsx | 682 +++--- .../youtube-qr-code/YouTubeGenerator.tsx | 490 ++-- .../tools/youtube-qr-code/page.tsx | 660 +++--- .../tools/zoom-qr-code/ZoomGenerator.tsx | 604 ++--- .../(marketing)/tools/zoom-qr-code/page.tsx | 616 ++--- src/app/(main)/api/admin/stats/route.ts | 436 ++-- src/app/(main)/api/analytics/summary/route.ts | 574 ++--- src/app/(main)/api/auth/signup/route.ts | 210 +- src/app/(main)/api/feedback/route.ts | 82 +- src/app/(main)/api/leads/route.ts | 206 +- src/app/(main)/api/qrs/[id]/feedback/route.ts | 244 +- src/app/(main)/api/qrs/public/[slug]/route.ts | 74 +- src/app/(main)/api/qrs/route.ts | 466 ++-- src/app/(main)/api/upload/route.ts | 164 +- src/app/(main)/coupon/[slug]/page.tsx | 350 +-- src/app/(main)/features/page.tsx | 86 +- src/app/(main)/feedback/[slug]/page.tsx | 408 ++-- .../guide/bulk-qr-code-generation/page.tsx | 196 +- .../guide/qr-code-best-practices/page.tsx | 208 +- .../(main)/guide/tracking-analytics/page.tsx | 206 +- src/app/(main)/layout.tsx | 132 +- src/app/(main)/r/[slug]/route.ts | 496 ++-- src/app/(main)/tools/page.tsx | 106 +- src/app/(main)/vcard/layout.tsx | 52 +- src/app/(main)/vcard/page.tsx | 548 ++--- src/app/sitemap.ts | 7 + src/lib/schema.ts | 14 +- 104 files changed, 24652 insertions(+), 24741 deletions(-) delete mode 100644 next-sitemap.config.js diff --git a/docker-compose.yml b/docker-compose.yml index 9d05f7c..4c3bd30 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,121 +1,121 @@ -services: - # PostgreSQL Database - db: - image: postgres:16-alpine - container_name: qrmaster-db - restart: unless-stopped - environment: - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_DB: ${POSTGRES_DB} - POSTGRES_INITDB_ARGS: "-E UTF8 --locale=en_US.utf8" - ports: - - "5435: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_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public - DIRECT_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public - REDIS_URL: redis://redis:6379 - NEXTAUTH_URL: ${NEXTAUTH_URL} - NEXTAUTH_SECRET: ${NEXTAUTH_SECRET} - NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3050} - IP_SALT: ${IP_SALT:-your-salt-change-in-production} - ENABLE_DEMO: ${ENABLE_DEMO:-false} - NEXT_PUBLIC_INDEXABLE: ${NEXT_PUBLIC_INDEXABLE:-true} - # Google OAuth - GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} - GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} - # Stripe - STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} - STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} - NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY:-} - STRIPE_PRICE_ID_PRO_MONTHLY: ${STRIPE_PRICE_ID_PRO_MONTHLY:-} - STRIPE_PRICE_ID_PRO_YEARLY: ${STRIPE_PRICE_ID_PRO_YEARLY:-} - STRIPE_PRICE_ID_BUSINESS_MONTHLY: ${STRIPE_PRICE_ID_BUSINESS_MONTHLY:-} - STRIPE_PRICE_ID_BUSINESS_YEARLY: ${STRIPE_PRICE_ID_BUSINESS_YEARLY:-} - # Email & Analytics - RESEND_API_KEY: ${RESEND_API_KEY:-} - NEXT_PUBLIC_POSTHOG_KEY: ${NEXT_PUBLIC_POSTHOG_KEY:-} - NEXT_PUBLIC_POSTHOG_HOST: ${NEXT_PUBLIC_POSTHOG_HOST:-https://us.i.posthog.com} - # Cloudflare R2 Storage - R2_ACCOUNT_ID: ${R2_ACCOUNT_ID:-} - R2_ACCESS_KEY_ID: ${R2_ACCESS_KEY_ID:-} - R2_SECRET_ACCESS_KEY: ${R2_SECRET_ACCESS_KEY:-} - R2_BUCKET_NAME: ${R2_BUCKET_NAME:-qrmaster-menus} - R2_PUBLIC_URL: ${R2_PUBLIC_URL:-} - 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 +services: + # PostgreSQL Database + db: + image: postgres:16-alpine + container_name: qrmaster-db + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_INITDB_ARGS: "-E UTF8 --locale=en_US.utf8" + ports: + - "5435: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_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public + DIRECT_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public + REDIS_URL: redis://redis:6379 + NEXTAUTH_URL: ${NEXTAUTH_URL} + NEXTAUTH_SECRET: ${NEXTAUTH_SECRET} + NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3050} + IP_SALT: ${IP_SALT:-your-salt-change-in-production} + ENABLE_DEMO: ${ENABLE_DEMO:-false} + NEXT_PUBLIC_INDEXABLE: ${NEXT_PUBLIC_INDEXABLE:-true} + # Google OAuth + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} + GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} + # Stripe + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY:-} + STRIPE_PRICE_ID_PRO_MONTHLY: ${STRIPE_PRICE_ID_PRO_MONTHLY:-} + STRIPE_PRICE_ID_PRO_YEARLY: ${STRIPE_PRICE_ID_PRO_YEARLY:-} + STRIPE_PRICE_ID_BUSINESS_MONTHLY: ${STRIPE_PRICE_ID_BUSINESS_MONTHLY:-} + STRIPE_PRICE_ID_BUSINESS_YEARLY: ${STRIPE_PRICE_ID_BUSINESS_YEARLY:-} + # Email & Analytics + RESEND_API_KEY: ${RESEND_API_KEY:-} + NEXT_PUBLIC_POSTHOG_KEY: ${NEXT_PUBLIC_POSTHOG_KEY:-} + NEXT_PUBLIC_POSTHOG_HOST: ${NEXT_PUBLIC_POSTHOG_HOST:-https://us.i.posthog.com} + # Cloudflare R2 Storage + R2_ACCOUNT_ID: ${R2_ACCOUNT_ID:-} + R2_ACCESS_KEY_ID: ${R2_ACCESS_KEY_ID:-} + R2_SECRET_ACCESS_KEY: ${R2_SECRET_ACCESS_KEY:-} + R2_BUCKET_NAME: ${R2_BUCKET_NAME:-qrmaster-menus} + R2_PUBLIC_URL: ${R2_PUBLIC_URL:-} + 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 diff --git a/next-sitemap.config.js b/next-sitemap.config.js deleted file mode 100644 index 86ea977..0000000 --- a/next-sitemap.config.js +++ /dev/null @@ -1,42 +0,0 @@ -/** @type {import('next-sitemap').IConfig} */ -module.exports = { - siteUrl: 'https://www.qrmaster.net', - generateRobotsTxt: true, - robotsTxtOptions: { - policies: [ - { - userAgent: '*', - allow: '/', - }, - ], - }, - transform: async (config, path) => { - // Custom priority and changefreq based on path - let priority = 0.7; - let changefreq = 'weekly'; - - if (path === '/') { - priority = 0.9; - changefreq = 'daily'; - } else if (path === '/blog') { - priority = 0.7; - changefreq = 'daily'; - } else if (path === '/pricing') { - priority = 0.8; - changefreq = 'weekly'; - } else if (path === '/faq') { - priority = 0.6; - changefreq = 'weekly'; - } else if (path.startsWith('/blog/')) { - priority = 0.6; - changefreq = 'weekly'; - } - - return { - loc: path, - changefreq, - priority, - lastmod: new Date().toISOString(), - }; - }, -}; diff --git a/package-lock.json b/package-lock.json index 81270b9..f35bbaa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,7 +66,6 @@ "cross-env": "^10.1.0", "eslint": "^8.56.0", "eslint-config-next": "^16.1.1", - "next-sitemap": "^4.2.3", "postcss": "^8.4.32", "prettier": "^3.1.1", "prisma": "^5.7.0", @@ -1373,13 +1372,6 @@ "node": ">=6.9.0" } }, - "node_modules/@corex/deepmerge": { - "version": "4.0.43", - "resolved": "https://registry.npmjs.org/@corex/deepmerge/-/deepmerge-4.0.43.tgz", - "integrity": "sha512-N8uEMrMPL0cu/bdboEWpQYb/0i2K5Qn8eCsxzOmxSggJbbQte7ljMRoXm917AbntqTGOzdTu+vP3KOOzoC70HQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@edge-runtime/cookies": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@edge-runtime/cookies/-/cookies-6.0.0.tgz", @@ -9444,41 +9436,6 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/next-sitemap": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/next-sitemap/-/next-sitemap-4.2.3.tgz", - "integrity": "sha512-vjdCxeDuWDzldhCnyFCQipw5bfpl4HmZA7uoo3GAaYGjGgfL4Cxb1CiztPuWGmS+auYs7/8OekRS8C2cjdAsjQ==", - "dev": true, - "funding": [ - { - "url": "https://github.com/iamvishnusankar/next-sitemap.git" - } - ], - "license": "MIT", - "dependencies": { - "@corex/deepmerge": "^4.0.43", - "@next/env": "^13.4.3", - "fast-glob": "^3.2.12", - "minimist": "^1.2.8" - }, - "bin": { - "next-sitemap": "bin/next-sitemap.mjs", - "next-sitemap-cjs": "bin/next-sitemap.cjs" - }, - "engines": { - "node": ">=14.18" - }, - "peerDependencies": { - "next": "*" - } - }, - "node_modules/next-sitemap/node_modules/@next/env": { - "version": "13.5.11", - "resolved": "https://registry.npmjs.org/@next/env/-/env-13.5.11.tgz", - "integrity": "sha512-fbb2C7HChgM7CemdCY+y3N1n8pcTKdqtQLbC7/EQtPdLvlMUT9JX/dBYl8MMZAtYG4uVMyPFHXckb68q/NRwqg==", - "dev": true, - "license": "MIT" - }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", diff --git a/package.json b/package.json index 7744f62..c42cf7a 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,6 @@ "cross-env": "^10.1.0", "eslint": "^8.56.0", "eslint-config-next": "^16.1.1", - "next-sitemap": "^4.2.3", "postcss": "^8.4.32", "prettier": "^3.1.1", "prisma": "^5.7.0", diff --git a/src/app/(main)/(app)/AppLayout.tsx b/src/app/(main)/(app)/AppLayout.tsx index b1e5ffd..e6b9249 100644 --- a/src/app/(main)/(app)/AppLayout.tsx +++ b/src/app/(main)/(app)/AppLayout.tsx @@ -1,254 +1,254 @@ -'use client'; - -import React, { useState, useEffect } from 'react'; -import Link from 'next/link'; -import { usePathname, useRouter } from 'next/navigation'; -import { Button } from '@/components/ui/Button'; -import { Dropdown, DropdownItem } from '@/components/ui/Dropdown'; -import { Footer } from '@/components/ui/Footer'; -import { useTranslation } from '@/hooks/useTranslation'; - -interface User { - id: string; - name: string | null; - email: string; - plan: string | null; -} - -export default function AppLayout({ - children, -}: { - children: React.ReactNode; -}) { - const pathname = usePathname(); - const router = useRouter(); - const { t } = useTranslation(); - const [sidebarOpen, setSidebarOpen] = useState(false); - const [user, setUser] = useState(null); - - // Fetch user data on mount - useEffect(() => { - const fetchUser = async () => { - try { - const response = await fetch('/api/user'); - if (response.ok) { - const userData = await response.json(); - setUser(userData); - } - } catch (error) { - console.error('Error fetching user:', error); - } - }; - - fetchUser(); - }, []); - - const handleSignOut = async () => { - // Track logout event before clearing data - try { - const { trackEvent, resetUser } = await import('@/components/PostHogProvider'); - trackEvent('user_logout'); - resetUser(); // Reset PostHog user session - } catch (error) { - console.error('PostHog tracking error:', error); - } - - // Clear all cookies - document.cookie.split(";").forEach(c => { - document.cookie = c.replace(/^ +/, "").replace(/=.*/, "=;expires=" + new Date().toUTCString() + ";path=/"); - }); - // Clear localStorage - localStorage.clear(); - // Redirect to home - router.push('/'); - }; - - // Get user initials for avatar (e.g., "Timo Schmidt" -> "TS") - const getUserInitials = () => { - if (!user) return 'U'; - - if (user.name) { - const names = user.name.trim().split(' '); - if (names.length >= 2) { - return (names[0][0] + names[names.length - 1][0]).toUpperCase(); - } - return user.name.substring(0, 2).toUpperCase(); - } - - // Fallback to email - return user.email.substring(0, 1).toUpperCase(); - }; - - // Get display name (first name or full name) - const getDisplayName = () => { - if (!user) return 'User'; - - if (user.name) { - return user.name; - } - - // Fallback to email without domain - return user.email.split('@')[0]; - }; - - const navigation = [ - { - name: t('nav.dashboard'), - href: '/dashboard', - icon: ( - - - - ), - }, - { - name: t('nav.create_qr'), - href: '/create', - icon: ( - - - - ), - }, - { - name: t('nav.bulk_creation'), - href: '/bulk-creation', - icon: ( - - - - ), - }, - { - name: t('nav.analytics'), - href: '/analytics', - icon: ( - - - - ), - }, - { - name: t('nav.pricing'), - href: '/pricing', - icon: ( - - - - ), - }, - { - name: t('nav.settings'), - href: '/settings', - icon: ( - - - - - ), - }, - ]; - - return ( -
- {/* Mobile sidebar backdrop */} - {sidebarOpen && ( -
setSidebarOpen(false)} - /> - )} - - {/* Sidebar */} - - - {/* Main content */} -
- {/* Top bar */} -
-
- - -
- {/* User Menu */} - -
- - {getUserInitials()} - -
- - {getDisplayName()} - - - - - - } - > - - Sign Out - -
-
-
-
- - {/* Page content */} -
- {children} -
- - {/* Footer */} -
-
-
- ); +'use client'; + +import React, { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { usePathname, useRouter } from 'next/navigation'; +import { Button } from '@/components/ui/Button'; +import { Dropdown, DropdownItem } from '@/components/ui/Dropdown'; +import { Footer } from '@/components/ui/Footer'; +import { useTranslation } from '@/hooks/useTranslation'; + +interface User { + id: string; + name: string | null; + email: string; + plan: string | null; +} + +export default function AppLayout({ + children, +}: { + children: React.ReactNode; +}) { + const pathname = usePathname(); + const router = useRouter(); + const { t } = useTranslation(); + const [sidebarOpen, setSidebarOpen] = useState(false); + const [user, setUser] = useState(null); + + // Fetch user data on mount + useEffect(() => { + const fetchUser = async () => { + try { + const response = await fetch('/api/user'); + if (response.ok) { + const userData = await response.json(); + setUser(userData); + } + } catch (error) { + console.error('Error fetching user:', error); + } + }; + + fetchUser(); + }, []); + + const handleSignOut = async () => { + // Track logout event before clearing data + try { + const { trackEvent, resetUser } = await import('@/components/PostHogProvider'); + trackEvent('user_logout'); + resetUser(); // Reset PostHog user session + } catch (error) { + console.error('PostHog tracking error:', error); + } + + // Clear all cookies + document.cookie.split(";").forEach(c => { + document.cookie = c.replace(/^ +/, "").replace(/=.*/, "=;expires=" + new Date().toUTCString() + ";path=/"); + }); + // Clear localStorage + localStorage.clear(); + // Redirect to home + router.push('/'); + }; + + // Get user initials for avatar (e.g., "Timo Schmidt" -> "TS") + const getUserInitials = () => { + if (!user) return 'U'; + + if (user.name) { + const names = user.name.trim().split(' '); + if (names.length >= 2) { + return (names[0][0] + names[names.length - 1][0]).toUpperCase(); + } + return user.name.substring(0, 2).toUpperCase(); + } + + // Fallback to email + return user.email.substring(0, 1).toUpperCase(); + }; + + // Get display name (first name or full name) + const getDisplayName = () => { + if (!user) return 'User'; + + if (user.name) { + return user.name; + } + + // Fallback to email without domain + return user.email.split('@')[0]; + }; + + const navigation = [ + { + name: t('nav.dashboard'), + href: '/dashboard', + icon: ( + + + + ), + }, + { + name: t('nav.create_qr'), + href: '/create', + icon: ( + + + + ), + }, + { + name: t('nav.bulk_creation'), + href: '/bulk-creation', + icon: ( + + + + ), + }, + { + name: t('nav.analytics'), + href: '/analytics', + icon: ( + + + + ), + }, + { + name: t('nav.pricing'), + href: '/pricing', + icon: ( + + + + ), + }, + { + name: t('nav.settings'), + href: '/settings', + icon: ( + + + + + ), + }, + ]; + + return ( +
+ {/* Mobile sidebar backdrop */} + {sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} + + {/* Sidebar */} + + + {/* Main content */} +
+ {/* Top bar */} +
+
+ + +
+ {/* User Menu */} + +
+ + {getUserInitials()} + +
+ + {getDisplayName()} + + + + + + } + > + + Sign Out + +
+
+
+
+ + {/* Page content */} +
+ {children} +
+ + {/* Footer */} +
+
+
+ ); } \ No newline at end of file diff --git a/src/app/(main)/(app)/analytics/page.tsx b/src/app/(main)/(app)/analytics/page.tsx index 61f5710..b930248 100644 --- a/src/app/(main)/(app)/analytics/page.tsx +++ b/src/app/(main)/(app)/analytics/page.tsx @@ -1,594 +1,594 @@ -'use client'; - -import React, { useState, useEffect, useCallback } from 'react'; -import dynamic from 'next/dynamic'; -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 { StatCard, Sparkline } from '@/components/analytics'; -import { Line, Doughnut } from 'react-chartjs-2'; -import { - Chart as ChartJS, - CategoryScale, - LinearScale, - PointElement, - LineElement, - BarElement, - ArcElement, - Title, - Tooltip, - Legend, - Filler, -} from 'chart.js'; -import { - BarChart3, - Users, - Smartphone, - Globe, - Calendar, - Download, - TrendingUp, - QrCode, - HelpCircle, -} from 'lucide-react'; - -// Dynamically import GeoMap to avoid SSR issues with d3 -const GeoMap = dynamic(() => import('@/components/analytics/GeoMap'), { - ssr: false, - loading: () => ( -
- Loading map... -
- ), -}); - -ChartJS.register( - CategoryScale, - LinearScale, - PointElement, - LineElement, - BarElement, - ArcElement, - Title, - Tooltip, - Legend, - Filler -); - -interface QRPerformance { - id: string; - title: string; - type: string; - totalScans: number; - uniqueScans: number; - conversion: number; - trend: 'up' | 'down' | 'flat'; - trendPercentage: number; - sparkline: number[]; - lastScanned: string | null; - isNew?: boolean; -} - -interface CountryStat { - country: string; - count: number; - percentage: number; - trend: 'up' | 'down' | 'flat'; - trendPercentage: number; - isNew?: boolean; -} - -interface AnalyticsData { - summary: { - totalScans: number; - uniqueScans: number; - avgScansPerQR: number; - mobilePercentage: number; - topCountry: string; - topCountryPercentage: number; - scansTrend?: { trend: 'up' | 'down' | 'flat'; percentage: number; isNew?: boolean }; - avgScansTrend?: { trend: 'up' | 'down' | 'flat'; percentage: number; isNew?: boolean }; - comparisonPeriod?: string; - }; - deviceStats: Record; - countryStats: CountryStat[]; - dailyScans: Record; - qrPerformance: QRPerformance[]; -} - -export default function AnalyticsPage() { - const { t } = useTranslation(); - const [timeRange, setTimeRange] = useState('7'); - const [loading, setLoading] = useState(true); - const [analyticsData, setAnalyticsData] = useState(null); - - const fetchAnalytics = useCallback(async () => { - setLoading(true); - try { - const response = await fetch(`/api/analytics/summary?range=${timeRange}`); - if (response.ok) { - const data = await response.json(); - setAnalyticsData(data); - } else { - setAnalyticsData(null); - } - } catch (error) { - console.error('Error fetching analytics:', error); - setAnalyticsData(null); - } finally { - setLoading(false); - } - }, [timeRange]); - - useEffect(() => { - fetchAnalytics(); - }, [fetchAnalytics]); - - const exportReport = () => { - if (!analyticsData) return; - - const csvData = [ - ['QR Master Analytics Report'], - ['Generated:', new Date().toLocaleString()], - ['Time Range:', `Last ${timeRange} days`], - [''], - ['Summary'], - ['Total Scans', analyticsData.summary.totalScans], - ['Unique Scans', analyticsData.summary.uniqueScans], - ['Mobile Usage %', analyticsData.summary.mobilePercentage], - ['Top Country', analyticsData.summary.topCountry], - [''], - ['Top QR Codes'], - ['Title', 'Type', 'Total Scans', 'Unique Scans', 'Conversion %', 'Last Scanned'], - ...analyticsData.qrPerformance.map((qr) => [ - qr.title, - qr.type, - qr.totalScans, - qr.uniqueScans, - qr.conversion, - qr.lastScanned ? new Date(qr.lastScanned).toLocaleString() : 'Never', - ]), - ]; - - const csv = csvData.map((row) => row.join(',')).join('\n'); - 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 daysToShow = parseInt(timeRange); - const dateRange = Array.from({ length: daysToShow }, (_, i) => { - const date = new Date(); - date.setDate(date.getDate() - (daysToShow - 1 - i)); - return date.toISOString().split('T')[0]; - }); - - const scanChartData = { - labels: dateRange.map((date) => { - const d = new Date(date); - return d.toLocaleDateString('en', { month: 'short', day: 'numeric' }); - }), - datasets: [ - { - label: 'Scans', - data: dateRange.map((date) => analyticsData?.dailyScans[date] || 0), - borderColor: 'rgb(59, 130, 246)', - backgroundColor: (context: any) => { - const chart = context.chart; - const { ctx, chartArea } = chart; - if (!chartArea) return 'rgba(59, 130, 246, 0.1)'; - - const gradient = ctx.createLinearGradient(0, chartArea.top, 0, chartArea.bottom); - gradient.addColorStop(0, 'rgba(59, 130, 246, 0.3)'); - gradient.addColorStop(1, 'rgba(59, 130, 246, 0.01)'); - return gradient; - }, - tension: 0.4, - fill: true, - pointRadius: 4, - pointBackgroundColor: 'rgb(59, 130, 246)', - pointBorderColor: '#fff', - pointBorderWidth: 2, - pointHoverRadius: 6, - }, - ], - }; - - const deviceChartData = { - labels: ['Desktop', 'Mobile', 'Tablet'], - datasets: [ - { - data: [ - analyticsData?.deviceStats.desktop || 0, - analyticsData?.deviceStats.mobile || 0, - analyticsData?.deviceStats.tablet || 0, - ], - backgroundColor: [ - 'rgba(59, 130, 246, 0.85)', - 'rgba(34, 197, 94, 0.85)', - 'rgba(249, 115, 22, 0.85)', - ], - borderWidth: 0, - hoverOffset: 4, - }, - ], - }; - - // Find top performing QR code - const topQR = analyticsData?.qrPerformance[0]; - - if (loading) { - return ( -
-
-
-
-
- {[1, 2, 3, 4].map((i) => ( -
- ))} -
-
-
-
-
-
-
- ); - } - - return ( -
- {/* Header */} -
-
-

QR Code Analytics

-

Track and analyze your QR code performance

-
- -
- {/* Date Range Selector */} -
- {[ - { value: '7', label: '7 Days' }, - { value: '30', label: '30 Days' }, - { value: '90', label: '90 Days' }, - ].map((range) => ( - - ))} -
- - -
-
- - {/* KPI Cards */} -
- } - /> - - } - /> - - } - /> - - } - /> -
- - {/* Main Chart Row */} -
- {/* Scans Over Time - Takes 2 columns */} - - - Scan Trends Over Time -
- - {timeRange} Days -
-
- -
- items[0]?.label || '', - label: (item) => `${item.formattedValue} scans`, - }, - }, - }, - scales: { - x: { - grid: { display: false }, - ticks: { color: '#9CA3AF' }, - }, - y: { - beginAtZero: true, - grid: { color: 'rgba(156, 163, 175, 0.1)' }, - ticks: { color: '#9CA3AF', precision: 0 }, - }, - }, - }} - /> -
-
-
- - {/* Device Types Donut */} - - - Device Types - - -
- {(analyticsData?.summary.totalScans || 0) > 0 ? ( - - ) : ( -

No scan data available

- )} -
-
-
-
- - {/* Geographic & Country Stats Row */} -
- {/* Geographic Insights with Map */} - - - Geographic Insights - - -
- -
-
-
- - {/* Top Countries Table */} - - - Top Countries - - - {(analyticsData?.countryStats?.length || 0) > 0 ? ( -
- {analyticsData!.countryStats.slice(0, 5).map((country, index) => ( -
-
- - {index + 1} - - {country.country} -
-
- {country.count.toLocaleString()} - - {country.percentage}% - - - {country.trend === 'up' ? '↑' : country.trend === 'down' ? '↓' : '→'} - {country.trendPercentage}%{country.isNew ? ' (new)' : ''} - -
-
- ))} -
- ) : ( -

No country data available yet

- )} -
-
-
- - {/* Top Performing QR Codes with Sparklines */} - - - - - Top Performing QR Codes - - - - {(analyticsData?.qrPerformance?.length || 0) > 0 ? ( -
- - - - - - - - - - - - - {analyticsData!.qrPerformance.map((qr) => ( - - - - - - - - - ))} - -
- QR Code - - Type - - Total Scans - - Unique Scans - -
- Conversions -
- -
-
Conversion Rate
-
- Percentage of unique scans vs total scans. Formula: (Unique Scans / Total Scans) × 100% -
-
-
-
-
-
- Trend -
- {qr.title} - - - {qr.type} - - - {qr.totalScans.toLocaleString()} - {qr.uniqueScans.toLocaleString()}{qr.conversion}% -
- - - {qr.trend === 'up' ? '↑' : qr.trend === 'down' ? '↓' : '→'} - {qr.trendPercentage}%{qr.isNew ? ' (new)' : ''} - -
-
-
- ) : ( -
- -

- No QR codes created yet. Create your first QR code to see analytics! -

-
- )} -
-
-
- ); +'use client'; + +import React, { useState, useEffect, useCallback } from 'react'; +import dynamic from 'next/dynamic'; +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 { StatCard, Sparkline } from '@/components/analytics'; +import { Line, Doughnut } from 'react-chartjs-2'; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + BarElement, + ArcElement, + Title, + Tooltip, + Legend, + Filler, +} from 'chart.js'; +import { + BarChart3, + Users, + Smartphone, + Globe, + Calendar, + Download, + TrendingUp, + QrCode, + HelpCircle, +} from 'lucide-react'; + +// Dynamically import GeoMap to avoid SSR issues with d3 +const GeoMap = dynamic(() => import('@/components/analytics/GeoMap'), { + ssr: false, + loading: () => ( +
+ Loading map... +
+ ), +}); + +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + BarElement, + ArcElement, + Title, + Tooltip, + Legend, + Filler +); + +interface QRPerformance { + id: string; + title: string; + type: string; + totalScans: number; + uniqueScans: number; + conversion: number; + trend: 'up' | 'down' | 'flat'; + trendPercentage: number; + sparkline: number[]; + lastScanned: string | null; + isNew?: boolean; +} + +interface CountryStat { + country: string; + count: number; + percentage: number; + trend: 'up' | 'down' | 'flat'; + trendPercentage: number; + isNew?: boolean; +} + +interface AnalyticsData { + summary: { + totalScans: number; + uniqueScans: number; + avgScansPerQR: number; + mobilePercentage: number; + topCountry: string; + topCountryPercentage: number; + scansTrend?: { trend: 'up' | 'down' | 'flat'; percentage: number; isNew?: boolean }; + avgScansTrend?: { trend: 'up' | 'down' | 'flat'; percentage: number; isNew?: boolean }; + comparisonPeriod?: string; + }; + deviceStats: Record; + countryStats: CountryStat[]; + dailyScans: Record; + qrPerformance: QRPerformance[]; +} + +export default function AnalyticsPage() { + const { t } = useTranslation(); + const [timeRange, setTimeRange] = useState('7'); + const [loading, setLoading] = useState(true); + const [analyticsData, setAnalyticsData] = useState(null); + + const fetchAnalytics = useCallback(async () => { + setLoading(true); + try { + const response = await fetch(`/api/analytics/summary?range=${timeRange}`); + if (response.ok) { + const data = await response.json(); + setAnalyticsData(data); + } else { + setAnalyticsData(null); + } + } catch (error) { + console.error('Error fetching analytics:', error); + setAnalyticsData(null); + } finally { + setLoading(false); + } + }, [timeRange]); + + useEffect(() => { + fetchAnalytics(); + }, [fetchAnalytics]); + + const exportReport = () => { + if (!analyticsData) return; + + const csvData = [ + ['QR Master Analytics Report'], + ['Generated:', new Date().toLocaleString()], + ['Time Range:', `Last ${timeRange} days`], + [''], + ['Summary'], + ['Total Scans', analyticsData.summary.totalScans], + ['Unique Scans', analyticsData.summary.uniqueScans], + ['Mobile Usage %', analyticsData.summary.mobilePercentage], + ['Top Country', analyticsData.summary.topCountry], + [''], + ['Top QR Codes'], + ['Title', 'Type', 'Total Scans', 'Unique Scans', 'Conversion %', 'Last Scanned'], + ...analyticsData.qrPerformance.map((qr) => [ + qr.title, + qr.type, + qr.totalScans, + qr.uniqueScans, + qr.conversion, + qr.lastScanned ? new Date(qr.lastScanned).toLocaleString() : 'Never', + ]), + ]; + + const csv = csvData.map((row) => row.join(',')).join('\n'); + 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 daysToShow = parseInt(timeRange); + const dateRange = Array.from({ length: daysToShow }, (_, i) => { + const date = new Date(); + date.setDate(date.getDate() - (daysToShow - 1 - i)); + return date.toISOString().split('T')[0]; + }); + + const scanChartData = { + labels: dateRange.map((date) => { + const d = new Date(date); + return d.toLocaleDateString('en', { month: 'short', day: 'numeric' }); + }), + datasets: [ + { + label: 'Scans', + data: dateRange.map((date) => analyticsData?.dailyScans[date] || 0), + borderColor: 'rgb(59, 130, 246)', + backgroundColor: (context: any) => { + const chart = context.chart; + const { ctx, chartArea } = chart; + if (!chartArea) return 'rgba(59, 130, 246, 0.1)'; + + const gradient = ctx.createLinearGradient(0, chartArea.top, 0, chartArea.bottom); + gradient.addColorStop(0, 'rgba(59, 130, 246, 0.3)'); + gradient.addColorStop(1, 'rgba(59, 130, 246, 0.01)'); + return gradient; + }, + tension: 0.4, + fill: true, + pointRadius: 4, + pointBackgroundColor: 'rgb(59, 130, 246)', + pointBorderColor: '#fff', + pointBorderWidth: 2, + pointHoverRadius: 6, + }, + ], + }; + + const deviceChartData = { + labels: ['Desktop', 'Mobile', 'Tablet'], + datasets: [ + { + data: [ + analyticsData?.deviceStats.desktop || 0, + analyticsData?.deviceStats.mobile || 0, + analyticsData?.deviceStats.tablet || 0, + ], + backgroundColor: [ + 'rgba(59, 130, 246, 0.85)', + 'rgba(34, 197, 94, 0.85)', + 'rgba(249, 115, 22, 0.85)', + ], + borderWidth: 0, + hoverOffset: 4, + }, + ], + }; + + // Find top performing QR code + const topQR = analyticsData?.qrPerformance[0]; + + if (loading) { + return ( +
+
+
+
+
+ {[1, 2, 3, 4].map((i) => ( +
+ ))} +
+
+
+
+
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

QR Code Analytics

+

Track and analyze your QR code performance

+
+ +
+ {/* Date Range Selector */} +
+ {[ + { value: '7', label: '7 Days' }, + { value: '30', label: '30 Days' }, + { value: '90', label: '90 Days' }, + ].map((range) => ( + + ))} +
+ + +
+
+ + {/* KPI Cards */} +
+ } + /> + + } + /> + + } + /> + + } + /> +
+ + {/* Main Chart Row */} +
+ {/* Scans Over Time - Takes 2 columns */} + + + Scan Trends Over Time +
+ + {timeRange} Days +
+
+ +
+ items[0]?.label || '', + label: (item) => `${item.formattedValue} scans`, + }, + }, + }, + scales: { + x: { + grid: { display: false }, + ticks: { color: '#9CA3AF' }, + }, + y: { + beginAtZero: true, + grid: { color: 'rgba(156, 163, 175, 0.1)' }, + ticks: { color: '#9CA3AF', precision: 0 }, + }, + }, + }} + /> +
+
+
+ + {/* Device Types Donut */} + + + Device Types + + +
+ {(analyticsData?.summary.totalScans || 0) > 0 ? ( + + ) : ( +

No scan data available

+ )} +
+
+
+
+ + {/* Geographic & Country Stats Row */} +
+ {/* Geographic Insights with Map */} + + + Geographic Insights + + +
+ +
+
+
+ + {/* Top Countries Table */} + + + Top Countries + + + {(analyticsData?.countryStats?.length || 0) > 0 ? ( +
+ {analyticsData!.countryStats.slice(0, 5).map((country, index) => ( +
+
+ + {index + 1} + + {country.country} +
+
+ {country.count.toLocaleString()} + + {country.percentage}% + + + {country.trend === 'up' ? '↑' : country.trend === 'down' ? '↓' : '→'} + {country.trendPercentage}%{country.isNew ? ' (new)' : ''} + +
+
+ ))} +
+ ) : ( +

No country data available yet

+ )} +
+
+
+ + {/* Top Performing QR Codes with Sparklines */} + + + + + Top Performing QR Codes + + + + {(analyticsData?.qrPerformance?.length || 0) > 0 ? ( +
+ + + + + + + + + + + + + {analyticsData!.qrPerformance.map((qr) => ( + + + + + + + + + ))} + +
+ QR Code + + Type + + Total Scans + + Unique Scans + +
+ Conversions +
+ +
+
Conversion Rate
+
+ Percentage of unique scans vs total scans. Formula: (Unique Scans / Total Scans) × 100% +
+
+
+
+
+
+ Trend +
+ {qr.title} + + + {qr.type} + + + {qr.totalScans.toLocaleString()} + {qr.uniqueScans.toLocaleString()}{qr.conversion}% +
+ + + {qr.trend === 'up' ? '↑' : qr.trend === 'down' ? '↓' : '→'} + {qr.trendPercentage}%{qr.isNew ? ' (new)' : ''} + +
+
+
+ ) : ( +
+ +

+ No QR codes created yet. Create your first QR code to see analytics! +

+
+ )} +
+
+
+ ); } \ No newline at end of file diff --git a/src/app/(main)/(app)/create/page.tsx b/src/app/(main)/(app)/create/page.tsx index 50611c8..888bf09 100644 --- a/src/app/(main)/(app)/create/page.tsx +++ b/src/app/(main)/(app)/create/page.tsx @@ -1,1027 +1,1027 @@ -'use client'; - -import React, { useState, useEffect, useRef } from 'react'; -import Link from 'next/link'; -import { useRouter } from 'next/navigation'; -import { QRCodeSVG } from 'qrcode.react'; -import { toPng } from 'html-to-image'; -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, cn } from '@/lib/utils'; -import { useTranslation } from '@/hooks/useTranslation'; -import { useCsrf } from '@/hooks/useCsrf'; -import { showToast } from '@/components/ui/Toast'; -import { - Globe, User, MapPin, Phone, FileText, Smartphone, Ticket, Star, HelpCircle, Upload -} from 'lucide-react'; - -// Tooltip component for form field help -const Tooltip = ({ text }: { text: string }) => ( -
- -
- {text} -
-
-
-); - -// Content-type specific frame options -const getFrameOptionsForContentType = (contentType: string) => { - const baseOptions = [{ id: 'none', label: 'No Frame' }, { id: 'scanme', label: 'Scan Me' }]; - - switch (contentType) { - case 'URL': - return [...baseOptions, { id: 'website', label: 'Website' }, { id: 'visit', label: 'Visit' }]; - case 'PHONE': - return [...baseOptions, { id: 'callme', label: 'Call Me' }, { id: 'call', label: 'Call' }]; - case 'GEO': - return [...baseOptions, { id: 'findus', label: 'Find Us' }, { id: 'navigate', label: 'Navigate' }]; - case 'VCARD': - return [...baseOptions, { id: 'contact', label: 'Contact' }, { id: 'save', label: 'Save' }]; - case 'SMS': - return [...baseOptions, { id: 'textme', label: 'Text Me' }, { id: 'message', label: 'Message' }]; - case 'WHATSAPP': - return [...baseOptions, { id: 'chatme', label: 'Chat Me' }, { id: 'whatsapp', label: 'WhatsApp' }]; - case 'TEXT': - return [...baseOptions, { id: 'read', label: 'Read' }, { id: 'info', label: 'Info' }]; - case 'PDF': - return [...baseOptions, { id: 'download', label: 'Download' }, { id: 'view', label: 'View PDF' }]; - case 'APP': - return [...baseOptions, { id: 'getapp', label: 'Get App' }, { id: 'download', label: 'Download' }]; - case 'COUPON': - return [...baseOptions, { id: 'redeem', label: 'Redeem' }, { id: 'save', label: 'Save Offer' }]; - case 'FEEDBACK': - return [...baseOptions, { id: 'review', label: 'Review' }, { id: 'feedback', label: 'Feedback' }]; - default: - return [...baseOptions, { id: 'website', label: 'Website' }, { id: 'visit', label: 'Visit' }]; - } -}; - -export default function CreatePage() { - const router = useRouter(); - const { t } = useTranslation(); - const { fetchWithCsrf } = useCsrf(); - const [loading, setLoading] = useState(false); - const [uploading, setUploading] = useState(false); - const [userPlan, setUserPlan] = useState('FREE'); - const qrRef = useRef(null); - - // Form state - const [title, setTitle] = useState(''); - const [contentType, setContentType] = useState('URL'); - const [content, setContent] = useState({ url: '' }); - const [isDynamic, setIsDynamic] = useState(true); - - // Style state - const [foregroundColor, setForegroundColor] = useState('#000000'); - const [backgroundColor, setBackgroundColor] = useState('#FFFFFF'); - const [cornerStyle, setCornerStyle] = useState('square'); - const [size, setSize] = useState(200); - const [frameType, setFrameType] = useState('none'); - - // Get frame options for current content type - const frameOptions = getFrameOptionsForContentType(contentType); - - // Reset frame type when content type changes (if current frame is not valid) - useEffect(() => { - const validIds = frameOptions.map(f => f.id); - if (!validIds.includes(frameType)) { - setFrameType('none'); - } - }, [contentType, frameOptions, frameType]); - - // Logo state - const [logoUrl, setLogoUrl] = useState(''); - const [logoSize, setLogoSize] = useState(24); - const [excavate, setExcavate] = useState(true); - - // QR preview - const [qrDataUrl, setQrDataUrl] = useState(''); - - // Check if user can customize colors (PRO+ only) - const canCustomizeColors = userPlan === 'PRO' || userPlan === 'BUSINESS'; - - // Load user plan - useEffect(() => { - const fetchUserPlan = async () => { - try { - const response = await fetch('/api/user/plan'); - if (response.ok) { - const data = await response.json(); - setUserPlan(data.plan || 'FREE'); - } - } catch (error) { - console.error('Error fetching user plan:', error); - } - }; - fetchUserPlan(); - }, []); - - const contrast = calculateContrast(foregroundColor, backgroundColor); - const hasGoodContrast = contrast >= 4.5; - - const contentTypes = [ - { value: 'URL', label: 'URL / Website', icon: Globe }, - { value: 'VCARD', label: 'Contact Card', icon: User }, - { value: 'GEO', label: 'Location / Maps', icon: MapPin }, - { value: 'PHONE', label: 'Phone Number', icon: Phone }, - { value: 'PDF', label: 'PDF / File', icon: FileText }, - { value: 'APP', label: 'App Download', icon: Smartphone }, - { value: 'COUPON', label: 'Coupon / Discount', icon: Ticket }, - { value: 'FEEDBACK', label: 'Feedback / Review', icon: Star }, - ]; - - // Get QR content based on content type - const getQRContent = () => { - switch (contentType) { - case 'URL': - return content.url || 'https://example.com'; - case 'PHONE': - return `tel:${content.phone || '+1234567890'}`; - case 'SMS': - return `sms:${content.phone || '+1234567890'}${content.message ? `?body=${encodeURIComponent(content.message)}` : ''}`; - case 'VCARD': - return `BEGIN:VCARD\nVERSION:3.0\nFN:${content.firstName || 'John'} ${content.lastName || 'Doe'}\nORG:${content.organization || 'Company'}\nTITLE:${content.title || 'Position'}\nEMAIL:${content.email || 'email@example.com'}\nTEL:${content.phone || '+1234567890'}\nEND:VCARD`; - case 'GEO': - const lat = content.latitude || 37.7749; - const lon = content.longitude || -122.4194; - const label = content.label ? `?q=${encodeURIComponent(content.label)}` : ''; - return `geo:${lat},${lon}${label}`; - case 'TEXT': - return content.text || 'Sample text'; - case 'WHATSAPP': - return `https://wa.me/${content.phone || '+1234567890'}${content.message ? `?text=${encodeURIComponent(content.message)}` : ''}`; - case 'PDF': - return content.fileUrl || 'https://example.com/file.pdf'; - case 'APP': - return content.fallbackUrl || content.iosUrl || content.androidUrl || 'https://example.com/app'; - case 'COUPON': - return `Coupon: ${content.code || 'SAVE20'} - ${content.discount || '20% OFF'}`; - case 'FEEDBACK': - return content.feedbackUrl || 'https://example.com/feedback'; - default: - return 'https://example.com'; - } - }; - - const qrContent = getQRContent(); - - const getFrameLabel = () => { - const frame = frameOptions.find((f: { id: string; label: string }) => f.id === frameType); - return frame?.id !== 'none' ? frame?.label : null; - }; - - const downloadQR = async (format: 'svg' | 'png') => { - if (!qrRef.current) return; - try { - if (format === 'png') { - const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3, backgroundColor: 'transparent' }); - const link = document.createElement('a'); - link.download = `qrcode-${title || 'download'}.png`; - link.href = dataUrl; - link.click(); - } else { - // For SVG, we might still want to use the library or just toPng if SVG export of HTML is not needed - // Simplest is to check if we can export the SVG element directly but that misses the frame HTML. - // html-to-image can generate SVG too. - // But usually for SVG users want the vector. Capturing HTML to SVG is possible but complex. - // For now, let's just stick to the SVG code export if NO FRAME is selected, - // otherwise warn or use toPng (as SVG). - // Actually, the previous implementation was good for pure QR. - // If frame is selected, we MUST use a raster export (PNG) or complex HTML-to-SVG. - // Let's rely on toPng for consistency with frames. - const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3, backgroundColor: 'transparent' }); - // Wait, exporting HTML to valid vector SVG is hard. - // Let's just offer PNG for frames for now to be safe, or just use the same PNG download for both buttons if frame is active? - // No, let's try to grab the INNER SVG if no frame, else... - if (frameType === 'none') { - const svgElement = qrRef.current.querySelector('svg'); - if (svgElement) { - const svgData = new XMLSerializer().serializeToString(svgElement); - const blob = new Blob([svgData], { type: 'image/svg+xml' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `qrcode-${title || 'download'}.svg`; - a.click(); - URL.revokeObjectURL(url); - } - } else { - showToast('SVG download not available with frames yet. Downloading PNG instead.', 'info'); - const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3, backgroundColor: 'transparent' }); - const link = document.createElement('a'); - link.download = `qrcode-${title || 'download'}.png`; - link.href = dataUrl; - link.click(); - } - } - } catch (err) { - console.error('Error downloading QR code:', err); - showToast('Error downloading QR code', 'error'); - } - }; - - const handleLogoUpload = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) { - if (file.size > 10 * 1024 * 1024) { // 10MB limit (soft limit for upload, will be resized) - showToast('Logo file size too large (max 10MB)', 'error'); - return; - } - - const reader = new FileReader(); - reader.onload = (evt) => { - const img = new Image(); - img.onload = () => { - const canvas = document.createElement('canvas'); - const maxDimension = 500; // Resize to max 500px - let width = img.width; - let height = img.height; - - if (width > maxDimension || height > maxDimension) { - if (width > height) { - height = Math.round((height * maxDimension) / width); - width = maxDimension; - } else { - width = Math.round((width * maxDimension) / height); - height = maxDimension; - } - } - - canvas.width = width; - canvas.height = height; - const ctx = canvas.getContext('2d'); - ctx?.drawImage(img, 0, 0, width, height); - - // Compress to JPEG/PNG with reduced quality to save space - const dataUrl = canvas.toDataURL(file.type === 'image/png' ? 'image/png' : 'image/jpeg', 0.8); - setLogoUrl(dataUrl); - }; - img.src = evt.target?.result as string; - }; - reader.readAsDataURL(file); - } - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setLoading(true); - - try { - const qrData = { - title, - contentType, - content, - isStatic: !isDynamic, - tags: [], - style: { - // FREE users can only use black/white - foregroundColor: canCustomizeColors ? foregroundColor : '#000000', - backgroundColor: canCustomizeColors ? backgroundColor : '#FFFFFF', - cornerStyle, - size, - imageSettings: (canCustomizeColors && logoUrl) ? { - src: logoUrl, - height: logoSize, - width: logoSize, - excavate, - } : undefined, - frameType, // Save frame type - }, - }; - - console.log('SENDING QR DATA:', qrData); - - const response = await fetchWithCsrf('/api/qrs', { - method: 'POST', - body: JSON.stringify(qrData), - }); - - const responseData = await response.json(); - console.log('RESPONSE DATA:', responseData); - - if (response.ok) { - showToast(`QR Code "${title}" created successfully!`, 'success'); - - // Wait a moment so user sees the toast, then redirect - setTimeout(() => { - router.push('/dashboard'); - router.refresh(); - }, 1000); - } else { - console.error('Error creating QR code:', responseData); - showToast(responseData.error || 'Error creating QR code', 'error'); - } - } catch (error) { - console.error('Error creating QR code:', error); - showToast('Error creating QR code. Please try again.', 'error'); - } finally { - setLoading(false); - } - }; - - const renderContentFields = () => { - switch (contentType) { - case 'URL': - return ( - setContent({ url: e.target.value })} - placeholder="https://example.com" - required - /> - ); - case 'PHONE': - return ( - setContent({ phone: e.target.value })} - placeholder="+1234567890" - required - /> - ); - case 'VCARD': - return ( - <> - setContent({ ...content, firstName: e.target.value })} - placeholder="John" - required - /> - setContent({ ...content, lastName: e.target.value })} - placeholder="Doe" - required - /> - setContent({ ...content, email: e.target.value })} - placeholder="john@example.com" - /> - setContent({ ...content, phone: e.target.value })} - placeholder="+1234567890" - /> - setContent({ ...content, organization: e.target.value })} - placeholder="Company Name" - /> - setContent({ ...content, title: e.target.value })} - placeholder="CEO" - /> - - ); - case 'GEO': - return ( - <> - setContent({ ...content, latitude: parseFloat(e.target.value) || 0 })} - placeholder="37.7749" - required - /> - setContent({ ...content, longitude: parseFloat(e.target.value) || 0 })} - placeholder="-122.4194" - required - /> - setContent({ ...content, label: e.target.value })} - placeholder="Golden Gate Bridge" - /> - - ); - case 'TEXT': - return ( -
- -