Compare commits
10 Commits
d4fc41c6bf
...
f84ec38af3
| Author | SHA1 | Date | |
|---|---|---|---|
| f84ec38af3 | |||
| a31d81dd3f | |||
| b5e588e265 | |||
| 2be1521e76 | |||
| 19edcd91bc | |||
| f7015e68a7 | |||
| d442bae300 | |||
| 567c22c588 | |||
| f8712015c0 | |||
| 4371b26423 |
27
bun.lock
27
bun.lock
@ -65,6 +65,8 @@
|
|||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
|
"playwright": "^1.56.1",
|
||||||
|
"posthog-js": "^1.292.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@ -81,6 +83,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.32.0",
|
"@eslint/js": "^9.32.0",
|
||||||
|
"@playwright/test": "^1.56.1",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@types/node": "^22.16.5",
|
"@types/node": "^22.16.5",
|
||||||
"@types/react": "^18.3.23",
|
"@types/react": "^18.3.23",
|
||||||
@ -237,6 +240,10 @@
|
|||||||
|
|
||||||
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||||
|
|
||||||
|
"@playwright/test": ["@playwright/test@1.56.1", "", { "dependencies": { "playwright": "1.56.1" }, "bin": { "playwright": "cli.js" } }, "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg=="],
|
||||||
|
|
||||||
|
"@posthog/core": ["@posthog/core@1.5.2", "", { "dependencies": { "cross-spawn": "^7.0.6" } }, "sha512-iedUP3EnOPPxTA2VaIrsrd29lSZnUV+ZrMnvY56timRVeZAXoYCkmjfIs3KBAsF8OUT5h1GXLSkoQdrV0r31OQ=="],
|
||||||
|
|
||||||
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
||||||
|
|
||||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||||
@ -563,6 +570,8 @@
|
|||||||
|
|
||||||
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||||
|
|
||||||
|
"core-js": ["core-js@3.46.0", "", {}, "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA=="],
|
||||||
|
|
||||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||||
|
|
||||||
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||||
@ -669,6 +678,8 @@
|
|||||||
|
|
||||||
"fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
|
"fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
|
||||||
|
|
||||||
|
"fflate": ["fflate@0.4.8", "", {}, "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA=="],
|
||||||
|
|
||||||
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||||
|
|
||||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||||
@ -835,6 +846,10 @@
|
|||||||
|
|
||||||
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
|
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
|
||||||
|
|
||||||
|
"playwright": ["playwright@1.56.1", "", { "dependencies": { "playwright-core": "1.56.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw=="],
|
||||||
|
|
||||||
|
"playwright-core": ["playwright-core@1.56.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ=="],
|
||||||
|
|
||||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||||
|
|
||||||
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
|
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
|
||||||
@ -857,6 +872,10 @@
|
|||||||
|
|
||||||
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
|
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
|
||||||
|
|
||||||
|
"posthog-js": ["posthog-js@1.292.0", "", { "dependencies": { "@posthog/core": "1.5.2", "core-js": "^3.38.1", "fflate": "^0.4.8", "preact": "^10.19.3", "web-vitals": "^4.2.4" } }, "sha512-sKBwrVT0vMqai8pAknmzK86vSQ5t36TDpQJyrLDj30ImkaiGuG4cVyI2wQ6l8vqdzk+rGAFUSP6Rqc2dMSQWWQ=="],
|
||||||
|
|
||||||
|
"preact": ["preact@10.27.2", "", {}, "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg=="],
|
||||||
|
|
||||||
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||||
|
|
||||||
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||||
@ -999,6 +1018,8 @@
|
|||||||
|
|
||||||
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
|
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
|
||||||
|
|
||||||
|
"web-vitals": ["web-vitals@4.2.4", "", {}, "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw=="],
|
||||||
|
|
||||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
|
|
||||||
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||||
@ -1063,6 +1084,8 @@
|
|||||||
|
|
||||||
"glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
"glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||||
|
|
||||||
|
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||||
|
|
||||||
"postcss-nested/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
|
"postcss-nested/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
|
||||||
|
|
||||||
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||||
@ -1075,6 +1098,8 @@
|
|||||||
|
|
||||||
"tailwindcss/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
|
"tailwindcss/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
|
||||||
|
|
||||||
|
"types/@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="],
|
||||||
|
|
||||||
"vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
|
"vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
|
||||||
|
|
||||||
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||||
@ -1133,6 +1158,8 @@
|
|||||||
|
|
||||||
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||||
|
|
||||||
|
"types/@types/bun/bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="],
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
|
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
|
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
|
||||||
|
|||||||
21
packages/backend/src/env/index.ts
vendored
21
packages/backend/src/env/index.ts
vendored
@ -1,16 +1,17 @@
|
|||||||
import { z } from 'zod';
|
import { z } from "zod";
|
||||||
import dotenv from 'dotenv';
|
import dotenv from "dotenv";
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config({ quiet: true });
|
||||||
|
|
||||||
const envSchema = z.object({
|
const envSchema = z.object({
|
||||||
PORT: z
|
PORT: z
|
||||||
.string()
|
.string()
|
||||||
.refine(
|
.refine(
|
||||||
(port) => parseInt(port) > 0 && parseInt(port) < 65536,
|
(port) => parseInt(port, 10) > 0 && parseInt(port, 10) < 65536,
|
||||||
"Invalid port number"
|
"Invalid port number",
|
||||||
),
|
),
|
||||||
DATABASE_URL: z.string().min(10)
|
DATABASE_URL: z.string().min(10),
|
||||||
|
FRONTEND_URL: z.url(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type Env = z.infer<typeof envSchema>;
|
type Env = z.infer<typeof envSchema>;
|
||||||
|
|||||||
85
packages/backend/src/index.ts
Normal file
85
packages/backend/src/index.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { ENV } from "./env";
|
||||||
|
import { getPetitions } from "./routes/get-petitions";
|
||||||
|
import { signPetition } from "./routes/sign-petition";
|
||||||
|
import { styleText } from "node:util";
|
||||||
|
|
||||||
|
const CORS_HEADERS = {
|
||||||
|
"Access-Control-Allow-Origin": ENV.FRONTEND_URL,
|
||||||
|
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||||
|
};
|
||||||
|
|
||||||
|
const allowCors = async (_: Request): Promise<Response> => {
|
||||||
|
return new Response(null, { status: 200, headers: CORS_HEADERS });
|
||||||
|
};
|
||||||
|
|
||||||
|
type Handler = (req: Request) => Promise<Response>;
|
||||||
|
type Middleware = (fn: Handler) => Handler;
|
||||||
|
|
||||||
|
const withCors = (fn: Handler): Handler => {
|
||||||
|
return async (req) => {
|
||||||
|
const res = await fn(req);
|
||||||
|
|
||||||
|
for (const [header, value] of Object.entries(CORS_HEADERS)) {
|
||||||
|
res.headers.set(header, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getColors = (status: number): Parameters<typeof styleText>[0] => {
|
||||||
|
if (status >= 200 && status < 300) {
|
||||||
|
return ["bgGreen", "white"];
|
||||||
|
} else if (status >= 300 && status < 400) {
|
||||||
|
return ["bgYellow", "white"];
|
||||||
|
} else if (status >= 400 && status < 500) {
|
||||||
|
return ["bgRed", "white"];
|
||||||
|
} else if (status >= 500) {
|
||||||
|
return ["bgRedBright", "white"];
|
||||||
|
} else {
|
||||||
|
return ["bgBlack", "white"];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const withLogger = (fn: Handler): Handler => {
|
||||||
|
return async (req) => {
|
||||||
|
const res = await fn(req);
|
||||||
|
|
||||||
|
const code = res.status;
|
||||||
|
|
||||||
|
const styles = getColors(code);
|
||||||
|
const codeText = styleText(styles, `${code}`);
|
||||||
|
|
||||||
|
const msg = `${codeText}: ${req.method} ${req.url}`;
|
||||||
|
console.log(msg);
|
||||||
|
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const withFrontendReferrer: Middleware = (fn) => {
|
||||||
|
return async (req) => {
|
||||||
|
const referrer = req.headers.get("referrer");
|
||||||
|
|
||||||
|
if (referrer !== ENV.FRONTEND_URL) {
|
||||||
|
return new Response(undefined, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return fn(req);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: ENV.PORT,
|
||||||
|
routes: {
|
||||||
|
"/health": new Response("alive!"),
|
||||||
|
"/sign": {
|
||||||
|
GET: withFrontendReferrer(withLogger(withCors(getPetitions))),
|
||||||
|
POST: withFrontendReferrer(withLogger(withCors(signPetition))),
|
||||||
|
OPTIONS: withFrontendReferrer(withLogger(allowCors)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`server running on ${server.url}`);
|
||||||
@ -1,8 +1,19 @@
|
|||||||
import { db } from "./database";
|
import { db } from "./database";
|
||||||
import { signaturesTable } from "./schema";
|
import { signaturesTable } from "./schema";
|
||||||
|
|
||||||
export const insertSignature = async (signature: typeof signaturesTable.$inferInsert): Promise<typeof signaturesTable.$inferSelect | undefined> => {
|
export const insertSignature = async (
|
||||||
const [insertedSignature] = await db.insert(signaturesTable).values(signature).returning();
|
signature: typeof signaturesTable.$inferInsert,
|
||||||
|
): Promise<typeof signaturesTable.$inferSelect | undefined> => {
|
||||||
|
const [insertedSignature] = await db
|
||||||
|
.insert(signaturesTable)
|
||||||
|
.values(signature)
|
||||||
|
.returning();
|
||||||
|
|
||||||
return insertedSignature;
|
return insertedSignature;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export const getSignatures = async (): Promise<
|
||||||
|
Array<typeof signaturesTable.$inferSelect>
|
||||||
|
> => {
|
||||||
|
return db.select().from(signaturesTable);
|
||||||
|
};
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { pgTable, text, uuid } from "drizzle-orm/pg-core";
|
import { pgTable, text, uuid, timestamp } from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
export const signaturesTable = pgTable("signatures", {
|
export const signaturesTable = pgTable("signatures", {
|
||||||
id: uuid().primaryKey().defaultRandom(),
|
id: uuid().primaryKey().defaultRandom(),
|
||||||
email: text().notNull(),
|
email: text().notNull(),
|
||||||
name: text(),
|
name: text(),
|
||||||
comment: text(),
|
comment: text(),
|
||||||
|
createdAt: timestamp().defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|||||||
10
packages/backend/src/routes/get-petitions.ts
Normal file
10
packages/backend/src/routes/get-petitions.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { signedPetitionArraySchema } from "types";
|
||||||
|
import { getSignatures } from "../models";
|
||||||
|
|
||||||
|
export const getPetitions = async (_: Request): Promise<Response> => {
|
||||||
|
const signatures = await getSignatures();
|
||||||
|
|
||||||
|
const parsedSignatures = signedPetitionArraySchema.parse(signatures);
|
||||||
|
|
||||||
|
return Response.json(parsedSignatures, { status: 200 });
|
||||||
|
};
|
||||||
@ -1,26 +1,29 @@
|
|||||||
import type z from "zod";
|
|
||||||
import { insertSignature } from "../models";
|
import { insertSignature } from "../models";
|
||||||
import { signedPetitionSchema, signPetitionSchema } from 'types'
|
import { signedPetitionSchema, signPetitionSchema } from "types";
|
||||||
|
|
||||||
export const signPetition = async (req: Request): Promise<Response> => {
|
export const signPetition = async (req: Request): Promise<Response> => {
|
||||||
const body = await req.json()
|
const body = await req.json();
|
||||||
|
|
||||||
const validatedBody = signPetitionSchema.safeParse(body);
|
const validatedBody = signPetitionSchema.safeParse(body);
|
||||||
if (!validatedBody.success) {
|
if (!validatedBody.success) {
|
||||||
return Response.json({ error: validatedBody.error }, { status: 400 });
|
console.log(validatedBody.error);
|
||||||
}
|
return Response.json({ error: validatedBody.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
const _insertedSignature = await insertSignature({
|
const insertedSignature = await insertSignature({
|
||||||
email: validatedBody.data.email,
|
email: validatedBody.data.email,
|
||||||
name: validatedBody.data.name,
|
name: validatedBody.data.name,
|
||||||
comment: validatedBody.data.comment,
|
comment: validatedBody.data.comment,
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!_insertedSignature) {
|
if (!insertedSignature) {
|
||||||
return Response.json({ error: "inserting signature in database" }, { status: 500 });
|
return Response.json(
|
||||||
}
|
{ error: "inserting signature in database" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const insertedSignature = _insertedSignature satisfies z.infer<typeof signedPetitionSchema>
|
const parsedSignedSignature = signedPetitionSchema.parse(insertedSignature);
|
||||||
|
|
||||||
return Response.json(insertedSignature, { status: 200 });
|
return Response.json(parsedSignedSignature, { status: 200 });
|
||||||
}
|
};
|
||||||
|
|||||||
1
packages/frontend/.gitignore
vendored
Normal file
1
packages/frontend/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
.env
|
||||||
210
packages/frontend/e2e/petition-form.spec.ts
Normal file
210
packages/frontend/e2e/petition-form.spec.ts
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
import { test, expect, Page } from "@playwright/test";
|
||||||
|
|
||||||
|
const getFormLocators = (page: Page) => {
|
||||||
|
const form = page.getByTestId("petition-form").first();
|
||||||
|
return {
|
||||||
|
form,
|
||||||
|
anonymousCheckbox: form.locator("#anonymous"),
|
||||||
|
nameInput: form.locator('input[placeholder="Your Name"]'),
|
||||||
|
emailInput: form.locator('input[type="email"]'),
|
||||||
|
commentTextarea: form.locator("textarea"),
|
||||||
|
submitButton: form.locator('button[type="submit"]'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
const mockSignatures: any[] = [];
|
||||||
|
|
||||||
|
await page.route("**/sign", async (route) => {
|
||||||
|
if (route.request().method() === "POST") {
|
||||||
|
const body = JSON.parse(route.request().postData() || "{}");
|
||||||
|
const newSignature = {
|
||||||
|
id: "fceff5b5-a185-4d42-a539-3a9c7b5edacd",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
...body,
|
||||||
|
};
|
||||||
|
mockSignatures.push(newSignature);
|
||||||
|
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(newSignature),
|
||||||
|
});
|
||||||
|
} else if (route.request().method() === "GET") {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(mockSignatures),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await route.continue();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("anonymous submission with valid email only", async ({ page }) => {
|
||||||
|
const { anonymousCheckbox, emailInput, submitButton } = getFormLocators(page);
|
||||||
|
await anonymousCheckbox.check();
|
||||||
|
await emailInput.fill("test@example.com");
|
||||||
|
await submitButton.click();
|
||||||
|
await expect(
|
||||||
|
page.getByText("Thank you for signing the petition.").first(),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(emailInput).toHaveValue("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("named submission with valid name and email", async ({ page }) => {
|
||||||
|
const { nameInput, emailInput, submitButton } = getFormLocators(page);
|
||||||
|
await nameInput.fill("John Doe");
|
||||||
|
await emailInput.fill("john@example.com");
|
||||||
|
await submitButton.click();
|
||||||
|
await expect(
|
||||||
|
page.getByText("Thank you for signing the petition.").first(),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(emailInput).toHaveValue("");
|
||||||
|
await expect(nameInput).toHaveValue("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("submission with valid comment", async ({ page }) => {
|
||||||
|
const { anonymousCheckbox, emailInput, commentTextarea, submitButton } =
|
||||||
|
getFormLocators(page);
|
||||||
|
await anonymousCheckbox.check();
|
||||||
|
await emailInput.fill("test@example.com");
|
||||||
|
await commentTextarea.fill("This carpark is essential for our community");
|
||||||
|
await submitButton.click();
|
||||||
|
await expect(
|
||||||
|
page.getByText("Thank you for signing the petition.").first(),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(commentTextarea).toHaveValue("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("comment with less than 5 characters shows validation error", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const { anonymousCheckbox, emailInput, commentTextarea, submitButton } =
|
||||||
|
getFormLocators(page);
|
||||||
|
await anonymousCheckbox.check();
|
||||||
|
await emailInput.fill("test@example.com");
|
||||||
|
await commentTextarea.fill("Hi");
|
||||||
|
await submitButton.click();
|
||||||
|
const validationMessage = await commentTextarea.evaluate(
|
||||||
|
(el: HTMLTextAreaElement) => el.validationMessage,
|
||||||
|
);
|
||||||
|
expect(validationMessage).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid email shows validation error", async ({ page }) => {
|
||||||
|
const { anonymousCheckbox, emailInput, submitButton } = getFormLocators(page);
|
||||||
|
await anonymousCheckbox.check();
|
||||||
|
await emailInput.fill("notanemail");
|
||||||
|
await submitButton.click();
|
||||||
|
const validationMessage = await emailInput.evaluate(
|
||||||
|
(el: HTMLInputElement) => el.validationMessage,
|
||||||
|
);
|
||||||
|
expect(validationMessage).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty required fields show validation error", async ({ page }) => {
|
||||||
|
const { nameInput, submitButton } = getFormLocators(page);
|
||||||
|
await submitButton.click();
|
||||||
|
const nameValidationMessage = await nameInput.evaluate(
|
||||||
|
(el: HTMLInputElement) => el.validationMessage,
|
||||||
|
);
|
||||||
|
expect(nameValidationMessage).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("anonymous checkbox toggles name field visibility", async ({ page }) => {
|
||||||
|
const { anonymousCheckbox, nameInput } = getFormLocators(page);
|
||||||
|
await expect(nameInput).toBeVisible();
|
||||||
|
await anonymousCheckbox.check();
|
||||||
|
await expect(nameInput).toBeHidden();
|
||||||
|
await anonymousCheckbox.uncheck();
|
||||||
|
await expect(nameInput).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("submit button is disabled while submitting", async ({ page }) => {
|
||||||
|
const mockSignatures: any[] = [];
|
||||||
|
|
||||||
|
await page.route("**/sign", async (route) => {
|
||||||
|
if (route.request().method() === "POST") {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
const body = JSON.parse(route.request().postData() || "{}");
|
||||||
|
const newSignature = {
|
||||||
|
id: `${Date.now()}-${Math.random()}`,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
...body,
|
||||||
|
};
|
||||||
|
mockSignatures.push(newSignature);
|
||||||
|
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(newSignature),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { anonymousCheckbox, emailInput, submitButton } = getFormLocators(page);
|
||||||
|
await anonymousCheckbox.check();
|
||||||
|
await emailInput.fill("test@example.com");
|
||||||
|
|
||||||
|
await submitButton.click();
|
||||||
|
await expect(submitButton).toBeDisabled();
|
||||||
|
await expect(submitButton).toHaveText("Submitting...");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("signature counter increases after submissions", async ({ page }) => {
|
||||||
|
const signatureCountLocator = page.locator("text=/\\d+ people/");
|
||||||
|
|
||||||
|
const { anonymousCheckbox, emailInput, submitButton } = getFormLocators(page);
|
||||||
|
|
||||||
|
await anonymousCheckbox.check();
|
||||||
|
await emailInput.fill("first@example.com");
|
||||||
|
await submitButton.click();
|
||||||
|
await expect(
|
||||||
|
page.getByText("Thank you for signing the petition.").first(),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await expect(signatureCountLocator).toContainText("1 people");
|
||||||
|
|
||||||
|
await anonymousCheckbox.check();
|
||||||
|
await emailInput.fill("second@example.com");
|
||||||
|
await submitButton.click();
|
||||||
|
await expect(
|
||||||
|
page.getByText("Thank you for signing the petition.").first(),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await expect(signatureCountLocator).toContainText("2 people");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("submitted petition appears on testimonies page with correct information", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const testName = "Jane Smith";
|
||||||
|
const testEmail = "jane@example.com";
|
||||||
|
const testComment =
|
||||||
|
"This carpark closure has significantly impacted my daily routine";
|
||||||
|
|
||||||
|
const { nameInput, emailInput, commentTextarea, submitButton } =
|
||||||
|
getFormLocators(page);
|
||||||
|
|
||||||
|
await nameInput.fill(testName);
|
||||||
|
await emailInput.fill(testEmail);
|
||||||
|
await commentTextarea.fill(testComment);
|
||||||
|
await submitButton.click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByText("Thank you for signing the petition.").first(),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto("testimonies");
|
||||||
|
|
||||||
|
await expect(page.getByText(testName)).toBeVisible();
|
||||||
|
await expect(page.getByText(testComment)).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.locator("text=/\\d+ Signatures/")).toContainText(
|
||||||
|
"1 Signatures",
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -11,23 +11,21 @@
|
|||||||
<meta name="description" content="Sign the petition asking Woking Council to provide updates and adequate parking solutions for Enterprise Place residents after Victoria Way Carpark closure. 300+ residents affected." />
|
<meta name="description" content="Sign the petition asking Woking Council to provide updates and adequate parking solutions for Enterprise Place residents after Victoria Way Carpark closure. 300+ residents affected." />
|
||||||
<meta name="keywords" content="Victoria Way Carpark, Woking parking, Enterprise Place, Woking Council, parking petition, Woking residents, DoubleTree Hilton parking, Woking campaign" />
|
<meta name="keywords" content="Victoria Way Carpark, Woking parking, Enterprise Place, Woking Council, parking petition, Woking residents, DoubleTree Hilton parking, Woking campaign" />
|
||||||
<meta name="author" content="Enterprise Place Residents" />
|
<meta name="author" content="Enterprise Place Residents" />
|
||||||
<link rel="canonical" href="https://victoria-way-carpark.lovable.app/" />
|
<meta http-equiv="referrer" content="strict-origin">
|
||||||
|
|
||||||
<!-- Open Graph / Facebook -->
|
<!-- Open Graph / Facebook -->
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:url" content="https://victoria-way-carpark.lovable.app/" />
|
<meta property="og:url" content="https://savevictoriaway.com" />
|
||||||
<meta property="og:title" content="Victoria Way Carpark Campaign - Woking Needs Parking Solutions" />
|
<meta property="og:title" content="Victoria Way Carpark Campaign - Woking Needs Parking Solutions" />
|
||||||
<meta property="og:description" content="Sign the petition asking Woking Council to provide updates and adequate parking solutions for this area of Woking after Victoria Way Carpark closure. 300+ residents affected." />
|
<meta property="og:description" content="Sign the petition asking Woking Council to provide updates and adequate parking solutions for this area of Woking after Victoria Way Carpark closure. 300+ residents affected." />
|
||||||
<meta property="og:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
|
|
||||||
<meta property="og:site_name" content="Victoria Way Carpark Campaign" />
|
<meta property="og:site_name" content="Victoria Way Carpark Campaign" />
|
||||||
<meta property="og:locale" content="en_GB" />
|
<meta property="og:locale" content="en_GB" />
|
||||||
|
|
||||||
<!-- Twitter -->
|
<!-- Twitter -->
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<meta name="twitter:url" content="https://victoria-way-carpark.lovable.app/" />
|
<meta name="twitter:url" content="https://savevictoriaway.com" />
|
||||||
<meta name="twitter:title" content="Victoria Way Carpark Campaign - Woking Needs Parking Solutions" />
|
<meta name="twitter:title" content="Victoria Way Carpark Campaign - Woking Needs Parking Solutions" />
|
||||||
<meta name="twitter:description" content="Sign the petition for Woking Council to provide parking solutions for Enterprise Place residents after Victoria Way Carpark closure." />
|
<meta name="twitter:description" content="Sign the petition for Woking Council to provide parking solutions for Enterprise Place residents after Victoria Way Carpark closure." />
|
||||||
<meta name="twitter:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
|
|
||||||
|
|
||||||
<!-- Structured Data / JSON-LD -->
|
<!-- Structured Data / JSON-LD -->
|
||||||
<script type="application/ld+json">
|
<script type="application/ld+json">
|
||||||
@ -35,7 +33,7 @@
|
|||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "WebSite",
|
"@type": "WebSite",
|
||||||
"name": "Victoria Way Carpark Campaign",
|
"name": "Victoria Way Carpark Campaign",
|
||||||
"url": "https://victoria-way-carpark.lovable.app/",
|
"url": "https://savevictoriaway.com",
|
||||||
"description": "Petition campaign for Woking Council to provide parking solutions after Victoria Way Carpark closure"
|
"description": "Petition campaign for Woking Council to provide parking solutions after Victoria Way Carpark closure"
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -8,7 +8,10 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"build:dev": "vite build --mode development",
|
"build:dev": "vite build --mode development",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:ui": "playwright test --ui",
|
||||||
|
"test:e2e:debug": "playwright test --debug"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
@ -49,6 +52,8 @@
|
|||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
|
"playwright": "^1.56.1",
|
||||||
|
"posthog-js": "^1.292.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@ -59,12 +64,13 @@
|
|||||||
"sonner": "^1.7.4",
|
"sonner": "^1.7.4",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"types": "workspace:types",
|
||||||
"vaul": "^0.9.9",
|
"vaul": "^0.9.9",
|
||||||
"zod": "^4.1.12",
|
"zod": "^4.1.12"
|
||||||
"types": "workspace:types"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.32.0",
|
"@eslint/js": "^9.32.0",
|
||||||
|
"@playwright/test": "^1.56.1",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@types/node": "^22.16.5",
|
"@types/node": "^22.16.5",
|
||||||
"@types/react": "^18.3.23",
|
"@types/react": "^18.3.23",
|
||||||
|
|||||||
85
packages/frontend/playwright-report/index.html
Normal file
85
packages/frontend/playwright-report/index.html
Normal file
File diff suppressed because one or more lines are too long
18
packages/frontend/playwright.config.ts
Normal file
18
packages/frontend/playwright.config.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./e2e",
|
||||||
|
fullyParallel: true,
|
||||||
|
workers: "100%",
|
||||||
|
reporter: "html",
|
||||||
|
use: {
|
||||||
|
baseURL: "http://localhost:8080",
|
||||||
|
trace: "on-first-retry",
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
use: { ...devices["Desktop Chrome"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@ -1,7 +1,6 @@
|
|||||||
import { Toaster } from "@/components/ui/toaster";
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
import { Toaster as Sonner } from "@/components/ui/sonner";
|
import { Toaster as Sonner } from "@/components/ui/sonner";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||||
import { Navbar } from "@/components/Navbar";
|
import { Navbar } from "@/components/Navbar";
|
||||||
import Index from "./pages/Index";
|
import Index from "./pages/Index";
|
||||||
@ -9,27 +8,26 @@ import Testimonies from "./pages/Testimonies";
|
|||||||
import Contact from "./pages/Contact";
|
import Contact from "./pages/Contact";
|
||||||
import Briefing from "./pages/Briefing";
|
import Briefing from "./pages/Briefing";
|
||||||
import NotFound from "./pages/NotFound";
|
import NotFound from "./pages/NotFound";
|
||||||
|
import { PetitionStateProvider } from "./state";
|
||||||
const queryClient = new QueryClient();
|
|
||||||
|
|
||||||
const App = () => (
|
const App = () => (
|
||||||
<QueryClientProvider client={queryClient}>
|
<PetitionStateProvider>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<Sonner />
|
<Sonner />
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Index />} />
|
<Route path="/" element={<Index />} />
|
||||||
<Route path="/testimonies" element={<Testimonies />} />
|
<Route path="/testimonies" element={<Testimonies />} />
|
||||||
<Route path="/contact" element={<Contact />} />
|
<Route path="/contact" element={<Contact />} />
|
||||||
<Route path="/briefing" element={<Briefing />} />
|
<Route path="/briefing" element={<Briefing />} />
|
||||||
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</QueryClientProvider>
|
</PetitionStateProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@ -4,137 +4,125 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { toast } from "sonner";
|
import { usePetitions } from "@/state";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
const petitionSchema = z.object({
|
|
||||||
name: z.string().trim().min(1, "Name is required").max(100, "Name must be less than 100 characters"),
|
|
||||||
email: z.string().trim().email("Invalid email address").max(255, "Email must be less than 255 characters"),
|
|
||||||
comment: z.string().trim().max(1000, "Comment must be less than 1000 characters").optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const anonymousSchema = z.object({
|
|
||||||
email: z.string().trim().email("Invalid email address").max(255, "Email must be less than 255 characters"),
|
|
||||||
comment: z.string().trim().max(1000, "Comment must be less than 1000 characters").optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
interface PetitionFormProps {
|
interface PetitionFormProps {
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PetitionForm = ({ compact = false }: PetitionFormProps) => {
|
export const PetitionForm = ({ compact = false }: PetitionFormProps) => {
|
||||||
const [formData, setFormData] = useState({
|
const [email, setEmail] = useState<string>("");
|
||||||
name: "",
|
const [name, setName] = useState<string>("");
|
||||||
email: "",
|
const [comment, setComment] = useState<string>("");
|
||||||
comment: "",
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
});
|
const [isAnonymous, setIsAnonymous] = useState(false);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [isAnonymous, setIsAnonymous] = useState(false);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const { onSignPetition } = usePetitions();
|
||||||
e.preventDefault();
|
const { toast } = useToast();
|
||||||
|
|
||||||
// Validate input based on mode
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
try {
|
e.preventDefault();
|
||||||
if (isAnonymous) {
|
|
||||||
anonymousSchema.parse({ email: formData.email, comment: formData.comment });
|
|
||||||
} else {
|
|
||||||
petitionSchema.parse(formData);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof z.ZodError) {
|
|
||||||
toast.error(error.errors[0].message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
if (isSubmitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
const optionalName = name.trim().length > 0 ? name.trim() : undefined;
|
||||||
const { error } = await supabase
|
const optionalComment =
|
||||||
.from('petition_signatures')
|
comment.trim().length > 0 ? comment.trim() : undefined;
|
||||||
.insert([{
|
|
||||||
name: isAnonymous ? 'Anonymous' : formData.name.trim(),
|
|
||||||
email: formData.email.trim(),
|
|
||||||
comment: formData.comment.trim() || null,
|
|
||||||
}]);
|
|
||||||
|
|
||||||
if (error) throw error;
|
setIsSubmitting(true);
|
||||||
|
|
||||||
toast.success("Thank you for signing! Your voice matters.");
|
await onSignPetition({
|
||||||
setFormData({ name: "", email: "", comment: "" });
|
email: email.trim(),
|
||||||
setIsAnonymous(false);
|
name: optionalName,
|
||||||
} catch (error) {
|
comment: optionalComment,
|
||||||
console.error('Error signing petition:', error);
|
});
|
||||||
toast.error("Failed to submit signature. Please try again.");
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
toast({
|
||||||
<form onSubmit={handleSubmit} className={`space-y-4 ${compact ? 'max-w-md' : 'max-w-xl'} mx-auto`}>
|
title: "Success!",
|
||||||
<div className="flex items-center space-x-2 mb-4 bg-muted/50 p-3 rounded-md">
|
description: "Thank you for signing the petition.",
|
||||||
<Checkbox
|
});
|
||||||
id="anonymous"
|
|
||||||
checked={isAnonymous}
|
|
||||||
onCheckedChange={(checked) => setIsAnonymous(checked as boolean)}
|
|
||||||
/>
|
|
||||||
<Label
|
|
||||||
htmlFor="anonymous"
|
|
||||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
|
||||||
>
|
|
||||||
Sign anonymously (only email required)
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isAnonymous && (
|
setName("");
|
||||||
<div>
|
setEmail("");
|
||||||
<Input
|
setComment("");
|
||||||
placeholder="Your Name"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
||||||
required
|
|
||||||
className="bg-background border-border"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
setIsSubmitting(false);
|
||||||
<Input
|
};
|
||||||
type="email"
|
|
||||||
placeholder="Your Email"
|
|
||||||
value={formData.email}
|
|
||||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
|
||||||
required
|
|
||||||
className="bg-background border-border"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
return (
|
||||||
<Textarea
|
<form
|
||||||
placeholder="Why is Victoria Way Carpark important to you? (Optional)"
|
data-testid="petition-form"
|
||||||
value={formData.comment}
|
onSubmit={handleSubmit}
|
||||||
onChange={(e) => setFormData({ ...formData, comment: e.target.value })}
|
className={`space-y-4 ${compact ? "max-w-md" : "max-w-xl"} mx-auto`}
|
||||||
className="bg-background border-border min-h-24"
|
>
|
||||||
/>
|
<div className="flex items-center space-x-2 mb-4 bg-muted/50 p-3 rounded-md">
|
||||||
</div>
|
<Checkbox
|
||||||
|
id="anonymous"
|
||||||
|
checked={isAnonymous}
|
||||||
|
onCheckedChange={(checked) => setIsAnonymous(checked as boolean)}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="anonymous"
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||||
|
>
|
||||||
|
Sign anonymously (only email required)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isAnonymous && (
|
{!isAnonymous && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<div>
|
||||||
Your signature will be recorded as "Anonymous" with your email for verification.
|
<Input
|
||||||
</p>
|
placeholder="Your Name"
|
||||||
)}
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={1}
|
||||||
|
maxLength={30}
|
||||||
|
className="bg-background border-border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<div>
|
||||||
type="submit"
|
<Input
|
||||||
size="lg"
|
type="email"
|
||||||
disabled={isSubmitting}
|
placeholder="Your Email"
|
||||||
className="w-full bg-gradient-to-r from-accent to-accent/90 hover:from-accent/90 hover:to-accent/80 text-accent-foreground shadow-[var(--shadow-elevated)] font-semibold disabled:opacity-50"
|
value={email}
|
||||||
>
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
{isSubmitting ? "Submitting..." : "Sign the Petition"}
|
required
|
||||||
</Button>
|
className="bg-background border-border"
|
||||||
</form>
|
/>
|
||||||
);
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Why is Victoria Way Carpark important to you? (Optional)"
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
minLength={5}
|
||||||
|
maxLength={10000}
|
||||||
|
className="bg-background border-border min-h-24"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAnonymous && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Your signature will be recorded as "Anonymous" with your email for
|
||||||
|
verification.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="lg"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-full bg-gradient-to-r from-accent to-accent/90 hover:from-accent/90 hover:to-accent/80 text-accent-foreground shadow-[var(--shadow-elevated)] font-semibold disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Submitting..." : "Sign the Petition"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,17 +0,0 @@
|
|||||||
// This file is automatically generated. Do not edit it directly.
|
|
||||||
import { createClient } from '@supabase/supabase-js';
|
|
||||||
import type { Database } from './types';
|
|
||||||
|
|
||||||
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL;
|
|
||||||
const SUPABASE_PUBLISHABLE_KEY = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY;
|
|
||||||
|
|
||||||
// Import the supabase client like this:
|
|
||||||
// import { supabase } from "@/integrations/supabase/client";
|
|
||||||
|
|
||||||
export const supabase = createClient<Database>(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, {
|
|
||||||
auth: {
|
|
||||||
storage: localStorage,
|
|
||||||
persistSession: true,
|
|
||||||
autoRefreshToken: true,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@ -1,198 +0,0 @@
|
|||||||
export type Json =
|
|
||||||
| string
|
|
||||||
| number
|
|
||||||
| boolean
|
|
||||||
| null
|
|
||||||
| { [key: string]: Json | undefined }
|
|
||||||
| Json[]
|
|
||||||
|
|
||||||
export type Database = {
|
|
||||||
// Allows to automatically instantiate createClient with right options
|
|
||||||
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
|
|
||||||
__InternalSupabase: {
|
|
||||||
PostgrestVersion: "13.0.5"
|
|
||||||
}
|
|
||||||
public: {
|
|
||||||
Tables: {
|
|
||||||
petition_signatures: {
|
|
||||||
Row: {
|
|
||||||
comment: string | null
|
|
||||||
created_at: string
|
|
||||||
email: string
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
Insert: {
|
|
||||||
comment?: string | null
|
|
||||||
created_at?: string
|
|
||||||
email: string
|
|
||||||
id?: string
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
Update: {
|
|
||||||
comment?: string | null
|
|
||||||
created_at?: string
|
|
||||||
email?: string
|
|
||||||
id?: string
|
|
||||||
name?: string
|
|
||||||
}
|
|
||||||
Relationships: []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Views: {
|
|
||||||
petition_signatures_public: {
|
|
||||||
Row: {
|
|
||||||
comment: string | null
|
|
||||||
created_at: string | null
|
|
||||||
id: string | null
|
|
||||||
name: string | null
|
|
||||||
}
|
|
||||||
Insert: {
|
|
||||||
comment?: string | null
|
|
||||||
created_at?: string | null
|
|
||||||
id?: string | null
|
|
||||||
name?: string | null
|
|
||||||
}
|
|
||||||
Update: {
|
|
||||||
comment?: string | null
|
|
||||||
created_at?: string | null
|
|
||||||
id?: string | null
|
|
||||||
name?: string | null
|
|
||||||
}
|
|
||||||
Relationships: []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Functions: {
|
|
||||||
[_ in never]: never
|
|
||||||
}
|
|
||||||
Enums: {
|
|
||||||
[_ in never]: never
|
|
||||||
}
|
|
||||||
CompositeTypes: {
|
|
||||||
[_ in never]: never
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type DatabaseWithoutInternals = Omit<Database, "__InternalSupabase">
|
|
||||||
|
|
||||||
type DefaultSchema = DatabaseWithoutInternals[Extract<keyof Database, "public">]
|
|
||||||
|
|
||||||
export type Tables<
|
|
||||||
DefaultSchemaTableNameOrOptions extends
|
|
||||||
| keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
|
|
||||||
| { schema: keyof DatabaseWithoutInternals },
|
|
||||||
TableName extends DefaultSchemaTableNameOrOptions extends {
|
|
||||||
schema: keyof DatabaseWithoutInternals
|
|
||||||
}
|
|
||||||
? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
|
|
||||||
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
|
|
||||||
: never = never,
|
|
||||||
> = DefaultSchemaTableNameOrOptions extends {
|
|
||||||
schema: keyof DatabaseWithoutInternals
|
|
||||||
}
|
|
||||||
? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
|
|
||||||
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
|
|
||||||
Row: infer R
|
|
||||||
}
|
|
||||||
? R
|
|
||||||
: never
|
|
||||||
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] &
|
|
||||||
DefaultSchema["Views"])
|
|
||||||
? (DefaultSchema["Tables"] &
|
|
||||||
DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
|
|
||||||
Row: infer R
|
|
||||||
}
|
|
||||||
? R
|
|
||||||
: never
|
|
||||||
: never
|
|
||||||
|
|
||||||
export type TablesInsert<
|
|
||||||
DefaultSchemaTableNameOrOptions extends
|
|
||||||
| keyof DefaultSchema["Tables"]
|
|
||||||
| { schema: keyof DatabaseWithoutInternals },
|
|
||||||
TableName extends DefaultSchemaTableNameOrOptions extends {
|
|
||||||
schema: keyof DatabaseWithoutInternals
|
|
||||||
}
|
|
||||||
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
|
|
||||||
: never = never,
|
|
||||||
> = DefaultSchemaTableNameOrOptions extends {
|
|
||||||
schema: keyof DatabaseWithoutInternals
|
|
||||||
}
|
|
||||||
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
|
||||||
Insert: infer I
|
|
||||||
}
|
|
||||||
? I
|
|
||||||
: never
|
|
||||||
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
|
|
||||||
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
|
|
||||||
Insert: infer I
|
|
||||||
}
|
|
||||||
? I
|
|
||||||
: never
|
|
||||||
: never
|
|
||||||
|
|
||||||
export type TablesUpdate<
|
|
||||||
DefaultSchemaTableNameOrOptions extends
|
|
||||||
| keyof DefaultSchema["Tables"]
|
|
||||||
| { schema: keyof DatabaseWithoutInternals },
|
|
||||||
TableName extends DefaultSchemaTableNameOrOptions extends {
|
|
||||||
schema: keyof DatabaseWithoutInternals
|
|
||||||
}
|
|
||||||
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
|
|
||||||
: never = never,
|
|
||||||
> = DefaultSchemaTableNameOrOptions extends {
|
|
||||||
schema: keyof DatabaseWithoutInternals
|
|
||||||
}
|
|
||||||
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
|
||||||
Update: infer U
|
|
||||||
}
|
|
||||||
? U
|
|
||||||
: never
|
|
||||||
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
|
|
||||||
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
|
|
||||||
Update: infer U
|
|
||||||
}
|
|
||||||
? U
|
|
||||||
: never
|
|
||||||
: never
|
|
||||||
|
|
||||||
export type Enums<
|
|
||||||
DefaultSchemaEnumNameOrOptions extends
|
|
||||||
| keyof DefaultSchema["Enums"]
|
|
||||||
| { schema: keyof DatabaseWithoutInternals },
|
|
||||||
EnumName extends DefaultSchemaEnumNameOrOptions extends {
|
|
||||||
schema: keyof DatabaseWithoutInternals
|
|
||||||
}
|
|
||||||
? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
|
|
||||||
: never = never,
|
|
||||||
> = DefaultSchemaEnumNameOrOptions extends {
|
|
||||||
schema: keyof DatabaseWithoutInternals
|
|
||||||
}
|
|
||||||
? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
|
|
||||||
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
|
|
||||||
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
|
|
||||||
: never
|
|
||||||
|
|
||||||
export type CompositeTypes<
|
|
||||||
PublicCompositeTypeNameOrOptions extends
|
|
||||||
| keyof DefaultSchema["CompositeTypes"]
|
|
||||||
| { schema: keyof DatabaseWithoutInternals },
|
|
||||||
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
|
|
||||||
schema: keyof DatabaseWithoutInternals
|
|
||||||
}
|
|
||||||
? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
|
|
||||||
: never = never,
|
|
||||||
> = PublicCompositeTypeNameOrOptions extends {
|
|
||||||
schema: keyof DatabaseWithoutInternals
|
|
||||||
}
|
|
||||||
? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
|
|
||||||
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
|
|
||||||
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
|
|
||||||
: never
|
|
||||||
|
|
||||||
export const Constants = {
|
|
||||||
public: {
|
|
||||||
Enums: {},
|
|
||||||
},
|
|
||||||
} as const
|
|
||||||
@ -1,5 +1,21 @@
|
|||||||
|
import React from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
import { PostHogProvider } from "posthog-js/react";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(<App />);
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<PostHogProvider
|
||||||
|
apiKey={import.meta.env.VITE_PUBLIC_POSTHOG_KEY}
|
||||||
|
options={{
|
||||||
|
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
|
||||||
|
defaults: '2025-05-24',
|
||||||
|
capture_exceptions: true,
|
||||||
|
debug: import.meta.env.MODE === "development",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<App />
|
||||||
|
</PostHogProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
|||||||
@ -1,15 +1,35 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { signedPetitionSchema, signPetitionSchema } from 'types';
|
import { signedPetitionSchema, signPetitionSchema } from "types";
|
||||||
|
|
||||||
const baseURL = import.meta.env.BASE_URL;
|
const backendUrl = import.meta.env.VITE_BACKEND_URL;
|
||||||
|
|
||||||
export const signSignature = async (signature: z.infer<typeof signPetitionSchema>): Promise<z.infer<typeof signedPetitionSchema>> => {
|
export const signedPetitionWithParsedDate = signedPetitionSchema.extend({
|
||||||
const res = await fetch(baseURL, {
|
createdAt: z.string().transform((date: string) => new Date(date)),
|
||||||
method: 'POST', body: JSON.stringify(signature),
|
});
|
||||||
})
|
|
||||||
|
|
||||||
const body = await res.json();
|
const signedPetitionSignatures = z.array(signedPetitionWithParsedDate);
|
||||||
const validatedBody = signedPetitionSchema.parse(body);
|
|
||||||
|
|
||||||
return validatedBody
|
export const getSignatures = async (): Promise<
|
||||||
}
|
z.infer<typeof signedPetitionSignatures>
|
||||||
|
> => {
|
||||||
|
const res = await fetch(`${backendUrl}/sign`, { referrer: location.origin });
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
const validatedBody = signedPetitionSignatures.parse(body);
|
||||||
|
|
||||||
|
return validatedBody;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const signPetition = async (
|
||||||
|
signature: z.infer<typeof signPetitionSchema>,
|
||||||
|
): Promise<z.infer<typeof signedPetitionWithParsedDate>> => {
|
||||||
|
const res = await fetch(`${backendUrl}/sign`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(signature),
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
const validatedBody = signedPetitionWithParsedDate.parse(body);
|
||||||
|
|
||||||
|
return validatedBody;
|
||||||
|
};
|
||||||
|
|||||||
@ -11,407 +11,494 @@ import emptyBuildingParking from "@/assets/empty-building-parking.jpg";
|
|||||||
import asahiBuilding from "@/assets/asahi-building.jpg";
|
import asahiBuilding from "@/assets/asahi-building.jpg";
|
||||||
import doubletreeHilton from "@/assets/doubletree-hilton.jpg";
|
import doubletreeHilton from "@/assets/doubletree-hilton.jpg";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useEffect, useState } from "react";
|
import { usePetitions } from "@/state";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
|
||||||
|
const STATIC_TESTEMONIES = [
|
||||||
|
{
|
||||||
|
name: "Marne Keefe",
|
||||||
|
comment:
|
||||||
|
"My daughter & her Fiancé moved into Enterprise Place last October, they saved for a long time & one of the deciding factors on purchasing their first home was Victoria Way car park. My daughter has a degenerative back disorder & ME. Since having to park in the Peacocks car park & walk through Woking after work she has been jeered at, whistled at & narrowly avoided being kicked in the face by a youth to name just a few very upsetting situations she has been confronted with. She feels unsafe & anxious, it has become a major upset to her & her partner.",
|
||||||
|
date: "1 week ago",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "John Costa",
|
||||||
|
comment:
|
||||||
|
"I used to use Victoria Way carpark everyday. My wife would come home and park her car there as she drivers to work everyday. Now she has to walk through town centre every time she goes and comes from work. Sometimes at night. This is not safe, not is it what we signed up for when we purchased our flat in Enterprise Place.",
|
||||||
|
date: "1 week ago",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Rio Keefe",
|
||||||
|
comment:
|
||||||
|
"I am a resident of Enterprise Place. I go to work early and come home late, it is not safe for me - a young woman - to be forced to walk through the town center and main roads in the dark every night to get to my home. Not to mention the toll it takes on me with my chronic illness. I have been shouted at, stared at and almost assaulted having to do this in the last seven months.",
|
||||||
|
date: "1 week ago",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const Index = () => {
|
const Index = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [signatureCount, setSignatureCount] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const { signatures } = usePetitions();
|
||||||
fetchSignatureCount();
|
|
||||||
|
|
||||||
// Set up realtime subscription for signature count
|
const scrollToPetition = () => {
|
||||||
const channel = supabase
|
document.getElementById("petition")?.scrollIntoView({ behavior: "smooth" });
|
||||||
.channel('signature-count')
|
};
|
||||||
.on(
|
|
||||||
'postgres_changes',
|
|
||||||
{
|
|
||||||
event: 'INSERT',
|
|
||||||
schema: 'public',
|
|
||||||
table: 'petition_signatures'
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
setSignatureCount(prev => prev + 1);
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.subscribe();
|
|
||||||
|
|
||||||
return () => {
|
return (
|
||||||
supabase.removeChannel(channel);
|
<div className="min-h-screen bg-background">
|
||||||
};
|
{/* Hero Section with Petition */}
|
||||||
}, []);
|
<section className="relative min-h-[700px] flex items-center justify-center text-center overflow-hidden py-16">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-cover bg-center"
|
||||||
|
style={{ backgroundImage: `url(${carparkHero})` }}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-primary/90 to-primary/70" />
|
||||||
|
|
||||||
const fetchSignatureCount = async () => {
|
<div className="relative z-10 max-w-4xl mx-auto px-6">
|
||||||
const { count } = await supabase
|
<h1 className="text-5xl md:text-6xl font-bold text-primary-foreground mb-6">
|
||||||
.from('petition_signatures')
|
Victoria Way Carpark: Woking Needs Parking Solutions
|
||||||
.select('*', { count: 'exact', head: true });
|
</h1>
|
||||||
|
<p className="text-xl md:text-2xl text-primary-foreground/90 mb-8">
|
||||||
|
We support safety-first action, but this area of Woking needs
|
||||||
|
adequate parking and communication from the council
|
||||||
|
</p>
|
||||||
|
|
||||||
setSignatureCount(count || 0);
|
<div className="bg-card/95 backdrop-blur-sm rounded-lg p-8 shadow-[var(--shadow-elevated)] mb-6">
|
||||||
};
|
<div className="mb-6">
|
||||||
|
<h2 className="text-3xl font-bold text-primary mb-2">
|
||||||
|
Sign the Petition
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg text-muted-foreground">
|
||||||
|
{signatures.length > 0 && (
|
||||||
|
<span className="font-semibold text-accent">
|
||||||
|
{signatures.length} people
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{signatures.length > 0 ? " have" : "Be the first to"} signed so
|
||||||
|
far
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<PetitionForm compact />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
const testimonials = [
|
{/* Impact Stats */}
|
||||||
{
|
<section className="py-16 px-6 bg-muted">
|
||||||
name: "Marne Keefe",
|
<div className="max-w-6xl mx-auto">
|
||||||
comment: "My daughter & her Fiancé moved into Enterprise Place last October, they saved for a long time & one of the deciding factors on purchasing their first home was Victoria Way car park. My daughter has a degenerative back disorder & ME. Since having to park in the Peacocks car park & walk through Woking after work she has been jeered at, whistled at & narrowly avoided being kicked in the face by a youth to name just a few very upsetting situations she has been confronted with. She feels unsafe & anxious, it has become a major upset to her & her partner.",
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||||
date: "1 week ago",
|
<StatCard icon={Home} number="120+" label="Affected Households" />
|
||||||
},
|
<StatCard icon={Users} number="300+" label="Residents Impacted" />
|
||||||
{
|
<StatCard icon={Car} number="0" label="Parking Spaces Provided" />
|
||||||
name: "John Costa",
|
<StatCard
|
||||||
comment: "I used to use Victoria Way carpark everyday. My wife would come home and park her car there as she drivers to work everyday. Now she has to walk through town centre every time she goes and comes from work. Sometimes at night. This is not safe, not is it what we signed up for when we purchased our flat in Enterprise Place.",
|
icon={AlertTriangle}
|
||||||
date: "1 week ago",
|
number="0"
|
||||||
},
|
label="Council Updates Since Closure"
|
||||||
{
|
/>
|
||||||
name: "Rio Keefe",
|
</div>
|
||||||
comment: "I am a resident of Enterprise Place. I go to work early and come home late, it is not safe for me - a young woman - to be forced to walk through the town center and main roads in the dark every night to get to my home. Not to mention the toll it takes on me with my chronic illness. I have been shouted at, stared at and almost assaulted having to do this in the last seven months.",
|
</div>
|
||||||
date: "1 week ago",
|
</section>
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const scrollToPetition = () => {
|
{/* Affected Area */}
|
||||||
document.getElementById("petition")?.scrollIntoView({ behavior: "smooth" });
|
<section className="py-16 px-6">
|
||||||
};
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<h2 className="text-4xl font-bold text-primary mb-8 text-center">
|
||||||
|
The Affected Area
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-12">
|
||||||
|
<div className="rounded-lg overflow-hidden shadow-[var(--shadow-card)]">
|
||||||
|
<img
|
||||||
|
src={carparkHero}
|
||||||
|
alt="Victoria Way Carpark"
|
||||||
|
className="w-full h-[300px] object-cover"
|
||||||
|
/>
|
||||||
|
<div className="bg-card p-4 border-t border-border">
|
||||||
|
<h3 className="font-semibold text-lg text-primary">
|
||||||
|
Victoria Way Carpark
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Closed due to safety concerns
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg overflow-hidden shadow-[var(--shadow-card)]">
|
||||||
|
<img
|
||||||
|
src={enterprisePlace}
|
||||||
|
alt="Enterprise Place"
|
||||||
|
className="w-full h-[300px] object-cover"
|
||||||
|
/>
|
||||||
|
<div className="bg-card p-4 border-t border-border">
|
||||||
|
<h3 className="font-semibold text-lg text-primary">
|
||||||
|
Enterprise Place
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Residents and workers without parking
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
return (
|
{/* Problem Statement */}
|
||||||
<div className="min-h-screen bg-background">
|
<section className="py-16 px-6 bg-muted">
|
||||||
{/* Hero Section with Petition */}
|
<div className="max-w-4xl mx-auto">
|
||||||
<section className="relative min-h-[700px] flex items-center justify-center text-center overflow-hidden py-16">
|
<div className="bg-secondary/10 border-l-4 border-secondary p-6 rounded-r-lg mb-8">
|
||||||
<div
|
<h3 className="text-xl font-semibold text-primary mb-2">
|
||||||
className="absolute inset-0 bg-cover bg-center"
|
We Thank the Council for Prioritising Safety
|
||||||
style={{ backgroundImage: `url(${carparkHero})` }}
|
</h3>
|
||||||
/>
|
<p className="text-foreground">
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-primary/90 to-primary/70" />
|
We understand that Victoria Way Carpark was closed due to safety
|
||||||
|
concerns, and we fully support putting public safety first. The
|
||||||
|
council made the right decision to act on these concerns.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="relative z-10 max-w-4xl mx-auto px-6">
|
<h2 className="text-4xl font-bold text-primary mb-8 text-center">
|
||||||
<h1 className="text-5xl md:text-6xl font-bold text-primary-foreground mb-6">
|
The Problems We Face Today
|
||||||
Victoria Way Carpark: Woking Needs Parking Solutions
|
</h2>
|
||||||
</h1>
|
<div className="prose prose-lg max-w-none text-foreground space-y-6">
|
||||||
<p className="text-xl md:text-2xl text-primary-foreground/90 mb-8">
|
<p className="text-lg leading-relaxed">
|
||||||
We support safety-first action, but this area of Woking needs adequate parking and communication from the council
|
However, since the closure, residents and workers in this area of
|
||||||
</p>
|
Woking have been left without communication or parking solutions.
|
||||||
|
We are asking the council to address three critical issues:
|
||||||
|
</p>
|
||||||
|
<div className="bg-card p-8 rounded-lg shadow-[var(--shadow-card)] border border-border">
|
||||||
|
<h3 className="text-2xl font-semibold text-primary mb-4">
|
||||||
|
What Woking Needs:
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-3 text-foreground">
|
||||||
|
<li className="flex items-start">
|
||||||
|
<span className="text-accent font-bold mr-3">1.</span>
|
||||||
|
<span>
|
||||||
|
<strong>Regular Updates:</strong> No information has been
|
||||||
|
provided on safety surveys, remediation progress, or future
|
||||||
|
plans for the site
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start">
|
||||||
|
<span className="text-accent font-bold mr-3">2.</span>
|
||||||
|
<span>
|
||||||
|
<strong>Parking in This Area:</strong> This part of Woking
|
||||||
|
needs adequate parking facilities for residents, workers,
|
||||||
|
and visitors
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start">
|
||||||
|
<span className="text-accent font-bold mr-3">3.</span>
|
||||||
|
<span>
|
||||||
|
<strong>Recognition of Impact:</strong> Parents, elderly
|
||||||
|
residents, disabled individuals, night workers, and local
|
||||||
|
businesses face daily hardship and safety risks
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted p-6 rounded-lg border border-border mt-6">
|
||||||
|
<p className="text-foreground font-medium">
|
||||||
|
<strong>This area deserves proper parking:</strong> Without
|
||||||
|
adequate parking facilities, families struggle with shopping,
|
||||||
|
elderly and disabled residents face accessibility challenges,
|
||||||
|
night shift workers worry about safety, and local businesses
|
||||||
|
lose customers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="bg-card/95 backdrop-blur-sm rounded-lg p-8 shadow-[var(--shadow-elevated)] mb-6">
|
{/* Potential Solutions */}
|
||||||
<div className="mb-6">
|
<section className="py-16 px-6">
|
||||||
<h2 className="text-3xl font-bold text-primary mb-2">Sign the Petition</h2>
|
<div className="max-w-6xl mx-auto">
|
||||||
<p className="text-lg text-muted-foreground">
|
<h2 className="text-4xl font-bold text-primary mb-8 text-center">
|
||||||
{signatureCount > 0 && <span className="font-semibold text-accent">{signatureCount} people</span>}
|
Potential Solutions for Woking Residents
|
||||||
{signatureCount > 0 ? ' have' : 'Be the first to'} signed so far
|
</h2>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<PetitionForm compact />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Impact Stats */}
|
<div className="bg-accent/10 border-l-4 border-accent p-6 rounded-r-lg mb-8">
|
||||||
<section className="py-16 px-6 bg-muted">
|
<h3 className="text-xl font-semibold text-primary mb-2">
|
||||||
<div className="max-w-6xl mx-auto">
|
The Ideal Solution Already Exists
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
</h3>
|
||||||
<StatCard icon={Home} number="120+" label="Affected Households" />
|
<p className="text-foreground">
|
||||||
<StatCard icon={Users} number="300+" label="Residents Impacted" />
|
The DoubleTree Hilton hotel, located directly in front of
|
||||||
<StatCard icon={Car} number="0" label="Parking Spaces Provided" />
|
Enterprise Place, has an underground car park that runs beneath
|
||||||
<StatCard icon={AlertTriangle} number="0" label="Council Updates Since Closure" />
|
Enterprise Place itself. This existing infrastructure could
|
||||||
</div>
|
provide immediate relief to residents. Why don't Enterprise Place
|
||||||
</div>
|
residents have access to parking that sits directly beneath their
|
||||||
</section>
|
homes?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Affected Area */}
|
<div className="mb-12">
|
||||||
<section className="py-16 px-6">
|
<h3 className="text-2xl font-semibold text-primary mb-6">
|
||||||
<div className="max-w-6xl mx-auto">
|
Short-Term Solutions
|
||||||
<h2 className="text-4xl font-bold text-primary mb-8 text-center">
|
</h3>
|
||||||
The Affected Area
|
<div className="bg-card p-6 rounded-lg shadow-[var(--shadow-card)] border border-border mb-8">
|
||||||
</h2>
|
<ul className="space-y-3 text-foreground">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-12">
|
<li className="flex items-start">
|
||||||
<div className="rounded-lg overflow-hidden shadow-[var(--shadow-card)]">
|
<span className="text-accent font-bold mr-3">•</span>
|
||||||
<img
|
<span>
|
||||||
src={carparkHero}
|
Negotiate with nearby facilities like Dukes Court and the
|
||||||
alt="Victoria Way Carpark"
|
Asahi Building for shared parking arrangements
|
||||||
className="w-full h-[300px] object-cover"
|
</span>
|
||||||
/>
|
</li>
|
||||||
<div className="bg-card p-4 border-t border-border">
|
<li className="flex items-start">
|
||||||
<h3 className="font-semibold text-lg text-primary">Victoria Way Carpark</h3>
|
<span className="text-accent font-bold mr-3">•</span>
|
||||||
<p className="text-sm text-muted-foreground">Closed due to safety concerns</p>
|
<span>
|
||||||
</div>
|
Convert unused building sites in the area to temporary
|
||||||
</div>
|
parking facilities
|
||||||
<div className="rounded-lg overflow-hidden shadow-[var(--shadow-card)]">
|
</span>
|
||||||
<img
|
</li>
|
||||||
src={enterprisePlace}
|
<li className="flex items-start">
|
||||||
alt="Enterprise Place"
|
<span className="text-accent font-bold mr-3">•</span>
|
||||||
className="w-full h-[300px] object-cover"
|
<span>
|
||||||
/>
|
Utilize underutilized parking at empty buildings in the
|
||||||
<div className="bg-card p-4 border-t border-border">
|
vicinity
|
||||||
<h3 className="font-semibold text-lg text-primary">Enterprise Place</h3>
|
</span>
|
||||||
<p className="text-sm text-muted-foreground">Residents and workers without parking</p>
|
</li>
|
||||||
</div>
|
<li className="flex items-start">
|
||||||
</div>
|
<span className="text-accent font-bold mr-3">•</span>
|
||||||
</div>
|
<span>
|
||||||
</div>
|
Temporary parking permits for affected Enterprise Place
|
||||||
</section>
|
residents in nearby council-owned spaces
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start">
|
||||||
|
<span className="text-accent font-bold mr-3">•</span>
|
||||||
|
<span>
|
||||||
|
Discounted rates at Victoria Place and other town centre
|
||||||
|
carparks for registered residents
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Problem Statement */}
|
<div className="mb-12">
|
||||||
<section className="py-16 px-6 bg-muted">
|
<h3 className="text-2xl font-semibold text-primary mb-6">
|
||||||
<div className="max-w-4xl mx-auto">
|
Long-Term Solutions
|
||||||
<div className="bg-secondary/10 border-l-4 border-secondary p-6 rounded-r-lg mb-8">
|
</h3>
|
||||||
<h3 className="text-xl font-semibold text-primary mb-2">We Thank the Council for Prioritising Safety</h3>
|
<div className="bg-card p-6 rounded-lg shadow-[var(--shadow-card)] border border-border mb-8">
|
||||||
<p className="text-foreground">
|
<ul className="space-y-3 text-foreground">
|
||||||
We understand that Victoria Way Carpark was closed due to safety concerns, and we fully support putting public safety first.
|
<li className="flex items-start">
|
||||||
The council made the right decision to act on these concerns.
|
<span className="text-accent font-bold mr-3">1.</span>
|
||||||
</p>
|
<span>
|
||||||
</div>
|
<strong>
|
||||||
|
Provide Enterprise Place residents access to the
|
||||||
|
DoubleTree Hilton underground car park
|
||||||
|
</strong>{" "}
|
||||||
|
- the infrastructure already exists beneath their building
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start">
|
||||||
|
<span className="text-accent font-bold mr-3">2.</span>
|
||||||
|
<span>
|
||||||
|
Repair and reopen Victoria Way Carpark with proper safety
|
||||||
|
measures
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start">
|
||||||
|
<span className="text-accent font-bold mr-3">3.</span>
|
||||||
|
<span>
|
||||||
|
Build new multi-storey parking facility in this area of
|
||||||
|
Woking
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start">
|
||||||
|
<span className="text-accent font-bold mr-3">4.</span>
|
||||||
|
<span>
|
||||||
|
Dedicated resident parking zones with permit systems
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2 className="text-4xl font-bold text-primary mb-8 text-center">
|
<div className="mb-12">
|
||||||
The Problems We Face Today
|
<h3 className="text-2xl font-semibold text-primary mb-6">
|
||||||
</h2>
|
Available Parking Infrastructure
|
||||||
<div className="prose prose-lg max-w-none text-foreground space-y-6">
|
</h3>
|
||||||
<p className="text-lg leading-relaxed">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
However, since the closure, residents and workers in this area of Woking have been left without communication or parking solutions.
|
<div className="rounded-lg overflow-hidden shadow-[var(--shadow-card)] border-2 border-accent">
|
||||||
We are asking the council to address three critical issues:
|
<img
|
||||||
</p>
|
src={doubletreeHilton}
|
||||||
<div className="bg-card p-8 rounded-lg shadow-[var(--shadow-card)] border border-border">
|
alt="DoubleTree Hilton with underground parking beneath Enterprise Place"
|
||||||
<h3 className="text-2xl font-semibold text-primary mb-4">What Woking Needs:</h3>
|
className="w-full h-[250px] object-cover"
|
||||||
<ul className="space-y-3 text-foreground">
|
/>
|
||||||
<li className="flex items-start">
|
<div className="bg-accent/5 p-4 border-t-2 border-accent">
|
||||||
<span className="text-accent font-bold mr-3">1.</span>
|
<h3 className="font-semibold text-lg text-primary">
|
||||||
<span><strong>Regular Updates:</strong> No information has been provided on safety surveys, remediation progress, or future plans for the site</span>
|
DoubleTree Hilton - The Ideal Solution
|
||||||
</li>
|
</h3>
|
||||||
<li className="flex items-start">
|
<p className="text-sm text-foreground font-medium">
|
||||||
<span className="text-accent font-bold mr-3">2.</span>
|
Underground car park directly beneath Enterprise Place -
|
||||||
<span><strong>Parking in This Area:</strong> This part of Woking needs adequate parking facilities for residents, workers, and visitors</span>
|
existing infrastructure that could solve the problem
|
||||||
</li>
|
immediately
|
||||||
<li className="flex items-start">
|
</p>
|
||||||
<span className="text-accent font-bold mr-3">3.</span>
|
</div>
|
||||||
<span><strong>Recognition of Impact:</strong> Parents, elderly residents, disabled individuals, night workers, and local businesses face daily hardship and safety risks</span>
|
</div>
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div className="bg-muted p-6 rounded-lg border border-border mt-6">
|
|
||||||
<p className="text-foreground font-medium">
|
|
||||||
<strong>This area deserves proper parking:</strong> Without adequate parking facilities, families struggle with shopping,
|
|
||||||
elderly and disabled residents face accessibility challenges, night shift workers worry about safety, and local businesses lose customers.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Potential Solutions */}
|
<div className="rounded-lg overflow-hidden shadow-[var(--shadow-card)]">
|
||||||
<section className="py-16 px-6">
|
<img
|
||||||
<div className="max-w-6xl mx-auto">
|
src={asahiBuilding}
|
||||||
<h2 className="text-4xl font-bold text-primary mb-8 text-center">
|
alt="Asahi Building with underground parking"
|
||||||
Potential Solutions for Woking Residents
|
className="w-full h-[250px] object-cover"
|
||||||
</h2>
|
/>
|
||||||
|
<div className="bg-card p-4 border-t border-border">
|
||||||
|
<h3 className="font-semibold text-lg text-primary">
|
||||||
|
Asahi Building
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Underground parking facility that could accommodate
|
||||||
|
residents through negotiation
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="bg-accent/10 border-l-4 border-accent p-6 rounded-r-lg mb-8">
|
<div className="rounded-lg overflow-hidden shadow-[var(--shadow-card)]">
|
||||||
<h3 className="text-xl font-semibold text-primary mb-2">The Ideal Solution Already Exists</h3>
|
<img
|
||||||
<p className="text-foreground">
|
src={unusedSite}
|
||||||
The DoubleTree Hilton hotel, located directly in front of Enterprise Place, has an underground car park that runs beneath Enterprise Place itself.
|
alt="Unused building site that could be converted to parking"
|
||||||
This existing infrastructure could provide immediate relief to residents. Why don't Enterprise Place residents have access to parking that sits directly beneath their homes?
|
className="w-full h-[250px] object-cover"
|
||||||
</p>
|
/>
|
||||||
</div>
|
<div className="bg-card p-4 border-t border-border">
|
||||||
|
<h3 className="font-semibold text-lg text-primary">
|
||||||
|
Unused Building Site
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This nearby site has been closed for a while and could be
|
||||||
|
temporarily converted to parking
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mb-12">
|
<div className="rounded-lg overflow-hidden shadow-[var(--shadow-card)]">
|
||||||
<h3 className="text-2xl font-semibold text-primary mb-6">Short-Term Solutions</h3>
|
<img
|
||||||
<div className="bg-card p-6 rounded-lg shadow-[var(--shadow-card)] border border-border mb-8">
|
src={dukesCourt}
|
||||||
<ul className="space-y-3 text-foreground">
|
alt="Dukes Court with car park facilities"
|
||||||
<li className="flex items-start">
|
className="w-full h-[250px] object-cover"
|
||||||
<span className="text-accent font-bold mr-3">•</span>
|
/>
|
||||||
<span>Negotiate with nearby facilities like Dukes Court and the Asahi Building for shared parking arrangements</span>
|
<div className="bg-card p-4 border-t border-border">
|
||||||
</li>
|
<h3 className="font-semibold text-lg text-primary">
|
||||||
<li className="flex items-start">
|
Dukes Court
|
||||||
<span className="text-accent font-bold mr-3">•</span>
|
</h3>
|
||||||
<span>Convert unused building sites in the area to temporary parking facilities</span>
|
<p className="text-sm text-muted-foreground">
|
||||||
</li>
|
Nearby facility with parking that could be part of the
|
||||||
<li className="flex items-start">
|
solution
|
||||||
<span className="text-accent font-bold mr-3">•</span>
|
</p>
|
||||||
<span>Utilize underutilized parking at empty buildings in the vicinity</span>
|
</div>
|
||||||
</li>
|
</div>
|
||||||
<li className="flex items-start">
|
|
||||||
<span className="text-accent font-bold mr-3">•</span>
|
|
||||||
<span>Temporary parking permits for affected Enterprise Place residents in nearby council-owned spaces</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start">
|
|
||||||
<span className="text-accent font-bold mr-3">•</span>
|
|
||||||
<span>Discounted rates at Victoria Place and other town centre carparks for registered residents</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-12">
|
<div className="rounded-lg overflow-hidden shadow-[var(--shadow-card)]">
|
||||||
<h3 className="text-2xl font-semibold text-primary mb-6">Long-Term Solutions</h3>
|
<img
|
||||||
<div className="bg-card p-6 rounded-lg shadow-[var(--shadow-card)] border border-border mb-8">
|
src={emptyBuildingParking}
|
||||||
<ul className="space-y-3 text-foreground">
|
alt="Empty building with available parking spaces"
|
||||||
<li className="flex items-start">
|
className="w-full h-[250px] object-cover"
|
||||||
<span className="text-accent font-bold mr-3">1.</span>
|
/>
|
||||||
<span><strong>Provide Enterprise Place residents access to the DoubleTree Hilton underground car park</strong> - the infrastructure already exists beneath their building</span>
|
<div className="bg-card p-4 border-t border-border">
|
||||||
</li>
|
<h3 className="font-semibold text-lg text-primary">
|
||||||
<li className="flex items-start">
|
Underutilized Parking
|
||||||
<span className="text-accent font-bold mr-3">2.</span>
|
</h3>
|
||||||
<span>Repair and reopen Victoria Way Carpark with proper safety measures</span>
|
<p className="text-sm text-muted-foreground">
|
||||||
</li>
|
Empty building with existing parking infrastructure that
|
||||||
<li className="flex items-start">
|
could serve residents
|
||||||
<span className="text-accent font-bold mr-3">3.</span>
|
</p>
|
||||||
<span>Build new multi-storey parking facility in this area of Woking</span>
|
</div>
|
||||||
</li>
|
</div>
|
||||||
<li className="flex items-start">
|
</div>
|
||||||
<span className="text-accent font-bold mr-3">4.</span>
|
</div>
|
||||||
<span>Dedicated resident parking zones with permit systems</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-12">
|
<div className="bg-secondary/10 border-l-4 border-secondary p-6 rounded-r-lg">
|
||||||
<h3 className="text-2xl font-semibold text-primary mb-6">Available Parking Infrastructure</h3>
|
<h3 className="text-xl font-semibold text-primary mb-2">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
We Need Communication
|
||||||
<div className="rounded-lg overflow-hidden shadow-[var(--shadow-card)] border-2 border-accent">
|
</h3>
|
||||||
<img
|
<p className="text-foreground">
|
||||||
src={doubletreeHilton}
|
The council hasn't shared any updates on remediation timelines,
|
||||||
alt="DoubleTree Hilton with underground parking beneath Enterprise Place"
|
safety surveys, or plans for alternative parking. Regular
|
||||||
className="w-full h-[250px] object-cover"
|
communication would help residents plan and understand what
|
||||||
/>
|
solutions are being considered for this area.
|
||||||
<div className="bg-accent/5 p-4 border-t-2 border-accent">
|
</p>
|
||||||
<h3 className="font-semibold text-lg text-primary">DoubleTree Hilton - The Ideal Solution</h3>
|
</div>
|
||||||
<p className="text-sm text-foreground font-medium">Underground car park directly beneath Enterprise Place - existing infrastructure that could solve the problem immediately</p>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg overflow-hidden shadow-[var(--shadow-card)]">
|
{/* Testimonials */}
|
||||||
<img
|
<section className="py-16 px-6 bg-muted">
|
||||||
src={asahiBuilding}
|
<div className="max-w-6xl mx-auto">
|
||||||
alt="Asahi Building with underground parking"
|
<h2 className="text-4xl font-bold text-primary mb-4 text-center">
|
||||||
className="w-full h-[250px] object-cover"
|
Hear from Your Constituents
|
||||||
/>
|
</h2>
|
||||||
<div className="bg-card p-4 border-t border-border">
|
<p className="text-xl text-muted-foreground mb-12 text-center">
|
||||||
<h3 className="font-semibold text-lg text-primary">Asahi Building</h3>
|
Real stories from real Woking residents affected by this decision
|
||||||
<p className="text-sm text-muted-foreground">Underground parking facility that could accommodate residents through negotiation</p>
|
</p>
|
||||||
</div>
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
</div>
|
<TestimonialCard {...STATIC_TESTEMONIES[0]} />
|
||||||
|
<TestimonialCard {...STATIC_TESTEMONIES[1]} />
|
||||||
|
<TestimonialCard {...STATIC_TESTEMONIES[2]} />
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
onClick={() => navigate("/testimonies")}
|
||||||
|
className="gap-2 bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-5 h-5" />
|
||||||
|
Read All {signatures.length > 0 && `${signatures.length} `}
|
||||||
|
Testimonies
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="rounded-lg overflow-hidden shadow-[var(--shadow-card)]">
|
{/* Additional Petition Section */}
|
||||||
<img
|
<section id="petition" className="py-16 px-6">
|
||||||
src={unusedSite}
|
<div className="max-w-4xl mx-auto text-center">
|
||||||
alt="Unused building site that could be converted to parking"
|
<h2 className="text-4xl font-bold text-primary mb-4">
|
||||||
className="w-full h-[250px] object-cover"
|
Add Your Story
|
||||||
/>
|
</h2>
|
||||||
<div className="bg-card p-4 border-t border-border">
|
<p className="text-xl text-muted-foreground mb-12">
|
||||||
<h3 className="font-semibold text-lg text-primary">Unused Building Site</h3>
|
Share why this matters to you and add your full testimony to the
|
||||||
<p className="text-sm text-muted-foreground">This nearby site has been closed for a while and could be temporarily converted to parking</p>
|
petition
|
||||||
</div>
|
</p>
|
||||||
</div>
|
<PetitionForm />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="rounded-lg overflow-hidden shadow-[var(--shadow-card)]">
|
{/* Call to Action */}
|
||||||
<img
|
<section className="py-16 px-6 bg-gradient-to-r from-primary to-primary/90 text-primary-foreground">
|
||||||
src={dukesCourt}
|
<div className="max-w-4xl mx-auto text-center">
|
||||||
alt="Dukes Court with car park facilities"
|
<h2 className="text-3xl font-bold mb-4">Every Signature Counts</h2>
|
||||||
className="w-full h-[250px] object-cover"
|
<p className="text-lg mb-8 text-primary-foreground/90">
|
||||||
/>
|
Help us show Woking Council that this area deserves communication,
|
||||||
<div className="bg-card p-4 border-t border-border">
|
updates, and adequate parking facilities
|
||||||
<h3 className="font-semibold text-lg text-primary">Dukes Court</h3>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">Nearby facility with parking that could be part of the solution</p>
|
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
</div>
|
<Button
|
||||||
</div>
|
size="lg"
|
||||||
|
onClick={scrollToPetition}
|
||||||
|
className="bg-gradient-to-r from-accent to-accent/90 hover:from-accent/90 hover:to-accent/80 text-accent-foreground font-semibold shadow-[var(--shadow-elevated)]"
|
||||||
|
>
|
||||||
|
Sign the Petition
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
variant="outline"
|
||||||
|
className="bg-primary-foreground text-primary hover:bg-primary-foreground/90 border-primary-foreground font-semibold"
|
||||||
|
>
|
||||||
|
Share This Campaign
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="rounded-lg overflow-hidden shadow-[var(--shadow-card)]">
|
{/* Footer */}
|
||||||
<img
|
<footer className="py-8 px-6 bg-card border-t border-border">
|
||||||
src={emptyBuildingParking}
|
<div className="max-w-6xl mx-auto text-center text-muted-foreground">
|
||||||
alt="Empty building with available parking spaces"
|
<p>
|
||||||
className="w-full h-[250px] object-cover"
|
© 2025 Save Victoria Way Carpark Campaign | For the residents and
|
||||||
/>
|
workers of Woking
|
||||||
<div className="bg-card p-4 border-t border-border">
|
</p>
|
||||||
<h3 className="font-semibold text-lg text-primary">Underutilized Parking</h3>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">Empty building with existing parking infrastructure that could serve residents</p>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-secondary/10 border-l-4 border-secondary p-6 rounded-r-lg">
|
|
||||||
<h3 className="text-xl font-semibold text-primary mb-2">We Need Communication</h3>
|
|
||||||
<p className="text-foreground">
|
|
||||||
The council hasn't shared any updates on remediation timelines, safety surveys, or plans for alternative parking.
|
|
||||||
Regular communication would help residents plan and understand what solutions are being considered for this area.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Testimonials */}
|
|
||||||
<section className="py-16 px-6 bg-muted">
|
|
||||||
<div className="max-w-6xl mx-auto">
|
|
||||||
<h2 className="text-4xl font-bold text-primary mb-4 text-center">
|
|
||||||
Hear from Your Constituents
|
|
||||||
</h2>
|
|
||||||
<p className="text-xl text-muted-foreground mb-12 text-center">
|
|
||||||
Real stories from real Woking residents affected by this decision
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
|
||||||
{testimonials.map((testimonial, index) => (
|
|
||||||
<TestimonialCard key={index} {...testimonial} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
onClick={() => navigate('/testimonies')}
|
|
||||||
className="gap-2 bg-primary text-primary-foreground hover:bg-primary/90"
|
|
||||||
>
|
|
||||||
<MessageSquare className="w-5 h-5" />
|
|
||||||
Read All {signatureCount > 0 && `${signatureCount} `}Testimonies
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Additional Petition Section */}
|
|
||||||
<section id="petition" className="py-16 px-6">
|
|
||||||
<div className="max-w-4xl mx-auto text-center">
|
|
||||||
<h2 className="text-4xl font-bold text-primary mb-4">
|
|
||||||
Add Your Story
|
|
||||||
</h2>
|
|
||||||
<p className="text-xl text-muted-foreground mb-12">
|
|
||||||
Share why this matters to you and add your full testimony to the petition
|
|
||||||
</p>
|
|
||||||
<PetitionForm />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Call to Action */}
|
|
||||||
<section className="py-16 px-6 bg-gradient-to-r from-primary to-primary/90 text-primary-foreground">
|
|
||||||
<div className="max-w-4xl mx-auto text-center">
|
|
||||||
<h2 className="text-3xl font-bold mb-4">
|
|
||||||
Every Signature Counts
|
|
||||||
</h2>
|
|
||||||
<p className="text-lg mb-8 text-primary-foreground/90">
|
|
||||||
Help us show Woking Council that this area deserves communication, updates, and adequate parking facilities
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
onClick={scrollToPetition}
|
|
||||||
className="bg-gradient-to-r from-accent to-accent/90 hover:from-accent/90 hover:to-accent/80 text-accent-foreground font-semibold shadow-[var(--shadow-elevated)]"
|
|
||||||
>
|
|
||||||
Sign the Petition
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
variant="outline"
|
|
||||||
className="bg-primary-foreground text-primary hover:bg-primary-foreground/90 border-primary-foreground font-semibold"
|
|
||||||
>
|
|
||||||
Share This Campaign
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="py-8 px-6 bg-card border-t border-border">
|
|
||||||
<div className="max-w-6xl mx-auto text-center text-muted-foreground">
|
|
||||||
<p>© 2025 Save Victoria Way Carpark Campaign | For the residents and workers of Woking</p>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Index;
|
export default Index;
|
||||||
|
|||||||
@ -1,136 +1,74 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { TestimonialCard } from "@/components/TestimonialCard";
|
import { TestimonialCard } from "@/components/TestimonialCard";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
|
||||||
import { ArrowLeft, Users } from "lucide-react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { formatDistanceToNow } from "date-fns";
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { ArrowLeft, Users } from "lucide-react";
|
||||||
interface Signature {
|
import { usePetitions } from "@/state";
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
comment: string | null;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Testimonies = () => {
|
const Testimonies = () => {
|
||||||
const [signatures, setSignatures] = useState<Signature[]>([]);
|
const { signatures } = usePetitions();
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
useEffect(() => {
|
const totalCount = signatures.length;
|
||||||
fetchSignatures();
|
|
||||||
|
|
||||||
// Set up realtime subscription for new signatures
|
const navigate = useNavigate();
|
||||||
const channel = supabase
|
|
||||||
.channel('schema-db-changes')
|
|
||||||
.on(
|
|
||||||
'postgres_changes',
|
|
||||||
{
|
|
||||||
event: 'INSERT',
|
|
||||||
schema: 'public',
|
|
||||||
table: 'petition_signatures'
|
|
||||||
},
|
|
||||||
(payload) => {
|
|
||||||
const newSignature = payload.new as Signature;
|
|
||||||
setSignatures(prev => [newSignature, ...prev]);
|
|
||||||
setTotalCount(prev => prev + 1);
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.subscribe();
|
|
||||||
|
|
||||||
return () => {
|
return (
|
||||||
supabase.removeChannel(channel);
|
<div className="min-h-screen bg-background">
|
||||||
};
|
{/* Header */}
|
||||||
}, []);
|
<header className="bg-card border-b border-border sticky top-0 z-10 shadow-sm">
|
||||||
|
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => navigate("/")}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Back to Campaign
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-2 text-primary">
|
||||||
|
<Users className="w-5 h-5" />
|
||||||
|
<span className="font-semibold">{totalCount} Signatures</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
const fetchSignatures = async () => {
|
{/* Content */}
|
||||||
try {
|
<main className="max-w-6xl mx-auto px-6 py-12">
|
||||||
// Get total count
|
<div className="text-center mb-12">
|
||||||
const { count } = await supabase
|
<h1 className="text-4xl md:text-5xl font-bold text-primary mb-4">
|
||||||
.from('petition_signatures')
|
Community Testimonies
|
||||||
.select('*', { count: 'exact', head: true });
|
</h1>
|
||||||
|
<p className="text-xl text-muted-foreground">
|
||||||
|
Real stories from residents affected by the closure of Victoria Way
|
||||||
|
Carpark
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
setTotalCount(count || 0);
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{signatures.map((signature) => (
|
||||||
|
<TestimonialCard
|
||||||
|
key={signature.id}
|
||||||
|
name={signature.name}
|
||||||
|
comment={signature.comment || ""}
|
||||||
|
date={formatDistanceToNow(new Date(signature.createdAt), {
|
||||||
|
addSuffix: true,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
// Get signatures with comments
|
{/* Footer */}
|
||||||
const { data, error } = await supabase
|
<footer className="py-8 px-6 bg-card border-t border-border mt-12">
|
||||||
.from('petition_signatures')
|
<div className="max-w-6xl mx-auto text-center text-muted-foreground">
|
||||||
.select('*')
|
<p>
|
||||||
.not('comment', 'is', null)
|
© 2025 Save Victoria Way Carpark Campaign | For the residents of
|
||||||
.order('created_at', { ascending: false });
|
Enterprise Place, Woking
|
||||||
|
</p>
|
||||||
if (error) throw error;
|
</div>
|
||||||
setSignatures(data || []);
|
</footer>
|
||||||
} catch (error) {
|
</div>
|
||||||
console.error('Error fetching signatures:', error);
|
);
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-background">
|
|
||||||
{/* Header */}
|
|
||||||
<header className="bg-card border-b border-border sticky top-0 z-10 shadow-sm">
|
|
||||||
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => navigate('/')}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="w-4 h-4" />
|
|
||||||
Back to Campaign
|
|
||||||
</Button>
|
|
||||||
<div className="flex items-center gap-2 text-primary">
|
|
||||||
<Users className="w-5 h-5" />
|
|
||||||
<span className="font-semibold">{totalCount} Signatures</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<main className="max-w-6xl mx-auto px-6 py-12">
|
|
||||||
<div className="text-center mb-12">
|
|
||||||
<h1 className="text-4xl md:text-5xl font-bold text-primary mb-4">
|
|
||||||
Community Testimonies
|
|
||||||
</h1>
|
|
||||||
<p className="text-xl text-muted-foreground">
|
|
||||||
Real stories from residents affected by the closure of Victoria Way Carpark
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<p className="text-muted-foreground">Loading testimonies...</p>
|
|
||||||
</div>
|
|
||||||
) : signatures.length === 0 ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<p className="text-muted-foreground">No testimonies yet. Be the first to share your story!</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{signatures.map((signature) => (
|
|
||||||
<TestimonialCard
|
|
||||||
key={signature.id}
|
|
||||||
name={signature.name}
|
|
||||||
comment={signature.comment || ""}
|
|
||||||
date={formatDistanceToNow(new Date(signature.created_at), { addSuffix: true })}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="py-8 px-6 bg-card border-t border-border mt-12">
|
|
||||||
<div className="max-w-6xl mx-auto text-center text-muted-foreground">
|
|
||||||
<p>© 2025 Save Victoria Way Carpark Campaign | For the residents of Enterprise Place, Woking</p>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Testimonies;
|
export default Testimonies;
|
||||||
|
|||||||
104
packages/frontend/src/state/index.tsx
Normal file
104
packages/frontend/src/state/index.tsx
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import {
|
||||||
|
getSignatures,
|
||||||
|
signedPetitionWithParsedDate,
|
||||||
|
signPetition,
|
||||||
|
} from "@/network";
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { signPetitionSchema } from "types";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// submitted is used to determine if the signature was inserted correctly
|
||||||
|
type SignatureWithState = z.infer<typeof signedPetitionWithParsedDate> & {
|
||||||
|
submitted: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PetitionStateType = {
|
||||||
|
signatures: SignatureWithState[];
|
||||||
|
onSignPetition: (
|
||||||
|
petition: z.infer<typeof signPetitionSchema>,
|
||||||
|
) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PetitionState = createContext<PetitionStateType>({
|
||||||
|
signatures: [],
|
||||||
|
onSignPetition: async () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PetitionStateProvider = ({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
}) => {
|
||||||
|
const [signatures, setSignatures] = useState<PetitionStateType["signatures"]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getSignatures().then((signatures) => {
|
||||||
|
setSignatures(signatures.map((s) => ({ ...s, submitted: true })));
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onSignPetition = useCallback<PetitionStateType["onSignPetition"]>(
|
||||||
|
async (signature) => {
|
||||||
|
const eagerPetitionId = Date.now().toString();
|
||||||
|
|
||||||
|
setSignatures((petitions) => [
|
||||||
|
{
|
||||||
|
id: eagerPetitionId,
|
||||||
|
createdAt: new Date(),
|
||||||
|
submitted: false,
|
||||||
|
...signature,
|
||||||
|
},
|
||||||
|
...petitions,
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await signPetition(signature);
|
||||||
|
|
||||||
|
setSignatures((petitions) => {
|
||||||
|
const newPetitionIndex = petitions.findIndex(
|
||||||
|
(p) => p.id === eagerPetitionId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newPetitionIndex === -1) {
|
||||||
|
throw new Error(
|
||||||
|
`new inserted petition not found: id: ${eagerPetitionId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
petitions[newPetitionIndex].submitted = true;
|
||||||
|
|
||||||
|
return [...petitions];
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(err);
|
||||||
|
|
||||||
|
setSignatures((petitions) =>
|
||||||
|
petitions.filter((p) => p.id !== eagerPetitionId),
|
||||||
|
);
|
||||||
|
|
||||||
|
toast.error(
|
||||||
|
"Sorry, had a problem inserting your signature. Please try again.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PetitionState.Provider value={{ signatures: signatures, onSignPetition }}>
|
||||||
|
{children}
|
||||||
|
</PetitionState.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePetitions = () => useContext(PetitionState);
|
||||||
56
packages/frontend/src/tests/petition-form.spec.ts
Normal file
56
packages/frontend/src/tests/petition-form.spec.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { test } from '@playwright/test';
|
||||||
|
|
||||||
|
// Mock response for successful signature
|
||||||
|
const mockSignatureResponse = {
|
||||||
|
id: 'mock-signature-id',
|
||||||
|
name: 'Test User',
|
||||||
|
message: 'This is a test signature',
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Global fetch mock for POST /sign
|
||||||
|
async function setupFetchMock(page) {
|
||||||
|
await page.route('**/sign', async (route, request) => {
|
||||||
|
if (request.method() === 'POST') {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(mockSignatureResponse),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await route.continue();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset state helper
|
||||||
|
async function resetMocks(page) {
|
||||||
|
// Currently no stateful mocks, but placeholder for future
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test suite for Petition Form page
|
||||||
|
test.describe('Petition Form Page', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await setupFetchMock(page);
|
||||||
|
await resetMocks(page);
|
||||||
|
await page.goto('/');
|
||||||
|
// Scroll to petition form section
|
||||||
|
await page.locator('#petition').scrollIntoViewIfNeeded();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('placeholder: anonymous submission', async ({ page }) => {
|
||||||
|
// TODO: Implement test for anonymous submission
|
||||||
|
});
|
||||||
|
|
||||||
|
test('placeholder: named submission', async ({ page }) => {
|
||||||
|
// TODO: Implement test for named submission
|
||||||
|
});
|
||||||
|
|
||||||
|
test('placeholder: validation errors', async ({ page }) => {
|
||||||
|
// TODO: Implement test for validation errors
|
||||||
|
});
|
||||||
|
|
||||||
|
test('placeholder: UI behavior', async ({ page }) => {
|
||||||
|
// TODO: Implement test for UI behavior
|
||||||
|
});
|
||||||
|
});
|
||||||
4
packages/frontend/test-results/.last-run.json
Normal file
4
packages/frontend/test-results/.last-run.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"status": "passed",
|
||||||
|
"failedTests": []
|
||||||
|
}
|
||||||
@ -5,14 +5,16 @@ import { componentTagger } from "lovable-tagger";
|
|||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig(({ mode }) => ({
|
export default defineConfig(({ mode }) => ({
|
||||||
server: {
|
server: {
|
||||||
host: "::",
|
host: "::",
|
||||||
port: 8080,
|
port: 8080,
|
||||||
},
|
},
|
||||||
plugins: [react(), mode === "development" && componentTagger()].filter(Boolean),
|
plugins: [react(), mode === "development" && componentTagger()].filter(
|
||||||
resolve: {
|
Boolean,
|
||||||
alias: {
|
),
|
||||||
"@": path.resolve(__dirname, "./src"),
|
resolve: {
|
||||||
},
|
alias: {
|
||||||
},
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
|
||||||
export const signPetitionSchema = z.object({
|
export const signPetitionSchema = z.object({
|
||||||
email: z.string(),
|
email: z.string(),
|
||||||
name: z.string().trim().min(1).max(30).nullable(),
|
name: z.string().trim().min(1).max(30).nullish(),
|
||||||
comment: z.string().trim().min(10).max(10_000).nullable(),
|
comment: z.string().trim().min(5).max(10_000).nullish(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const signedPetitionSchema = signPetitionSchema.extend({
|
export const signedPetitionSchema = signPetitionSchema.extend({
|
||||||
id: z.uuid(),
|
id: z.uuid(),
|
||||||
})
|
createdAt: z.date().transform((date: Date) => date.toISOString()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const signedPetitionArraySchema = z.array(signedPetitionSchema);
|
||||||
|
|||||||
Reference in New Issue
Block a user