This commit is contained in:
Timo Knuth 2025-10-18 17:55:32 +02:00
parent 254e6490b8
commit 91b78cb284
65 changed files with 4481 additions and 1078 deletions

View File

@ -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": []

View File

@ -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

202
package-lock.json generated
View File

@ -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",

View File

@ -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",

32
public/favicon.svg Normal file
View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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>
);
}

View File

@ -53,7 +53,7 @@ export default function CreatePage() {
}; };
fetchUserPlan(); fetchUserPlan();
}, []); }, []);
const contrast = calculateContrast(foregroundColor, backgroundColor); const contrast = calculateContrast(foregroundColor, backgroundColor);
const hasGoodContrast = contrast >= 4.5; const hasGoodContrast = contrast >= 4.5;
@ -123,7 +123,7 @@ export default function CreatePage() {
light: backgroundColor, light: backgroundColor,
}, },
}); });
const blob = new Blob([svg], { type: 'image/svg+xml' }); const blob = new Blob([svg], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
@ -165,9 +165,9 @@ export default function CreatePage() {
size, size,
}, },
}; };
console.log('SENDING QR DATA:', qrData); console.log('SENDING QR DATA:', qrData);
const response = await fetch('/api/qrs', { const response = await fetch('/api/qrs', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -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}>
@ -307,21 +308,21 @@ export default function CreatePage() {
placeholder="My QR Code" placeholder="My QR Code"
required required
/> />
<Select <Select
label="Content Type" label="Content Type"
value={contentType} value={contentType}
onChange={(e) => setContentType(e.target.value)} onChange={(e) => setContentType(e.target.value)}
options={contentTypes} options={contentTypes}
/> />
{renderContentFields()} {renderContentFields()}
<Input <Input
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>
@ -427,7 +428,7 @@ export default function CreatePage() {
</div> </div>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<Select <Select
label="Corner Style" label="Corner Style"
@ -438,7 +439,7 @@ export default function CreatePage() {
{ value: 'rounded', label: 'Rounded' }, { value: 'rounded', label: 'Rounded' },
]} ]}
/> />
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Size: {size}px Size: {size}px
@ -453,7 +454,7 @@ export default function CreatePage() {
/> />
</div> </div>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Badge variant={hasGoodContrast ? 'success' : 'warning'}> <Badge variant={hasGoodContrast ? 'success' : 'warning'}>
{hasGoodContrast ? 'Good contrast' : 'Low contrast'} {hasGoodContrast ? 'Good contrast' : 'Low contrast'}
@ -490,11 +491,11 @@ export default function CreatePage() {
</div> </div>
)} )}
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<Button <Button
variant="outline" variant="outline"
className="w-full" className="w-full"
type="button" type="button"
onClick={() => { onClick={() => {
const svg = document.querySelector('#create-qr-preview svg'); const svg = document.querySelector('#create-qr-preview svg');
@ -512,9 +513,9 @@ export default function CreatePage() {
> >
Download SVG Download SVG
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
className="w-full" className="w-full"
type="button" type="button"
onClick={() => { onClick={() => {
const svg = document.querySelector('#create-qr-preview svg'); const svg = document.querySelector('#create-qr-preview svg');
@ -525,7 +526,7 @@ export default function CreatePage() {
const svgData = new XMLSerializer().serializeToString(svg); const svgData = new XMLSerializer().serializeToString(svg);
const blob = new Blob([svgData], { type: 'image/svg+xml' }); const blob = new Blob([svgData], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
img.onload = () => { img.onload = () => {
canvas.width = 200; canvas.width = 200;
canvas.height = 200; canvas.height = 200;

View File

@ -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,9 +346,21 @@ 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>
<Link href="/create"> <div className="flex gap-3">
<Button>Create New QR Code</Button> {qrCodes.length > 0 && (
</Link> <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">
<Button>Create New QR Code</Button>
</Link>
</div>
</div> </div>
{loading ? ( {loading ? (
@ -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>

View File

@ -220,17 +220,17 @@ export default function IntegrationsPage() {
<div className="text-3xl">{integration.icon}</div> <div className="text-3xl">{integration.icon}</div>
<div> <div>
<CardTitle className="text-lg">{integration.name}</CardTitle> <CardTitle className="text-lg">{integration.name}</CardTitle>
<Badge <Badge
variant={ variant={
integration.status === 'active' ? 'success' : integration.status === 'active' ? 'success' :
integration.status === 'coming_soon' ? 'warning' : integration.status === 'coming_soon' ? 'warning' :
'default' 'default'
} }
className="mt-1" className="mt-1"
> >
{integration.status === 'active' ? 'Active' : {integration.status === 'active' ? 'Active' :
integration.status === 'coming_soon' ? 'Coming Soon' : integration.status === 'coming_soon' ? 'Coming Soon' :
'Inactive'} 'Inactive'}
</Badge> </Badge>
</div> </div>
</div> </div>
@ -238,7 +238,7 @@ export default function IntegrationsPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm text-gray-600 mb-4">{integration.description}</p> <p className="text-sm text-gray-600 mb-4">{integration.description}</p>
<div className="space-y-2 mb-4"> <div className="space-y-2 mb-4">
{integration.features.slice(0, 3).map((feature, index) => ( {integration.features.slice(0, 3).map((feature, index) => (
<div key={index} className="flex items-start space-x-2"> <div key={index} className="flex items-start space-x-2">
@ -277,112 +277,112 @@ export default function IntegrationsPage() {
<div className="bg-white rounded-xl shadow-lg border border-gray-200 p-6 max-w-lg mx-auto"> <div className="bg-white rounded-xl shadow-lg border border-gray-200 p-6 max-w-lg mx-auto">
<h2 className="text-lg font-semibold mb-4">Setup {selectedIntegration.name}</h2> <h2 className="text-lg font-semibold mb-4">Setup {selectedIntegration.name}</h2>
<div className="space-y-4"> <div className="space-y-4">
{selectedIntegration.id === 'zapier' && ( {selectedIntegration.id === 'zapier' && (
<> <>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Webhook URL Webhook URL
</label>
<Input
value="https://hooks.zapier.com/hooks/catch/123456/abcdef/"
readOnly
className="font-mono text-sm"
/>
<p className="text-sm text-gray-500 mt-1">
Copy this URL to your Zapier trigger
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Events to Send
</label>
<div className="space-y-2">
<label className="flex items-center">
<input type="checkbox" className="mr-2" defaultChecked />
<span className="text-sm">QR Code Scanned</span>
</label>
<label className="flex items-center">
<input type="checkbox" className="mr-2" defaultChecked />
<span className="text-sm">QR Code Created</span>
</label>
<label className="flex items-center">
<input type="checkbox" className="mr-2" />
<span className="text-sm">QR Code Updated</span>
</label> </label>
<Input
value="https://hooks.zapier.com/hooks/catch/123456/abcdef/"
readOnly
className="font-mono text-sm"
/>
<p className="text-sm text-gray-500 mt-1">
Copy this URL to your Zapier trigger
</p>
</div> </div>
</div>
<div className="p-4 bg-gray-50 rounded-lg"> <div>
<h4 className="font-medium text-gray-900 mb-2">Sample Payload</h4> <label className="block text-sm font-medium text-gray-700 mb-2">
<pre className="text-xs text-gray-600 overflow-x-auto"> Events to Send
{`{ </label>
<div className="space-y-2">
<label className="flex items-center">
<input type="checkbox" className="mr-2" defaultChecked />
<span className="text-sm">QR Code Scanned</span>
</label>
<label className="flex items-center">
<input type="checkbox" className="mr-2" defaultChecked />
<span className="text-sm">QR Code Created</span>
</label>
<label className="flex items-center">
<input type="checkbox" className="mr-2" />
<span className="text-sm">QR Code Updated</span>
</label>
</div>
</div>
<div className="p-4 bg-gray-50 rounded-lg">
<h4 className="font-medium text-gray-900 mb-2">Sample Payload</h4>
<pre className="text-xs text-gray-600 overflow-x-auto">
{`{
"event": "qr_scanned", "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"
}`} }`}
</pre> </pre>
</div> </div>
</> </>
)} )}
{selectedIntegration.id === 'airtable' && ( {selectedIntegration.id === 'airtable' && (
<> <>
<Input <Input
label="API Key" label="API Key"
type="password" type="password"
value={apiKey} value={apiKey}
onChange={(e) => setApiKey(e.target.value)} onChange={(e) => setApiKey(e.target.value)}
placeholder="key..." placeholder="key..."
/> />
<Input <Input
label="Base ID" label="Base ID"
value="" value=""
placeholder="app..." placeholder="app..."
/> />
<Input <Input
label="Table Name" label="Table Name"
value="" value=""
placeholder="QR Codes" placeholder="QR Codes"
/> />
<Button variant="outline" onClick={handleTestConnection}> <Button variant="outline" onClick={handleTestConnection}>
Test Connection Test Connection
</Button>
</>
)}
{selectedIntegration.id === 'google-sheets' && (
<>
<div className="text-center p-6">
<Button>
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Connect Google Account
</Button> </Button>
</div> </>
<Input )}
label="Spreadsheet URL"
value=""
placeholder="https://docs.google.com/spreadsheets/..."
/>
</>
)}
<div className="flex justify-end space-x-3 pt-4"> {selectedIntegration.id === 'google-sheets' && (
<Button variant="outline" onClick={() => setShowSetupDialog(false)}> <>
Cancel <div className="text-center p-6">
</Button> <Button>
<Button onClick={handleSaveIntegration}> <svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
Save Integration <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" />
</Button> <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" />
</div> <path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
</svg>
Connect Google Account
</Button>
</div>
<Input
label="Spreadsheet URL"
value=""
placeholder="https://docs.google.com/spreadsheets/..."
/>
</>
)}
<div className="flex justify-end space-x-3 pt-4">
<Button variant="outline" onClick={() => setShowSetupDialog(false)}>
Cancel
</Button>
<Button onClick={handleSaveIntegration}>
Save Integration
</Button>
</div>
</div> </div>
</div> </div>
</Dialog> </Dialog>

View File

@ -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">

View File

@ -1,357 +1,229 @@
'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, );
});
// Only run once and only when authenticated if (!confirmed) {
if (hasTriggeredCheckout) {
console.log('Already triggered checkout, skipping...');
return; return;
} }
if (!user) { setLoading('FREE');
console.log('Not authenticated - no user in localStorage');
return;
}
// Check for pending plan in localStorage try {
const pendingPlanStr = localStorage.getItem('pendingPlan'); const response = await fetch('/api/stripe/cancel-subscription', {
if (pendingPlanStr) { method: 'POST',
try { headers: {
const pendingPlan = JSON.parse(pendingPlanStr); 'Content-Type': 'application/json',
console.log('✅ Found pending plan:', pendingPlan); },
});
// Clear pending plan immediately if (!response.ok) {
localStorage.removeItem('pendingPlan'); const error = await response.json();
throw new Error(error.error || 'Failed to cancel subscription');
// 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(() => {
console.log('🚀 Calling handleSubscribe now...');
handleSubscribe(selectedPlan.id, priceId);
}, 500);
} else {
console.error('❌ Plan not found:', pendingPlan.planId);
}
} catch (e) {
console.error('Error parsing pending plan:', e);
localStorage.removeItem('pendingPlan');
} }
} else {
console.log('No pending plan in localStorage'); showToast('Successfully downgraded to Free plan', 'success');
// Refresh to update the plan
setTimeout(() => {
window.location.reload();
}, 1500);
} catch (error: any) {
console.error('Error canceling subscription:', error);
showToast(error.message || 'Failed to downgrade. Please try again.', 'error');
setLoading(null);
} }
}, [user, hasTriggeredCheckout]); };
const plans = [
{
key: 'free',
name: 'Free',
price: '€0',
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; <Card
const priceId = key={plan.key}
billingInterval === 'yearly' ? plan.priceIdYearly : plan.priceIdMonthly; className={plan.popular ? 'border-primary-500 shadow-xl relative' : ''}
const isLoading = loading === plan.id; >
{plan.popular && (
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
<Badge variant="info" className="px-3 py-1">
Most Popular
</Badge>
</div>
)}
return ( <CardHeader className="text-center pb-8">
<Card <CardTitle className="text-2xl mb-4">
key={plan.id} {plan.name}
className={`relative ${ </CardTitle>
plan.popular <div className="flex items-baseline justify-center">
? 'border-primary-500 border-2 shadow-xl scale-105' <span className="text-4xl font-bold">
: 'border-gray-200' {plan.price}
}`} </span>
> <span className="text-gray-600 ml-2">
{plan.popular && ( {plan.period}
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2"> </span>
<Badge variant="info" className="px-4 py-1 text-sm"> </div>
Beliebteste Wahl </CardHeader>
</Badge>
</div>
)}
<CardHeader className="text-center pb-6"> <CardContent className="space-y-6">
{plan.icon && <div className="text-4xl mb-4">{plan.icon}</div>} <ul className="space-y-3">
<CardTitle className="text-2xl mb-2">{plan.name}</CardTitle> {plan.features.map((feature: string, index: number) => (
<p className="text-sm text-gray-600">{plan.description}</p> <li key={index} className="flex items-start space-x-3">
<svg className="w-5 h-5 text-success-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
<span className="text-gray-700">{feature}</span>
</li>
))}
</ul>
<div className="mt-6"> <Button
<div className="flex items-baseline justify-center"> variant={plan.buttonVariant}
<span className="text-5xl font-bold">{price}</span> className="w-full"
<span className="text-gray-600 ml-2"> size="lg"
/{billingInterval === 'yearly' ? 'Jahr' : 'Monat'} disabled={plan.disabled || loading === plan.key.toUpperCase()}
</span> onClick={plan.key === 'free' ? (plan as any).onDowngrade : (plan as any).onUpgrade}
</div> >
{billingInterval === 'yearly' && plan.price > 0 && ( {loading === plan.key.toUpperCase() ? 'Processing...' : plan.buttonText}
<p className="text-sm text-gray-500 mt-2"> </Button>
{(price / 12).toFixed(2)} pro Monat </CardContent>
</p> </Card>
)} ))}
</div>
</CardHeader>
<CardContent className="space-y-6">
<ul className="space-y-3">
{plan.features.map((feature, index) => (
<li key={index} className="flex items-start space-x-3">
<svg
className="w-5 h-5 text-success-500 flex-shrink-0 mt-0.5"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<span className="text-gray-700">{feature}</span>
</li>
))}
</ul>
<Button
variant={plan.popular ? 'primary' : 'outline'}
className="w-full"
size="lg"
onClick={() => handleSubscribe(plan.id, priceId)}
disabled={isLoading}
>
{isLoading ? 'Lädt...' : plan.cta}
</Button>
</CardContent>
</Card>
);
})}
</div> </div>
{/* FAQ Section */} <div className="text-center mt-12">
<div className="mt-20 max-w-3xl mx-auto"> <p className="text-gray-600">
<h2 className="text-3xl font-bold text-center mb-8">Häufige Fragen</h2> All plans include unlimited static QR codes and basic customization.
<div className="space-y-4"> </p>
<Card> <p className="text-gray-600 mt-2">
<CardContent className="p-6"> Need help choosing? <a href="mailto:support@qrmaster.com" className="text-primary-600 hover:text-primary-700 underline">Contact our team</a>
<h3 className="font-semibold mb-2">Kann ich jederzeit kündigen?</h3> </p>
<p className="text-gray-600">
Ja, Sie können Ihr Abo jederzeit kündigen. Es läuft dann bis zum Ende des
bezahlten Zeitraums weiter.
</p>
</CardContent>
</Card>
<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>
</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>
); );

View File

@ -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>
);
}

View File

@ -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 userStr = localStorage.getItem('user'); const fetchUserData = async () => {
if (userStr) {
try { try {
const user = JSON.parse(userStr); // Load from localStorage
setName(user.name || ''); const userStr = localStorage.getItem('user');
setEmail(user.email || ''); if (userStr) {
} catch (e) { const user = JSON.parse(userStr);
console.error('Failed to parse user data:', e); setName(user.name || '');
} setEmail(user.email || '');
} }
// 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,97 +84,305 @@ 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>
<div className="space-y-6"> {/* Tabs */}
{/* Profile Settings */} <div className="border-b border-gray-200 mb-6">
<Card> <nav className="-mb-px flex space-x-8">
<CardHeader> <button
<CardTitle>Profile Information</CardTitle> onClick={() => setActiveTab('profile')}
</CardHeader> className={`py-4 px-1 border-b-2 font-medium text-sm ${
<CardContent className="space-y-4"> activeTab === 'profile'
<div> ? 'border-primary-500 text-primary-600'
<label className="block text-sm font-medium text-gray-700 mb-2"> : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
Name }`}
</label>
<input
type="text"
value={name}
onChange={(e) => setName(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 your name"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Email
</label>
<input
type="email"
value={email}
disabled
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">
Email cannot be changed
</p>
</div>
</CardContent>
</Card>
{/* Language Settings */}
<Card>
<CardHeader>
<CardTitle>Language Preferences</CardTitle>
</CardHeader>
<CardContent>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Display Language
</label>
<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>
</div>
</CardContent>
</Card>
{/* Save Button */}
<div className="flex justify-end">
<Button
onClick={handleSave}
disabled={loading}
size="lg"
variant="primary"
> >
{loading ? 'Saving...' : 'Save Changes'} Profile
</Button> </button>
</div> <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> </div>
{/* Tab Content */}
{activeTab === 'profile' && (
<div className="space-y-6">
{/* Profile Information */}
<Card>
<CardHeader>
<CardTitle>Profile Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(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 your name"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Email
</label>
<input
type="email"
value={email}
disabled
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500 cursor-not-allowed"
/>
<p className="text-xs text-gray-500 mt-1">
Email cannot be changed
</p>
</div>
</CardContent>
</Card>
{/* Security */}
<Card>
<CardHeader>
<CardTitle>Security</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-gray-900">Password</h3>
<p className="text-sm text-gray-500 mt-1">
Update your password to keep your account secure
</p>
</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>
</Card>
{/* Save Button */}
<div className="flex justify-end">
<Button
onClick={handleSaveProfile}
disabled={loading}
size="lg"
variant="primary"
>
{loading ? 'Saving...' : 'Save Changes'}
</Button>
</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>
); );
} }

View File

@ -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">

View File

@ -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) { const data = await response.json();
// Auto sign in after signup
const result = await signIn('credentials', {
email,
password,
redirect: false,
});
if (result?.ok) { if (response.ok && data.success) {
router.push('/dashboard'); // Store user in localStorage for client-side
} localStorage.setItem('user', JSON.stringify(data.user));
// Redirect to dashboard
router.push('/dashboard');
router.refresh();
} else { } else {
const data = await response.json();
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 (

View File

@ -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,

View File

@ -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',
}, },
]; ];

View File

@ -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?',

View File

@ -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,18 +107,18 @@ 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>
</div> </div>
<div> <div>
<h3 className="font-semibold mb-4">Product</h3> <h3 className="font-semibold mb-4">Product</h3>
<ul className="space-y-2 text-gray-400"> <ul className="space-y-2 text-gray-400">
@ -127,33 +128,34 @@ export default function MarketingLayout({
<li><Link href="/blog" className="hover:text-white">Blog</Link></li> <li><Link href="/blog" className="hover:text-white">Blog</Link></li>
</ul> </ul>
</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>&copy; 2024 QR Master. All rights reserved.</p> <p>&copy; 2025 QR Master. All rights reserved.</p>
</div> </div>
</div> </div>
</footer> </footer>
{/* Cookie Banner */}
<CookieBanner />
</div> </div>
); );
} }

View File

@ -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>
</>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -63,17 +63,20 @@ 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
id: qr.id, .filter(qr => qr.type === 'DYNAMIC')
title: qr.title, .map(qr => ({
type: qr.type, id: qr.id,
totalScans: qr.scans.length, title: qr.title,
uniqueScans: qr.scans.filter(s => s.isUnique).length, type: qr.type,
conversion: qr.scans.length > 0 totalScans: qr.scans.length,
? Math.round((qr.scans.filter(s => s.isUnique).length / qr.scans.length) * 100) uniqueScans: qr.scans.filter(s => s.isUnique).length,
: 0, conversion: qr.scans.length > 0
})).sort((a, b) => b.totalScans - a.totalScans); ? Math.round((qr.scans.filter(s => s.isUnique).length / qr.scans.length) * 100)
: 0,
}))
.sort((a, b) => b.totalScans - a.totalScans);
return NextResponse.json({ return NextResponse.json({
summary: { summary: {

View File

@ -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`
);
}
}

View File

@ -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({
id: user.id, success: true,
name: user.name, user: {
email: user.email, id: user.id,
name: user.name,
email: user.email,
},
}); });
} catch (error) { } catch (error) {
if (error instanceof z.ZodError) { if (error instanceof z.ZodError) {

View File

@ -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,45 +46,28 @@ 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,
user: { id: user.id, email: user.email, name: user.name } user: { id: user.id, email: user.email, name: user.name }
}); });
} catch (error) { } catch (error) {
console.error('Login error:', error); console.error('Login error:', error);

14
src/app/api/csrf/route.ts Normal file
View File

@ -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 });
}

View File

@ -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,
}, },
}); });

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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,
}, },
}); });

View File

@ -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;

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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);

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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">
<AuthProvider> <PostHogProvider>
{children} <AuthProvider>
</AuthProvider> {children}
<ToastContainer /> </AuthProvider>
<CookieBanner />
<ToastContainer />
</PostHogProvider>
</body> </body>
</html> </html>
); );

View File

@ -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>
</>
);
}

View File

@ -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();
}
}

View File

@ -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>
<DropdownItem onClick={() => onEdit(qr.id)}>Edit</DropdownItem> {qr.type === 'DYNAMIC' && (
<DropdownItem onClick={() => onDuplicate(qr.id)}>Duplicate</DropdownItem> <DropdownItem onClick={() => onEdit(qr.id)}>Edit</DropdownItem>
<DropdownItem onClick={() => onPause(qr.id)}> )}
{qr.status === 'ACTIVE' ? 'Pause' : 'Resume'} {qr.type === 'DYNAMIC' && (
</DropdownItem> <DropdownItem onClick={() => onPause(qr.id)}>
{qr.status === 'ACTIVE' ? 'Pause' : 'Resume'}
</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>
<div className="flex items-center justify-between"> {qr.type === 'DYNAMIC' && (
<span className="text-gray-500">Scans:</span> <div className="flex items-center justify-between">
<span className="text-gray-900">{qr.scans || 0}</span> <span className="text-gray-500">Scans:</span>
</div> <span className="text-gray-900">{qr.scans || 0}</span>
</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>

View File

@ -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',

View File

@ -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>
); );

View File

@ -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}

View File

@ -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>

View File

@ -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} />

View File

@ -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>

View File

@ -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>
); );

View File

@ -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">

View File

@ -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">

View File

@ -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}

View File

@ -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>
);
}

62
src/hooks/useCsrf.ts Normal file
View File

@ -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,
};
}

View File

@ -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",

View File

@ -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",

38
src/lib/cookieConfig.ts Normal file
View File

@ -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;
}

79
src/lib/csrf.ts Normal file
View File

@ -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;
}

138
src/lib/rateLimit.ts Normal file
View File

@ -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,
},
};

View File

@ -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,