MVP
This commit is contained in:
parent
254e6490b8
commit
91b78cb284
|
|
@ -18,7 +18,9 @@
|
||||||
"Bash(git remote add:*)",
|
"Bash(git remote add:*)",
|
||||||
"Bash(git push:*)",
|
"Bash(git push:*)",
|
||||||
"Bash(git remote set-url:*)",
|
"Bash(git remote set-url:*)",
|
||||||
"Bash(npm install:*)"
|
"Bash(npm install:*)",
|
||||||
|
"Bash(npm run build:*)",
|
||||||
|
"Bash(ls:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|
|
||||||
2
LICENSE
2
LICENSE
|
|
@ -1,6 +1,6 @@
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2024 QR Master
|
Copyright (c) 2025 QR Master
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
|
||||||
|
|
@ -10,17 +10,21 @@
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^1.0.12",
|
"@auth/prisma-adapter": "^1.0.12",
|
||||||
|
"@edge-runtime/cookies": "^6.0.0",
|
||||||
"@prisma/client": "^5.7.0",
|
"@prisma/client": "^5.7.0",
|
||||||
"@stripe/stripe-js": "^8.0.0",
|
"@stripe/stripe-js": "^8.0.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"chart.js": "^4.4.0",
|
"chart.js": "^4.4.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
|
"file-saver": "^2.0.5",
|
||||||
"i18next": "^23.7.6",
|
"i18next": "^23.7.6",
|
||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"next": "14.2.18",
|
"next": "14.2.18",
|
||||||
"next-auth": "^4.24.5",
|
"next-auth": "^4.24.5",
|
||||||
"papaparse": "^5.4.1",
|
"papaparse": "^5.4.1",
|
||||||
|
"posthog-js": "^1.276.0",
|
||||||
"qrcode": "^1.5.3",
|
"qrcode": "^1.5.3",
|
||||||
"qrcode.react": "^3.1.0",
|
"qrcode.react": "^3.1.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
|
@ -31,11 +35,13 @@
|
||||||
"sharp": "^0.33.1",
|
"sharp": "^0.33.1",
|
||||||
"stripe": "^19.1.0",
|
"stripe": "^19.1.0",
|
||||||
"tailwind-merge": "^2.2.0",
|
"tailwind-merge": "^2.2.0",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/node": "^20.10.5",
|
"@types/node": "^20.10.5",
|
||||||
"@types/papaparse": "^5.3.14",
|
"@types/papaparse": "^5.3.14",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
|
|
@ -128,6 +134,15 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@edge-runtime/cookies": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@edge-runtime/cookies/-/cookies-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-VVO/8AwC2qVbygLb2IOkX1zWFx2yWIHzFv4D602CTnoRffd/+cdcXqpSydKaedFrk7a1dRYXbWwjzfV/gwZ2Gw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz",
|
||||||
|
|
@ -1521,6 +1536,12 @@
|
||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@posthog/core": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-hxLL8kZNHH098geedcxCz8y6xojkNYbmJEW+1vFXsmPcExyCXIUUJ/34X6xa9GcprKxd0Wsx3vfJQLQX4iVPhw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@prisma/client": {
|
"node_modules/@prisma/client": {
|
||||||
"version": "5.22.0",
|
"version": "5.22.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
|
||||||
|
|
@ -1652,6 +1673,13 @@
|
||||||
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/file-saver": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/json5": {
|
"node_modules/@types/json5": {
|
||||||
"version": "0.0.29",
|
"version": "0.0.29",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
|
||||||
|
|
@ -3370,6 +3398,23 @@
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/core-js": {
|
||||||
|
"version": "3.46.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.46.0.tgz",
|
||||||
|
"integrity": "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/core-js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/core-util-is": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/crc-32": {
|
"node_modules/crc-32": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||||
|
|
@ -4418,6 +4463,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.4.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz",
|
||||||
|
"integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/file-entry-cache": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
||||||
|
|
@ -4431,6 +4482,12 @@
|
||||||
"node": "^10.12.0 || >=12.0.0"
|
"node": "^10.12.0 || >=12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/file-saver": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/file-selector": {
|
"node_modules/file-selector": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
|
||||||
|
|
@ -4948,6 +5005,12 @@
|
||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immediate": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
|
|
@ -4991,7 +5054,6 @@
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/internal-slot": {
|
"node_modules/internal-slot": {
|
||||||
|
|
@ -5609,6 +5671,18 @@
|
||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jszip": {
|
||||||
|
"version": "3.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||||
|
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||||
|
"license": "(MIT OR GPL-3.0-or-later)",
|
||||||
|
"dependencies": {
|
||||||
|
"lie": "~3.3.0",
|
||||||
|
"pako": "~1.0.2",
|
||||||
|
"readable-stream": "~2.3.6",
|
||||||
|
"setimmediate": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
|
|
@ -5653,6 +5727,15 @@
|
||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lie": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"immediate": "~3.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lilconfig": {
|
"node_modules/lilconfig": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||||
|
|
@ -5957,6 +6040,15 @@
|
||||||
"url": "https://github.com/sponsors/panva"
|
"url": "https://github.com/sponsors/panva"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/next-auth/node_modules/uuid": {
|
||||||
|
"version": "8.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||||
|
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/next-sitemap": {
|
"node_modules/next-sitemap": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/next-sitemap/-/next-sitemap-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/next-sitemap/-/next-sitemap-4.2.3.tgz",
|
||||||
|
|
@ -6319,6 +6411,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0"
|
"license": "BlueOak-1.0.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
"node_modules/papaparse": {
|
"node_modules/papaparse": {
|
||||||
"version": "5.5.3",
|
"version": "5.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
|
||||||
|
|
@ -6629,6 +6727,41 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/posthog-js": {
|
||||||
|
"version": "1.276.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.276.0.tgz",
|
||||||
|
"integrity": "sha512-FYZE1037LrAoKKeUU0pUL7u8WwNK2BVeg5TFApwquVPUdj9h7u5Z077A313hPN19Ar+7Y+VHxqYqdHc4VNsVgw==",
|
||||||
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
|
"dependencies": {
|
||||||
|
"@posthog/core": "1.3.0",
|
||||||
|
"core-js": "^3.38.1",
|
||||||
|
"fflate": "^0.4.8",
|
||||||
|
"preact": "^10.19.3",
|
||||||
|
"web-vitals": "^4.2.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@rrweb/types": "2.0.0-alpha.17",
|
||||||
|
"rrweb-snapshot": "2.0.0-alpha.17"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@rrweb/types": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"rrweb-snapshot": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/posthog-js/node_modules/preact": {
|
||||||
|
"version": "10.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
|
||||||
|
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/preact"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/preact": {
|
"node_modules/preact": {
|
||||||
"version": "10.11.3",
|
"version": "10.11.3",
|
||||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz",
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz",
|
||||||
|
|
@ -6703,6 +6836,12 @@
|
||||||
"fsevents": "2.3.3"
|
"fsevents": "2.3.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/process-nextick-args": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/prop-types": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
|
|
@ -6876,6 +7015,27 @@
|
||||||
"pify": "^2.3.0"
|
"pify": "^2.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/readable-stream": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"core-util-is": "~1.0.0",
|
||||||
|
"inherits": "~2.0.3",
|
||||||
|
"isarray": "~1.0.0",
|
||||||
|
"process-nextick-args": "~2.0.0",
|
||||||
|
"safe-buffer": "~5.1.1",
|
||||||
|
"string_decoder": "~1.1.1",
|
||||||
|
"util-deprecate": "~1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/readable-stream/node_modules/isarray": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||||
|
|
@ -7082,6 +7242,12 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/safe-push-apply": {
|
"node_modules/safe-push-apply": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
||||||
|
|
@ -7193,6 +7359,12 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/setimmediate": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/sharp": {
|
"node_modules/sharp": {
|
||||||
"version": "0.33.5",
|
"version": "0.33.5",
|
||||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
|
||||||
|
|
@ -7415,6 +7587,15 @@
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string_decoder": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string-width": {
|
"node_modules/string-width": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
|
@ -8173,16 +8354,19 @@
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
"version": "8.3.2",
|
"version": "13.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"uuid": "dist/bin/uuid"
|
"uuid": "dist-node/bin/uuid"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/void-elements": {
|
"node_modules/void-elements": {
|
||||||
|
|
@ -8194,6 +8378,12 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/web-vitals": {
|
||||||
|
"version": "4.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz",
|
||||||
|
"integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -26,17 +26,21 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^1.0.12",
|
"@auth/prisma-adapter": "^1.0.12",
|
||||||
|
"@edge-runtime/cookies": "^6.0.0",
|
||||||
"@prisma/client": "^5.7.0",
|
"@prisma/client": "^5.7.0",
|
||||||
"@stripe/stripe-js": "^8.0.0",
|
"@stripe/stripe-js": "^8.0.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"chart.js": "^4.4.0",
|
"chart.js": "^4.4.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
|
"file-saver": "^2.0.5",
|
||||||
"i18next": "^23.7.6",
|
"i18next": "^23.7.6",
|
||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"next": "14.2.18",
|
"next": "14.2.18",
|
||||||
"next-auth": "^4.24.5",
|
"next-auth": "^4.24.5",
|
||||||
"papaparse": "^5.4.1",
|
"papaparse": "^5.4.1",
|
||||||
|
"posthog-js": "^1.276.0",
|
||||||
"qrcode": "^1.5.3",
|
"qrcode": "^1.5.3",
|
||||||
"qrcode.react": "^3.1.0",
|
"qrcode.react": "^3.1.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
|
@ -47,11 +51,13 @@
|
||||||
"sharp": "^0.33.1",
|
"sharp": "^0.33.1",
|
||||||
"stripe": "^19.1.0",
|
"stripe": "^19.1.0",
|
||||||
"tailwind-merge": "^2.2.0",
|
"tailwind-merge": "^2.2.0",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/node": "^20.10.5",
|
"@types/node": "^20.10.5",
|
||||||
"@types/papaparse": "^5.3.14",
|
"@types/papaparse": "^5.3.14",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Modern gradient background -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#3B82F6;stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:#1D4ED8;stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect width="32" height="32" rx="7" fill="url(#bgGradient)"/>
|
||||||
|
|
||||||
|
<!-- Modern QR code pattern with rounded corners -->
|
||||||
|
<!-- Top left corner finder -->
|
||||||
|
<rect x="5" y="5" width="10" height="10" rx="2" fill="white"/>
|
||||||
|
<rect x="7" y="7" width="6" height="6" rx="1" fill="#1D4ED8"/>
|
||||||
|
|
||||||
|
<!-- Top right corner finder -->
|
||||||
|
<rect x="17" y="5" width="10" height="10" rx="2" fill="white"/>
|
||||||
|
<rect x="19" y="7" width="6" height="6" rx="1" fill="#1D4ED8"/>
|
||||||
|
|
||||||
|
<!-- Bottom left corner finder -->
|
||||||
|
<rect x="5" y="17" width="10" height="10" rx="2" fill="white"/>
|
||||||
|
<rect x="7" y="19" width="6" height="6" rx="1" fill="#1D4ED8"/>
|
||||||
|
|
||||||
|
<!-- Modern data pattern with circles and rounded squares -->
|
||||||
|
<circle cx="19" cy="17" r="1.5" fill="white"/>
|
||||||
|
<circle cx="23" cy="17" r="1.5" fill="white"/>
|
||||||
|
<circle cx="19" cy="21" r="1.5" fill="white"/>
|
||||||
|
<circle cx="23" cy="21" r="1.5" fill="white"/>
|
||||||
|
<circle cx="19" cy="25" r="1.5" fill="white"/>
|
||||||
|
<circle cx="23" cy="25" r="1.5" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
|
|
@ -1,21 +1,32 @@
|
||||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<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"/>
|
<!-- Modern gradient background -->
|
||||||
<rect x="6" y="6" width="4" height="4" fill="white"/>
|
<defs>
|
||||||
<rect x="6" y="12" width="4" height="4" fill="white"/>
|
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
<rect x="6" y="18" width="4" height="4" fill="white"/>
|
<stop offset="0%" style="stop-color:#3B82F6;stop-opacity:1" />
|
||||||
<rect x="12" y="6" width="4" height="4" fill="white"/>
|
<stop offset="100%" style="stop-color:#1D4ED8;stop-opacity:1" />
|
||||||
<rect x="12" y="18" width="4" height="4" fill="white"/>
|
</linearGradient>
|
||||||
<rect x="18" y="6" width="4" height="4" fill="white"/>
|
</defs>
|
||||||
<rect x="18" y="12" width="4" height="4" fill="white"/>
|
|
||||||
<rect x="18" y="18" width="4" height="4" fill="white"/>
|
<rect width="32" height="32" rx="7" fill="url(#bgGradient)"/>
|
||||||
<rect x="24" y="6" width="2" height="2" fill="white"/>
|
|
||||||
<rect x="24" y="10" width="2" height="2" fill="white"/>
|
<!-- Modern QR code pattern with rounded corners -->
|
||||||
<rect x="24" y="14" width="2" height="2" fill="white"/>
|
<!-- Top left corner finder -->
|
||||||
<rect x="24" y="18" width="2" height="2" fill="white"/>
|
<rect x="5" y="5" width="10" height="10" rx="2" fill="white"/>
|
||||||
<rect x="24" y="22" width="2" height="2" fill="white"/>
|
<rect x="7" y="7" width="6" height="6" rx="1" fill="#1D4ED8"/>
|
||||||
<rect x="6" y="24" width="2" height="2" fill="white"/>
|
|
||||||
<rect x="10" y="24" width="2" height="2" fill="white"/>
|
<!-- Top right corner finder -->
|
||||||
<rect x="14" y="24" width="2" height="2" fill="white"/>
|
<rect x="17" y="5" width="10" height="10" rx="2" fill="white"/>
|
||||||
<rect x="18" y="24" width="2" height="2" fill="white"/>
|
<rect x="19" y="7" width="6" height="6" rx="1" fill="#1D4ED8"/>
|
||||||
<rect x="22" y="24" width="2" height="2" fill="white"/>
|
|
||||||
|
<!-- Bottom left corner finder -->
|
||||||
|
<rect x="5" y="17" width="10" height="10" rx="2" fill="white"/>
|
||||||
|
<rect x="7" y="19" width="6" height="6" rx="1" fill="#1D4ED8"/>
|
||||||
|
|
||||||
|
<!-- Modern data pattern with circles and rounded squares -->
|
||||||
|
<circle cx="19" cy="17" r="1.5" fill="white"/>
|
||||||
|
<circle cx="23" cy="17" r="1.5" fill="white"/>
|
||||||
|
<circle cx="19" cy="21" r="1.5" fill="white"/>
|
||||||
|
<circle cx="23" cy="21" r="1.5" fill="white"/>
|
||||||
|
<circle cx="19" cy="25" r="1.5" fill="white"/>
|
||||||
|
<circle cx="23" cy="25" r="1.5" fill="white"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.4 KiB |
|
|
@ -124,22 +124,23 @@ export default function AnalyticsPage() {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Prepare chart data
|
// Prepare chart data based on selected time range
|
||||||
const last7Days = Array.from({ length: 7 }, (_, i) => {
|
const daysToShow = parseInt(timeRange);
|
||||||
|
const dateRange = Array.from({ length: daysToShow }, (_, i) => {
|
||||||
const date = new Date();
|
const date = new Date();
|
||||||
date.setDate(date.getDate() - (6 - i));
|
date.setDate(date.getDate() - (daysToShow - 1 - i));
|
||||||
return date.toISOString().split('T')[0];
|
return date.toISOString().split('T')[0];
|
||||||
});
|
});
|
||||||
|
|
||||||
const scanChartData = {
|
const scanChartData = {
|
||||||
labels: last7Days.map(date => {
|
labels: dateRange.map(date => {
|
||||||
const d = new Date(date);
|
const d = new Date(date);
|
||||||
return d.toLocaleDateString('en', { month: 'short', day: 'numeric' });
|
return d.toLocaleDateString('en', { month: 'short', day: 'numeric' });
|
||||||
}),
|
}),
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: 'Scans',
|
label: 'Scans',
|
||||||
data: last7Days.map(date => analyticsData?.dailyScans[date] || 0),
|
data: dateRange.map(date => analyticsData?.dailyScans[date] || 0),
|
||||||
borderColor: 'rgb(37, 99, 235)',
|
borderColor: 'rgb(37, 99, 235)',
|
||||||
backgroundColor: 'rgba(37, 99, 235, 0.1)',
|
backgroundColor: 'rgba(37, 99, 235, 0.1)',
|
||||||
tension: 0.4,
|
tension: 0.4,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,594 @@
|
||||||
|
'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 { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import { showToast } from '@/components/ui/Toast';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
import { saveAs } from 'file-saver';
|
||||||
|
|
||||||
|
interface BulkQRData {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeneratedQR {
|
||||||
|
title: string;
|
||||||
|
content: string; // Original URL
|
||||||
|
svg: string; // SVG markup
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BulkCreationPage() {
|
||||||
|
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 [generatedQRs, setGeneratedQRs] = useState<GeneratedQR[]>([]);
|
||||||
|
const [userPlan, setUserPlan] = useState<string>('FREE');
|
||||||
|
|
||||||
|
// Check user plan on mount
|
||||||
|
React.useEffect(() => {
|
||||||
|
const checkPlan = 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 checking plan:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
checkPlan();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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[]) => {
|
||||||
|
// Limit to 1000 rows
|
||||||
|
const limitedData = rawData.slice(0, 1000);
|
||||||
|
|
||||||
|
// Auto-detect columns
|
||||||
|
if (limitedData.length > 0) {
|
||||||
|
const columns = Object.keys(limitedData[0]);
|
||||||
|
const autoMapping: Record<string, string> = {};
|
||||||
|
|
||||||
|
columns.forEach((col) => {
|
||||||
|
const lowerCol = col.toLowerCase();
|
||||||
|
if (lowerCol.includes('title') || lowerCol.includes('name') || lowerCol === 'test') {
|
||||||
|
autoMapping.title = col;
|
||||||
|
} else if (lowerCol.includes('content') || lowerCol.includes('url') || lowerCol.includes('data') || lowerCol.includes('link')) {
|
||||||
|
autoMapping.content = col;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// If no title column found, use first column
|
||||||
|
if (!autoMapping.title && columns.length > 0) {
|
||||||
|
autoMapping.title = columns[0];
|
||||||
|
}
|
||||||
|
// If no content column found, use second column
|
||||||
|
if (!autoMapping.content && columns.length > 1) {
|
||||||
|
autoMapping.content = columns[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
setMapping(autoMapping);
|
||||||
|
}
|
||||||
|
|
||||||
|
setData(limitedData);
|
||||||
|
setStep('preview');
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateStaticQRCodes = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const qrCodes: GeneratedQR[] = [];
|
||||||
|
|
||||||
|
// Generate all QR codes client-side (Static QR Codes)
|
||||||
|
for (const row of data) {
|
||||||
|
const title = row[mapping.title as keyof typeof row] || 'Untitled';
|
||||||
|
const content = row[mapping.content as keyof typeof row] || 'https://example.com';
|
||||||
|
|
||||||
|
// Create a temporary div to render QR code
|
||||||
|
const tempDiv = document.createElement('div');
|
||||||
|
tempDiv.style.display = 'none';
|
||||||
|
document.body.appendChild(tempDiv);
|
||||||
|
|
||||||
|
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||||
|
svg.setAttribute('width', '300');
|
||||||
|
svg.setAttribute('height', '300');
|
||||||
|
tempDiv.appendChild(svg);
|
||||||
|
|
||||||
|
// Use qrcode library to generate SVG
|
||||||
|
const QRCode = require('qrcode');
|
||||||
|
const qrSvg = await QRCode.toString(content, {
|
||||||
|
type: 'svg',
|
||||||
|
width: 300,
|
||||||
|
margin: 2,
|
||||||
|
color: {
|
||||||
|
dark: '#000000',
|
||||||
|
light: '#FFFFFF',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
qrCodes.push({
|
||||||
|
title: String(title),
|
||||||
|
content: String(content), // Store the original URL
|
||||||
|
svg: qrSvg,
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.removeChild(tempDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
setGeneratedQRs(qrCodes);
|
||||||
|
setStep('complete');
|
||||||
|
showToast(`Successfully generated ${qrCodes.length} static QR codes!`, 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('QR generation error:', error);
|
||||||
|
showToast('Failed to generate QR codes', 'error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadAllQRCodes = async () => {
|
||||||
|
const zip = new JSZip();
|
||||||
|
|
||||||
|
generatedQRs.forEach((qr, index) => {
|
||||||
|
const fileName = `${qr.title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_${index + 1}.svg`;
|
||||||
|
zip.file(fileName, qr.svg);
|
||||||
|
});
|
||||||
|
|
||||||
|
const blob = await zip.generateAsync({ type: 'blob' });
|
||||||
|
saveAs(blob, 'qr-codes-bulk.zip');
|
||||||
|
showToast('Download started!', 'success');
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveQRCodesToDatabase = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const qrCodesToSave = generatedQRs.map((qr) => ({
|
||||||
|
title: qr.title,
|
||||||
|
isStatic: true, // This tells the API it's a static QR code
|
||||||
|
contentType: 'URL',
|
||||||
|
content: { url: qr.content }, // Content needs to be an object with url property
|
||||||
|
status: 'ACTIVE',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Save each QR code to the database
|
||||||
|
const savePromises = qrCodesToSave.map((qr) =>
|
||||||
|
fetch('/api/qrs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(qr),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await Promise.all(savePromises);
|
||||||
|
const failedCount = results.filter((r) => !r.ok).length;
|
||||||
|
|
||||||
|
if (failedCount === 0) {
|
||||||
|
showToast(`Successfully saved ${qrCodesToSave.length} QR codes!`, 'success');
|
||||||
|
// Redirect to dashboard after 1 second
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '/dashboard';
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
showToast(`Saved ${qrCodesToSave.length - failedCount} QR codes, ${failedCount} failed`, 'warning');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving QR codes:', error);
|
||||||
|
showToast('Failed to save QR codes', 'error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadTemplate = () => {
|
||||||
|
const template = [
|
||||||
|
{ title: 'Product Page', content: 'https://example.com/product' },
|
||||||
|
{ title: 'Landing Page', content: 'https://example.com/landing' },
|
||||||
|
{ title: 'Contact Form', content: 'https://example.com/contact' },
|
||||||
|
];
|
||||||
|
|
||||||
|
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 = 'bulk-qr-template.csv';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show upgrade prompt if not Business plan
|
||||||
|
if (userPlan !== 'BUSINESS') {
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<Card className="mt-12">
|
||||||
|
<CardContent className="p-12 text-center">
|
||||||
|
<div className="w-20 h-20 bg-warning-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
|
<svg className="w-10 h-10 text-warning-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">Business Plan Required</h2>
|
||||||
|
<p className="text-gray-600 mb-8 max-w-md mx-auto">
|
||||||
|
Bulk QR code creation is exclusively available for Business plan subscribers.
|
||||||
|
Upgrade now to generate up to 1,000 static QR codes at once.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-center space-x-4">
|
||||||
|
<Button variant="outline" onClick={() => window.location.href = '/dashboard'}>
|
||||||
|
Back to Dashboard
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => window.location.href = '/pricing'}>
|
||||||
|
Upgrade to Business
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Template Warning Banner */}
|
||||||
|
<Card className="mb-6 bg-warning-50 border-warning-200">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<svg className="w-6 h-6 text-warning-600 flex-shrink-0 mt-0.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>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-warning-900 mb-1">Please Follow the Template Format</h3>
|
||||||
|
<p className="text-sm text-warning-800">
|
||||||
|
Download the template below and follow the format exactly. Your CSV must include columns for <strong>title</strong> and <strong>content</strong> (URL).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Info Banner */}
|
||||||
|
<Card className="mb-6 bg-blue-50 border-blue-200">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<svg className="w-6 h-6 text-blue-600 flex-shrink-0 mt-0.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>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-blue-900 mb-1">Static QR Codes Only</h3>
|
||||||
|
<p className="text-sm text-blue-800">
|
||||||
|
Bulk creation generates <strong>static QR codes</strong> that cannot be edited after creation.
|
||||||
|
These QR codes do not include tracking or analytics. Perfect for print materials and offline use.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 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">Download</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 1,000 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">Simple Format</p>
|
||||||
|
<p className="text-sm text-gray-500">Just title & URL</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="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>
|
||||||
|
<p className="font-medium text-gray-900">Static QR Codes</p>
|
||||||
|
<p className="text-sm text-gray-500">No tracking included</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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">Instant Download</p>
|
||||||
|
<p className="text-sm text-gray-500">Get ZIP with all SVGs</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-2 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/URL 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>
|
||||||
|
|
||||||
|
<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">Content</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.content] || '').substring(0, 50)}...
|
||||||
|
</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={generateStaticQRCodes} loading={loading}>
|
||||||
|
Generate {data.length} Static 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">Generation Complete!</h2>
|
||||||
|
<p className="text-gray-600 mb-8">
|
||||||
|
Successfully generated {generatedQRs.length} static QR codes
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-8 max-w-6xl mx-auto">
|
||||||
|
{generatedQRs.slice(0, 8).map((qr, index) => (
|
||||||
|
<div key={index} className="bg-white border border-gray-200 rounded-lg p-6 flex flex-col items-center shadow-sm hover:shadow-md transition-shadow">
|
||||||
|
<div className="w-full flex items-center justify-center mb-4" style={{ height: '160px' }}>
|
||||||
|
<div
|
||||||
|
dangerouslySetInnerHTML={{ __html: qr.svg }}
|
||||||
|
className="qr-code-container"
|
||||||
|
style={{ maxWidth: '160px', maxHeight: '160px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-900 font-medium text-center break-words w-full">{qr.title}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style jsx>{`
|
||||||
|
.qr-code-container :global(svg) {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
max-width: 160px !important;
|
||||||
|
max-height: 160px !important;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
<div className="flex justify-center space-x-4">
|
||||||
|
<Button variant="outline" onClick={() => {
|
||||||
|
setStep('upload');
|
||||||
|
setData([]);
|
||||||
|
setMapping({});
|
||||||
|
setGeneratedQRs([]);
|
||||||
|
}}>
|
||||||
|
Create More
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={downloadAllQRCodes}>
|
||||||
|
<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 All as ZIP
|
||||||
|
</Button>
|
||||||
|
<Button onClick={saveQRCodesToDatabase} loading={loading}>
|
||||||
|
<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="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
|
||||||
|
</svg>
|
||||||
|
Save QR Codes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -287,7 +287,8 @@ export default function CreatePage() {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Create QR Code</h1>
|
<h1 className="text-3xl font-bold text-gray-900">{t('create.title')}</h1>
|
||||||
|
<p className="text-gray-600 mt-2">{t('create.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
|
|
@ -321,7 +322,7 @@ export default function CreatePage() {
|
||||||
label="Tags (comma-separated)"
|
label="Tags (comma-separated)"
|
||||||
value={tags}
|
value={tags}
|
||||||
onChange={(e) => setTags(e.target.value)}
|
onChange={(e) => setTags(e.target.value)}
|
||||||
placeholder="marketing, campaign, 2024"
|
placeholder="marketing, campaign, 2025"
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
import { useCsrf } from '@/hooks/useCsrf';
|
||||||
import { showToast } from '@/components/ui/Toast';
|
import { showToast } from '@/components/ui/Toast';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/Dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/Dialog';
|
||||||
|
|
||||||
|
|
@ -22,17 +23,20 @@ interface QRCodeData {
|
||||||
status: 'ACTIVE' | 'PAUSED';
|
status: 'ACTIVE' | 'PAUSED';
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
scans: number;
|
scans: number;
|
||||||
|
style?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const { fetchWithCsrf } = useCsrf();
|
||||||
const [qrCodes, setQrCodes] = useState<QRCodeData[]>([]);
|
const [qrCodes, setQrCodes] = useState<QRCodeData[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [userPlan, setUserPlan] = useState<string>('FREE');
|
const [userPlan, setUserPlan] = useState<string>('FREE');
|
||||||
const [showUpgradeDialog, setShowUpgradeDialog] = useState(false);
|
const [showUpgradeDialog, setShowUpgradeDialog] = useState(false);
|
||||||
const [upgradedPlan, setUpgradedPlan] = useState<string>('');
|
const [upgradedPlan, setUpgradedPlan] = useState<string>('');
|
||||||
|
const [deletingAll, setDeletingAll] = useState(false);
|
||||||
const [stats, setStats] = useState({
|
const [stats, setStats] = useState({
|
||||||
totalScans: 0,
|
totalScans: 0,
|
||||||
activeQRCodes: 0,
|
activeQRCodes: 0,
|
||||||
|
|
@ -116,7 +120,7 @@ export default function DashboardPage() {
|
||||||
slug: 'dynamische-vs-statische-qr-codes',
|
slug: 'dynamische-vs-statische-qr-codes',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'QR-Code Marketing-Strategien für 2024',
|
title: 'QR-Code Marketing-Strategien für 2025',
|
||||||
excerpt: 'Die besten Marketing-Strategien mit QR-Codes für Ihr Unternehmen...',
|
excerpt: 'Die besten Marketing-Strategien mit QR-Codes für Ihr Unternehmen...',
|
||||||
readTime: '7 Min',
|
readTime: '7 Min',
|
||||||
slug: 'qr-code-marketing-strategien',
|
slug: 'qr-code-marketing-strategien',
|
||||||
|
|
@ -205,19 +209,98 @@ export default function DashboardPage() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleEdit = (id: string) => {
|
const handleEdit = (id: string) => {
|
||||||
console.log('Edit QR:', id);
|
// Redirect to edit page
|
||||||
|
router.push(`/qr/${id}/edit`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDuplicate = (id: string) => {
|
const handlePause = async (id: string) => {
|
||||||
console.log('Duplicate QR:', id);
|
try {
|
||||||
|
const qr = qrCodes.find(q => q.id === id);
|
||||||
|
if (!qr) return;
|
||||||
|
|
||||||
|
const newStatus = qr.status === 'ACTIVE' ? 'PAUSED' : 'ACTIVE';
|
||||||
|
|
||||||
|
const response = await fetch(`/api/qrs/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ status: newStatus }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Update local state
|
||||||
|
setQrCodes(qrCodes.map(q =>
|
||||||
|
q.id === id ? { ...q, status: newStatus } : q
|
||||||
|
));
|
||||||
|
showToast(`QR code ${newStatus === 'ACTIVE' ? 'resumed' : 'paused'}!`, 'success');
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to update status');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating QR status:', error);
|
||||||
|
showToast('Failed to update QR code status', 'error');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePause = (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
console.log('Pause QR:', id);
|
if (!confirm('Are you sure you want to delete this QR code? This action cannot be undone.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/qrs/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Remove from local state
|
||||||
|
setQrCodes(qrCodes.filter(q => q.id !== id));
|
||||||
|
showToast('QR code deleted successfully!', 'success');
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to delete');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting QR:', error);
|
||||||
|
showToast('Failed to delete QR code', 'error');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (id: string) => {
|
const handleDeleteAll = async () => {
|
||||||
console.log('Delete QR:', id);
|
if (!confirm('Are you sure you want to delete ALL QR codes? This action cannot be undone.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Double confirmation
|
||||||
|
if (!confirm('This will permanently delete ALL your QR codes. Are you absolutely sure?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeletingAll(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchWithCsrf('/api/qrs/delete-all', {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setQrCodes([]);
|
||||||
|
setStats({
|
||||||
|
totalScans: 0,
|
||||||
|
activeQRCodes: 0,
|
||||||
|
conversionRate: 0,
|
||||||
|
});
|
||||||
|
showToast(`Successfully deleted ${data.deletedCount} QR code${data.deletedCount !== 1 ? 's' : ''}`, 'success');
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to delete all QR codes');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting all QR codes:', error);
|
||||||
|
showToast('Failed to delete QR codes', 'error');
|
||||||
|
} finally {
|
||||||
|
setDeletingAll(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPlanBadgeColor = (plan: string) => {
|
const getPlanBadgeColor = (plan: string) => {
|
||||||
|
|
@ -263,10 +346,22 @@ export default function DashboardPage() {
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h2 className="text-xl font-semibold text-gray-900">{t('dashboard.recent_codes')}</h2>
|
<h2 className="text-xl font-semibold text-gray-900">{t('dashboard.recent_codes')}</h2>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{qrCodes.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleDeleteAll}
|
||||||
|
disabled={deletingAll}
|
||||||
|
className="border-red-600 text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
{deletingAll ? 'Deleting...' : 'Delete All'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Link href="/create">
|
<Link href="/create">
|
||||||
<Button>Create New QR Code</Button>
|
<Button>Create New QR Code</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
|
@ -292,7 +387,6 @@ export default function DashboardPage() {
|
||||||
key={qr.id}
|
key={qr.id}
|
||||||
qr={qr}
|
qr={qr}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDuplicate={handleDuplicate}
|
|
||||||
onPause={handlePause}
|
onPause={handlePause}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
/>
|
/>
|
||||||
|
|
@ -351,11 +445,11 @@ export default function DashboardPage() {
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-600 mr-2">✓</span>
|
<span className="text-green-600 mr-2">✓</span>
|
||||||
<span>Branding (Logo, Farben anpassen)</span>
|
<span>Branding (Farben anpassen)</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-600 mr-2">✓</span>
|
<span className="text-green-600 mr-2">✓</span>
|
||||||
<span>Detaillierte Analytics</span>
|
<span>Detaillierte Analytics (Devices, Locations, Time-Series)</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-600 mr-2">✓</span>
|
<span className="text-green-600 mr-2">✓</span>
|
||||||
|
|
@ -363,7 +457,7 @@ export default function DashboardPage() {
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-600 mr-2">✓</span>
|
<span className="text-green-600 mr-2">✓</span>
|
||||||
<span>Passwortschutz für QR-Codes</span>
|
<span>SVG/PNG Download</span>
|
||||||
</li>
|
</li>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -375,15 +469,11 @@ export default function DashboardPage() {
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-600 mr-2">✓</span>
|
<span className="text-green-600 mr-2">✓</span>
|
||||||
<span>Team-Zugänge (bis zu 3 User)</span>
|
<span>Alles von Pro</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-600 mr-2">✓</span>
|
<span className="text-green-600 mr-2">✓</span>
|
||||||
<span>Benutzerdefinierte Domains</span>
|
<span>Bulk QR-Generierung (bis 1,000)</span>
|
||||||
</li>
|
|
||||||
<li className="flex items-start">
|
|
||||||
<span className="text-green-600 mr-2">✓</span>
|
|
||||||
<span>White-Label</span>
|
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-600 mr-2">✓</span>
|
<span className="text-green-600 mr-2">✓</span>
|
||||||
|
|
|
||||||
|
|
@ -316,11 +316,11 @@ export default function IntegrationsPage() {
|
||||||
<div className="p-4 bg-gray-50 rounded-lg">
|
<div className="p-4 bg-gray-50 rounded-lg">
|
||||||
<h4 className="font-medium text-gray-900 mb-2">Sample Payload</h4>
|
<h4 className="font-medium text-gray-900 mb-2">Sample Payload</h4>
|
||||||
<pre className="text-xs text-gray-600 overflow-x-auto">
|
<pre className="text-xs text-gray-600 overflow-x-auto">
|
||||||
{`{
|
{`{
|
||||||
"event": "qr_scanned",
|
"event": "qr_scanned",
|
||||||
"qr_id": "abc123",
|
"qr_id": "abc123",
|
||||||
"title": "Product Page",
|
"title": "Product Page",
|
||||||
"timestamp": "2024-01-01T12:00:00Z",
|
"timestamp": "2025-01-01T12:00:00Z",
|
||||||
"location": "United States",
|
"location": "United States",
|
||||||
"device": "mobile"
|
"device": "mobile"
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -359,10 +359,10 @@ export default function IntegrationsPage() {
|
||||||
<div className="text-center p-6">
|
<div className="text-center p-6">
|
||||||
<Button>
|
<Button>
|
||||||
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
|
<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="#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="#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="#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"/>
|
<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>
|
</svg>
|
||||||
Connect Google Account
|
Connect Google Account
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ export default function AppLayout({
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{
|
{
|
||||||
name: 'Dashboard',
|
name: t('nav.dashboard'),
|
||||||
href: '/dashboard',
|
href: '/dashboard',
|
||||||
icon: (
|
icon: (
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|
@ -39,7 +39,7 @@ export default function AppLayout({
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Create QR',
|
name: t('nav.create_qr'),
|
||||||
href: '/create',
|
href: '/create',
|
||||||
icon: (
|
icon: (
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|
@ -48,7 +48,16 @@ export default function AppLayout({
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Analytics',
|
name: t('nav.bulk_creation'),
|
||||||
|
href: '/bulk-creation',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-5 h-5" 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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: t('nav.analytics'),
|
||||||
href: '/analytics',
|
href: '/analytics',
|
||||||
icon: (
|
icon: (
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|
@ -57,7 +66,7 @@ export default function AppLayout({
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Pricing',
|
name: t('nav.pricing'),
|
||||||
href: '/pricing',
|
href: '/pricing',
|
||||||
icon: (
|
icon: (
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|
@ -66,7 +75,7 @@ export default function AppLayout({
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Settings',
|
name: t('nav.settings'),
|
||||||
href: '/settings',
|
href: '/settings',
|
||||||
icon: (
|
icon: (
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|
|
||||||
|
|
@ -1,304 +1,202 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
import { showToast } from '@/components/ui/Toast';
|
import { showToast } from '@/components/ui/Toast';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
export default function PricingPage() {
|
export default function PricingPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const [user, setUser] = useState<any>(null);
|
|
||||||
const [loading, setLoading] = useState<string | null>(null);
|
const [loading, setLoading] = useState<string | null>(null);
|
||||||
const [billingInterval, setBillingInterval] = useState<'monthly' | 'yearly'>('monthly');
|
const [currentPlan, setCurrentPlan] = useState<string>('FREE');
|
||||||
const [hasTriggeredCheckout, setHasTriggeredCheckout] = useState(false);
|
|
||||||
|
|
||||||
// Check for user in localStorage
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedUser = localStorage.getItem('user');
|
// Fetch current user plan
|
||||||
if (storedUser) {
|
const fetchUserPlan = async () => {
|
||||||
setUser(JSON.parse(storedUser));
|
try {
|
||||||
|
const response = await fetch('/api/user/plan');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setCurrentPlan(data.plan || 'FREE');
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user plan:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchUserPlan();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const plans = [
|
const handleUpgrade = async (plan: 'PRO' | 'BUSINESS') => {
|
||||||
{
|
setLoading(plan);
|
||||||
id: 'FREE',
|
|
||||||
name: 'Free / Starter',
|
|
||||||
icon: '',
|
|
||||||
price: 0,
|
|
||||||
priceYearly: 0,
|
|
||||||
description: 'Privatnutzer & Testkunden',
|
|
||||||
features: [
|
|
||||||
'3 dynamische QR-Codes',
|
|
||||||
'Unbegrenzte statische QR-Codes',
|
|
||||||
'Basis-Scan-Tracking',
|
|
||||||
'Standard QR-Design-Vorlagen',
|
|
||||||
],
|
|
||||||
cta: 'Get Started',
|
|
||||||
popular: false,
|
|
||||||
priceIdMonthly: null,
|
|
||||||
priceIdYearly: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'PRO',
|
|
||||||
name: 'Pro',
|
|
||||||
icon: '',
|
|
||||||
price: 9,
|
|
||||||
priceYearly: 90,
|
|
||||||
description: 'Selbstständige / kleine Firmen',
|
|
||||||
features: [
|
|
||||||
'50 dynamische QR-Codes',
|
|
||||||
'Unbegrenzte statische QR-Codes',
|
|
||||||
'Erweiterte Analytik (Scans, Geräte, Standorte)',
|
|
||||||
'Individuelles Branding (Farben & Logo)',
|
|
||||||
'Download als SVG/PNG',
|
|
||||||
],
|
|
||||||
cta: 'Upgrade to Pro',
|
|
||||||
popular: true,
|
|
||||||
priceIdMonthly: process.env.NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_MONTHLY,
|
|
||||||
priceIdYearly: process.env.NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_YEARLY,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'BUSINESS',
|
|
||||||
name: 'Business',
|
|
||||||
icon: '',
|
|
||||||
price: 29,
|
|
||||||
priceYearly: 290,
|
|
||||||
description: 'Agenturen / Startups',
|
|
||||||
features: [
|
|
||||||
'500 dynamische QR-Codes',
|
|
||||||
'Unbegrenzte statische QR-Codes',
|
|
||||||
'Alles aus Pro',
|
|
||||||
'Prioritäts-E-Mail-Support',
|
|
||||||
'Erweiterte Tracking & Insights',
|
|
||||||
],
|
|
||||||
cta: 'Upgrade to Business',
|
|
||||||
popular: false,
|
|
||||||
priceIdMonthly: process.env.NEXT_PUBLIC_STRIPE_PRICE_ID_BUSINESS_MONTHLY,
|
|
||||||
priceIdYearly: process.env.NEXT_PUBLIC_STRIPE_PRICE_ID_BUSINESS_YEARLY,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleSubscribe = async (planId: string, priceId: string | null | undefined) => {
|
|
||||||
console.log('🔵 handleSubscribe called:', { planId, priceId, hasUser: !!user });
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
// Save the plan selection in localStorage so we can continue after login
|
|
||||||
const pendingPlan = {
|
|
||||||
planId,
|
|
||||||
interval: billingInterval,
|
|
||||||
};
|
|
||||||
console.log('💾 Saving pending plan to localStorage:', pendingPlan);
|
|
||||||
localStorage.setItem('pendingPlan', JSON.stringify(pendingPlan));
|
|
||||||
|
|
||||||
// Verify it was saved
|
|
||||||
const saved = localStorage.getItem('pendingPlan');
|
|
||||||
console.log('✅ Verified saved:', saved);
|
|
||||||
|
|
||||||
// Use window.location instead of router.push to ensure localStorage is written
|
|
||||||
console.log('🔄 Redirecting to login...');
|
|
||||||
window.location.href = '/login?redirect=/pricing';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (planId === 'FREE') {
|
|
||||||
showToast('Sie nutzen bereits den kostenlosen Plan!', 'info');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!priceId) {
|
|
||||||
showToast('Preisdetails nicht verfügbar', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(planId);
|
const response = await fetch('/api/stripe/create-checkout-session', {
|
||||||
|
|
||||||
const response = await fetch('/api/stripe/checkout', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
priceId,
|
plan,
|
||||||
plan: planId,
|
billingInterval: 'month',
|
||||||
userEmail: user.email,
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to create checkout session');
|
||||||
if (response.ok && data.url) {
|
|
||||||
window.location.href = data.url;
|
|
||||||
} else {
|
|
||||||
showToast(data.error || 'Fehler beim Erstellen der Checkout-Session', 'error');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { url } = await response.json();
|
||||||
|
window.location.href = url;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating checkout session:', error);
|
console.error('Error creating checkout session:', error);
|
||||||
showToast('Ein Fehler ist aufgetreten', 'error');
|
showToast('Failed to start checkout. Please try again.', 'error');
|
||||||
} finally {
|
|
||||||
setLoading(null);
|
setLoading(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto-trigger checkout after login if plan is selected
|
const handleDowngrade = async () => {
|
||||||
useEffect(() => {
|
// Show confirmation dialog
|
||||||
console.log('Pricing useEffect triggered:', {
|
const confirmed = window.confirm(
|
||||||
hasUser: !!user,
|
'Are you sure you want to downgrade to the Free plan? Your subscription will be canceled immediately and you will lose access to premium features.'
|
||||||
hasTriggeredCheckout,
|
);
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading('FREE');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/stripe/cancel-subscription', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only run once and only when authenticated
|
if (!response.ok) {
|
||||||
if (hasTriggeredCheckout) {
|
const error = await response.json();
|
||||||
console.log('Already triggered checkout, skipping...');
|
throw new Error(error.error || 'Failed to cancel subscription');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
showToast('Successfully downgraded to Free plan', 'success');
|
||||||
console.log('Not authenticated - no user in localStorage');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for pending plan in localStorage
|
// Refresh to update the plan
|
||||||
const pendingPlanStr = localStorage.getItem('pendingPlan');
|
|
||||||
if (pendingPlanStr) {
|
|
||||||
try {
|
|
||||||
const pendingPlan = JSON.parse(pendingPlanStr);
|
|
||||||
console.log('✅ Found pending plan:', pendingPlan);
|
|
||||||
|
|
||||||
// Clear pending plan immediately
|
|
||||||
localStorage.removeItem('pendingPlan');
|
|
||||||
|
|
||||||
// Mark as triggered to prevent re-runs
|
|
||||||
setHasTriggeredCheckout(true);
|
|
||||||
|
|
||||||
// Set the billing interval
|
|
||||||
setBillingInterval(pendingPlan.interval);
|
|
||||||
|
|
||||||
// Find the plan
|
|
||||||
const selectedPlan = plans.find((p) => p.id === pendingPlan.planId);
|
|
||||||
if (selectedPlan) {
|
|
||||||
const priceId =
|
|
||||||
pendingPlan.interval === 'yearly'
|
|
||||||
? selectedPlan.priceIdYearly
|
|
||||||
: selectedPlan.priceIdMonthly;
|
|
||||||
|
|
||||||
console.log('✅ Found plan and priceId:', selectedPlan.name, priceId);
|
|
||||||
|
|
||||||
// Trigger checkout after a short delay
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
console.log('🚀 Calling handleSubscribe now...');
|
window.location.reload();
|
||||||
handleSubscribe(selectedPlan.id, priceId);
|
}, 1500);
|
||||||
}, 500);
|
} catch (error: any) {
|
||||||
} else {
|
console.error('Error canceling subscription:', error);
|
||||||
console.error('❌ Plan not found:', pendingPlan.planId);
|
showToast(error.message || 'Failed to downgrade. Please try again.', 'error');
|
||||||
|
setLoading(null);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
};
|
||||||
console.error('Error parsing pending plan:', e);
|
|
||||||
localStorage.removeItem('pendingPlan');
|
const plans = [
|
||||||
}
|
{
|
||||||
} else {
|
key: 'free',
|
||||||
console.log('No pending plan in localStorage');
|
name: 'Free',
|
||||||
}
|
price: '€0',
|
||||||
}, [user, hasTriggeredCheckout]);
|
period: 'forever',
|
||||||
|
features: [
|
||||||
|
'3 dynamic QR codes',
|
||||||
|
'Unlimited static QR codes',
|
||||||
|
'Basic scan tracking',
|
||||||
|
'Standard QR design templates',
|
||||||
|
],
|
||||||
|
buttonText: currentPlan === 'FREE' ? 'Current Plan' : 'Downgrade to Free',
|
||||||
|
buttonVariant: 'outline' as const,
|
||||||
|
disabled: currentPlan === 'FREE',
|
||||||
|
popular: false,
|
||||||
|
onDowngrade: handleDowngrade,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pro',
|
||||||
|
name: 'Pro',
|
||||||
|
price: '€9',
|
||||||
|
period: 'per month',
|
||||||
|
features: [
|
||||||
|
'50 dynamic QR codes',
|
||||||
|
'Unlimited static QR codes',
|
||||||
|
'Advanced analytics (scans, devices, locations)',
|
||||||
|
'Custom branding (colors)',
|
||||||
|
'Download as SVG/PNG',
|
||||||
|
],
|
||||||
|
buttonText: currentPlan === 'PRO' ? 'Current Plan' : 'Upgrade to Pro',
|
||||||
|
buttonVariant: 'primary' as const,
|
||||||
|
disabled: currentPlan === 'PRO',
|
||||||
|
popular: true,
|
||||||
|
onUpgrade: () => handleUpgrade('PRO'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'business',
|
||||||
|
name: 'Business',
|
||||||
|
price: '€29',
|
||||||
|
period: 'per month',
|
||||||
|
features: [
|
||||||
|
'500 dynamic QR codes',
|
||||||
|
'Unlimited static QR codes',
|
||||||
|
'Everything from Pro',
|
||||||
|
'Bulk QR Creation (up to 1,000)',
|
||||||
|
'Priority email support',
|
||||||
|
'Advanced tracking & insights',
|
||||||
|
],
|
||||||
|
buttonText: currentPlan === 'BUSINESS' ? 'Current Plan' : 'Upgrade to Business',
|
||||||
|
buttonVariant: 'primary' as const,
|
||||||
|
disabled: currentPlan === 'BUSINESS',
|
||||||
|
popular: false,
|
||||||
|
onUpgrade: () => handleUpgrade('BUSINESS'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-12">
|
<div className="container mx-auto px-4 py-12">
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-4">
|
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
||||||
Wählen Sie Ihren Plan
|
Choose Your Plan
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl text-gray-600 mb-8">
|
<p className="text-xl text-gray-600">
|
||||||
Starten Sie kostenlos. Upgraden Sie jederzeit.
|
Select the perfect plan for your QR code needs
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Billing Toggle */}
|
|
||||||
<div className="inline-flex items-center space-x-4 bg-gray-100 p-1 rounded-lg">
|
|
||||||
<button
|
|
||||||
onClick={() => setBillingInterval('monthly')}
|
|
||||||
className={`px-6 py-2 rounded-md font-medium transition-colors ${
|
|
||||||
billingInterval === 'monthly'
|
|
||||||
? 'bg-white text-gray-900 shadow'
|
|
||||||
: 'text-gray-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Monatlich
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setBillingInterval('yearly')}
|
|
||||||
className={`px-6 py-2 rounded-md font-medium transition-colors ${
|
|
||||||
billingInterval === 'yearly'
|
|
||||||
? 'bg-white text-gray-900 shadow'
|
|
||||||
: 'text-gray-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Jährlich
|
|
||||||
<Badge variant="success" className="ml-2">
|
|
||||||
Spare 17%
|
|
||||||
</Badge>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid md:grid-cols-3 gap-8 max-w-6xl mx-auto">
|
<div className="grid md:grid-cols-3 gap-8 max-w-6xl mx-auto">
|
||||||
{plans.map((plan) => {
|
{plans.map((plan) => (
|
||||||
const price = billingInterval === 'yearly' ? plan.priceYearly : plan.price;
|
|
||||||
const priceId =
|
|
||||||
billingInterval === 'yearly' ? plan.priceIdYearly : plan.priceIdMonthly;
|
|
||||||
const isLoading = loading === plan.id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
<Card
|
||||||
key={plan.id}
|
key={plan.key}
|
||||||
className={`relative ${
|
className={plan.popular ? 'border-primary-500 shadow-xl relative' : ''}
|
||||||
plan.popular
|
|
||||||
? 'border-primary-500 border-2 shadow-xl scale-105'
|
|
||||||
: 'border-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{plan.popular && (
|
{plan.popular && (
|
||||||
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
|
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
|
||||||
<Badge variant="info" className="px-4 py-1 text-sm">
|
<Badge variant="info" className="px-3 py-1">
|
||||||
Beliebteste Wahl
|
Most Popular
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CardHeader className="text-center pb-6">
|
<CardHeader className="text-center pb-8">
|
||||||
{plan.icon && <div className="text-4xl mb-4">{plan.icon}</div>}
|
<CardTitle className="text-2xl mb-4">
|
||||||
<CardTitle className="text-2xl mb-2">{plan.name}</CardTitle>
|
{plan.name}
|
||||||
<p className="text-sm text-gray-600">{plan.description}</p>
|
</CardTitle>
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<div className="flex items-baseline justify-center">
|
<div className="flex items-baseline justify-center">
|
||||||
<span className="text-5xl font-bold">{price}€</span>
|
<span className="text-4xl font-bold">
|
||||||
<span className="text-gray-600 ml-2">
|
{plan.price}
|
||||||
/{billingInterval === 'yearly' ? 'Jahr' : 'Monat'}
|
</span>
|
||||||
|
<span className="text-gray-600 ml-2">
|
||||||
|
{plan.period}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
{billingInterval === 'yearly' && plan.price > 0 && (
|
|
||||||
<p className="text-sm text-gray-500 mt-2">
|
|
||||||
{(price / 12).toFixed(2)}€ pro Monat
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{plan.features.map((feature, index) => (
|
{plan.features.map((feature: string, index: number) => (
|
||||||
<li key={index} className="flex items-start space-x-3">
|
<li key={index} className="flex items-start space-x-3">
|
||||||
<svg
|
<svg className="w-5 h-5 text-success-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
className="w-5 h-5 text-success-500 flex-shrink-0 mt-0.5"
|
<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" />
|
||||||
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>
|
</svg>
|
||||||
<span className="text-gray-700">{feature}</span>
|
<span className="text-gray-700">{feature}</span>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -306,52 +204,26 @@ export default function PricingPage() {
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={plan.popular ? 'primary' : 'outline'}
|
variant={plan.buttonVariant}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
size="lg"
|
size="lg"
|
||||||
onClick={() => handleSubscribe(plan.id, priceId)}
|
disabled={plan.disabled || loading === plan.key.toUpperCase()}
|
||||||
disabled={isLoading}
|
onClick={plan.key === 'free' ? (plan as any).onDowngrade : (plan as any).onUpgrade}
|
||||||
>
|
>
|
||||||
{isLoading ? 'Lädt...' : plan.cta}
|
{loading === plan.key.toUpperCase() ? 'Processing...' : plan.buttonText}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* FAQ Section */}
|
<div className="text-center mt-12">
|
||||||
<div className="mt-20 max-w-3xl mx-auto">
|
|
||||||
<h2 className="text-3xl font-bold text-center mb-8">Häufige Fragen</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<h3 className="font-semibold mb-2">Kann ich jederzeit kündigen?</h3>
|
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
Ja, Sie können Ihr Abo jederzeit kündigen. Es läuft dann bis zum Ende des
|
All plans include unlimited static QR codes and basic customization.
|
||||||
bezahlten Zeitraums weiter.
|
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
<p className="text-gray-600 mt-2">
|
||||||
</Card>
|
Need help choosing? <a href="mailto:support@qrmaster.com" className="text-primary-600 hover:text-primary-700 underline">Contact our team</a>
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<h3 className="font-semibold mb-2">Welche Zahlungsmethoden akzeptieren Sie?</h3>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
Wir akzeptieren alle gängigen Kreditkarten und SEPA-Lastschrift über Stripe.
|
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<h3 className="font-semibold mb-2">Was passiert mit meinen QR-Codes bei Downgrade?</h3>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
Ihre QR-Codes bleiben erhalten, Sie können nur keine neuen mehr erstellen, wenn das Limit erreicht ist.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,210 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { showToast } from '@/components/ui/Toast';
|
||||||
|
|
||||||
|
export default function EditQRPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const qrId = params.id as string;
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [qrCode, setQrCode] = useState<any>(null);
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [content, setContent] = useState<any>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchQRCode = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/qrs/${qrId}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setQrCode(data);
|
||||||
|
setTitle(data.title);
|
||||||
|
setContent(data.content || {});
|
||||||
|
} else {
|
||||||
|
showToast('Failed to load QR code', 'error');
|
||||||
|
router.push('/dashboard');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching QR code:', error);
|
||||||
|
showToast('Failed to load QR code', 'error');
|
||||||
|
router.push('/dashboard');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchQRCode();
|
||||||
|
}, [qrId, router]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/qrs/${qrId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showToast('QR code updated successfully!', 'success');
|
||||||
|
router.push('/dashboard');
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
showToast(error.error || 'Failed to update QR code', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating QR code:', error);
|
||||||
|
showToast('Failed to update QR code', 'error');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
|
||||||
|
<p className="text-gray-600">Loading QR code...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!qrCode) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static QR codes cannot be edited
|
||||||
|
if (qrCode.type === 'STATIC') {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto mt-12">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-12 text-center">
|
||||||
|
<div className="w-20 h-20 bg-warning-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
|
<svg className="w-10 h-10 text-warning-600" 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>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">Static QR Code</h2>
|
||||||
|
<p className="text-gray-600 mb-8">
|
||||||
|
Static QR codes cannot be edited because their content is embedded directly in the QR code image.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => router.push('/dashboard')}>
|
||||||
|
Back to Dashboard
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Edit QR Code</h1>
|
||||||
|
<p className="text-gray-600 mt-2">Update your dynamic QR code content</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>QR Code Details</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<Input
|
||||||
|
label="Title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Enter QR code title"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
{qrCode.contentType === 'URL' && (
|
||||||
|
<Input
|
||||||
|
label="URL"
|
||||||
|
type="url"
|
||||||
|
value={content.url || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, url: e.target.value })}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{qrCode.contentType === 'PHONE' && (
|
||||||
|
<Input
|
||||||
|
label="Phone Number"
|
||||||
|
type="tel"
|
||||||
|
value={content.phone || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, phone: e.target.value })}
|
||||||
|
placeholder="+1234567890"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{qrCode.contentType === 'EMAIL' && (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
value={content.email || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, email: e.target.value })}
|
||||||
|
placeholder="email@example.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Subject (Optional)"
|
||||||
|
value={content.subject || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, subject: e.target.value })}
|
||||||
|
placeholder="Email subject"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{qrCode.contentType === 'TEXT' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Text Content
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={content.text || ''}
|
||||||
|
onChange={(e) => setContent({ ...content, text: e.target.value })}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
rows={4}
|
||||||
|
placeholder="Enter your text content"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-4 pt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push('/dashboard')}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
loading={saving}
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,44 +3,78 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
import { showToast } from '@/components/ui/Toast';
|
import { showToast } from '@/components/ui/Toast';
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import ChangePasswordModal from '@/components/settings/ChangePasswordModal';
|
||||||
|
|
||||||
|
type TabType = 'profile' | 'subscription';
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { t, setLanguage, language } = useTranslation();
|
const [activeTab, setActiveTab] = useState<TabType>('profile');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||||
|
|
||||||
// Form states
|
// Profile states
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [selectedLanguage, setSelectedLanguage] = useState(language || 'en');
|
|
||||||
|
|
||||||
// Load user data and language from localStorage
|
// Subscription states
|
||||||
|
const [plan, setPlan] = useState('FREE');
|
||||||
|
const [usageStats, setUsageStats] = useState({
|
||||||
|
dynamicUsed: 0,
|
||||||
|
dynamicLimit: 3,
|
||||||
|
staticUsed: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load user data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const fetchUserData = async () => {
|
||||||
|
try {
|
||||||
|
// Load from localStorage
|
||||||
const userStr = localStorage.getItem('user');
|
const userStr = localStorage.getItem('user');
|
||||||
if (userStr) {
|
if (userStr) {
|
||||||
try {
|
|
||||||
const user = JSON.parse(userStr);
|
const user = JSON.parse(userStr);
|
||||||
setName(user.name || '');
|
setName(user.name || '');
|
||||||
setEmail(user.email || '');
|
setEmail(user.email || '');
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to parse user data:', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load saved language preference
|
// Fetch plan from API
|
||||||
const savedLocale = localStorage.getItem('locale');
|
const planResponse = await fetch('/api/user/plan');
|
||||||
if (savedLocale && (savedLocale === 'en' || savedLocale === 'de')) {
|
if (planResponse.ok) {
|
||||||
setSelectedLanguage(savedLocale);
|
const data = await planResponse.json();
|
||||||
|
setPlan(data.plan || 'FREE');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch usage stats from API
|
||||||
|
const statsResponse = await fetch('/api/user/stats');
|
||||||
|
if (statsResponse.ok) {
|
||||||
|
const data = await statsResponse.json();
|
||||||
|
setUsageStats(data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load user data:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchUserData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSaveProfile = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Update language
|
// Save to backend API
|
||||||
setLanguage(selectedLanguage);
|
const response = await fetch('/api/user/profile', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to update profile');
|
||||||
|
}
|
||||||
|
|
||||||
// Update user data in localStorage
|
// Update user data in localStorage
|
||||||
const userStr = localStorage.getItem('user');
|
const userStr = localStorage.getItem('user');
|
||||||
|
|
@ -50,24 +84,133 @@ export default function SettingsPage() {
|
||||||
localStorage.setItem('user', JSON.stringify(user));
|
localStorage.setItem('user', JSON.stringify(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast('Settings saved successfully!', 'success');
|
showToast('Profile updated successfully!', 'success');
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error('Error saving settings:', error);
|
console.error('Error saving profile:', error);
|
||||||
showToast('Failed to save settings', 'error');
|
showToast(error.message || 'Failed to update profile', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleManageSubscription = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/stripe/portal', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to open subscription management');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to Stripe Customer Portal
|
||||||
|
window.location.href = data.url;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error opening portal:', error);
|
||||||
|
showToast(error.message || 'Failed to open subscription management', 'error');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAccount = async () => {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
'Are you sure you want to delete your account? This will permanently delete all your data, including all QR codes and analytics. This action cannot be undone.'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
// Double confirmation for safety
|
||||||
|
const doubleConfirmed = window.confirm(
|
||||||
|
'This is your last warning. Are you absolutely sure you want to permanently delete your account?'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!doubleConfirmed) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user/delete', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to delete account');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear local storage and redirect to login
|
||||||
|
localStorage.clear();
|
||||||
|
showToast('Account deleted successfully', 'success');
|
||||||
|
|
||||||
|
// Redirect to home page after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '/';
|
||||||
|
}, 1500);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error deleting account:', error);
|
||||||
|
showToast(error.message || 'Failed to delete account', 'error');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPlanLimits = () => {
|
||||||
|
switch (plan) {
|
||||||
|
case 'PRO':
|
||||||
|
return { dynamic: 50, price: '€9', period: 'per month' };
|
||||||
|
case 'BUSINESS':
|
||||||
|
return { dynamic: 500, price: '€29', period: 'per month' };
|
||||||
|
default:
|
||||||
|
return { dynamic: 3, price: '€0', period: 'forever' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const planLimits = getPlanLimits();
|
||||||
|
const usagePercentage = (usageStats.dynamicUsed / usageStats.dynamicLimit) * 100;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Settings</h1>
|
<h1 className="text-3xl font-bold text-gray-900">Settings</h1>
|
||||||
<p className="text-gray-600 mt-2">Manage your account settings and preferences</p>
|
<p className="text-gray-600 mt-2">Manage your account settings and preferences</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="border-b border-gray-200 mb-6">
|
||||||
|
<nav className="-mb-px flex space-x-8">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('profile')}
|
||||||
|
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||||
|
activeTab === 'profile'
|
||||||
|
? 'border-primary-500 text-primary-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Profile
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('subscription')}
|
||||||
|
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||||
|
activeTab === 'subscription'
|
||||||
|
? 'border-primary-500 text-primary-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Subscription
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
{activeTab === 'profile' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Profile Settings */}
|
{/* Profile Information */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Profile Information</CardTitle>
|
<CardTitle>Profile Information</CardTitle>
|
||||||
|
|
@ -95,7 +238,6 @@ export default function SettingsPage() {
|
||||||
value={email}
|
value={email}
|
||||||
disabled
|
disabled
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500 cursor-not-allowed"
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500 cursor-not-allowed"
|
||||||
placeholder="your@email.com"
|
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
Email cannot be changed
|
Email cannot be changed
|
||||||
|
|
@ -104,35 +246,57 @@ export default function SettingsPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Language Settings */}
|
{/* Security */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Language Preferences</CardTitle>
|
<CardTitle>Security</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<h3 className="text-sm font-medium text-gray-900">Password</h3>
|
||||||
Display Language
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
</label>
|
Update your password to keep your account secure
|
||||||
<select
|
|
||||||
value={selectedLanguage}
|
|
||||||
onChange={(e) => setSelectedLanguage(e.target.value)}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
>
|
|
||||||
<option value="en">English</option>
|
|
||||||
<option value="de">Deutsch (German)</option>
|
|
||||||
</select>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Choose your preferred language for the interface
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowPasswordModal(true)}
|
||||||
|
>
|
||||||
|
Change Password
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Account Deletion */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-red-600">Delete Account</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-900">Delete your account</h3>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Permanently delete your account and all data. This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="border-red-600 text-red-600 hover:bg-red-50"
|
||||||
|
onClick={handleDeleteAccount}
|
||||||
|
>
|
||||||
|
Delete Account
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Save Button */}
|
{/* Save Button */}
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={handleSaveProfile}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
|
@ -141,6 +305,84 @@ export default function SettingsPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'subscription' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Current Plan */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle>Current Plan</CardTitle>
|
||||||
|
<Badge variant={plan === 'FREE' ? 'default' : plan === 'PRO' ? 'info' : 'warning'}>
|
||||||
|
{plan}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-baseline">
|
||||||
|
<span className="text-4xl font-bold">{planLimits.price}</span>
|
||||||
|
<span className="text-gray-600 ml-2">{planLimits.period}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-600">Dynamic QR Codes</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{usageStats.dynamicUsed} of {usageStats.dynamicLimit} used
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-primary-600 h-2 rounded-full transition-all"
|
||||||
|
style={{ width: `${Math.min(usagePercentage, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-600">Static QR Codes</span>
|
||||||
|
<span className="font-medium">Unlimited ∞</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div className="bg-success-600 h-2 rounded-full" style={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{plan !== 'FREE' && (
|
||||||
|
<div className="pt-4 border-t">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleManageSubscription}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Loading...' : 'Manage Subscription'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{plan === 'FREE' && (
|
||||||
|
<div className="pt-4 border-t">
|
||||||
|
<Button variant="primary" className="w-full" onClick={() => window.location.href = '/pricing'}>
|
||||||
|
Upgrade Plan
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Change Password Modal */}
|
||||||
|
<ChangePasswordModal
|
||||||
|
isOpen={showPasswordModal}
|
||||||
|
onClose={() => setShowPasswordModal(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setShowPasswordModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,13 @@ import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
import { useCsrf } from '@/hooks/useCsrf';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { fetchWithCsrf, loading: csrfLoading } = useCsrf();
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
@ -23,9 +25,8 @@ export default function LoginPage() {
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/auth/simple-login', {
|
const response = await fetchWithCsrf('/api/auth/simple-login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -50,8 +51,8 @@ export default function LoginPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGoogleSignIn = () => {
|
const handleGoogleSignIn = () => {
|
||||||
// Google sign-in disabled for now
|
// Redirect to Google OAuth API route
|
||||||
alert('Google sign-in coming soon!');
|
window.location.href = '/api/auth/google';
|
||||||
};
|
};
|
||||||
|
|
||||||
// Demo login
|
// Demo login
|
||||||
|
|
@ -109,8 +110,8 @@ export default function LoginPage() {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" className="w-full" loading={loading}>
|
<Button type="submit" className="w-full" loading={loading} disabled={csrfLoading || loading}>
|
||||||
Sign In
|
{csrfLoading ? 'Loading...' : 'Sign In'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="relative my-6">
|
<div className="relative my-6">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { signIn } from 'next-auth/react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
|
|
@ -43,19 +42,16 @@ export default function SignupPage() {
|
||||||
body: JSON.stringify({ name, email, password }),
|
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();
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && data.success) {
|
||||||
|
// Store user in localStorage for client-side
|
||||||
|
localStorage.setItem('user', JSON.stringify(data.user));
|
||||||
|
|
||||||
|
// Redirect to dashboard
|
||||||
|
router.push('/dashboard');
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
setError(data.error || 'Failed to create account');
|
setError(data.error || 'Failed to create account');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -66,7 +62,8 @@ export default function SignupPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGoogleSignIn = () => {
|
const handleGoogleSignIn = () => {
|
||||||
signIn('google', { callbackUrl: '/dashboard' });
|
// Redirect to Google OAuth API route
|
||||||
|
window.location.href = '/api/auth/google';
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ const blogPosts: Record<string, BlogPostData> = {
|
||||||
dateModified: '2025-10-16T09:00:00Z',
|
dateModified: '2025-10-16T09:00:00Z',
|
||||||
readTime: '8 Min',
|
readTime: '8 Min',
|
||||||
category: 'Analytics',
|
category: 'Analytics',
|
||||||
image: 'https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=1200&q=80',
|
image: 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=1200&q=80',
|
||||||
imageAlt: 'QR code analytics dashboard showing real-time scan metrics and campaign performance data',
|
imageAlt: 'QR code analytics dashboard showing real-time scan metrics and campaign performance data',
|
||||||
author: 'QR Master Team',
|
author: 'QR Master Team',
|
||||||
authorUrl: 'https://www.qrmaster.com/about',
|
authorUrl: 'https://www.qrmaster.com/about',
|
||||||
|
|
@ -140,6 +140,193 @@ const blogPosts: Record<string, BlogPostData> = {
|
||||||
<p>Whether you're deploying QR codes for event tracking, print marketing, bulk generation, or API-driven automation, scan analytics provides the data foundation for smarter, more effective campaigns. Start leveraging QR analytics today to unlock the full potential of your QR marketing strategy.</p>
|
<p>Whether you're deploying QR codes for event tracking, print marketing, bulk generation, or API-driven automation, scan analytics provides the data foundation for smarter, more effective campaigns. Start leveraging QR analytics today to unlock the full potential of your QR marketing strategy.</p>
|
||||||
</div>`,
|
</div>`,
|
||||||
},
|
},
|
||||||
|
'dynamische-vs-statische-qr-codes': {
|
||||||
|
slug: 'dynamische-vs-statische-qr-codes',
|
||||||
|
title: 'Dynamische vs. Statische QR-Codes: Der ultimative Vergleich',
|
||||||
|
excerpt: 'Entdecken Sie die wichtigsten Unterschiede zwischen dynamischen und statischen QR-Codes und wählen Sie die richtige Option für Ihre Kampagne.',
|
||||||
|
date: 'October 15, 2025',
|
||||||
|
datePublished: '2025-10-15T09:00:00Z',
|
||||||
|
dateModified: '2025-10-15T09:00:00Z',
|
||||||
|
readTime: '6 Min',
|
||||||
|
category: 'Grundlagen',
|
||||||
|
image: 'https://images.unsplash.com/photo-1603791440384-56cd371ee9a7?w=1200&q=80',
|
||||||
|
imageAlt: 'Vergleich zwischen dynamischen und statischen QR-Codes mit Diagrammen',
|
||||||
|
author: 'QR Master Team',
|
||||||
|
authorUrl: 'https://www.qrmaster.com/about',
|
||||||
|
content: `<div class="blog-content">
|
||||||
|
<h2>Was sind statische QR-Codes?</h2>
|
||||||
|
<p>Statische QR-Codes enthalten fest eingebettete Informationen, die nach der Erstellung nicht mehr geändert werden können. Der QR-Code speichert die Daten direkt – zum Beispiel eine URL, einen Text oder Kontaktdaten. Sobald der Code gedruckt ist, bleibt sein Inhalt permanent.</p>
|
||||||
|
|
||||||
|
<h3>Vorteile statischer QR-Codes</h3>
|
||||||
|
<p><strong>Für immer gültig:</strong> Statische QR-Codes funktionieren unabhängig von Servern oder Abonnements. Sie sind ideal für Anwendungen, bei denen Sie garantiert langfristige Verfügbarkeit benötigen.</p>
|
||||||
|
<p><strong>Keine laufenden Kosten:</strong> Da keine Server-Infrastruktur erforderlich ist, fallen keine monatlichen Gebühren an.</p>
|
||||||
|
<p><strong>Schneller Scan:</strong> Direkter Zugriff auf Inhalte ohne Umleitung über Server.</p>
|
||||||
|
|
||||||
|
<h3>Nachteile statischer QR-Codes</h3>
|
||||||
|
<p><strong>Nicht editierbar:</strong> Nach dem Druck können Sie die verlinkten Inhalte nicht mehr ändern.</p>
|
||||||
|
<p><strong>Keine Analytics:</strong> Sie können nicht nachverfolgen, wie oft der Code gescannt wurde.</p>
|
||||||
|
<p><strong>Größer bei langen URLs:</strong> Je mehr Daten eingebettet sind, desto komplexer und größer wird der QR-Code.</p>
|
||||||
|
|
||||||
|
<h2>Was sind dynamische QR-Codes?</h2>
|
||||||
|
<p>Dynamische QR-Codes enthalten eine kurze Weiterleitungs-URL, die auf einen Server verweist. Der Server speichert die eigentliche Ziel-URL. Das bedeutet, dass Sie die Ziel-URL jederzeit ändern können, ohne den gedruckten QR-Code neu erstellen zu müssen.</p>
|
||||||
|
|
||||||
|
<h3>Vorteile dynamischer QR-Codes</h3>
|
||||||
|
<p><strong>Editierbar:</strong> Ändern Sie die Ziel-URL jederzeit, auch nach dem Druck.</p>
|
||||||
|
<p><strong>Detaillierte Analytics:</strong> Verfolgen Sie Scans nach Standort, Gerät, Zeit und mehr.</p>
|
||||||
|
<p><strong>Retargeting-fähig:</strong> Nutzen Sie Scan-Daten für personalisierte Marketing-Kampagnen.</p>
|
||||||
|
<p><strong>Kompakter Code:</strong> Da nur eine kurze Umleitungs-URL eingebettet ist, bleibt der QR-Code kleiner und einfacher zu scannen.</p>
|
||||||
|
|
||||||
|
<h3>Nachteile dynamischer QR-Codes</h3>
|
||||||
|
<p><strong>Erfordert Abo:</strong> Dynamische QR-Codes benötigen eine aktive Server-Infrastruktur, was meist mit monatlichen Kosten verbunden ist.</p>
|
||||||
|
<p><strong>Abhängig vom Server:</strong> Wenn der Server ausfällt oder das Abo abläuft, funktioniert der QR-Code nicht mehr.</p>
|
||||||
|
|
||||||
|
<h2>Vergleichstabelle: Statisch vs. Dynamisch</h2>
|
||||||
|
<div class="overflow-x-auto my-8">
|
||||||
|
<table class="min-w-full border-collapse border border-gray-300">
|
||||||
|
<thead class="bg-gray-100">
|
||||||
|
<tr>
|
||||||
|
<th class="border border-gray-300 px-6 py-3 text-left font-semibold text-gray-900">Feature</th>
|
||||||
|
<th class="border border-gray-300 px-6 py-3 text-left font-semibold text-gray-900">Statisch</th>
|
||||||
|
<th class="border border-gray-300 px-6 py-3 text-left font-semibold text-gray-900">Dynamisch</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="bg-white">
|
||||||
|
<td class="border border-gray-300 px-6 py-4 font-medium text-gray-900">Editierbar</td>
|
||||||
|
<td class="border border-gray-300 px-6 py-4 text-gray-700">Nein</td>
|
||||||
|
<td class="border border-gray-300 px-6 py-4 text-gray-700">Ja</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="bg-gray-50">
|
||||||
|
<td class="border border-gray-300 px-6 py-4 font-medium text-gray-900">Analytics</td>
|
||||||
|
<td class="border border-gray-300 px-6 py-4 text-gray-700">Nein</td>
|
||||||
|
<td class="border border-gray-300 px-6 py-4 text-gray-700">Ja</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="bg-white">
|
||||||
|
<td class="border border-gray-300 px-6 py-4 font-medium text-gray-900">Kosten</td>
|
||||||
|
<td class="border border-gray-300 px-6 py-4 text-gray-700">Kostenlos</td>
|
||||||
|
<td class="border border-gray-300 px-6 py-4 text-gray-700">Abo erforderlich</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="bg-gray-50">
|
||||||
|
<td class="border border-gray-300 px-6 py-4 font-medium text-gray-900">Größe</td>
|
||||||
|
<td class="border border-gray-300 px-6 py-4 text-gray-700">Größer bei langen URLs</td>
|
||||||
|
<td class="border border-gray-300 px-6 py-4 text-gray-700">Immer kompakt</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="bg-white">
|
||||||
|
<td class="border border-gray-300 px-6 py-4 font-medium text-gray-900">Gültigkeitsdauer</td>
|
||||||
|
<td class="border border-gray-300 px-6 py-4 text-gray-700">Für immer</td>
|
||||||
|
<td class="border border-gray-300 px-6 py-4 text-gray-700">Abhängig vom Abo</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Wann sollten Sie welchen QR-Code-Typ verwenden?</h2>
|
||||||
|
<h3>Verwenden Sie statische QR-Codes für:</h3>
|
||||||
|
<p>• Visitenkarten mit festen Kontaktdaten</p>
|
||||||
|
<p>• WLAN-Passwörter</p>
|
||||||
|
<p>• Produktverpackungen mit permanenten URLs</p>
|
||||||
|
<p>• Anwendungen, bei denen Sie keine Analytics benötigen</p>
|
||||||
|
|
||||||
|
<h3>Verwenden Sie dynamische QR-Codes für:</h3>
|
||||||
|
<p>• Marketing-Kampagnen mit wechselnden Angeboten</p>
|
||||||
|
<p>• Event-Tickets mit aktualisierbaren Informationen</p>
|
||||||
|
<p>• Print-Anzeigen, bei denen Sie die Landingpage optimieren möchten</p>
|
||||||
|
<p>• Jede Anwendung, bei der Sie Scan-Statistiken tracken möchten</p>
|
||||||
|
|
||||||
|
<h2>Fazit</h2>
|
||||||
|
<p>Statische QR-Codes sind ideal für permanente, unveränderliche Inhalte ohne Tracking-Bedarf. Dynamische QR-Codes bieten Flexibilität, Analytics und Marketing-Power – perfekt für professionelle Kampagnen. Wählen Sie basierend auf Ihren Anforderungen: Langfristige Stabilität oder Marketing-Flexibilität?</p>
|
||||||
|
</div>`,
|
||||||
|
},
|
||||||
|
'qr-codes-im-restaurant': {
|
||||||
|
slug: 'qr-codes-im-restaurant',
|
||||||
|
title: 'QR-Codes im Restaurant: Digitale Speisekarten & kontaktloses Bestellen',
|
||||||
|
excerpt: 'Erfahren Sie, wie Restaurants QR-Codes für digitale Menüs, kontaktlose Bestellungen und verbessertes Gästeerlebnis einsetzen.',
|
||||||
|
date: 'October 14, 2025',
|
||||||
|
datePublished: '2025-10-14T09:00:00Z',
|
||||||
|
dateModified: '2025-10-14T09:00:00Z',
|
||||||
|
readTime: '7 Min',
|
||||||
|
category: 'Anwendungsfälle',
|
||||||
|
image: 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=1200&q=80',
|
||||||
|
imageAlt: 'Restaurant-Tisch mit QR-Code für digitale Speisekarte',
|
||||||
|
author: 'QR Master Team',
|
||||||
|
authorUrl: 'https://www.qrmaster.com/about',
|
||||||
|
content: `<div class="blog-content">
|
||||||
|
<h2>Warum QR-Codes für Restaurants?</h2>
|
||||||
|
<p>Seit der Pandemie haben QR-Codes die Gastronomie revolutioniert. Sie ermöglichen kontaktloses Bestellen, reduzieren Druckkosten für Speisekarten und verbessern das Gästeerlebnis durch interaktive Funktionen. Moderne Gäste erwarten digitale Lösungen – QR-Codes liefern genau das.</p>
|
||||||
|
|
||||||
|
<h2>Digitale Speisekarten mit QR-Codes</h2>
|
||||||
|
<h3>Vorteile für Restaurants</h3>
|
||||||
|
<p><strong>Kosten sparen:</strong> Keine teuren Speisekarten-Neudrucke bei Preisänderungen oder neuen Gerichten.</p>
|
||||||
|
<p><strong>Aktualisierungen in Echtzeit:</strong> Passen Sie Ihr Menü sofort an – täglich wechselnde Angebote, Verfügbarkeiten oder Allergiehinweise.</p>
|
||||||
|
<p><strong>Mehrsprachigkeit:</strong> Bieten Sie Ihre Speisekarte automatisch in mehreren Sprachen an.</p>
|
||||||
|
<p><strong>Hygiene:</strong> Gäste scannen mit ihrem eigenen Smartphone – keine gemeinsam genutzten Speisekarten mehr.</p>
|
||||||
|
|
||||||
|
<h3>Vorteile für Gäste</h3>
|
||||||
|
<p>• Sofortiger Zugriff auf die Speisekarte ohne Warten</p>
|
||||||
|
<p>• Hochauflösende Bilder der Gerichte</p>
|
||||||
|
<p>• Detaillierte Nährwertangaben und Allergiehinweise</p>
|
||||||
|
<p>• Personalisierte Empfehlungen</p>
|
||||||
|
|
||||||
|
<h2>Kontaktloses Bestellen via QR-Code</h2>
|
||||||
|
<p>QR-Codes ermöglichen es Gästen, direkt vom Tisch aus zu bestellen – ohne auf Kellner zu warten. Das System ist einfach: Gast scannt Code → wählt Gerichte aus → bestätigt Bestellung → zahlt digital.</p>
|
||||||
|
|
||||||
|
<h3>Vorteile des kontaktlosen Bestellens</h3>
|
||||||
|
<p><strong>Schnellerer Service:</strong> Bestellungen gehen direkt in die Küche, ohne Zwischenschritte.</p>
|
||||||
|
<p><strong>Weniger Fehler:</strong> Gäste geben ihre Bestellung selbst ein – keine Missverständnisse mehr.</p>
|
||||||
|
<p><strong>Upselling-Möglichkeiten:</strong> Zeigen Sie automatisch passende Empfehlungen oder Zusatzprodukte an.</p>
|
||||||
|
<p><strong>Höhere Tischrotation:</strong> Weniger Wartezeiten bedeuten mehr bediente Gäste pro Abend.</p>
|
||||||
|
|
||||||
|
<h2>QR-Code-Platzierung im Restaurant</h2>
|
||||||
|
<h3>Auf dem Tisch</h3>
|
||||||
|
<p>Der klassische Ansatz: QR-Codes auf Tischaufstellern, Tischsets oder direkt auf dem Tisch. Ideal für Restaurants mit festem Sitzplatz.</p>
|
||||||
|
|
||||||
|
<h3>An der Wand oder am Eingang</h3>
|
||||||
|
<p>Für Schnellrestaurants oder Cafés: Gäste scannen beim Betreten und bestellen an der Theke oder am Tisch.</p>
|
||||||
|
|
||||||
|
<h3>Auf Rechnungen und Flyern</h3>
|
||||||
|
<p>Nutzen Sie QR-Codes auf Rechnungen für Online-Bewertungen, Treueprogramme oder Social-Media-Verlinkungen.</p>
|
||||||
|
|
||||||
|
<h2>Best Practices für Restaurant-QR-Codes</h2>
|
||||||
|
<h3>1. Design: Markenkonform und ansprechend</h3>
|
||||||
|
<p>Gestalten Sie QR-Codes in Ihren Markenfarben mit Ihrem Logo. Ein professionelles Design erhöht die Scan-Rate.</p>
|
||||||
|
|
||||||
|
<h3>2. Klare Anweisungen</h3>
|
||||||
|
<p>Fügen Sie einen Call-to-Action hinzu: "Scannen für Speisekarte" oder "Hier bestellen". Nicht jeder Gast ist mit QR-Codes vertraut.</p>
|
||||||
|
|
||||||
|
<h3>3. Mobile Optimierung</h3>
|
||||||
|
<p>Ihre digitale Speisekarte MUSS mobilfreundlich sein. Große Schriftarten, einfache Navigation, schnelle Ladezeiten.</p>
|
||||||
|
|
||||||
|
<h3>4. Testen Sie regelmäßig</h3>
|
||||||
|
<p>Prüfen Sie wöchentlich, ob alle QR-Codes funktionieren und zur richtigen Seite führen.</p>
|
||||||
|
|
||||||
|
<h2>Analytics: Messen Sie den Erfolg</h2>
|
||||||
|
<p>Mit dynamischen QR-Codes erhalten Sie wertvolle Insights:</p>
|
||||||
|
<p>• Wie viele Gäste scannen den QR-Code?</p>
|
||||||
|
<p>• Welche Gerichte werden am häufigsten angesehen?</p>
|
||||||
|
<p>• Zu welchen Uhrzeiten ist die Nachfrage am höchsten?</p>
|
||||||
|
<p>• Welche Tische haben die höchste Scan-Rate?</p>
|
||||||
|
|
||||||
|
<h2>Praxisbeispiel: Pizza-Restaurant "Bella Italia"</h2>
|
||||||
|
<p>Das fiktive Restaurant "Bella Italia" führte QR-Code-Bestellungen ein und erzielte innerhalb von 3 Monaten:</p>
|
||||||
|
<p>• 40% schnellere Bestellabwicklung</p>
|
||||||
|
<p>• 25% höherer Umsatz durch Upselling-Vorschläge</p>
|
||||||
|
<p>• 90% der Gäste bevorzugen QR-Bestellungen gegenüber klassischer Bedienung</p>
|
||||||
|
<p>• 50% Kosteneinsparung bei Speisekarten-Druck</p>
|
||||||
|
|
||||||
|
<h2>Häufige Fragen (FAQ)</h2>
|
||||||
|
<h3>Was, wenn Gäste kein Smartphone haben?</h3>
|
||||||
|
<p>Halten Sie einige gedruckte Speisekarten als Backup bereit – besonders für ältere Gäste.</p>
|
||||||
|
|
||||||
|
<h3>Wie sicher ist das Bezahlen via QR-Code?</h3>
|
||||||
|
<p>Nutzen Sie etablierte Payment-Anbieter wie Stripe oder PayPal, die höchste Sicherheitsstandards erfüllen.</p>
|
||||||
|
|
||||||
|
<h3>Brauche ich technisches Know-how?</h3>
|
||||||
|
<p>Nein! Plattformen wie QR Master ermöglichen die Erstellung und Verwaltung von Restaurant-QR-Codes ohne Programmierkenntnisse.</p>
|
||||||
|
|
||||||
|
<h2>Fazit</h2>
|
||||||
|
<p>QR-Codes sind die Zukunft der Gastronomie. Sie verbessern das Gästeerlebnis, sparen Kosten und steigern den Umsatz. Egal ob kleines Café oder gehobenes Restaurant – QR-Codes lohnen sich für jeden Betrieb. Starten Sie noch heute mit Ihrer digitalen Transformation!</p>
|
||||||
|
</div>`,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function truncateAtWord(text: string, maxLength: number): string {
|
function truncateAtWord(text: string, maxLength: number): string {
|
||||||
|
|
@ -210,7 +397,7 @@ export default function BlogPostPage({ params }: { params: { slug: string } }) {
|
||||||
{ name: post.title, url: `/blog/${post.slug}` },
|
{ name: post.title, url: `/blog/${post.slug}` },
|
||||||
];
|
];
|
||||||
|
|
||||||
const schemas = [
|
const schemas: any[] = [
|
||||||
blogPostingSchema({
|
blogPostingSchema({
|
||||||
title: post.title,
|
title: post.title,
|
||||||
description: post.excerpt,
|
description: post.excerpt,
|
||||||
|
|
|
||||||
|
|
@ -52,25 +52,25 @@ const blogPosts = [
|
||||||
date: 'October 16, 2025',
|
date: 'October 16, 2025',
|
||||||
readTime: '8 Min',
|
readTime: '8 Min',
|
||||||
category: 'Analytics',
|
category: 'Analytics',
|
||||||
image: 'https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=800&q=80',
|
image: 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=800&q=80',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: 'qr-codes-im-restaurant',
|
slug: 'qr-codes-im-restaurant',
|
||||||
title: 'QR Codes in Restaurants: The Digital Menu Revolution',
|
title: 'QR Codes in Restaurants: The Digital Menu Revolution',
|
||||||
excerpt: 'Discover how QR codes are revolutionizing the restaurant industry and what benefits they offer for restaurants and guests.',
|
excerpt: 'Discover how QR codes are revolutionizing the restaurant industry and what benefits they offer for restaurants and guests.',
|
||||||
date: 'January 15, 2024',
|
date: 'January 15, 2025',
|
||||||
readTime: '5 Min',
|
readTime: '5 Min',
|
||||||
category: 'Restaurant',
|
category: 'Restaurant',
|
||||||
image: 'https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=800&q=80',
|
image: 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=800&q=80',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: 'dynamische-vs-statische-qr-codes',
|
slug: 'dynamische-vs-statische-qr-codes',
|
||||||
title: 'Dynamic vs Static QR Codes: What\'s the Difference?',
|
title: 'Dynamic vs Static QR Codes: What\'s the Difference?',
|
||||||
excerpt: 'A comprehensive comparison between dynamic and static QR codes and when you should use each type.',
|
excerpt: 'A comprehensive comparison between dynamic and static QR codes and when you should use each type.',
|
||||||
date: 'January 10, 2024',
|
date: 'January 10, 2025',
|
||||||
readTime: '3 Min',
|
readTime: '3 Min',
|
||||||
category: 'Basics',
|
category: 'Basics',
|
||||||
image: 'https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=800&q=80',
|
image: 'https://images.unsplash.com/photo-1603791440384-56cd371ee9a7?w=800&q=80',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ const faqs = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'How do I brand my QR codes?',
|
question: 'How do I brand my QR codes?',
|
||||||
answer: 'QR Master offers full customization options including custom colors, logo embedding, rounded corners, and pattern styles. Branded QR codes maintain scannability while matching your brand identity. Upload your logo, choose your color palette, and preview designs before downloading.',
|
answer: 'QR Master offers customization options including custom colors, corner styles, and pattern designs. Branded QR codes maintain scannability while matching your brand identity. Choose your color palette and preview designs before downloading.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Is scan analytics GDPR compliant?',
|
question: 'Is scan analytics GDPR compliant?',
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import React, { useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import CookieBanner from '@/components/CookieBanner';
|
||||||
import en from '@/i18n/en.json';
|
import en from '@/i18n/en.json';
|
||||||
|
|
||||||
export default function MarketingLayout({
|
export default function MarketingLayout({
|
||||||
|
|
@ -28,7 +29,7 @@ export default function MarketingLayout({
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-white">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="sticky top-0 z-50 bg-white border-b border-gray-200">
|
<header className="sticky top-0 z-50 bg-white border-b border-gray-200">
|
||||||
<nav className="container mx-auto px-4 py-4">
|
<nav className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<Link href="/" className="flex items-center space-x-2">
|
<Link href="/" className="flex items-center space-x-2">
|
||||||
|
|
@ -106,13 +107,13 @@ export default function MarketingLayout({
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="bg-gray-900 text-white py-12 mt-20">
|
<footer className="bg-gray-900 text-white py-12 mt-20">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||||
<div className="grid md:grid-cols-4 gap-8">
|
<div className="grid md:grid-cols-4 gap-8">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center space-x-2 mb-4">
|
<Link href="/" className="flex items-center space-x-2 mb-4 hover:opacity-80 transition-opacity">
|
||||||
<img src="/logo.svg" alt="QR Master" className="w-8 h-8 brightness-0 invert" />
|
<img src="/favicon.svg" alt="QR Master" className="w-10 h-10" />
|
||||||
<span className="text-xl font-bold">QR Master</span>
|
<span className="text-xl font-bold">QR Master</span>
|
||||||
</div>
|
</Link>
|
||||||
<p className="text-gray-400">
|
<p className="text-gray-400">
|
||||||
Create custom QR codes in seconds with advanced tracking and analytics.
|
Create custom QR codes in seconds with advanced tracking and analytics.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -129,31 +130,32 @@ export default function MarketingLayout({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold mb-4">Company</h3>
|
<h3 className="font-semibold mb-4">Resources</h3>
|
||||||
<ul className="space-y-2 text-gray-400">
|
<ul className="space-y-2 text-gray-400">
|
||||||
<li><a href="#" className="hover:text-white">About</a></li>
|
<li><Link href="/pricing" className="hover:text-white">Full Pricing</Link></li>
|
||||||
<li><a href="#" className="hover:text-white">Careers</a></li>
|
<li><Link href="/faq" className="hover:text-white">All Questions</Link></li>
|
||||||
<li><a href="#" className="hover:text-white">Contact</a></li>
|
<li><Link href="/blog" className="hover:text-white">Blog</Link></li>
|
||||||
<li><a href="#" className="hover:text-white">Partners</a></li>
|
<li><Link href="/signup" className="hover:text-white">Get Started</Link></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold mb-4">Legal</h3>
|
<h3 className="font-semibold mb-4">Legal</h3>
|
||||||
<ul className="space-y-2 text-gray-400">
|
<ul className="space-y-2 text-gray-400">
|
||||||
<li><a href="#" className="hover:text-white">Privacy Policy</a></li>
|
<li><Link href="/privacy" className="hover:text-white">Privacy Policy</Link></li>
|
||||||
<li><a href="#" className="hover:text-white">Terms of Service</a></li>
|
<li><Link href="/terms" className="hover:text-white">Terms of Service</Link></li>
|
||||||
<li><a href="#" className="hover:text-white">Cookie Policy</a></li>
|
|
||||||
<li><a href="#" className="hover:text-white">GDPR</a></li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-gray-800 mt-8 pt-8 text-center text-gray-400">
|
<div className="border-t border-gray-800 mt-8 pt-8 text-center text-gray-400">
|
||||||
<p>© 2024 QR Master. All rights reserved.</p>
|
<p>© 2025 QR Master. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
{/* Cookie Banner */}
|
||||||
|
<CookieBanner />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,267 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import type { Metadata } from 'next';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import SeoJsonLd from '@/components/SeoJsonLd';
|
|
||||||
import { productSchema } from '@/lib/schema';
|
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Badge } from '@/components/ui/Badge';
|
|
||||||
|
|
||||||
function truncateAtWord(text: string, maxLength: number): string {
|
|
||||||
if (text.length <= maxLength) return text;
|
|
||||||
const truncated = text.slice(0, maxLength);
|
|
||||||
const lastSpace = truncated.lastIndexOf(' ');
|
|
||||||
return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
|
||||||
const title = truncateAtWord('QR Master Pricing & Bulk Plans', 60);
|
|
||||||
const description = truncateAtWord(
|
|
||||||
'Choose flexible plans for bulk QR generation, analytics & branding.',
|
|
||||||
160
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
alternates: {
|
|
||||||
canonical: 'https://www.qrmaster.com/pricing',
|
|
||||||
languages: {
|
|
||||||
'x-default': 'https://www.qrmaster.com/pricing',
|
|
||||||
en: 'https://www.qrmaster.com/pricing',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
openGraph: {
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
url: 'https://www.qrmaster.com/pricing',
|
|
||||||
type: 'website',
|
|
||||||
},
|
|
||||||
twitter: {
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PricingPage() {
|
|
||||||
const productData = {
|
|
||||||
name: 'QR Master Plans',
|
|
||||||
description: 'Flexible pricing for dynamic QR codes, bulk generation, branded designs, and analytics.',
|
|
||||||
offers: [
|
|
||||||
{
|
|
||||||
name: 'Free Plan',
|
|
||||||
price: '0',
|
|
||||||
priceCurrency: 'USD',
|
|
||||||
availability: 'https://schema.org/InStock',
|
|
||||||
url: 'https://www.qrmaster.com/pricing',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Pro Plan',
|
|
||||||
price: '29',
|
|
||||||
priceCurrency: 'USD',
|
|
||||||
availability: 'https://schema.org/InStock',
|
|
||||||
url: 'https://www.qrmaster.com/pricing',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Business Plan',
|
|
||||||
price: '99',
|
|
||||||
priceCurrency: 'USD',
|
|
||||||
availability: 'https://schema.org/InStock',
|
|
||||||
url: 'https://www.qrmaster.com/pricing',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const plans = [
|
|
||||||
{
|
|
||||||
name: 'Free',
|
|
||||||
price: '$0',
|
|
||||||
interval: '/mo',
|
|
||||||
description: 'Perfect for personal projects',
|
|
||||||
features: [
|
|
||||||
'10 Dynamic QR Codes',
|
|
||||||
'Basic Scan Analytics',
|
|
||||||
'Standard QR Design Templates',
|
|
||||||
'Community Support',
|
|
||||||
],
|
|
||||||
cta: 'Get Started',
|
|
||||||
ctaLink: '/signup',
|
|
||||||
popular: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Pro',
|
|
||||||
price: '$29',
|
|
||||||
interval: '/mo',
|
|
||||||
description: 'For professionals and small teams',
|
|
||||||
features: [
|
|
||||||
'Unlimited Dynamic QR Codes',
|
|
||||||
'Advanced Scan Analytics',
|
|
||||||
'Branded QR Codes with Custom Colors & Logo',
|
|
||||||
'Bulk QR Generation (up to 1,000)',
|
|
||||||
'UTM Campaign Tracking',
|
|
||||||
'Download as SVG/PNG/PDF',
|
|
||||||
'Priority Support',
|
|
||||||
],
|
|
||||||
cta: 'Start Pro Trial',
|
|
||||||
ctaLink: '/signup?plan=pro',
|
|
||||||
popular: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Business',
|
|
||||||
price: '$99',
|
|
||||||
interval: '/mo',
|
|
||||||
description: 'For agencies and enterprises',
|
|
||||||
features: [
|
|
||||||
'Everything in Pro',
|
|
||||||
'Unlimited Bulk QR Generation',
|
|
||||||
'API Access for Automation',
|
|
||||||
'White-Label Options',
|
|
||||||
'Advanced Security Features',
|
|
||||||
'Custom Integrations',
|
|
||||||
'Dedicated Account Manager',
|
|
||||||
'SLA Guarantee',
|
|
||||||
],
|
|
||||||
cta: 'Contact Sales',
|
|
||||||
ctaLink: '/signup?plan=business',
|
|
||||||
popular: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<SeoJsonLd data={productSchema(productData)} />
|
|
||||||
<div className="py-20 bg-gradient-to-b from-gray-50 to-white">
|
|
||||||
<div className="container mx-auto px-4">
|
|
||||||
<div className="text-center mb-16">
|
|
||||||
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-6">
|
|
||||||
Pricing Plans
|
|
||||||
</h1>
|
|
||||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
|
||||||
Choose flexible plans for bulk QR generation, analytics, and branded QR codes.
|
|
||||||
Start free, upgrade anytime.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid md:grid-cols-3 gap-8 max-w-6xl mx-auto mb-20">
|
|
||||||
{plans.map((plan, index) => (
|
|
||||||
<Card
|
|
||||||
key={index}
|
|
||||||
className={`relative ${
|
|
||||||
plan.popular
|
|
||||||
? 'border-2 border-blue-600 shadow-xl transform scale-105'
|
|
||||||
: 'border-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{plan.popular && (
|
|
||||||
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
|
|
||||||
<Badge variant="info" className="px-4 py-1 text-sm font-semibold">
|
|
||||||
Most Popular
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CardHeader className="text-center pb-6">
|
|
||||||
<CardTitle className="text-2xl mb-2">{plan.name}</CardTitle>
|
|
||||||
<p className="text-sm text-gray-600 mb-6">{plan.description}</p>
|
|
||||||
|
|
||||||
<div className="flex items-baseline justify-center">
|
|
||||||
<span className="text-5xl font-bold text-gray-900">{plan.price}</span>
|
|
||||||
<span className="text-gray-600 ml-2 text-lg">{plan.interval}</span>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
<ul className="space-y-3 mb-8">
|
|
||||||
{plan.features.map((feature, fIndex) => (
|
|
||||||
<li key={fIndex} className="flex items-start space-x-3">
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5 text-green-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>
|
|
||||||
|
|
||||||
<Link href={plan.ctaLink}>
|
|
||||||
<Button
|
|
||||||
variant={plan.popular ? 'primary' : 'outline'}
|
|
||||||
className="w-full"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
{plan.cta}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* FAQ Section */}
|
|
||||||
<div className="mt-20 max-w-3xl mx-auto">
|
|
||||||
<h2 className="text-3xl font-bold text-center mb-12 text-gray-900">
|
|
||||||
Pricing FAQs
|
|
||||||
</h2>
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<h3 className="font-semibold text-lg mb-3 text-gray-900">
|
|
||||||
Can I cancel my subscription anytime?
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-700 leading-relaxed">
|
|
||||||
Yes, you can cancel your subscription at any time. Your account will remain active
|
|
||||||
until the end of your current billing period. No refunds for partial months.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<h3 className="font-semibold text-lg mb-3 text-gray-900">
|
|
||||||
What happens to my QR codes if I downgrade?
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-700 leading-relaxed">
|
|
||||||
All your existing QR codes remain active and functional. You won't be able to create
|
|
||||||
new codes if you exceed your new plan's limit, but existing codes continue working.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<h3 className="font-semibold text-lg mb-3 text-gray-900">
|
|
||||||
Do you offer annual billing?
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-700 leading-relaxed">
|
|
||||||
Yes! Annual billing saves you 17% compared to monthly billing. Contact sales for
|
|
||||||
custom annual contracts with additional discounts for enterprise customers.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<h3 className="font-semibold text-lg mb-3 text-gray-900">
|
|
||||||
Is there a free trial for paid plans?
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-700 leading-relaxed">
|
|
||||||
Yes, all paid plans include a 14-day free trial. No credit card required to start.
|
|
||||||
Test all premium features before committing to a paid plan.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,228 @@
|
||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: 'Privacy Policy | QR Master',
|
||||||
|
description: 'Privacy Policy and data protection information for QR Master',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PrivacyPage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white py-12">
|
||||||
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-4xl">
|
||||||
|
<div className="mb-8">
|
||||||
|
<Link href="/" className="text-primary-600 hover:text-primary-700 font-medium">
|
||||||
|
← Back to Home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl font-bold text-gray-900 mb-4">Privacy Policy</h1>
|
||||||
|
<p className="text-gray-600 mb-8">Last updated: January 2025</p>
|
||||||
|
|
||||||
|
<div className="prose prose-lg max-w-none">
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">1. Introduction</h2>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
Welcome to QR Master ("we," "our," or "us"). We respect your privacy and are committed to protecting your personal data.
|
||||||
|
This privacy policy will inform you about how we look after your personal data when you visit our website and use our services,
|
||||||
|
and tell you about your privacy rights and how the law protects you.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">2. Information We Collect</h2>
|
||||||
|
<p className="text-gray-700 mb-4">We collect and process the following data about you:</p>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-3">2.1 Information You Provide</h3>
|
||||||
|
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
||||||
|
<li><strong>Account Information:</strong> Name, email address, and password when you create an account</li>
|
||||||
|
<li><strong>Payment Information:</strong> Payment details processed securely through Stripe (we do not store credit card information)</li>
|
||||||
|
<li><strong>QR Code Data:</strong> Content, URLs, and customization settings for QR codes you create</li>
|
||||||
|
<li><strong>Profile Information:</strong> Any additional information you choose to provide</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-3">2.2 Information We Collect Automatically</h3>
|
||||||
|
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
||||||
|
<li><strong>Usage Data:</strong> QR code scans, analytics data, and interaction with our services</li>
|
||||||
|
<li><strong>Technical Data:</strong> IP address, browser type, device information, and operating system</li>
|
||||||
|
<li><strong>Analytics Data:</strong> Website usage analytics collected via PostHog (only with your consent)</li>
|
||||||
|
<li><strong>Cookies:</strong> We use cookies to improve your experience (see our Cookie Policy below)</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">3. How We Use Your Information</h2>
|
||||||
|
<p className="text-gray-700 mb-4">We use your personal data for the following purposes:</p>
|
||||||
|
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
||||||
|
<li>To provide and maintain our QR code generation and analytics services</li>
|
||||||
|
<li>To process your payments and manage your subscription</li>
|
||||||
|
<li>To provide customer support and respond to your inquiries</li>
|
||||||
|
<li>To improve our services and develop new features</li>
|
||||||
|
<li>To detect and prevent fraud and abuse</li>
|
||||||
|
<li>To comply with legal obligations</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">4. Legal Basis for Processing (GDPR)</h2>
|
||||||
|
<p className="text-gray-700 mb-4">We process your personal data under the following legal bases:</p>
|
||||||
|
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
||||||
|
<li><strong>Contract Performance:</strong> Processing necessary to provide our services to you</li>
|
||||||
|
<li><strong>Consent:</strong> Where you have given clear consent for specific purposes</li>
|
||||||
|
<li><strong>Legitimate Interests:</strong> For improving our services, security, and fraud prevention</li>
|
||||||
|
<li><strong>Legal Obligation:</strong> To comply with applicable laws and regulations</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">5. Data Sharing and Third Parties</h2>
|
||||||
|
<p className="text-gray-700 mb-4">We may share your data with the following third parties:</p>
|
||||||
|
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
||||||
|
<li><strong>Stripe:</strong> Payment processing (subject to Stripe's privacy policy)</li>
|
||||||
|
<li><strong>PostHog:</strong> Website analytics platform for tracking user behavior and improving our services (only with your consent, subject to PostHog's privacy policy)</li>
|
||||||
|
<li><strong>Cloud Hosting:</strong> Vercel and database providers for hosting our services</li>
|
||||||
|
<li><strong>Service Providers:</strong> Companies that help us provide our services (under strict confidentiality agreements)</li>
|
||||||
|
<li><strong>Legal Requirements:</strong> When required by law or to protect our rights</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
We do not sell your personal data to third parties. PostHog analytics are only activated if you accept analytics cookies,
|
||||||
|
and we use privacy-friendly settings including respecting Do Not Track (DNT) headers.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">6. Data Security</h2>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
We implement appropriate technical and organizational measures to protect your personal data, including:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
||||||
|
<li>Secure HTTPS transmission for data in transit</li>
|
||||||
|
<li>Secure password hashing using bcrypt</li>
|
||||||
|
<li>Database security and access controls</li>
|
||||||
|
<li>Cookie-based authentication with HttpOnly flags</li>
|
||||||
|
<li>CSRF protection for sensitive operations</li>
|
||||||
|
<li>Rate limiting to prevent abuse</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">7. Data Retention</h2>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
We retain your personal data only for as long as necessary to fulfill the purposes outlined in this policy:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
||||||
|
<li><strong>Active Accounts:</strong> Data retained while your account is active</li>
|
||||||
|
<li><strong>Deleted Accounts:</strong> Most data deleted immediately upon account deletion</li>
|
||||||
|
<li><strong>Legal Requirements:</strong> Some data may be retained to comply with legal obligations</li>
|
||||||
|
<li><strong>Analytics Data:</strong> Aggregated, anonymized data may be retained indefinitely</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">8. Your Rights (GDPR)</h2>
|
||||||
|
<p className="text-gray-700 mb-4">Under GDPR, you have the following rights:</p>
|
||||||
|
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
||||||
|
<li><strong>Right to Access:</strong> Request a copy of your personal data</li>
|
||||||
|
<li><strong>Right to Rectification:</strong> Correct inaccurate or incomplete data (edit name in settings)</li>
|
||||||
|
<li><strong>Right to Erasure:</strong> Request deletion of your data (account deletion available in settings)</li>
|
||||||
|
<li><strong>Right to Restriction:</strong> Request limitation of processing</li>
|
||||||
|
<li><strong>Right to Data Portability:</strong> Receive your data in a portable format (available upon request)</li>
|
||||||
|
<li><strong>Right to Object:</strong> Object to processing based on legitimate interests</li>
|
||||||
|
<li><strong>Right to Withdraw Consent:</strong> Withdraw consent at any time</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
To exercise these rights, please contact us at{' '}
|
||||||
|
<a href="mailto:privacy@qrmaster.com" className="text-primary-600 hover:text-primary-700">
|
||||||
|
privacy@qrmaster.com
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">9. Cookies</h2>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
We use cookies to improve your experience on our website. Cookies are small text files stored on your device.
|
||||||
|
</p>
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-3">Types of Cookies We Use:</h3>
|
||||||
|
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
||||||
|
<li><strong>Essential Cookies:</strong> Required for authentication and basic functionality (userId, CSRF token)</li>
|
||||||
|
<li><strong>Preference Cookies:</strong> Remember your settings and cookie consent preferences (cookieConsent)</li>
|
||||||
|
<li><strong>Analytics Cookies:</strong> PostHog analytics cookies to track page views, user behavior, and improve our services (only with your consent)</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
You can control cookies through your browser settings and our cookie banner. Analytics cookies are only set if you accept them
|
||||||
|
through our cookie banner. Essential cookies are required for the website to function and cannot be disabled.
|
||||||
|
</p>
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-3">PostHog Analytics:</h3>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
PostHog is our analytics platform that helps us understand how users interact with our website. When you accept analytics cookies:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
||||||
|
<li>PostHog tracks page views, clicks, and user journeys</li>
|
||||||
|
<li>We collect device type, browser, operating system, and referral source</li>
|
||||||
|
<li>PostHog respects Do Not Track (DNT) browser settings</li>
|
||||||
|
<li>No personally identifiable information (PII) is sent to PostHog without explicit identification</li>
|
||||||
|
<li>Data is processed in accordance with PostHog's privacy policy</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">10. International Data Transfers</h2>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
Your data may be transferred to and processed in countries outside the European Economic Area (EEA).
|
||||||
|
We ensure appropriate safeguards are in place, including Standard Contractual Clauses (SCCs) and
|
||||||
|
adequacy decisions by the European Commission.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">11. Children's Privacy</h2>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
Our services are not intended for children under 16 years of age. We do not knowingly collect personal data
|
||||||
|
from children. If you believe we have collected data from a child, please contact us immediately.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">12. Changes to This Policy</h2>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
We may update this privacy policy from time to time. We will notify you of significant changes through
|
||||||
|
a prominent notice on our website. Continued use of our services after changes constitutes
|
||||||
|
acceptance of the updated policy.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">13. Contact Us</h2>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
If you have any questions about this privacy policy or our data practices, please contact us:
|
||||||
|
</p>
|
||||||
|
<div className="bg-gray-50 p-6 rounded-lg">
|
||||||
|
<p className="text-gray-700 mb-2"><strong>Email:</strong> privacy@qrmaster.com</p>
|
||||||
|
<p className="text-gray-700 mb-2"><strong>Website:</strong> <a href="/" className="text-primary-600 hover:text-primary-700">qrmaster.com</a></p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">14. Supervisory Authority</h2>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
If you are located in the EEA and believe we have not addressed your concerns adequately,
|
||||||
|
you have the right to lodge a complaint with your local data protection supervisory authority.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 pt-8 border-t border-gray-200">
|
||||||
|
<p className="text-gray-600 text-center">
|
||||||
|
<Link href="/terms" className="text-primary-600 hover:text-primary-700 mr-4">
|
||||||
|
Terms of Service
|
||||||
|
</Link>
|
||||||
|
<Link href="/" className="text-primary-600 hover:text-primary-700">
|
||||||
|
Back to Home
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,304 @@
|
||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: 'Terms of Service | QR Master',
|
||||||
|
description: 'Terms of Service and usage conditions for QR Master',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TermsPage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white py-12">
|
||||||
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-4xl">
|
||||||
|
<div className="mb-8">
|
||||||
|
<Link href="/" className="text-primary-600 hover:text-primary-700 font-medium">
|
||||||
|
← Back to Home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl font-bold text-gray-900 mb-4">Terms of Service</h1>
|
||||||
|
<p className="text-gray-600 mb-8">Last updated: January 2025</p>
|
||||||
|
|
||||||
|
<div className="prose prose-lg max-w-none">
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">1. Agreement to Terms</h2>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
By accessing or using QR Master ("Service"), you agree to be bound by these Terms of Service ("Terms").
|
||||||
|
If you disagree with any part of these terms, you may not access the Service.
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
These Terms apply to all visitors, users, and others who access or use the Service.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">2. Description of Service</h2>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
QR Master is a SaaS platform that provides:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
||||||
|
<li>QR code generation (static and dynamic)</li>
|
||||||
|
<li>QR code customization and branding</li>
|
||||||
|
<li>Analytics and tracking for dynamic QR codes</li>
|
||||||
|
<li>Bulk QR code creation</li>
|
||||||
|
<li>QR code management dashboard</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">3. Account Registration</h2>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-3">3.1 Account Creation</h3>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
To use certain features, you must create an account. You agree to:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
||||||
|
<li>Provide accurate, current, and complete information</li>
|
||||||
|
<li>Maintain and update your information to keep it accurate</li>
|
||||||
|
<li>Maintain the security of your password</li>
|
||||||
|
<li>Accept responsibility for all activities under your account</li>
|
||||||
|
<li>Notify us immediately of any unauthorized use</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-3">3.2 Account Eligibility</h3>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
You must be at least 16 years old to use this Service. By creating an account, you represent that you meet this requirement.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-3">3.3 Account Termination</h3>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
We reserve the right to suspend or terminate your account at any time for violations of these Terms,
|
||||||
|
fraudulent activity, or other reasons at our sole discretion.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">4. Subscription Plans and Payments</h2>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-3">4.1 Plans</h3>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
We offer multiple subscription tiers:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
||||||
|
<li><strong>Free Plan:</strong> Limited features with usage restrictions</li>
|
||||||
|
<li><strong>Pro Plan:</strong> Enhanced features and higher limits</li>
|
||||||
|
<li><strong>Business Plan:</strong> Full features with maximum limits</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-3">4.2 Billing</h3>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
Paid subscriptions are billed monthly in advance. By subscribing, you agree to:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
||||||
|
<li>Pay all fees associated with your chosen plan</li>
|
||||||
|
<li>Provide current, complete, and accurate billing information</li>
|
||||||
|
<li>Update payment information promptly to avoid service interruption</li>
|
||||||
|
<li>Accept that fees are non-refundable except as required by law</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-3">4.3 Payment Processing</h3>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
All payments are processed securely through Stripe. We do not store your credit card information.
|
||||||
|
Your payment information is subject to Stripe's Terms of Service and Privacy Policy.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-3">4.4 Automatic Renewal</h3>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
Subscriptions automatically renew at the end of each billing period unless cancelled.
|
||||||
|
You may cancel your subscription at any time through your account settings or the Stripe Customer Portal.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-3">4.5 Cancellation and Refunds</h3>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
You may cancel your subscription at any time. Cancellation takes effect at the end of the current billing period.
|
||||||
|
No refunds will be provided for partial months or unused services, except as required by applicable law.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-3">4.6 Price Changes</h3>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
We reserve the right to change our pricing. We will provide at least 30 days' notice before any price changes take effect.
|
||||||
|
Continued use of the Service after a price change constitutes acceptance of the new pricing.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">5. Acceptable Use</h2>
|
||||||
|
|
||||||
|
<p className="text-gray-700 mb-4">You agree NOT to use the Service to:</p>
|
||||||
|
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
||||||
|
<li>Violate any laws or regulations</li>
|
||||||
|
<li>Infringe on intellectual property rights</li>
|
||||||
|
<li>Transmit malware, viruses, or harmful code</li>
|
||||||
|
<li>Engage in phishing, spam, or fraudulent activities</li>
|
||||||
|
<li>Create QR codes linking to illegal, harmful, or malicious content</li>
|
||||||
|
<li>Harass, abuse, or harm others</li>
|
||||||
|
<li>Impersonate any person or entity</li>
|
||||||
|
<li>Interfere with or disrupt the Service</li>
|
||||||
|
<li>Attempt to gain unauthorized access to our systems</li>
|
||||||
|
<li>Use automated tools to access the Service without permission</li>
|
||||||
|
<li>Resell or redistribute the Service without authorization</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
We reserve the right to investigate and take appropriate legal action against anyone who violates these provisions.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">6. Content and Intellectual Property</h2>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-3">6.1 Your Content</h3>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
You retain all rights to the content you create using our Service (URLs, text, images in QR codes, etc.).
|
||||||
|
By using our Service, you grant us a limited license to store, process, and display your content solely
|
||||||
|
for the purpose of providing the Service to you.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-3">6.2 Our Content</h3>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
The Service and its original content (excluding user-generated content), features, and functionality
|
||||||
|
are owned by QR Master and are protected by international copyright, trademark, and other intellectual property laws.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-3">6.3 Generated QR Codes</h3>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
QR codes you generate using our Service are yours to use as you wish. However, you are responsible for ensuring
|
||||||
|
that the content encoded in QR codes complies with these Terms and applicable laws.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">7. Service Availability and Modifications</h2>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-3">7.1 Service Availability</h3>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
We strive to provide reliable service but do not guarantee uninterrupted access. The Service may be temporarily
|
||||||
|
unavailable due to maintenance, updates, or circumstances beyond our control.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-3">7.2 Modifications</h3>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
We reserve the right to modify, suspend, or discontinue any part of the Service at any time with or without notice.
|
||||||
|
We will not be liable to you or any third party for any modification, suspension, or discontinuation of the Service.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">8. Data and Privacy</h2>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
Your use of the Service is also governed by our Privacy Policy. Please review our{' '}
|
||||||
|
<Link href="/privacy" className="text-primary-600 hover:text-primary-700">Privacy Policy</Link>
|
||||||
|
{' '}to understand how we collect, use, and protect your data.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">9. Disclaimers and Limitations of Liability</h2>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-3">9.1 Disclaimer of Warranties</h3>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
THE SERVICE IS PROVIDED "AS IS" AND "AS AVAILABLE" WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED,
|
||||||
|
INCLUDING BUT NOT LIMITED TO IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-3">9.2 Limitation of Liability</h3>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
TO THE MAXIMUM EXTENT PERMITTED BY LAW, QR MASTER SHALL NOT BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL,
|
||||||
|
CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR ANY LOSS OF PROFITS OR REVENUES, WHETHER INCURRED DIRECTLY OR INDIRECTLY,
|
||||||
|
OR ANY LOSS OF DATA, USE, GOODWILL, OR OTHER INTANGIBLE LOSSES.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-3">9.3 Maximum Liability</h3>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
Our total liability to you for all claims arising from your use of the Service shall not exceed the amount
|
||||||
|
you paid us in the 12 months preceding the claim.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">10. Indemnification</h2>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
You agree to indemnify and hold harmless QR Master and its officers, directors, employees, and agents from any
|
||||||
|
claims, damages, losses, liabilities, and expenses (including legal fees) arising from:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
||||||
|
<li>Your use of the Service</li>
|
||||||
|
<li>Your violation of these Terms</li>
|
||||||
|
<li>Your violation of any rights of another party</li>
|
||||||
|
<li>Content you create or share using the Service</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">11. Governing Law and Dispute Resolution</h2>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-3">11.1 Governing Law</h3>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
These Terms shall be governed by and construed in accordance with the laws of the jurisdiction in which
|
||||||
|
QR Master operates, without regard to conflict of law provisions.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-3">11.2 Dispute Resolution</h3>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
Any disputes arising from these Terms or your use of the Service shall first be attempted to be resolved
|
||||||
|
through good-faith negotiation. If unresolved, disputes may be brought in the courts of competent jurisdiction.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">12. Changes to Terms</h2>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
We reserve the right to modify these Terms at any time. We will provide notice of significant changes by:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
|
||||||
|
<li>Posting the updated Terms with a new "Last Updated" date</li>
|
||||||
|
<li>Displaying a prominent notice on our website</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
Your continued use of the Service after changes take effect constitutes acceptance of the revised Terms.
|
||||||
|
If you do not agree to the new Terms, you must stop using the Service and may cancel your account.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">13. Severability</h2>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
If any provision of these Terms is found to be unenforceable or invalid, that provision will be limited
|
||||||
|
or eliminated to the minimum extent necessary, and the remaining provisions will remain in full force and effect.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">14. Entire Agreement</h2>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
These Terms, together with our Privacy Policy, constitute the entire agreement between you and QR Master
|
||||||
|
regarding the Service and supersede all prior agreements and understandings.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">15. Contact Information</h2>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
If you have questions about these Terms, please contact us:
|
||||||
|
</p>
|
||||||
|
<div className="bg-gray-50 p-6 rounded-lg">
|
||||||
|
<p className="text-gray-700 mb-2"><strong>Email:</strong> legal@qrmaster.com</p>
|
||||||
|
<p className="text-gray-700 mb-2"><strong>Website:</strong> <a href="/" className="text-primary-600 hover:text-primary-700">qrmaster.com</a></p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 pt-8 border-t border-gray-200">
|
||||||
|
<p className="text-gray-600 text-center">
|
||||||
|
<Link href="/privacy" className="text-primary-600 hover:text-primary-700 mr-4">
|
||||||
|
Privacy Policy
|
||||||
|
</Link>
|
||||||
|
<Link href="/" className="text-primary-600 hover:text-primary-700">
|
||||||
|
Back to Home
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -63,8 +63,10 @@ export async function GET(request: NextRequest) {
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, number>);
|
}, {} as Record<string, number>);
|
||||||
|
|
||||||
// QR performance
|
// QR performance (only show DYNAMIC QR codes since STATIC don't track scans)
|
||||||
const qrPerformance = qrCodes.map(qr => ({
|
const qrPerformance = qrCodes
|
||||||
|
.filter(qr => qr.type === 'DYNAMIC')
|
||||||
|
.map(qr => ({
|
||||||
id: qr.id,
|
id: qr.id,
|
||||||
title: qr.title,
|
title: qr.title,
|
||||||
type: qr.type,
|
type: qr.type,
|
||||||
|
|
@ -73,7 +75,8 @@ export async function GET(request: NextRequest) {
|
||||||
conversion: qr.scans.length > 0
|
conversion: qr.scans.length > 0
|
||||||
? Math.round((qr.scans.filter(s => s.isUnique).length / qr.scans.length) * 100)
|
? Math.round((qr.scans.filter(s => s.isUnique).length / qr.scans.length) * 100)
|
||||||
: 0,
|
: 0,
|
||||||
})).sort((a, b) => b.totalScans - a.totalScans);
|
}))
|
||||||
|
.sort((a, b) => b.totalScans - a.totalScans);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
summary: {
|
summary: {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const code = searchParams.get('code');
|
||||||
|
|
||||||
|
// If no code, redirect to Google OAuth
|
||||||
|
if (!code) {
|
||||||
|
const googleClientId = process.env.GOOGLE_CLIENT_ID;
|
||||||
|
|
||||||
|
if (!googleClientId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Google Client ID not configured' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`;
|
||||||
|
const scope = 'openid email profile';
|
||||||
|
|
||||||
|
const googleAuthUrl = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${googleClientId}&redirect_uri=${redirectUri}&response_type=code&scope=${scope}`;
|
||||||
|
|
||||||
|
return NextResponse.redirect(googleAuthUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle callback with code
|
||||||
|
try {
|
||||||
|
const googleClientId = process.env.GOOGLE_CLIENT_ID;
|
||||||
|
const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET;
|
||||||
|
|
||||||
|
if (!googleClientId || !googleClientSecret) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Google OAuth not configured' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`;
|
||||||
|
|
||||||
|
// Exchange code for tokens
|
||||||
|
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
code,
|
||||||
|
client_id: googleClientId,
|
||||||
|
client_secret: googleClientSecret,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tokenResponse.ok) {
|
||||||
|
throw new Error('Failed to exchange code for tokens');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await tokenResponse.json();
|
||||||
|
|
||||||
|
// Get user info from Google
|
||||||
|
const userInfoResponse = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${tokens.access_token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userInfoResponse.ok) {
|
||||||
|
throw new Error('Failed to get user info');
|
||||||
|
}
|
||||||
|
|
||||||
|
const userInfo = await userInfoResponse.json();
|
||||||
|
|
||||||
|
// Here you would:
|
||||||
|
// 1. Check if user exists in database
|
||||||
|
// 2. Create user if they don't exist
|
||||||
|
// 3. Create session cookie
|
||||||
|
// 4. Redirect to dashboard
|
||||||
|
|
||||||
|
// For now, just redirect to login with error message
|
||||||
|
return NextResponse.redirect(
|
||||||
|
`${process.env.NEXT_PUBLIC_APP_URL}/login?error=google-not-fully-configured`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Google OAuth error:', error);
|
||||||
|
return NextResponse.redirect(
|
||||||
|
`${process.env.NEXT_PUBLIC_APP_URL}/login?error=google-signin-failed`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { csrfProtection } from '@/lib/csrf';
|
||||||
|
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||||
|
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
||||||
|
|
||||||
const signupSchema = z.object({
|
const signupSchema = z.object({
|
||||||
name: z.string().min(2),
|
name: z.string().min(2),
|
||||||
|
|
@ -11,6 +15,36 @@ const signupSchema = z.object({
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
// CSRF Protection
|
||||||
|
const csrfCheck = csrfProtection(request);
|
||||||
|
if (!csrfCheck.valid) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: csrfCheck.error },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate Limiting
|
||||||
|
const clientId = getClientIdentifier(request);
|
||||||
|
const rateLimitResult = rateLimit(clientId, RateLimits.SIGNUP);
|
||||||
|
|
||||||
|
if (!rateLimitResult.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Too many signup attempts. Please try again later.',
|
||||||
|
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
||||||
|
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
||||||
|
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name, email, password } = signupSchema.parse(body);
|
const { name, email, password } = signupSchema.parse(body);
|
||||||
|
|
||||||
|
|
@ -38,10 +72,16 @@ export async function POST(request: NextRequest) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Set cookie for auto-login after signup
|
||||||
|
cookies().set('userId', user.id, getAuthCookieOptions());
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,42 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
|
import { csrfProtection } from '@/lib/csrf';
|
||||||
|
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||||
|
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
// CSRF Protection
|
||||||
|
const csrfCheck = csrfProtection(request);
|
||||||
|
if (!csrfCheck.valid) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: csrfCheck.error },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate Limiting
|
||||||
|
const clientId = getClientIdentifier(request);
|
||||||
|
const rateLimitResult = rateLimit(clientId, RateLimits.LOGIN);
|
||||||
|
|
||||||
|
if (!rateLimitResult.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Too many login attempts. Please try again later.',
|
||||||
|
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
||||||
|
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
||||||
|
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const { email, password } = await request.json();
|
const { email, password } = await request.json();
|
||||||
|
|
||||||
// Find user
|
// Find user
|
||||||
|
|
@ -13,41 +46,24 @@ export async function POST(request: NextRequest) {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
// Create user if doesn't exist (for demo)
|
return NextResponse.json(
|
||||||
const hashedPassword = await bcrypt.hash(password, 12);
|
{ error: 'Invalid email or password' },
|
||||||
const newUser = await db.user.create({
|
{ status: 401 }
|
||||||
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
|
// Verify password
|
||||||
// In production, you would check: const isValid = await bcrypt.compare(password, user.password || '');
|
const isValid = await bcrypt.compare(password, user.password || '');
|
||||||
const isValid = true; // DEMO MODE - accepts any password
|
|
||||||
|
if (!isValid) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid email or password' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Set cookie
|
// Set cookie
|
||||||
cookies().set('userId', user.id, {
|
cookies().set('userId', user.id, getAuthCookieOptions());
|
||||||
httpOnly: true,
|
|
||||||
secure: false,
|
|
||||||
sameSite: 'lax',
|
|
||||||
maxAge: 60 * 60 * 24 * 7, // 7 days
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getOrCreateCsrfToken } from '@/lib/csrf';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/csrf
|
||||||
|
* Returns a CSRF token for the current session
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
const token = getOrCreateCsrfToken();
|
||||||
|
|
||||||
|
return NextResponse.json({ csrfToken: token });
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getServerSession } from 'next-auth';
|
import { cookies } from 'next/headers';
|
||||||
import { authOptions } from '@/lib/auth';
|
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
|
@ -18,15 +17,15 @@ export async function GET(
|
||||||
{ params }: { params: { id: string } }
|
{ params }: { params: { id: string } }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(authOptions);
|
const userId = cookies().get('userId')?.value;
|
||||||
if (!session?.user?.id) {
|
if (!userId) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const qrCode = await db.qRCode.findFirst({
|
const qrCode = await db.qRCode.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: params.id,
|
id: params.id,
|
||||||
userId: session.user.id,
|
userId,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
scans: {
|
scans: {
|
||||||
|
|
@ -56,8 +55,8 @@ export async function PATCH(
|
||||||
{ params }: { params: { id: string } }
|
{ params }: { params: { id: string } }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(authOptions);
|
const userId = cookies().get('userId')?.value;
|
||||||
if (!session?.user?.id) {
|
if (!userId) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -68,7 +67,7 @@ export async function PATCH(
|
||||||
const existing = await db.qRCode.findFirst({
|
const existing = await db.qRCode.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: params.id,
|
id: params.id,
|
||||||
userId: session.user.id,
|
userId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -119,8 +118,8 @@ export async function DELETE(
|
||||||
{ params }: { params: { id: string } }
|
{ params }: { params: { id: string } }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(authOptions);
|
const userId = cookies().get('userId')?.value;
|
||||||
if (!session?.user?.id) {
|
if (!userId) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,7 +127,7 @@ export async function DELETE(
|
||||||
const existing = await db.qRCode.findFirst({
|
const existing = await db.qRCode.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: params.id,
|
id: params.id,
|
||||||
userId: session.user.id,
|
userId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { csrfProtection } from '@/lib/csrf';
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// CSRF Protection
|
||||||
|
const csrfCheck = csrfProtection(request);
|
||||||
|
if (!csrfCheck.valid) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: csrfCheck.error },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = cookies().get('userId')?.value;
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all QR codes for this user
|
||||||
|
const result = await db.qRCode.deleteMany({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
deletedCount: result.count,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting all QR codes:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { stripe } from '@/lib/stripe';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const userId = cookies().get('userId')?.value;
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user with subscription info
|
||||||
|
const user = await db.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
stripeSubscriptionId: true,
|
||||||
|
plan: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already on free plan
|
||||||
|
if (user.plan === 'FREE') {
|
||||||
|
return NextResponse.json({ error: 'Already on free plan' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// No active subscription
|
||||||
|
if (!user.stripeSubscriptionId) {
|
||||||
|
// Just update plan to FREE if somehow plan is not FREE but no subscription
|
||||||
|
await db.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
plan: 'FREE',
|
||||||
|
stripePriceId: null,
|
||||||
|
stripeCurrentPeriodEnd: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel the Stripe subscription
|
||||||
|
await stripe.subscriptions.cancel(user.stripeSubscriptionId);
|
||||||
|
|
||||||
|
// Update user plan to FREE
|
||||||
|
await db.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
plan: 'FREE',
|
||||||
|
stripeSubscriptionId: null,
|
||||||
|
stripePriceId: null,
|
||||||
|
stripeCurrentPeriodEnd: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error canceling subscription:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to cancel subscription' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { stripe, STRIPE_PLANS } from '@/lib/stripe';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Get user from cookie (using userId like other routes)
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const userId = cookieStore.get('userId')?.value;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized - Please log in' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get plan and billing interval from request
|
||||||
|
const { plan, billingInterval = 'month' } = await request.json();
|
||||||
|
|
||||||
|
if (!plan || !['PRO', 'BUSINESS'].includes(plan)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid plan. Must be PRO or BUSINESS' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the Stripe price ID for the plan
|
||||||
|
const planConfig = STRIPE_PLANS[plan as 'PRO' | 'BUSINESS'];
|
||||||
|
const priceId = billingInterval === 'year' ? planConfig.priceIdYearly : planConfig.priceId;
|
||||||
|
|
||||||
|
if (!priceId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Stripe price ID not configured for this plan' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user from database
|
||||||
|
const user = await db.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create or get Stripe customer
|
||||||
|
let customerId = user.stripeCustomerId;
|
||||||
|
|
||||||
|
if (!customerId) {
|
||||||
|
const customer = await stripe.customers.create({
|
||||||
|
email: user.email,
|
||||||
|
metadata: {
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
customerId = customer.id;
|
||||||
|
|
||||||
|
// Update user with Stripe customer ID
|
||||||
|
await db.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { stripeCustomerId: customerId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Stripe Checkout Session
|
||||||
|
const checkoutSession = await stripe.checkout.sessions.create({
|
||||||
|
customer: customerId,
|
||||||
|
mode: 'subscription',
|
||||||
|
payment_method_types: ['card'],
|
||||||
|
line_items: [
|
||||||
|
{
|
||||||
|
price: priceId,
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
|
||||||
|
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?canceled=true`,
|
||||||
|
metadata: {
|
||||||
|
userId: user.id,
|
||||||
|
plan,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ url: checkoutSession.url });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating checkout session:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { stripe } from '@/lib/stripe';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const userId = cookies().get('userId')?.value;
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user with Stripe customer ID
|
||||||
|
const user = await db.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
stripeCustomerId: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user doesn't have a Stripe customer ID, they can't access the portal
|
||||||
|
if (!user.stripeCustomerId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'No active subscription found' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Stripe Customer Portal session
|
||||||
|
const portalSession = await stripe.billingPortal.sessions.create({
|
||||||
|
customer: user.stripeCustomerId,
|
||||||
|
return_url: `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/settings`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ url: portalSession.url });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating portal session:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to create portal session' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { stripe } from '@/lib/stripe';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual sync endpoint to update user subscription from Stripe
|
||||||
|
* Use this if the automatic webhook/verify failed
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Use cookie-based auth
|
||||||
|
const userId = cookies().get('userId')?.value;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await db.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.stripeCustomerId) {
|
||||||
|
return NextResponse.json({ error: 'No Stripe customer ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all subscriptions for this customer
|
||||||
|
const subscriptions = await stripe.subscriptions.list({
|
||||||
|
customer: user.stripeCustomerId,
|
||||||
|
status: 'active',
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (subscriptions.data.length === 0) {
|
||||||
|
// No active subscription - set to FREE
|
||||||
|
await db.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
stripeSubscriptionId: null,
|
||||||
|
stripePriceId: null,
|
||||||
|
stripeCurrentPeriodEnd: null,
|
||||||
|
plan: 'FREE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
plan: 'FREE',
|
||||||
|
message: 'No active subscription found, set to FREE plan',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription: any = subscriptions.data[0];
|
||||||
|
|
||||||
|
// Determine plan from price ID
|
||||||
|
const priceId = subscription.items.data[0]?.price?.id;
|
||||||
|
let plan = 'PRO'; // default
|
||||||
|
|
||||||
|
// Check against known price IDs
|
||||||
|
if (priceId === process.env.STRIPE_PRICE_ID_BUSINESS_MONTHLY ||
|
||||||
|
priceId === process.env.STRIPE_PRICE_ID_BUSINESS_YEARLY) {
|
||||||
|
plan = 'BUSINESS';
|
||||||
|
} else if (priceId === process.env.STRIPE_PRICE_ID_PRO_MONTHLY ||
|
||||||
|
priceId === process.env.STRIPE_PRICE_ID_PRO_YEARLY) {
|
||||||
|
plan = 'PRO';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current_period_end
|
||||||
|
const periodEndTimestamp = subscription.current_period_end
|
||||||
|
|| subscription.currentPeriodEnd
|
||||||
|
|| subscription.billing_cycle_anchor;
|
||||||
|
|
||||||
|
const currentPeriodEnd = periodEndTimestamp
|
||||||
|
? new Date(periodEndTimestamp * 1000)
|
||||||
|
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
console.log('Syncing subscription:', {
|
||||||
|
subscriptionId: subscription.id,
|
||||||
|
priceId,
|
||||||
|
plan,
|
||||||
|
periodEndTimestamp,
|
||||||
|
currentPeriodEnd,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update user in database
|
||||||
|
await db.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
stripeSubscriptionId: subscription.id,
|
||||||
|
stripePriceId: priceId,
|
||||||
|
stripeCurrentPeriodEnd: currentPeriodEnd,
|
||||||
|
plan: plan as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
plan,
|
||||||
|
subscriptionId: subscription.id,
|
||||||
|
currentPeriodEnd,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error syncing subscription:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -38,22 +38,43 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
// Only process if payment was successful
|
// Only process if payment was successful
|
||||||
if (checkoutSession.payment_status === 'paid' && checkoutSession.subscription) {
|
if (checkoutSession.payment_status === 'paid' && checkoutSession.subscription) {
|
||||||
const subscription = await stripe.subscriptions.retrieve(
|
const subscriptionId = typeof checkoutSession.subscription === 'string'
|
||||||
checkoutSession.subscription as string
|
? checkoutSession.subscription
|
||||||
);
|
: checkoutSession.subscription.id;
|
||||||
|
|
||||||
|
// Retrieve the full subscription object
|
||||||
|
const subscription: any = await stripe.subscriptions.retrieve(subscriptionId);
|
||||||
|
|
||||||
// Determine plan from metadata or price ID
|
// Determine plan from metadata or price ID
|
||||||
const plan = checkoutSession.metadata?.plan || 'PRO';
|
const plan = checkoutSession.metadata?.plan || 'PRO';
|
||||||
|
|
||||||
|
// Debug log to see the subscription structure
|
||||||
|
console.log('Full subscription object:', JSON.stringify(subscription, null, 2));
|
||||||
|
|
||||||
|
// Get current_period_end - Stripe returns it as a Unix timestamp
|
||||||
|
// Try different possible field names
|
||||||
|
const periodEndTimestamp = subscription.current_period_end
|
||||||
|
|| subscription.currentPeriodEnd
|
||||||
|
|| subscription.billing_cycle_anchor;
|
||||||
|
|
||||||
|
const currentPeriodEnd = periodEndTimestamp
|
||||||
|
? new Date(periodEndTimestamp * 1000)
|
||||||
|
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // Default to 30 days from now
|
||||||
|
|
||||||
|
console.log('Subscription data:', {
|
||||||
|
id: subscription.id,
|
||||||
|
periodEndTimestamp,
|
||||||
|
currentPeriodEnd,
|
||||||
|
priceId: subscription.items?.data?.[0]?.price?.id,
|
||||||
|
});
|
||||||
|
|
||||||
// Update user in database
|
// Update user in database
|
||||||
await db.user.update({
|
await db.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: {
|
data: {
|
||||||
stripeSubscriptionId: subscription.id,
|
stripeSubscriptionId: subscription.id,
|
||||||
stripePriceId: subscription.items.data[0].price.id,
|
stripePriceId: subscription.items.data[0].price.id,
|
||||||
stripeCurrentPeriodEnd: new Date(
|
stripeCurrentPeriodEnd: currentPeriodEnd,
|
||||||
subscription.current_period_end * 1000
|
|
||||||
),
|
|
||||||
plan: plan as any,
|
plan: plan as any,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -37,10 +37,18 @@ export async function POST(request: NextRequest) {
|
||||||
const session = event.data.object as Stripe.Checkout.Session;
|
const session = event.data.object as Stripe.Checkout.Session;
|
||||||
|
|
||||||
if (session.mode === 'subscription') {
|
if (session.mode === 'subscription') {
|
||||||
const subscription = await stripe.subscriptions.retrieve(
|
const subscription: any = await stripe.subscriptions.retrieve(
|
||||||
session.subscription as string
|
session.subscription as string
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const periodEndTimestamp = subscription.current_period_end
|
||||||
|
|| subscription.currentPeriodEnd
|
||||||
|
|| subscription.billing_cycle_anchor;
|
||||||
|
|
||||||
|
const currentPeriodEnd = periodEndTimestamp
|
||||||
|
? new Date(periodEndTimestamp * 1000)
|
||||||
|
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
await db.user.update({
|
await db.user.update({
|
||||||
where: {
|
where: {
|
||||||
stripeCustomerId: session.customer as string,
|
stripeCustomerId: session.customer as string,
|
||||||
|
|
@ -48,9 +56,7 @@ export async function POST(request: NextRequest) {
|
||||||
data: {
|
data: {
|
||||||
stripeSubscriptionId: subscription.id,
|
stripeSubscriptionId: subscription.id,
|
||||||
stripePriceId: subscription.items.data[0].price.id,
|
stripePriceId: subscription.items.data[0].price.id,
|
||||||
stripeCurrentPeriodEnd: new Date(
|
stripeCurrentPeriodEnd: currentPeriodEnd,
|
||||||
subscription.current_period_end * 1000
|
|
||||||
),
|
|
||||||
plan: (session.metadata?.plan || 'FREE') as any,
|
plan: (session.metadata?.plan || 'FREE') as any,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -59,7 +65,15 @@ export async function POST(request: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'customer.subscription.updated': {
|
case 'customer.subscription.updated': {
|
||||||
const subscription = event.data.object as Stripe.Subscription;
|
const subscription: any = event.data.object as Stripe.Subscription;
|
||||||
|
|
||||||
|
const periodEndTimestamp = subscription.current_period_end
|
||||||
|
|| subscription.currentPeriodEnd
|
||||||
|
|| subscription.billing_cycle_anchor;
|
||||||
|
|
||||||
|
const currentPeriodEnd = periodEndTimestamp
|
||||||
|
? new Date(periodEndTimestamp * 1000)
|
||||||
|
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
await db.user.update({
|
await db.user.update({
|
||||||
where: {
|
where: {
|
||||||
|
|
@ -67,9 +81,7 @@ export async function POST(request: NextRequest) {
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
stripePriceId: subscription.items.data[0].price.id,
|
stripePriceId: subscription.items.data[0].price.id,
|
||||||
stripeCurrentPeriodEnd: new Date(
|
stripeCurrentPeriodEnd: currentPeriodEnd,
|
||||||
subscription.current_period_end * 1000
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const userId = cookies().get('userId')?.value;
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user from database
|
||||||
|
const user = await db.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
database: user,
|
||||||
|
localStorage: 'Check in browser console',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Debug error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { stripe } from '@/lib/stripe';
|
||||||
|
import { csrfProtection } from '@/lib/csrf';
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// CSRF Protection
|
||||||
|
const csrfCheck = csrfProtection(request);
|
||||||
|
if (!csrfCheck.valid) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: csrfCheck.error },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = cookies().get('userId')?.value;
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user data including Stripe information
|
||||||
|
const user = await db.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
stripeSubscriptionId: true,
|
||||||
|
stripeCustomerId: true,
|
||||||
|
plan: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel Stripe subscription if user has one
|
||||||
|
if (user.stripeSubscriptionId && user.plan !== 'FREE') {
|
||||||
|
try {
|
||||||
|
await stripe.subscriptions.cancel(user.stripeSubscriptionId);
|
||||||
|
} catch (stripeError) {
|
||||||
|
console.error('Error canceling Stripe subscription:', stripeError);
|
||||||
|
// Continue with deletion even if Stripe cancellation fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete user and all related data (cascading deletes should handle QR codes, scans, etc.)
|
||||||
|
await db.user.delete({
|
||||||
|
where: { id: userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear auth cookie
|
||||||
|
cookies().delete('userId');
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting account:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { csrfProtection } from '@/lib/csrf';
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// CSRF Protection
|
||||||
|
const csrfCheck = csrfProtection(request);
|
||||||
|
if (!csrfCheck.valid) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: csrfCheck.error },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = cookies().get('userId')?.value;
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { currentPassword, newPassword } = body;
|
||||||
|
|
||||||
|
if (!currentPassword || !newPassword) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Current password and new password are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'New password must be at least 8 characters' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user with password
|
||||||
|
const user = await db.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
password: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify current password
|
||||||
|
const isPasswordValid = await bcrypt.compare(currentPassword, user.password);
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Current password is incorrect' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash new password
|
||||||
|
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
||||||
|
|
||||||
|
// Update password
|
||||||
|
await db.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { password: hashedPassword },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error changing password:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// Use cookie-based auth instead of NextAuth
|
// Use cookie-based auth instead of NextAuth
|
||||||
|
|
@ -17,6 +19,8 @@ export async function GET(request: NextRequest) {
|
||||||
plan: true,
|
plan: true,
|
||||||
stripeCurrentPeriodEnd: true,
|
stripeCurrentPeriodEnd: true,
|
||||||
stripePriceId: true,
|
stripePriceId: true,
|
||||||
|
stripeCustomerId: true,
|
||||||
|
stripeSubscriptionId: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -25,9 +29,11 @@ export async function GET(request: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
plan: user.plan,
|
plan: user.plan || 'FREE',
|
||||||
currentPeriodEnd: user.stripeCurrentPeriodEnd,
|
currentPeriodEnd: user.stripeCurrentPeriodEnd,
|
||||||
priceId: user.stripePriceId,
|
priceId: user.stripePriceId,
|
||||||
|
stripeCustomerId: user.stripeCustomerId,
|
||||||
|
stripeSubscriptionId: user.stripeSubscriptionId,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching user plan:', error);
|
console.error('Error fetching user plan:', error);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const userId = cookies().get('userId')?.value;
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { name } = body;
|
||||||
|
|
||||||
|
if (!name || name.trim().length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Name is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user name in database
|
||||||
|
const updatedUser = await db.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { name: name.trim() },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
user: updatedUser,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating profile:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const userId = cookies().get('userId')?.value;
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user with plan info
|
||||||
|
const user = await db.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
plan: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count dynamic QR codes
|
||||||
|
const dynamicQRCount = await db.qRCode.count({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
type: 'DYNAMIC',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Count static QR codes
|
||||||
|
const staticQRCount = await db.qRCode.count({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
type: 'STATIC',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Determine limits based on plan
|
||||||
|
let dynamicLimit = 3; // FREE plan default
|
||||||
|
if (user.plan === 'PRO') {
|
||||||
|
dynamicLimit = 50;
|
||||||
|
} else if (user.plan === 'BUSINESS') {
|
||||||
|
dynamicLimit = 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
dynamicUsed: dynamicQRCount,
|
||||||
|
dynamicLimit,
|
||||||
|
staticUsed: staticQRCount,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user stats:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,8 @@ import type { Metadata } from 'next';
|
||||||
import '@/styles/globals.css';
|
import '@/styles/globals.css';
|
||||||
import { ToastContainer } from '@/components/ui/Toast';
|
import { ToastContainer } from '@/components/ui/Toast';
|
||||||
import AuthProvider from '@/components/SessionProvider';
|
import AuthProvider from '@/components/SessionProvider';
|
||||||
|
import { PostHogProvider } from '@/components/PostHogProvider';
|
||||||
|
import CookieBanner from '@/components/CookieBanner';
|
||||||
|
|
||||||
const isIndexable = process.env.NEXT_PUBLIC_INDEXABLE === 'true';
|
const isIndexable = process.env.NEXT_PUBLIC_INDEXABLE === 'true';
|
||||||
|
|
||||||
|
|
@ -16,6 +18,13 @@ export const metadata: Metadata = {
|
||||||
robots: isIndexable
|
robots: isIndexable
|
||||||
? { index: true, follow: true }
|
? { index: true, follow: true }
|
||||||
: { index: false, follow: false },
|
: { index: false, follow: false },
|
||||||
|
icons: {
|
||||||
|
icon: [
|
||||||
|
{ url: '/favicon.svg', type: 'image/svg+xml' },
|
||||||
|
{ url: '/logo.svg', type: 'image/svg+xml' },
|
||||||
|
],
|
||||||
|
apple: '/logo.svg',
|
||||||
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
site: '@qrmaster',
|
site: '@qrmaster',
|
||||||
|
|
@ -47,10 +56,13 @@ export default function RootLayout({
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className="font-sans">
|
<body className="font-sans">
|
||||||
|
<PostHogProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
{children}
|
{children}
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
<CookieBanner />
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
|
</PostHogProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
|
||||||
|
export default function CookieBanner() {
|
||||||
|
const [showBanner, setShowBanner] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if user has already made a choice
|
||||||
|
const cookieConsent = localStorage.getItem('cookieConsent');
|
||||||
|
if (!cookieConsent) {
|
||||||
|
// Show banner after a short delay for better UX
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setShowBanner(true);
|
||||||
|
}, 1000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAccept = () => {
|
||||||
|
localStorage.setItem('cookieConsent', 'accepted');
|
||||||
|
setShowBanner(false);
|
||||||
|
// Reload page to initialize PostHog
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDecline = () => {
|
||||||
|
localStorage.setItem('cookieConsent', 'declined');
|
||||||
|
setShowBanner(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!showBanner) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Cookie Banner - Bottom Left Corner */}
|
||||||
|
<div className="fixed bottom-4 left-4 z-50 max-w-md animate-slide-in">
|
||||||
|
<div className="bg-white rounded-lg shadow-2xl border border-gray-200 p-6">
|
||||||
|
<div className="flex items-start gap-3 mb-4">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<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 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold text-gray-900 mb-2">
|
||||||
|
We use cookies
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 text-sm leading-relaxed mb-3">
|
||||||
|
We use essential cookies for authentication and analytics cookies to improve your experience.{' '}
|
||||||
|
<Link href="/privacy" className="text-primary-600 hover:text-primary-700 font-medium underline">
|
||||||
|
Learn more
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Cookie Categories */}
|
||||||
|
<div className="space-y-1.5 mb-4">
|
||||||
|
<div className="flex items-center text-xs">
|
||||||
|
<svg className="w-3.5 h-3.5 text-success-600 mr-1.5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-gray-700"><strong>Essential:</strong> Authentication, CSRF protection</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center text-xs">
|
||||||
|
<svg className="w-3.5 h-3.5 text-primary-600 mr-1.5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-gray-700"><strong>Analytics:</strong> PostHog for website analytics</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleDecline}
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Decline
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleAccept}
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Accept All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style jsx>{`
|
||||||
|
@keyframes slide-in {
|
||||||
|
from {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-in {
|
||||||
|
animation: slide-in 0.4s ease-out;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
|
import posthog from 'posthog-js';
|
||||||
|
|
||||||
|
export function PostHogProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if user has consented to analytics cookies
|
||||||
|
const cookieConsent = localStorage.getItem('cookieConsent');
|
||||||
|
|
||||||
|
// Only initialize PostHog if user has accepted cookies
|
||||||
|
if (cookieConsent === 'accepted') {
|
||||||
|
posthog.init('phc_97JBJVVQlqqiZuTVRHuBnnG9HasOv3GSsdeVjossizJ', {
|
||||||
|
api_host: 'https://us.i.posthog.com',
|
||||||
|
person_profiles: 'identified_only',
|
||||||
|
capture_pageview: false, // We'll capture manually
|
||||||
|
capture_pageleave: true,
|
||||||
|
autocapture: true,
|
||||||
|
// Privacy-friendly settings
|
||||||
|
respect_dnt: true,
|
||||||
|
opt_out_capturing_by_default: false,
|
||||||
|
loaded: (posthog) => {
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
posthog.debug();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
return () => {
|
||||||
|
if (cookieConsent === 'accepted') {
|
||||||
|
posthog.opt_out_capturing();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Track page views
|
||||||
|
useEffect(() => {
|
||||||
|
const cookieConsent = localStorage.getItem('cookieConsent');
|
||||||
|
|
||||||
|
if (cookieConsent === 'accepted' && pathname) {
|
||||||
|
let url = window.origin + pathname;
|
||||||
|
if (searchParams && searchParams.toString()) {
|
||||||
|
url = url + `?${searchParams.toString()}`;
|
||||||
|
}
|
||||||
|
posthog.capture('$pageview', {
|
||||||
|
$current_url: url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [pathname, searchParams]);
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to identify user after login
|
||||||
|
*/
|
||||||
|
export function identifyUser(userId: string, traits?: Record<string, any>) {
|
||||||
|
const cookieConsent = localStorage.getItem('cookieConsent');
|
||||||
|
if (cookieConsent === 'accepted') {
|
||||||
|
posthog.identify(userId, traits);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to track custom events
|
||||||
|
*/
|
||||||
|
export function trackEvent(eventName: string, properties?: Record<string, any>) {
|
||||||
|
const cookieConsent = localStorage.getItem('cookieConsent');
|
||||||
|
if (cookieConsent === 'accepted') {
|
||||||
|
posthog.capture(eventName, properties);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to reset user on logout
|
||||||
|
*/
|
||||||
|
export function resetUser() {
|
||||||
|
const cookieConsent = localStorage.getItem('cookieConsent');
|
||||||
|
if (cookieConsent === 'accepted') {
|
||||||
|
posthog.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,9 +18,9 @@ interface QRCodeCardProps {
|
||||||
status: 'ACTIVE' | 'PAUSED';
|
status: 'ACTIVE' | 'PAUSED';
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
scans?: number;
|
scans?: number;
|
||||||
|
style?: any;
|
||||||
};
|
};
|
||||||
onEdit: (id: string) => void;
|
onEdit: (id: string) => void;
|
||||||
onDuplicate: (id: string) => void;
|
|
||||||
onPause: (id: string) => void;
|
onPause: (id: string) => void;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -28,7 +28,6 @@ interface QRCodeCardProps {
|
||||||
export const QRCodeCard: React.FC<QRCodeCardProps> = ({
|
export const QRCodeCard: React.FC<QRCodeCardProps> = ({
|
||||||
qr,
|
qr,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDuplicate,
|
|
||||||
onPause,
|
onPause,
|
||||||
onDelete,
|
onDelete,
|
||||||
}) => {
|
}) => {
|
||||||
|
|
@ -138,11 +137,14 @@ export const QRCodeCard: React.FC<QRCodeCardProps> = ({
|
||||||
>
|
>
|
||||||
<DropdownItem onClick={() => downloadQR('png')}>Download PNG</DropdownItem>
|
<DropdownItem onClick={() => downloadQR('png')}>Download PNG</DropdownItem>
|
||||||
<DropdownItem onClick={() => downloadQR('svg')}>Download SVG</DropdownItem>
|
<DropdownItem onClick={() => downloadQR('svg')}>Download SVG</DropdownItem>
|
||||||
|
{qr.type === 'DYNAMIC' && (
|
||||||
<DropdownItem onClick={() => onEdit(qr.id)}>Edit</DropdownItem>
|
<DropdownItem onClick={() => onEdit(qr.id)}>Edit</DropdownItem>
|
||||||
<DropdownItem onClick={() => onDuplicate(qr.id)}>Duplicate</DropdownItem>
|
)}
|
||||||
|
{qr.type === 'DYNAMIC' && (
|
||||||
<DropdownItem onClick={() => onPause(qr.id)}>
|
<DropdownItem onClick={() => onPause(qr.id)}>
|
||||||
{qr.status === 'ACTIVE' ? 'Pause' : 'Resume'}
|
{qr.status === 'ACTIVE' ? 'Pause' : 'Resume'}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
|
)}
|
||||||
<DropdownItem onClick={() => onDelete(qr.id)} className="text-red-600">
|
<DropdownItem onClick={() => onDelete(qr.id)} className="text-red-600">
|
||||||
Delete
|
Delete
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
|
|
@ -153,8 +155,8 @@ export const QRCodeCard: React.FC<QRCodeCardProps> = ({
|
||||||
<QRCodeSVG
|
<QRCodeSVG
|
||||||
value={qrUrl}
|
value={qrUrl}
|
||||||
size={96}
|
size={96}
|
||||||
fgColor="#000000"
|
fgColor={qr.style?.foregroundColor || '#000000'}
|
||||||
bgColor="#FFFFFF"
|
bgColor={qr.style?.backgroundColor || '#FFFFFF'}
|
||||||
level="M"
|
level="M"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -164,10 +166,12 @@ export const QRCodeCard: React.FC<QRCodeCardProps> = ({
|
||||||
<span className="text-gray-500">Type:</span>
|
<span className="text-gray-500">Type:</span>
|
||||||
<span className="text-gray-900">{qr.contentType}</span>
|
<span className="text-gray-900">{qr.contentType}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{qr.type === 'DYNAMIC' && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-gray-500">Scans:</span>
|
<span className="text-gray-500">Scans:</span>
|
||||||
<span className="text-gray-900">{qr.scans || 0}</span>
|
<span className="text-gray-900">{qr.scans || 0}</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-gray-500">Created:</span>
|
<span className="text-gray-500">Created:</span>
|
||||||
<span className="text-gray-900">{formatDate(qr.createdAt)}</span>
|
<span className="text-gray-900">{formatDate(qr.createdAt)}</span>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Card, CardContent } from '@/components/ui/Card';
|
import { Card, CardContent } from '@/components/ui/Card';
|
||||||
import { formatNumber } from '@/lib/utils';
|
import { formatNumber } from '@/lib/utils';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
|
||||||
interface StatsGridProps {
|
interface StatsGridProps {
|
||||||
stats: {
|
stats: {
|
||||||
|
|
@ -13,12 +14,13 @@ interface StatsGridProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const StatsGrid: React.FC<StatsGridProps> = ({ stats }) => {
|
export const StatsGrid: React.FC<StatsGridProps> = ({ stats }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
// Only show growth if there are actual scans
|
// Only show growth if there are actual scans
|
||||||
const showGrowth = stats.totalScans > 0;
|
const showGrowth = stats.totalScans > 0;
|
||||||
|
|
||||||
const cards = [
|
const cards = [
|
||||||
{
|
{
|
||||||
title: 'Total Scans',
|
title: t('dashboard.stats.total_scans'),
|
||||||
value: formatNumber(stats.totalScans),
|
value: formatNumber(stats.totalScans),
|
||||||
change: showGrowth ? '+12%' : 'No data yet',
|
change: showGrowth ? '+12%' : 'No data yet',
|
||||||
changeType: showGrowth ? 'positive' : 'neutral' as 'positive' | 'negative' | 'neutral',
|
changeType: showGrowth ? 'positive' : 'neutral' as 'positive' | 'negative' | 'neutral',
|
||||||
|
|
@ -30,7 +32,7 @@ export const StatsGrid: React.FC<StatsGridProps> = ({ stats }) => {
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Active QR Codes',
|
title: t('dashboard.stats.active_codes'),
|
||||||
value: stats.activeQRCodes.toString(),
|
value: stats.activeQRCodes.toString(),
|
||||||
change: stats.activeQRCodes > 0 ? `${stats.activeQRCodes} active` : 'Create your first',
|
change: stats.activeQRCodes > 0 ? `${stats.activeQRCodes} active` : 'Create your first',
|
||||||
changeType: stats.activeQRCodes > 0 ? 'positive' : 'neutral' as 'positive' | 'negative' | 'neutral',
|
changeType: stats.activeQRCodes > 0 ? 'positive' : 'neutral' as 'positive' | 'negative' | 'neutral',
|
||||||
|
|
@ -41,7 +43,7 @@ export const StatsGrid: React.FC<StatsGridProps> = ({ stats }) => {
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Conversion Rate',
|
title: t('dashboard.stats.conversion_rate'),
|
||||||
value: `${stats.conversionRate}%`,
|
value: `${stats.conversionRate}%`,
|
||||||
change: stats.totalScans > 0 ? `${stats.conversionRate}% rate` : 'No scans yet',
|
change: stats.totalScans > 0 ? `${stats.conversionRate}% rate` : 'No scans yet',
|
||||||
changeType: stats.conversionRate > 0 ? 'positive' : 'neutral' as 'positive' | 'negative' | 'neutral',
|
changeType: stats.conversionRate > 0 ? 'positive' : 'neutral' as 'positive' | 'negative' | 'neutral',
|
||||||
|
|
|
||||||
|
|
@ -15,15 +15,12 @@ export const FAQ: React.FC<FAQProps> = ({ t }) => {
|
||||||
'static_vs_dynamic',
|
'static_vs_dynamic',
|
||||||
'forever',
|
'forever',
|
||||||
'file_type',
|
'file_type',
|
||||||
'password',
|
|
||||||
'analytics',
|
'analytics',
|
||||||
'privacy',
|
|
||||||
'bulk',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="faq" className="py-16 bg-gray-50">
|
<section id="faq" className="py-16 bg-gray-50">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
|
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
|
||||||
{t.faq.title}
|
{t.faq.title}
|
||||||
|
|
@ -57,6 +54,12 @@ export const FAQ: React.FC<FAQProps> = ({ t }) => {
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center mt-8">
|
||||||
|
<a href="/faq" className="text-primary-600 hover:text-primary-700 font-medium">
|
||||||
|
View All Questions →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -27,11 +27,20 @@ export const Features: React.FC<FeaturesProps> = ({ t }) => {
|
||||||
),
|
),
|
||||||
color: 'text-purple-600 bg-purple-100',
|
color: 'text-purple-600 bg-purple-100',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'unlimited',
|
||||||
|
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 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
color: 'text-green-600 bg-green-100',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="py-16 bg-gray-50">
|
<section className="py-16 bg-gray-50">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
|
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
|
||||||
{t.features.title}
|
{t.features.title}
|
||||||
|
|
|
||||||
|
|
@ -12,15 +12,15 @@ interface HeroProps {
|
||||||
|
|
||||||
export const Hero: React.FC<HeroProps> = ({ t }) => {
|
export const Hero: React.FC<HeroProps> = ({ t }) => {
|
||||||
const templateCards = [
|
const templateCards = [
|
||||||
{ title: 'Restaurant Menu', color: 'bg-pink-100', icon: '🍽️' },
|
{ title: 'URL/Website', color: 'bg-blue-100', icon: '🌐' },
|
||||||
{ title: 'Business Card', color: 'bg-blue-100', icon: '💼' },
|
{ title: 'WiFi', color: 'bg-purple-100', icon: '📶' },
|
||||||
{ title: 'Event Tickets', color: 'bg-green-100', icon: '🎫' },
|
{ title: 'Email', color: 'bg-green-100', icon: '📧' },
|
||||||
{ title: 'WiFi Access', color: 'bg-purple-100', icon: '📶' },
|
{ title: 'Phone Number', color: 'bg-pink-100', icon: '📞' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative overflow-hidden bg-gradient-to-br from-blue-50 via-white to-purple-50 py-20">
|
<section className="relative overflow-hidden bg-gradient-to-br from-blue-50 via-white to-purple-50 py-20">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||||
<div className="grid lg:grid-cols-2 gap-12 items-center">
|
<div className="grid lg:grid-cols-2 gap-12 items-center">
|
||||||
{/* Left Content */}
|
{/* Left Content */}
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
|
|
@ -56,7 +56,7 @@ export const Hero: React.FC<HeroProps> = ({ t }) => {
|
||||||
{t.hero.cta_primary}
|
{t.hero.cta_primary}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/create">
|
<Link href="/#pricing">
|
||||||
<Button variant="outline" size="lg" className="text-lg px-8 py-4 w-full sm:w-auto">
|
<Button variant="outline" size="lg" className="text-lg px-8 py-4 w-full sm:w-auto">
|
||||||
{t.hero.cta_secondary}
|
{t.hero.cta_secondary}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -28,22 +28,6 @@ export default function HomePageClient() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Hero t={t} />
|
<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} />
|
<InstantGenerator t={t} />
|
||||||
<StaticVsDynamic t={t} />
|
<StaticVsDynamic t={t} />
|
||||||
<Features t={t} />
|
<Features t={t} />
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ export const InstantGenerator: React.FC<InstantGeneratorProps> = ({ t }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="py-16 bg-gray-50">
|
<section className="py-16 bg-gray-50">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
|
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
|
||||||
{t.generator.title}
|
{t.generator.title}
|
||||||
|
|
@ -180,7 +180,7 @@ export const InstantGenerator: React.FC<InstantGeneratorProps> = ({ t }) => {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button className="w-full">
|
<Button className="w-full" onClick={() => window.location.href = '/login'}>
|
||||||
{t.generator.save_track}
|
{t.generator.save_track}
|
||||||
</Button>
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ export const Pricing: React.FC<PricingProps> = ({ t }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="pricing" className="py-16">
|
<section id="pricing" className="py-16">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
|
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
|
||||||
{t.pricing.title}
|
{t.pricing.title}
|
||||||
|
|
@ -67,7 +67,7 @@ export const Pricing: React.FC<PricingProps> = ({ t }) => {
|
||||||
|
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{t.pricing[plan.key].features.map((feature: string, index: number) => (
|
{t.pricing[plan.key].features.slice(0, 3).map((feature: string, index: number) => (
|
||||||
<li key={index} className="flex items-start space-x-3">
|
<li key={index} className="flex items-start space-x-3">
|
||||||
<svg className="w-5 h-5 text-success-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
<svg className="w-5 h-5 text-success-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
|
@ -88,6 +88,12 @@ export const Pricing: React.FC<PricingProps> = ({ t }) => {
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center mt-8">
|
||||||
|
<a href="/#pricing" className="text-primary-600 hover:text-primary-700 font-medium">
|
||||||
|
View Full Pricing Details →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ interface StaticVsDynamicProps {
|
||||||
export const StaticVsDynamic: React.FC<StaticVsDynamicProps> = ({ t }) => {
|
export const StaticVsDynamic: React.FC<StaticVsDynamicProps> = ({ t }) => {
|
||||||
return (
|
return (
|
||||||
<section className="py-16">
|
<section className="py-16">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||||
<div className="grid lg:grid-cols-2 gap-8 max-w-6xl mx-auto">
|
<div className="grid lg:grid-cols-2 gap-8 max-w-6xl mx-auto">
|
||||||
{/* Static QR Codes */}
|
{/* Static QR Codes */}
|
||||||
<Card className="relative">
|
<Card className="relative">
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export const StatsStrip: React.FC<StatsStripProps> = ({ t }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="py-16 bg-gray-50">
|
<section className="py-16 bg-gray-50">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
|
||||||
{stats.map((stat, index) => (
|
{stats.map((stat, index) => (
|
||||||
<div key={stat.key} className="text-center">
|
<div key={stat.key} className="text-center">
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ export const TemplateCards: React.FC<TemplateCardsProps> = ({ t }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="py-16">
|
<section className="py-16">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
|
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
|
||||||
{t.templates.title}
|
{t.templates.title}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { showToast } from '@/components/ui/Toast';
|
||||||
|
|
||||||
|
interface ChangePasswordModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChangePasswordModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
}: ChangePasswordModalProps) {
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||||
|
showToast('Please fill in all fields', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
showToast('New password must be at least 8 characters', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
showToast('New passwords do not match', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPassword === newPassword) {
|
||||||
|
showToast('New password must be different from current password', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user/password', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
currentPassword,
|
||||||
|
newPassword,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to change password');
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('Password changed successfully!', 'success');
|
||||||
|
setCurrentPassword('');
|
||||||
|
setNewPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
onSuccess();
|
||||||
|
} catch (error: any) {
|
||||||
|
showToast(error.message || 'Failed to change password', 'error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<div className="flex min-h-screen items-center justify-center p-4">
|
||||||
|
<div className="relative bg-white rounded-lg shadow-xl max-w-md w-full p-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900">Change Password</h2>
|
||||||
|
<p className="text-gray-600 mt-2">
|
||||||
|
Enter your current password and choose a new one
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Current Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
placeholder="Enter current password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
New Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
placeholder="Enter new password (min. 8 characters)"
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Confirm New Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
placeholder="Confirm new password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex space-x-3 pt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Updating...' : 'Update Password'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch and manage CSRF token for client-side requests
|
||||||
|
*/
|
||||||
|
export function useCsrf() {
|
||||||
|
const [csrfToken, setCsrfToken] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchCsrfToken() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/csrf');
|
||||||
|
const data = await response.json();
|
||||||
|
setCsrfToken(data.csrfToken);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch CSRF token:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchCsrfToken();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to add CSRF token to fetch headers
|
||||||
|
*/
|
||||||
|
const getHeaders = (additionalHeaders: HeadersInit = {}) => {
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...additionalHeaders,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (csrfToken) {
|
||||||
|
headers['x-csrf-token'] = csrfToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper for fetch with automatic CSRF token injection
|
||||||
|
*/
|
||||||
|
const fetchWithCsrf = async (url: string, options: RequestInit = {}) => {
|
||||||
|
const headers = getHeaders(options.headers as HeadersInit);
|
||||||
|
|
||||||
|
return fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
csrfToken,
|
||||||
|
loading,
|
||||||
|
getHeaders,
|
||||||
|
fetchWithCsrf,
|
||||||
|
};
|
||||||
|
}
|
||||||
108
src/i18n/de.json
108
src/i18n/de.json
|
|
@ -5,21 +5,27 @@
|
||||||
"faq": "FAQ",
|
"faq": "FAQ",
|
||||||
"blog": "Blog",
|
"blog": "Blog",
|
||||||
"login": "Anmelden",
|
"login": "Anmelden",
|
||||||
"dashboard": "Dashboard"
|
"dashboard": "Dashboard",
|
||||||
|
"create_qr": "QR erstellen",
|
||||||
|
"bulk_creation": "Massen-Erstellung",
|
||||||
|
"analytics": "Analytik",
|
||||||
|
"settings": "Einstellungen"
|
||||||
},
|
},
|
||||||
"hero": {
|
"hero": {
|
||||||
"badge": "🚀 Der beliebteste QR-Code-Generator im Internet",
|
"badge": "Kostenloser QR-Code-Generator",
|
||||||
"title": "Erstelle individuelle QR-Codes in Sekunden",
|
"title": "Erstellen Sie QR-Codes, die überall funktionieren",
|
||||||
"subtitle": "Generiere statische und dynamische QR-Codes mit erweiterten Tracking-Funktionen, professionellen Vorlagen und nahtlosen Integrationen.",
|
"subtitle": "Generieren Sie statische und dynamische QR-Codes mit Tracking, individuellem Branding und Massen-Erstellung. Kostenlos für immer.",
|
||||||
"features": [
|
"features": [
|
||||||
"Keine Kreditkarte zum Starten erforderlich",
|
"Keine Kreditkarte zum Starten erforderlich",
|
||||||
"QR-Codes für immer kostenlos erstellen",
|
"QR-Codes für immer kostenlos erstellen",
|
||||||
"Erweiterte Verfolgung und Analytik",
|
"Erweiterte Verfolgung und Analytik",
|
||||||
"Professionelle Vorlagen inklusive"
|
"Individuelle Farben und Stile"
|
||||||
],
|
],
|
||||||
"cta_primary": "QR-Code kostenlos erstellen",
|
"cta_primary": "QR-Code kostenlos erstellen",
|
||||||
"cta_secondary": "Demo ansehen",
|
"cta_secondary": "Preise ansehen",
|
||||||
"engagement_badge": "+47% Engagement-Steigerung"
|
"engagement_badge": "Kostenlos für immer",
|
||||||
|
"get_started": "Loslegen",
|
||||||
|
"view_full_pricing": "Alle Preisdetails ansehen →"
|
||||||
},
|
},
|
||||||
"trust": {
|
"trust": {
|
||||||
"users": "10.000+ Aktive Nutzer",
|
"users": "10.000+ Aktive Nutzer",
|
||||||
|
|
@ -109,10 +115,18 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pricing": {
|
"pricing": {
|
||||||
"title": "Einfache, transparente Preise",
|
"title": "Wählen Sie Ihren Plan",
|
||||||
"subtitle": "Wählen Sie den Plan, der zu Ihnen passt",
|
"subtitle": "Wählen Sie den perfekten Plan für Ihre QR-Code-Bedürfnisse",
|
||||||
|
"choose_plan": "Wählen Sie Ihren Plan",
|
||||||
|
"select_plan": "Wählen Sie den perfekten Plan für Ihre QR-Code-Bedürfnisse",
|
||||||
|
"current_plan": "Aktueller Plan",
|
||||||
|
"upgrade_to": "Upgrade auf",
|
||||||
|
"downgrade_to_free": "Zu Kostenlos zurückstufen",
|
||||||
|
"most_popular": "Beliebteste",
|
||||||
|
"all_plans_note": "Alle Pläne beinhalten unbegrenzte statische QR-Codes und Basis-Anpassung.",
|
||||||
"free": {
|
"free": {
|
||||||
"title": "Kostenlos",
|
"title": "Kostenlos",
|
||||||
|
"name": "Free",
|
||||||
"price": "€0",
|
"price": "€0",
|
||||||
"period": "für immer",
|
"period": "für immer",
|
||||||
"features": [
|
"features": [
|
||||||
|
|
@ -124,6 +138,7 @@
|
||||||
},
|
},
|
||||||
"pro": {
|
"pro": {
|
||||||
"title": "Pro",
|
"title": "Pro",
|
||||||
|
"name": "Pro",
|
||||||
"price": "€9",
|
"price": "€9",
|
||||||
"period": "pro Monat",
|
"period": "pro Monat",
|
||||||
"badge": "Beliebteste",
|
"badge": "Beliebteste",
|
||||||
|
|
@ -131,18 +146,20 @@
|
||||||
"50 dynamische QR-Codes",
|
"50 dynamische QR-Codes",
|
||||||
"Unbegrenzte statische QR-Codes",
|
"Unbegrenzte statische QR-Codes",
|
||||||
"Erweiterte Analytik (Scans, Geräte, Standorte)",
|
"Erweiterte Analytik (Scans, Geräte, Standorte)",
|
||||||
"Individuelles Branding (Farben & Logo)",
|
"Individuelles Branding (Farben)",
|
||||||
"Download als SVG/PNG"
|
"Download als SVG/PNG"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"business": {
|
"business": {
|
||||||
"title": "Business",
|
"title": "Business",
|
||||||
|
"name": "Business",
|
||||||
"price": "€29",
|
"price": "€29",
|
||||||
"period": "pro Monat",
|
"period": "pro Monat",
|
||||||
"features": [
|
"features": [
|
||||||
"500 dynamische QR-Codes",
|
"500 dynamische QR-Codes",
|
||||||
"Unbegrenzte statische QR-Codes",
|
"Unbegrenzte statische QR-Codes",
|
||||||
"Alles aus Pro",
|
"Alles aus Pro",
|
||||||
|
"Massen-QR-Erstellung (bis zu 1.000)",
|
||||||
"Prioritäts-E-Mail-Support",
|
"Prioritäts-E-Mail-Support",
|
||||||
"Erweiterte Tracking & Insights"
|
"Erweiterte Tracking & Insights"
|
||||||
]
|
]
|
||||||
|
|
@ -203,14 +220,42 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"create": {
|
"create": {
|
||||||
"title": "Dynamische QR-Codes erstellen",
|
"title": "QR-Code erstellen",
|
||||||
|
"subtitle": "Generieren Sie dynamische und statische QR-Codes mit individuellem Branding",
|
||||||
"content": "Inhalt",
|
"content": "Inhalt",
|
||||||
"type": "QR-Code-Typ",
|
"type": "QR-Code-Typ",
|
||||||
"style": "Stil & Branding",
|
"style": "Stil & Branding",
|
||||||
"preview": "Live-Vorschau"
|
"preview": "Live-Vorschau",
|
||||||
|
"title_label": "Titel",
|
||||||
|
"title_placeholder": "Mein QR-Code",
|
||||||
|
"content_type": "Inhaltstyp",
|
||||||
|
"url_label": "URL",
|
||||||
|
"url_placeholder": "https://beispiel.de",
|
||||||
|
"tags_label": "Tags (durch Komma getrennt)",
|
||||||
|
"tags_placeholder": "marketing, kampagne, 2025",
|
||||||
|
"qr_code_type": "QR-Code-Typ",
|
||||||
|
"dynamic": "Dynamisch",
|
||||||
|
"static": "Statisch",
|
||||||
|
"recommended": "Empfohlen",
|
||||||
|
"dynamic_description": "Dynamisch: Scans verfolgen, URL später bearbeiten, Analytik ansehen. QR enthält Tracking-Link.",
|
||||||
|
"static_description": "Statisch: Direkt zum Inhalt, kein Tracking, nicht bearbeitbar. QR enthält tatsächlichen Inhalt.",
|
||||||
|
"foreground_color": "Vordergrundfarbe",
|
||||||
|
"background_color": "Hintergrundfarbe",
|
||||||
|
"corner_style": "Eckenstil",
|
||||||
|
"size": "Größe",
|
||||||
|
"good_contrast": "Guter Kontrast",
|
||||||
|
"contrast_ratio": "Kontrastverhältnis",
|
||||||
|
"download_svg": "SVG herunterladen",
|
||||||
|
"download_png": "PNG herunterladen",
|
||||||
|
"save_qr_code": "QR-Code speichern"
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
"title": "Analytik",
|
"title": "Analytik",
|
||||||
|
"subtitle": "Verfolgen und analysieren Sie die Performance Ihrer QR-Codes",
|
||||||
|
"export_report": "Bericht exportieren",
|
||||||
|
"from_last_period": "vom letzten Zeitraum",
|
||||||
|
"no_mobile_scans": "Keine mobilen Scans",
|
||||||
|
"of_total": "der Gesamtmenge",
|
||||||
"ranges": {
|
"ranges": {
|
||||||
"7d": "7 Tage",
|
"7d": "7 Tage",
|
||||||
"30d": "30 Tage",
|
"30d": "30 Tage",
|
||||||
|
|
@ -219,7 +264,7 @@
|
||||||
"kpis": {
|
"kpis": {
|
||||||
"total_scans": "Gesamte Scans",
|
"total_scans": "Gesamte Scans",
|
||||||
"avg_scans": "Ø Scans/QR",
|
"avg_scans": "Ø Scans/QR",
|
||||||
"mobile_usage": "Mobile Nutzung %",
|
"mobile_usage": "Mobile Nutzung",
|
||||||
"top_country": "Top Land"
|
"top_country": "Top Land"
|
||||||
},
|
},
|
||||||
"charts": {
|
"charts": {
|
||||||
|
|
@ -230,21 +275,43 @@
|
||||||
"table": {
|
"table": {
|
||||||
"qr_code": "QR-Code",
|
"qr_code": "QR-Code",
|
||||||
"type": "Typ",
|
"type": "Typ",
|
||||||
|
"total_scans": "Gesamte Scans",
|
||||||
|
"unique_scans": "Einzigartige Scans",
|
||||||
|
"conversion": "Konversion",
|
||||||
|
"trend": "Trend",
|
||||||
"scans": "Scans",
|
"scans": "Scans",
|
||||||
|
"percentage": "Prozent",
|
||||||
|
"country": "Land",
|
||||||
"performance": "Performance",
|
"performance": "Performance",
|
||||||
"created": "Erstellt",
|
"created": "Erstellt",
|
||||||
"status": "Status"
|
"status": "Status"
|
||||||
}
|
},
|
||||||
|
"performance_title": "QR-Code-Performance"
|
||||||
},
|
},
|
||||||
"bulk": {
|
"bulk": {
|
||||||
"title": "Bulk-Upload",
|
"title": "Massen-Erstellung",
|
||||||
|
"subtitle": "Erstellen Sie mehrere QR-Codes gleichzeitig aus CSV- oder Excel-Dateien",
|
||||||
|
"template_warning_title": "Bitte folgen Sie dem Vorlagenformat",
|
||||||
|
"template_warning_text": "Laden Sie die Vorlage unten herunter und folgen Sie dem Format genau. Ihre CSV muss Spalten für Titel und Inhalt (URL) enthalten.",
|
||||||
|
"static_only_title": "Nur statische QR-Codes",
|
||||||
|
"static_only_text": "Massen-Erstellung generiert statische QR-Codes, die nach der Erstellung nicht bearbeitet werden können. Diese QR-Codes beinhalten kein Tracking oder Analytik. Perfekt für Druckmaterialien und Offline-Nutzung.",
|
||||||
|
"download_template": "Vorlage herunterladen",
|
||||||
|
"no_file_selected": "Keine ausgewählt",
|
||||||
|
"simple_format": "Einfaches Format",
|
||||||
|
"just_title_url": "Nur Titel & URL",
|
||||||
|
"static_qr_codes": "Statische QR-Codes",
|
||||||
|
"no_tracking": "Kein Tracking enthalten",
|
||||||
|
"instant_download": "Sofortiger Download",
|
||||||
|
"get_zip": "ZIP mit allen SVGs erhalten",
|
||||||
|
"max_rows": "max 1.000 Zeilen",
|
||||||
"steps": {
|
"steps": {
|
||||||
"upload": "Upload",
|
"upload": "Datei hochladen",
|
||||||
"preview": "Vorschau",
|
"preview": "Vorschau & Zuordnung",
|
||||||
"complete": "Abschließen"
|
"download": "Herunterladen"
|
||||||
},
|
},
|
||||||
"drag_drop": "Datei hier hinziehen oder klicken zum Durchsuchen",
|
"drag_drop": "Datei hier hinziehen",
|
||||||
"supported_formats": "Unterstützte Formate: .csv, .xls, .xlsx"
|
"or_click": "oder klicken zum Durchsuchen",
|
||||||
|
"supported_formats": "Unterstützt CSV, XLS, XLSX (max 1.000 Zeilen)"
|
||||||
},
|
},
|
||||||
"integrations": {
|
"integrations": {
|
||||||
"title": "Integrationen",
|
"title": "Integrationen",
|
||||||
|
|
@ -285,6 +352,7 @@
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Einstellungen",
|
"title": "Einstellungen",
|
||||||
|
"subtitle": "Verwalten Sie Ihre Kontoeinstellungen und Präferenzen",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"profile": "Profil",
|
"profile": "Profil",
|
||||||
"billing": "Abrechnung",
|
"billing": "Abrechnung",
|
||||||
|
|
|
||||||
120
src/i18n/en.json
120
src/i18n/en.json
|
|
@ -5,27 +5,31 @@
|
||||||
"faq": "FAQ",
|
"faq": "FAQ",
|
||||||
"blog": "Blog",
|
"blog": "Blog",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
"dashboard": "Dashboard"
|
"dashboard": "Dashboard",
|
||||||
|
"create_qr": "Create QR",
|
||||||
|
"bulk_creation": "Bulk Creation",
|
||||||
|
"analytics": "Analytics",
|
||||||
|
"settings": "Settings"
|
||||||
},
|
},
|
||||||
"hero": {
|
"hero": {
|
||||||
"badge": "🚀 The Internet's Favorite QR Code Creator",
|
"badge": "Free QR Code Generator",
|
||||||
"title": "Create Custom QR Codes in Seconds",
|
"title": "Create QR Codes That Work Everywhere",
|
||||||
"subtitle": "Generate static and dynamic QR codes with advanced tracking, professional templates, and seamless integrations.",
|
"subtitle": "Generate static and dynamic QR codes with tracking, custom branding, and bulk generation. Free forever.",
|
||||||
"features": [
|
"features": [
|
||||||
"No credit card required to start",
|
"No credit card required to start",
|
||||||
"Create QR codes free forever",
|
"Create QR codes free forever",
|
||||||
"Advanced tracking and analytics",
|
"Advanced tracking and analytics",
|
||||||
"Professional templates included"
|
"Custom colors and styles"
|
||||||
],
|
],
|
||||||
"cta_primary": "Make a QR Code Free",
|
"cta_primary": "Make a QR Code Free",
|
||||||
"cta_secondary": "Watch Demo",
|
"cta_secondary": "View Pricing",
|
||||||
"engagement_badge": "+47% Engagement Up"
|
"engagement_badge": "Free Forever"
|
||||||
},
|
},
|
||||||
"trust": {
|
"trust": {
|
||||||
"users": "10,000+ Active Users",
|
"users": "Trusted by small businesses",
|
||||||
"codes": "500,000+ QR Codes Created",
|
"codes": "Simple QR code creation",
|
||||||
"scans": "50M+ Scans Tracked",
|
"scans": "Track every scan",
|
||||||
"countries": "120+ Countries"
|
"countries": "Works worldwide"
|
||||||
},
|
},
|
||||||
"industries": {
|
"industries": {
|
||||||
"restaurant": "Restaurant Chain",
|
"restaurant": "Restaurant Chain",
|
||||||
|
|
@ -89,7 +93,11 @@
|
||||||
},
|
},
|
||||||
"customization": {
|
"customization": {
|
||||||
"title": "Full Customization",
|
"title": "Full Customization",
|
||||||
"description": "Brand your QR codes with custom colors, logos, and styling options."
|
"description": "Brand your QR codes with custom colors and styling options."
|
||||||
|
},
|
||||||
|
"unlimited": {
|
||||||
|
"title": "Unlimited Static QR Codes",
|
||||||
|
"description": "Create as many static QR codes as you need. Free forever, no limits."
|
||||||
},
|
},
|
||||||
"bulk": {
|
"bulk": {
|
||||||
"title": "Bulk Operations",
|
"title": "Bulk Operations",
|
||||||
|
|
@ -109,10 +117,18 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pricing": {
|
"pricing": {
|
||||||
"title": "Simple, transparent pricing",
|
"title": "Choose Your Plan",
|
||||||
"subtitle": "Choose the plan that's right for you",
|
"subtitle": "Select the perfect plan for your QR code needs",
|
||||||
|
"choose_plan": "Choose Your Plan",
|
||||||
|
"select_plan": "Select the perfect plan for your QR code needs",
|
||||||
|
"current_plan": "Current Plan",
|
||||||
|
"upgrade_to": "Upgrade to",
|
||||||
|
"downgrade_to_free": "Downgrade to Free",
|
||||||
|
"most_popular": "Most Popular",
|
||||||
|
"all_plans_note": "All plans include unlimited static QR codes and basic customization.",
|
||||||
"free": {
|
"free": {
|
||||||
"title": "Free",
|
"title": "Free",
|
||||||
|
"name": "Free",
|
||||||
"price": "€0",
|
"price": "€0",
|
||||||
"period": "forever",
|
"period": "forever",
|
||||||
"features": [
|
"features": [
|
||||||
|
|
@ -124,6 +140,7 @@
|
||||||
},
|
},
|
||||||
"pro": {
|
"pro": {
|
||||||
"title": "Pro",
|
"title": "Pro",
|
||||||
|
"name": "Pro",
|
||||||
"price": "€9",
|
"price": "€9",
|
||||||
"period": "per month",
|
"period": "per month",
|
||||||
"badge": "Most Popular",
|
"badge": "Most Popular",
|
||||||
|
|
@ -131,18 +148,20 @@
|
||||||
"50 dynamic QR codes",
|
"50 dynamic QR codes",
|
||||||
"Unlimited static QR codes",
|
"Unlimited static QR codes",
|
||||||
"Advanced analytics (scans, devices, locations)",
|
"Advanced analytics (scans, devices, locations)",
|
||||||
"Custom branding (colors & logo)",
|
"Custom branding (colors)",
|
||||||
"Download as SVG/PNG"
|
"Download as SVG/PNG"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"business": {
|
"business": {
|
||||||
"title": "Business",
|
"title": "Business",
|
||||||
|
"name": "Business",
|
||||||
"price": "€29",
|
"price": "€29",
|
||||||
"period": "per month",
|
"period": "per month",
|
||||||
"features": [
|
"features": [
|
||||||
"500 dynamic QR codes",
|
"500 dynamic QR codes",
|
||||||
"Unlimited static QR codes",
|
"Unlimited static QR codes",
|
||||||
"Everything from Pro",
|
"Everything from Pro",
|
||||||
|
"Bulk QR Creation (up to 1,000)",
|
||||||
"Priority email support",
|
"Priority email support",
|
||||||
"Advanced tracking & insights"
|
"Advanced tracking & insights"
|
||||||
]
|
]
|
||||||
|
|
@ -203,14 +222,42 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"create": {
|
"create": {
|
||||||
"title": "Create Dynamic QR Codes",
|
"title": "Create QR Code",
|
||||||
|
"subtitle": "Generate dynamic and static QR codes with custom branding",
|
||||||
"content": "Content",
|
"content": "Content",
|
||||||
"type": "QR Code Type",
|
"type": "QR Code Type",
|
||||||
"style": "Style & Branding",
|
"style": "Style & Branding",
|
||||||
"preview": "Live Preview"
|
"preview": "Live Preview",
|
||||||
|
"title_label": "Title",
|
||||||
|
"title_placeholder": "My QR Code",
|
||||||
|
"content_type": "Content Type",
|
||||||
|
"url_label": "URL",
|
||||||
|
"url_placeholder": "https://example.com",
|
||||||
|
"tags_label": "Tags (comma-separated)",
|
||||||
|
"tags_placeholder": "marketing, campaign, 2025",
|
||||||
|
"qr_code_type": "QR Code Type",
|
||||||
|
"dynamic": "Dynamic",
|
||||||
|
"static": "Static",
|
||||||
|
"recommended": "Recommended",
|
||||||
|
"dynamic_description": "Dynamic: Track scans, edit URL later, view analytics. QR contains tracking link.",
|
||||||
|
"static_description": "Static: Direct to content, no tracking, cannot edit. QR contains actual content.",
|
||||||
|
"foreground_color": "Foreground Color",
|
||||||
|
"background_color": "Background Color",
|
||||||
|
"corner_style": "Corner Style",
|
||||||
|
"size": "Size",
|
||||||
|
"good_contrast": "Good contrast",
|
||||||
|
"contrast_ratio": "Contrast ratio",
|
||||||
|
"download_svg": "Download SVG",
|
||||||
|
"download_png": "Download PNG",
|
||||||
|
"save_qr_code": "Save QR Code"
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
"title": "Analytics",
|
"title": "Analytics",
|
||||||
|
"subtitle": "Track and analyze your QR code performance",
|
||||||
|
"export_report": "Export Report",
|
||||||
|
"from_last_period": "from last period",
|
||||||
|
"no_mobile_scans": "No mobile scans",
|
||||||
|
"of_total": "of total",
|
||||||
"ranges": {
|
"ranges": {
|
||||||
"7d": "7 Days",
|
"7d": "7 Days",
|
||||||
"30d": "30 Days",
|
"30d": "30 Days",
|
||||||
|
|
@ -219,7 +266,7 @@
|
||||||
"kpis": {
|
"kpis": {
|
||||||
"total_scans": "Total Scans",
|
"total_scans": "Total Scans",
|
||||||
"avg_scans": "Avg Scans/QR",
|
"avg_scans": "Avg Scans/QR",
|
||||||
"mobile_usage": "Mobile Usage %",
|
"mobile_usage": "Mobile Usage",
|
||||||
"top_country": "Top Country"
|
"top_country": "Top Country"
|
||||||
},
|
},
|
||||||
"charts": {
|
"charts": {
|
||||||
|
|
@ -230,21 +277,43 @@
|
||||||
"table": {
|
"table": {
|
||||||
"qr_code": "QR Code",
|
"qr_code": "QR Code",
|
||||||
"type": "Type",
|
"type": "Type",
|
||||||
|
"total_scans": "Total Scans",
|
||||||
|
"unique_scans": "Unique Scans",
|
||||||
|
"conversion": "Conversion",
|
||||||
|
"trend": "Trend",
|
||||||
"scans": "Scans",
|
"scans": "Scans",
|
||||||
|
"percentage": "Percentage",
|
||||||
|
"country": "Country",
|
||||||
"performance": "Performance",
|
"performance": "Performance",
|
||||||
"created": "Created",
|
"created": "Created",
|
||||||
"status": "Status"
|
"status": "Status"
|
||||||
}
|
},
|
||||||
|
"performance_title": "QR Code Performance"
|
||||||
},
|
},
|
||||||
"bulk": {
|
"bulk": {
|
||||||
"title": "Bulk Upload",
|
"title": "Bulk Creation",
|
||||||
|
"subtitle": "Create multiple QR codes at once from CSV or Excel files",
|
||||||
|
"template_warning_title": "Please Follow the Template Format",
|
||||||
|
"template_warning_text": "Download the template below and follow the format exactly. Your CSV must include columns for title and content (URL).",
|
||||||
|
"static_only_title": "Static QR Codes Only",
|
||||||
|
"static_only_text": "Bulk creation generates static QR codes that cannot be edited after creation. These QR codes do not include tracking or analytics. Perfect for print materials and offline use.",
|
||||||
|
"download_template": "Download Template",
|
||||||
|
"no_file_selected": "No file selected",
|
||||||
|
"simple_format": "Simple Format",
|
||||||
|
"just_title_url": "Just title & URL",
|
||||||
|
"static_qr_codes": "Static QR Codes",
|
||||||
|
"no_tracking": "No tracking included",
|
||||||
|
"instant_download": "Instant Download",
|
||||||
|
"get_zip": "Get ZIP with all SVGs",
|
||||||
|
"max_rows": "max 1,000 rows",
|
||||||
"steps": {
|
"steps": {
|
||||||
"upload": "Upload",
|
"upload": "Upload File",
|
||||||
"preview": "Preview",
|
"preview": "Preview & Map",
|
||||||
"complete": "Complete"
|
"download": "Download"
|
||||||
},
|
},
|
||||||
"drag_drop": "Drag & drop your file here, or click to browse",
|
"drag_drop": "Drag & drop your file here",
|
||||||
"supported_formats": "Supported formats: .csv, .xls, .xlsx"
|
"or_click": "or click to browse",
|
||||||
|
"supported_formats": "Supports CSV, XLS, XLSX (max 1,000 rows)"
|
||||||
},
|
},
|
||||||
"integrations": {
|
"integrations": {
|
||||||
"title": "Integrations",
|
"title": "Integrations",
|
||||||
|
|
@ -285,6 +354,7 @@
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
|
"subtitle": "Manage your account settings and preferences",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"billing": "Billing",
|
"billing": "Billing",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
/**
|
||||||
|
* Cookie configuration helpers
|
||||||
|
* Automatically uses secure settings in production
|
||||||
|
*/
|
||||||
|
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cookie options for authentication cookies
|
||||||
|
*/
|
||||||
|
export function getAuthCookieOptions() {
|
||||||
|
return {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isProduction, // HTTPS only in production
|
||||||
|
sameSite: 'lax' as const,
|
||||||
|
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cookie options for CSRF tokens
|
||||||
|
* Note: httpOnly is false so client-side JavaScript can read the token
|
||||||
|
*/
|
||||||
|
export function getCsrfCookieOptions() {
|
||||||
|
return {
|
||||||
|
httpOnly: false, // Client needs to read this token
|
||||||
|
secure: isProduction, // HTTPS only in production
|
||||||
|
sameSite: 'lax' as const,
|
||||||
|
maxAge: 60 * 60 * 24, // 24 hours
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if running in production
|
||||||
|
*/
|
||||||
|
export function isProductionEnvironment(): boolean {
|
||||||
|
return isProduction;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { getCsrfCookieOptions } from './cookieConfig';
|
||||||
|
|
||||||
|
const CSRF_TOKEN_COOKIE = 'csrf_token';
|
||||||
|
const CSRF_TOKEN_HEADER = 'x-csrf-token';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a new CSRF token and set it as a cookie
|
||||||
|
*/
|
||||||
|
export function generateCsrfToken(): string {
|
||||||
|
const token = uuidv4();
|
||||||
|
|
||||||
|
cookies().set(CSRF_TOKEN_COOKIE, token, getCsrfCookieOptions());
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the CSRF token from cookies
|
||||||
|
*/
|
||||||
|
export function getCsrfToken(): string | undefined {
|
||||||
|
return cookies().get(CSRF_TOKEN_COOKIE)?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate CSRF token from request header against cookie
|
||||||
|
*/
|
||||||
|
export function validateCsrfToken(headerToken: string | null): boolean {
|
||||||
|
if (!headerToken) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieToken = getCsrfToken();
|
||||||
|
|
||||||
|
if (!cookieToken) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constant-time comparison to prevent timing attacks
|
||||||
|
return cookieToken === headerToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSRF Protection middleware for API routes
|
||||||
|
*/
|
||||||
|
export function csrfProtection(request: Request): { valid: boolean; error?: string } {
|
||||||
|
const method = request.method;
|
||||||
|
|
||||||
|
// Only protect state-changing methods
|
||||||
|
if (['GET', 'HEAD', 'OPTIONS'].includes(method)) {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerToken = request.headers.get(CSRF_TOKEN_HEADER);
|
||||||
|
|
||||||
|
if (!validateCsrfToken(headerToken)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: 'Invalid or missing CSRF token'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get CSRF token for client-side use
|
||||||
|
* This should be called from a GET endpoint
|
||||||
|
*/
|
||||||
|
export function getOrCreateCsrfToken(): string {
|
||||||
|
let token = getCsrfToken();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
token = generateCsrfToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
/**
|
||||||
|
* Simple in-memory rate limiter
|
||||||
|
* For production with multiple servers, consider using Redis/Upstash
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface RateLimitEntry {
|
||||||
|
count: number;
|
||||||
|
resetAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store rate limit data in memory
|
||||||
|
const rateLimitStore = new Map<string, RateLimitEntry>();
|
||||||
|
|
||||||
|
// Cleanup old entries every 5 minutes
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, entry] of rateLimitStore.entries()) {
|
||||||
|
if (entry.resetAt < now) {
|
||||||
|
rateLimitStore.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 5 * 60 * 1000);
|
||||||
|
|
||||||
|
export interface RateLimitConfig {
|
||||||
|
/**
|
||||||
|
* Maximum number of requests allowed in the window
|
||||||
|
*/
|
||||||
|
maxRequests: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time window in seconds
|
||||||
|
*/
|
||||||
|
windowSeconds: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unique identifier for this rate limiter (e.g., 'login', 'signup')
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RateLimitResult {
|
||||||
|
success: boolean;
|
||||||
|
limit: number;
|
||||||
|
remaining: number;
|
||||||
|
reset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a request should be rate limited
|
||||||
|
*
|
||||||
|
* @param identifier - Unique identifier for the client (e.g., IP address, email)
|
||||||
|
* @param config - Rate limit configuration
|
||||||
|
* @returns RateLimitResult
|
||||||
|
*/
|
||||||
|
export function rateLimit(
|
||||||
|
identifier: string,
|
||||||
|
config: RateLimitConfig
|
||||||
|
): RateLimitResult {
|
||||||
|
const key = `${config.name}:${identifier}`;
|
||||||
|
const now = Date.now();
|
||||||
|
const windowMs = config.windowSeconds * 1000;
|
||||||
|
|
||||||
|
let entry = rateLimitStore.get(key);
|
||||||
|
|
||||||
|
// Create new entry if doesn't exist or expired
|
||||||
|
if (!entry || entry.resetAt < now) {
|
||||||
|
entry = {
|
||||||
|
count: 0,
|
||||||
|
resetAt: now + windowMs,
|
||||||
|
};
|
||||||
|
rateLimitStore.set(key, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment counter
|
||||||
|
entry.count++;
|
||||||
|
|
||||||
|
const remaining = Math.max(0, config.maxRequests - entry.count);
|
||||||
|
const success = entry.count <= config.maxRequests;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success,
|
||||||
|
limit: config.maxRequests,
|
||||||
|
remaining,
|
||||||
|
reset: entry.resetAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get client identifier from request (IP address)
|
||||||
|
*/
|
||||||
|
export function getClientIdentifier(request: Request): string {
|
||||||
|
// Try to get real IP from headers (for proxies/load balancers)
|
||||||
|
const forwardedFor = request.headers.get('x-forwarded-for');
|
||||||
|
if (forwardedFor) {
|
||||||
|
return forwardedFor.split(',')[0].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const realIp = request.headers.get('x-real-ip');
|
||||||
|
if (realIp) {
|
||||||
|
return realIp;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback (this won't work well in production behind a proxy)
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Predefined rate limit configurations
|
||||||
|
*/
|
||||||
|
export const RateLimits = {
|
||||||
|
// Login: 5 attempts per 15 minutes
|
||||||
|
LOGIN: {
|
||||||
|
name: 'login',
|
||||||
|
maxRequests: 5,
|
||||||
|
windowSeconds: 15 * 60,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Signup: 3 accounts per hour
|
||||||
|
SIGNUP: {
|
||||||
|
name: 'signup',
|
||||||
|
maxRequests: 3,
|
||||||
|
windowSeconds: 60 * 60,
|
||||||
|
},
|
||||||
|
|
||||||
|
// API: 100 requests per minute
|
||||||
|
API: {
|
||||||
|
name: 'api',
|
||||||
|
maxRequests: 100,
|
||||||
|
windowSeconds: 60,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Password reset: 3 attempts per hour
|
||||||
|
PASSWORD_RESET: {
|
||||||
|
name: 'password-reset',
|
||||||
|
maxRequests: 3,
|
||||||
|
windowSeconds: 60 * 60,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -5,7 +5,7 @@ if (!process.env.STRIPE_SECRET_KEY) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
|
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
|
||||||
apiVersion: '2024-11-20.acacia',
|
apiVersion: '2025-09-30.clover',
|
||||||
typescript: true,
|
typescript: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -36,10 +36,10 @@ export const STRIPE_PLANS = {
|
||||||
interval: 'month',
|
interval: 'month',
|
||||||
features: [
|
features: [
|
||||||
'50 QR-Codes',
|
'50 QR-Codes',
|
||||||
'Branding (Logo, Farben)',
|
'Branding (Farben)',
|
||||||
'Detaillierte Analytics (Datum, Gerät, Stadt)',
|
'Detaillierte Analytics (Datum, Gerät, Stadt)',
|
||||||
'CSV-Export',
|
'CSV-Export',
|
||||||
'Passwortschutz',
|
'SVG/PNG Download',
|
||||||
],
|
],
|
||||||
limits: {
|
limits: {
|
||||||
dynamicQRCodes: 50,
|
dynamicQRCodes: 50,
|
||||||
|
|
@ -57,16 +57,14 @@ export const STRIPE_PLANS = {
|
||||||
interval: 'month',
|
interval: 'month',
|
||||||
features: [
|
features: [
|
||||||
'500 QR-Codes',
|
'500 QR-Codes',
|
||||||
'Team-Zugänge (bis 3 User)',
|
'Alles von Pro',
|
||||||
'API-Zugang',
|
'Bulk QR-Generierung (bis 1,000)',
|
||||||
'Benutzerdefinierte Domains',
|
|
||||||
'White-Label',
|
|
||||||
'Prioritäts-Support',
|
'Prioritäts-Support',
|
||||||
],
|
],
|
||||||
limits: {
|
limits: {
|
||||||
dynamicQRCodes: 500,
|
dynamicQRCodes: 500,
|
||||||
staticQRCodes: -1,
|
staticQRCodes: -1,
|
||||||
teamMembers: 3,
|
teamMembers: 1,
|
||||||
},
|
},
|
||||||
priceId: process.env.STRIPE_PRICE_ID_BUSINESS_MONTHLY,
|
priceId: process.env.STRIPE_PRICE_ID_BUSINESS_MONTHLY,
|
||||||
priceIdYearly: process.env.STRIPE_PRICE_ID_BUSINESS_YEARLY,
|
priceIdYearly: process.env.STRIPE_PRICE_ID_BUSINESS_YEARLY,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue