From dde7f9a2596fa64b683c94c0ef32089d3e0adb10 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 28 Apr 2026 08:10:28 +1200 Subject: [PATCH 01/22] feature(stripe): implement api route in cms folder that creates members and send payment link to the frontend, created a basic sign up form and a success screen for testing purposes --- cms/.env.example | 8 ++ cms/package.json | 3 +- cms/pnpm-lock.yaml | 70 ++++++++++++ cms/src/app/stripe/checkout/route.ts | 79 ++++++++++++++ cms/src/app/stripe/webhook/route.ts | 67 ++++++++++++ cms/src/collections/Members.ts | 3 + web/src/app/api/checkout/route.ts | 31 ++++++ web/src/app/signup/page.tsx | 153 +++++++++++++++++++++++++++ web/src/app/signup/success/page.tsx | 34 ++++++ 9 files changed, 447 insertions(+), 1 deletion(-) create mode 100644 cms/src/app/stripe/checkout/route.ts create mode 100644 cms/src/app/stripe/webhook/route.ts create mode 100644 web/src/app/api/checkout/route.ts create mode 100644 web/src/app/signup/page.tsx create mode 100644 web/src/app/signup/success/page.tsx diff --git a/cms/.env.example b/cms/.env.example index 99e5a63..0bb83ad 100644 --- a/cms/.env.example +++ b/cms/.env.example @@ -1,2 +1,10 @@ DATABASE_URL=mongodb://127.0.0.1/your-database-name PAYLOAD_SECRET=YOUR_SECRET_HERE + +# Stripe — copy keys from Stripe Dashboard > Developers +STRIPE_SECRET_KEY=sk.... +STRIPE_WEBHOOK_SECRET=whsec_... +STRIPE_PRICE_ID=price... + +# URL of the public web app (used for Stripe success/cancel redirects) +WEB_URL=http://localhost:3000 diff --git a/cms/package.json b/cms/package.json index f559403..cc1c19d 100644 --- a/cms/package.json +++ b/cms/package.json @@ -18,6 +18,7 @@ "test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts" }, "dependencies": { + "@payloadcms/db-postgres": "3.79.0", "@payloadcms/next": "3.79.0", "@payloadcms/richtext-lexical": "3.79.0", "@payloadcms/ui": "3.79.0", @@ -29,7 +30,7 @@ "react": "19.2.1", "react-dom": "19.2.1", "sharp": "0.34.2", - "@payloadcms/db-postgres": "3.79.0" + "stripe": "^22.1.0" }, "devDependencies": { "@playwright/test": "1.58.2", diff --git a/cms/pnpm-lock.yaml b/cms/pnpm-lock.yaml index ac24162..12aef2d 100644 --- a/cms/pnpm-lock.yaml +++ b/cms/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: sharp: specifier: 0.34.2 version: 0.34.2 + stripe: + specifier: ^22.1.0 + version: 22.1.0(@types/node@22.19.9) devDependencies: '@playwright/test': specifier: 1.58.2 @@ -917,160 +920,189 @@ packages: resolution: {integrity: sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm64@1.2.4': resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.1.0': resolution: {integrity: sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.1.0': resolution: {integrity: sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.1.0': resolution: {integrity: sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.1.0': resolution: {integrity: sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.1.0': resolution: {integrity: sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.1.0': resolution: {integrity: sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.2': resolution: {integrity: sha512-D8n8wgWmPDakc83LORcfJepdOSN6MvWNzzz2ux0MnIbOqdieRZwVYY32zxVx+IFUT8er5KPcyU3XXsn+GzG/0Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.2': resolution: {integrity: sha512-0DZzkvuEOqQUP9mo2kjjKNok5AmnOr1jB2XYjkaoNRwpAYMDzRmAqUIa1nRi58S2WswqSfPOWLNOr0FDT3H5RQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.2': resolution: {integrity: sha512-EGZ1xwhBI7dNISwxjChqBGELCWMGDvmxZXKjQRuqMrakhO8QoMgqCrdjnAqJq/CScxfRn+Bb7suXBElKQpPDiw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.2': resolution: {integrity: sha512-sD7J+h5nFLMMmOXYH4DD9UtSNBD05tWSSdWAcEyzqW8Cn5UxXvsHAxmxSesYUsTOBmUnjtxghKDl15EvfqLFbQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.2': resolution: {integrity: sha512-NEE2vQ6wcxYav1/A22OOxoSOGiKnNmDzCYFOZ949xFmrWZOVII1Bp3NqVVpvj+3UeHMFyN5eP/V5hzViQ5CZNA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.2': resolution: {integrity: sha512-DOYMrDm5E6/8bm/yQLCWyuDJwUnlevR8xtF8bs+gjZ7cyUNYXiSf/E8Kp0Ss5xasIaXSHzb888V1BE4i1hFhAA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.2': resolution: {integrity: sha512-/VI4mdlJ9zkaq53MbIG6rZY+QRN3MLbR6usYlgITEzi4Rpx5S6LFKsycOQjkOGmqTNmkIdLjEvooFKwww6OpdQ==} @@ -1253,24 +1285,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@15.4.8': resolution: {integrity: sha512-DX/L8VHzrr1CfwaVjBQr3GWCqNNFgyWJbeQ10Lx/phzbQo3JNAxUok1DZ8JHRGcL6PgMRgj6HylnLNndxn4Z6A==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@15.4.8': resolution: {integrity: sha512-9fLAAXKAL3xEIFdKdzG5rUSvSiZTLLTCc6JKq1z04DR4zY7DbAPcRvNm3K1inVhTiQCs19ZRAgUerHiVKMZZIA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@15.4.8': resolution: {integrity: sha512-s45V7nfb5g7dbS7JK6XZDcapicVrMMvX2uYgOHP16QuKH/JA285oy6HcxlKqwUNaFY/UC6EvQ8QZUOo19cBKSA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@15.4.8': resolution: {integrity: sha512-KjgeQyOAq7t/HzAJcWPGA8X+4WY03uSCZ2Ekk98S9OgCFsb6lfBE3dbUzUuEQAN2THbwYgFfxX2yFTCMm8Kehw==} @@ -1396,66 +1432,79 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -1716,41 +1765,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -3770,6 +3827,15 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} + stripe@22.1.0: + resolution: {integrity: sha512-w/xHyJGxXWnLPbNHG13sz/fae0MrFGC80Oz7YbICQymbfpqfEcsoG+6yG+9BWb81PWc4rrkeSO4wmTcmefmbLw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + strtok3@8.1.0: resolution: {integrity: sha512-ExzDvHYPj6F6QkSNe/JxSlBxTh3OrI6wrAIz53ulxo1c4hBJ1bT9C/JrAthEKHWG9riVH3Xzg7B03Oxty6S2Lw==} engines: {node: '>=16'} @@ -8251,6 +8317,10 @@ snapshots: strip-json-comments@5.0.3: {} + stripe@22.1.0(@types/node@22.19.9): + optionalDependencies: + '@types/node': 22.19.9 + strtok3@8.1.0: dependencies: '@tokenizer/token': 0.3.0 diff --git a/cms/src/app/stripe/checkout/route.ts b/cms/src/app/stripe/checkout/route.ts new file mode 100644 index 0000000..918bf8a --- /dev/null +++ b/cms/src/app/stripe/checkout/route.ts @@ -0,0 +1,79 @@ +import { NextRequest } from 'next/server' +import configPromise from '@payload-config' +import { getPayload } from 'payload' +import Stripe from 'stripe' + +export const POST = async (request: NextRequest) => { + let body: { name?: string; email?: string; password?: string; phone?: string } + try { + body = await request.json() + } catch { + return Response.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const { name, email, password, phone } = body + if (!name || !email || !password || !phone) { + return Response.json({ error: 'Missing required fields: name, email, password, phone' }, { status: 400 }) + } + + const stripeSecretKey = process.env.STRIPE_SECRET_KEY + const priceId = process.env.STRIPE_PRICE_ID + const webUrl = process.env.WEB_URL || 'http://localhost:3000' + + if (!stripeSecretKey || !priceId) { + return Response.json({ error: 'Stripe not configured' }, { status: 500 }) + } + + const payload = await getPayload({ config: configPromise }) + + let memberId: number + try { + const member = await payload.create({ + collection: 'members', + data: { + name, + email, + password, + phone, + status: 'pending', + }, + }) + memberId = member.id + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to create member' + if (message.toLowerCase().includes('duplicate') || message.toLowerCase().includes('unique')) { + return Response.json({ error: 'An account with this email already exists' }, { status: 409 }) + } + return Response.json({ error: message }, { status: 400 }) + } + + const stripe = new Stripe(stripeSecretKey, { apiVersion: '2026-04-22.dahlia' }) + + let customerId: string + try { + const customer = await stripe.customers.create({ + email, + name, + metadata: { memberId: String(memberId) }, + }) + customerId = customer.id + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to create Stripe customer' + return Response.json({ error: message }, { status: 502 }) + } + + try { + const session = await stripe.checkout.sessions.create({ + mode: 'payment', + customer: customerId, + line_items: [{ price: priceId, quantity: 1 }], + success_url: `${webUrl}/signup/success?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${webUrl}/signup?cancelled=true`, + metadata: { memberId: String(memberId) }, + }) + return Response.json({ checkoutUrl: session.url }) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to create Stripe checkout session' + return Response.json({ error: message }, { status: 502 }) + } +} diff --git a/cms/src/app/stripe/webhook/route.ts b/cms/src/app/stripe/webhook/route.ts new file mode 100644 index 0000000..a49e1c0 --- /dev/null +++ b/cms/src/app/stripe/webhook/route.ts @@ -0,0 +1,67 @@ +import { NextRequest } from 'next/server' +import configPromise from '@payload-config' +import { getPayload } from 'payload' +import Stripe from 'stripe' + +export const POST = async (request: NextRequest) => { + const stripeSecretKey = process.env.STRIPE_SECRET_KEY + const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET + + if (!stripeSecretKey || !webhookSecret) { + return Response.json({ error: 'Stripe not configured' }, { status: 500 }) + } + + const stripe = new Stripe(stripeSecretKey, { apiVersion: '2026-04-22.dahlia' }) + + // Raw body must be read before any other parsing — required for signature verification + const rawBody = await request.text() + const signature = request.headers.get('stripe-signature') + + if (!signature) { + return Response.json({ error: 'Missing stripe-signature header' }, { status: 400 }) + } + + let event: Stripe.Event + try { + event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Webhook signature verification failed' + return Response.json({ error: message }, { status: 400 }) + } + + if (event.type === 'checkout.session.completed') { + const session = event.data.object as Stripe.Checkout.Session + + if (session.payment_status !== 'paid') { + return Response.json({ received: true }) + } + + const memberId = session.metadata?.memberId + const stripeCustomerId = session.customer as string | null + + if (!memberId) { + console.error('Stripe webhook: missing memberId in session metadata', { sessionId: session.id }) + return Response.json({ received: true }) + } + + const payload = await getPayload({ config: configPromise }) + + try { + await payload.update({ + collection: 'members', + id: Number(memberId), + data: { + status: 'active', + ...(stripeCustomerId ? { stripeCustomerId } : {}), + }, + }) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to update member' + console.error('Stripe webhook: failed to update member', { memberId, error: message }) + // Return 500 so Stripe retries with exponential backoff + return Response.json({ error: message }, { status: 500 }) + } + } + + return Response.json({ received: true }) +} diff --git a/cms/src/collections/Members.ts b/cms/src/collections/Members.ts index 5a4b873..acfa431 100644 --- a/cms/src/collections/Members.ts +++ b/cms/src/collections/Members.ts @@ -3,6 +3,9 @@ import type { CollectionConfig } from 'payload' export const Members: CollectionConfig = { slug: 'members', auth: true, + access: { + create: () => true, + }, admin: { useAsTitle: 'name', }, diff --git a/web/src/app/api/checkout/route.ts b/web/src/app/api/checkout/route.ts new file mode 100644 index 0000000..cc84640 --- /dev/null +++ b/web/src/app/api/checkout/route.ts @@ -0,0 +1,31 @@ +import { NextRequest } from 'next/server' + +export const POST = async (request: NextRequest) => { + const cmsUrl = process.env.CMS_URL + + if (!cmsUrl) { + return Response.json({ error: 'CMS_URL not configured' }, { status: 500 }) + } + + let body: unknown + try { + body = await request.json() + } catch { + return Response.json({ error: 'Invalid request body' }, { status: 400 }) + } + + let cmsResponse: Response + try { + cmsResponse = await fetch(`${cmsUrl}/stripe/checkout`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to reach CMS' + return Response.json({ error: message }, { status: 502 }) + } + + const data = await cmsResponse.json() + return Response.json(data, { status: cmsResponse.status }) +} diff --git a/web/src/app/signup/page.tsx b/web/src/app/signup/page.tsx new file mode 100644 index 0000000..81229dd --- /dev/null +++ b/web/src/app/signup/page.tsx @@ -0,0 +1,153 @@ +'use client' + +import { useState, FormEvent, Suspense } from 'react' +import { useSearchParams } from 'next/navigation' + +function SignupForm() { + const searchParams = useSearchParams() + const wasCancelled = searchParams.get('cancelled') === 'true' + + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const [fieldErrors, setFieldErrors] = useState>({}) + + const validate = (data: { + name: string + email: string + password: string + confirmPassword: string + phone: string + }) => { + const errors: Record = {} + if (!data.name.trim()) errors.name = 'Name is required' + if (!data.email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) { + errors.email = 'Valid email is required' + } + if (data.password.length < 8) errors.password = 'Password must be at least 8 characters' + if (data.password !== data.confirmPassword) errors.confirmPassword = 'Passwords do not match' + if (!data.phone.trim()) errors.phone = 'Phone number is required' + return errors + } + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + setError(null) + setFieldErrors({}) + + const form = e.currentTarget + const data = { + name: (form.elements.namedItem('name') as HTMLInputElement).value, + email: (form.elements.namedItem('email') as HTMLInputElement).value, + password: (form.elements.namedItem('password') as HTMLInputElement).value, + confirmPassword: (form.elements.namedItem('confirmPassword') as HTMLInputElement).value, + phone: (form.elements.namedItem('phone') as HTMLInputElement).value, + } + + const errors = validate(data) + if (Object.keys(errors).length > 0) { + setFieldErrors(errors) + return + } + + setIsLoading(true) + try { + const response = await fetch('/api/checkout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: data.name, + email: data.email, + password: data.password, + phone: data.phone, + }), + }) + + const result = await response.json() + + if (!response.ok || !result.checkoutUrl) { + setError(result.error ?? 'Something went wrong. Please try again.') + return + } + + window.location.href = result.checkoutUrl + } catch { + setError('Network error. Please check your connection and try again.') + } finally { + setIsLoading(false) + } + } + + const inputClass = + 'w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ssa-red' + + return ( +
+
+

Become a Member

+

Join the Singapore Students' Association

+ + {wasCancelled && ( +
+ Payment was cancelled. You can try again below. +
+ )} + + {error && ( +
+ {error} +
+ )} + +
+
+ + + {fieldErrors.name &&

{fieldErrors.name}

} +
+ +
+ + + {fieldErrors.email &&

{fieldErrors.email}

} +
+ +
+ + + {fieldErrors.phone &&

{fieldErrors.phone}

} +
+ +
+ + + {fieldErrors.password &&

{fieldErrors.password}

} +
+ +
+ + + {fieldErrors.confirmPassword && ( +

{fieldErrors.confirmPassword}

+ )} +
+ + +
+
+
+ ) +} + +export default function SignupPage() { + return ( + Loading...}> + + + ) +} diff --git a/web/src/app/signup/success/page.tsx b/web/src/app/signup/success/page.tsx new file mode 100644 index 0000000..c85b2cc --- /dev/null +++ b/web/src/app/signup/success/page.tsx @@ -0,0 +1,34 @@ +import Link from 'next/link' + +interface Props { + searchParams: Promise<{ session_id?: string }> +} + +export default async function SignupSuccessPage({ searchParams }: Props) { + const params = await searchParams + const hasSession = Boolean(params.session_id) + + return ( +
+
+

Welcome to SSA!

+ {hasSession ? ( +

+ Your payment was successful and your membership is now active. Check your email for a + confirmation. +

+ ) : ( +

+ If you completed your payment, your membership will be activated shortly. +

+ )} + + Go to Homepage + +
+
+ ) +} From 86020729c7df647955d560015ccacc6988c80c91 Mon Sep 17 00:00:00 2001 From: Richman Tan Date: Tue, 28 Apr 2026 23:48:54 +1200 Subject: [PATCH 02/22] feat/styling and seperating out the sign up page --- cms/src/app/(payload)/admin/importMap.js | 2 +- cms/src/app/stripe/checkout/route.ts | 23 +- cms/src/collections/Members.ts | 52 ++- cms/src/payload-types.ts | 14 + web/src/app/signup/page.tsx | 491 +++++++++++++++++++---- 5 files changed, 495 insertions(+), 87 deletions(-) diff --git a/cms/src/app/(payload)/admin/importMap.js b/cms/src/app/(payload)/admin/importMap.js index f410558..eb1ae47 100644 --- a/cms/src/app/(payload)/admin/importMap.js +++ b/cms/src/app/(payload)/admin/importMap.js @@ -1,5 +1,5 @@ import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc' export const importMap = { - '@payloadcms/next/rsc#CollectionCards': CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1, + "@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } diff --git a/cms/src/app/stripe/checkout/route.ts b/cms/src/app/stripe/checkout/route.ts index 918bf8a..97d697d 100644 --- a/cms/src/app/stripe/checkout/route.ts +++ b/cms/src/app/stripe/checkout/route.ts @@ -4,14 +4,26 @@ import { getPayload } from 'payload' import Stripe from 'stripe' export const POST = async (request: NextRequest) => { - let body: { name?: string; email?: string; password?: string; phone?: string } + let body: { + name?: string + email?: string + password?: string + phone?: string + upi?: string + studentId?: string + areaOfStudy?: string + yearOfUniversity?: '1' | '2' | '3' | '4' | '5+' | 'postgrad' + gender?: 'male' | 'female' | 'non-binary' | 'prefer-not-to-say' + ethnicity?: 'chinese' | 'malay' | 'indian' | 'eurasian' | 'other' + returningMember?: boolean + } try { body = await request.json() } catch { return Response.json({ error: 'Invalid JSON body' }, { status: 400 }) } - const { name, email, password, phone } = body + const { name, email, password, phone, upi, studentId, areaOfStudy, yearOfUniversity, gender, ethnicity, returningMember } = body if (!name || !email || !password || !phone) { return Response.json({ error: 'Missing required fields: name, email, password, phone' }, { status: 400 }) } @@ -35,6 +47,13 @@ export const POST = async (request: NextRequest) => { email, password, phone, + upi, + studentId, + areaOfStudy, + yearOfUniversity, + gender, + ethnicity, + returningMember: returningMember ?? false, status: 'pending', }, }) diff --git a/cms/src/collections/Members.ts b/cms/src/collections/Members.ts index acfa431..fe83aca 100644 --- a/cms/src/collections/Members.ts +++ b/cms/src/collections/Members.ts @@ -55,6 +55,56 @@ export const Members: CollectionConfig = { { name: 'emergencyContactPhone', type: 'text', - } + }, + { + name: 'upi', + type: 'text', + }, + { + name: 'studentId', + type: 'text', + }, + { + name: 'areaOfStudy', + type: 'text', + }, + { + name: 'yearOfUniversity', + type: 'select', + options: [ + { label: 'Year 1', value: '1' }, + { label: 'Year 2', value: '2' }, + { label: 'Year 3', value: '3' }, + { label: 'Year 4', value: '4' }, + { label: 'Year 5+', value: '5+' }, + { label: 'Postgraduate', value: 'postgrad' }, + ], + }, + { + name: 'gender', + type: 'select', + options: [ + { label: 'Male', value: 'male' }, + { label: 'Female', value: 'female' }, + { label: 'Non-binary', value: 'non-binary' }, + { label: 'Prefer not to say', value: 'prefer-not-to-say' }, + ], + }, + { + name: 'ethnicity', + type: 'select', + options: [ + { label: 'Chinese', value: 'chinese' }, + { label: 'Malay', value: 'malay' }, + { label: 'Indian', value: 'indian' }, + { label: 'Eurasian', value: 'eurasian' }, + { label: 'Other', value: 'other' }, + ], + }, + { + name: 'returningMember', + type: 'checkbox', + defaultValue: false, + }, ] } \ No newline at end of file diff --git a/cms/src/payload-types.ts b/cms/src/payload-types.ts index 4d23a8b..c4e3671 100644 --- a/cms/src/payload-types.ts +++ b/cms/src/payload-types.ts @@ -249,6 +249,13 @@ export interface Member { stripeCustomerId?: string | null; emergencyContactName?: string | null; emergencyContactPhone?: string | null; + upi?: string | null; + studentId?: string | null; + areaOfStudy?: string | null; + yearOfUniversity?: ('1' | '2' | '3' | '4' | '5+' | 'postgrad') | null; + gender?: ('male' | 'female' | 'non-binary' | 'prefer-not-to-say') | null; + ethnicity?: ('chinese' | 'malay' | 'indian' | 'eurasian' | 'other') | null; + returningMember?: boolean | null; updatedAt: string; createdAt: string; email: string; @@ -465,6 +472,13 @@ export interface MembersSelect { stripeCustomerId?: T; emergencyContactName?: T; emergencyContactPhone?: T; + upi?: T; + studentId?: T; + areaOfStudy?: T; + yearOfUniversity?: T; + gender?: T; + ethnicity?: T; + returningMember?: T; updatedAt?: T; createdAt?: T; email?: T; diff --git a/web/src/app/signup/page.tsx b/web/src/app/signup/page.tsx index 81229dd..9563533 100644 --- a/web/src/app/signup/page.tsx +++ b/web/src/app/signup/page.tsx @@ -1,51 +1,384 @@ 'use client' -import { useState, FormEvent, Suspense } from 'react' +import { useState, Suspense } from 'react' import { useSearchParams } from 'next/navigation' +const TOTAL_STEPS = 4 + +type FormData = { + firstName: string + lastName: string + email: string + phone: string + password: string + confirmPassword: string + upi: string + studentId: string + areaOfStudy: string + yearOfUniversity: string + gender: string + ethnicity: string + returningMember: string +} + +const initialFormData: FormData = { + firstName: '', + lastName: '', + email: '', + phone: '', + password: '', + confirmPassword: '', + upi: '', + studentId: '', + areaOfStudy: '', + yearOfUniversity: '', + gender: '', + ethnicity: '', + returningMember: '', +} + +function ProgressBar({ step }: { step: number }) { + const progress = (step / TOTAL_STEPS) * 100 + return ( +
+
+
+ ) +} + +function InputField({ + label, + required, + placeholder, + value, + onChange, + type = 'text', + error, +}: { + label: string + required?: boolean + placeholder?: string + value: string + onChange: (v: string) => void + type?: string + error?: string +}) { + return ( +
+ + onChange(e.target.value)} + className="w-full rounded-lg px-3 py-2 text-sm outline-none border border-transparent focus:border-ssa-red bg-white placeholder:text-gray-400" + /> + {error &&

{error}

} +
+ ) +} + +function SelectField({ + label, + required, + placeholder, + value, + onChange, + options, +}: { + label: string + required?: boolean + placeholder?: string + value: string + onChange: (v: string) => void + options: { value: string; label: string }[] +}) { + return ( +
+ + +
+ ) +} + +function CardSection({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
+

+ {title} +

+
+
+ {children} +
+ ) +} + +function Step1({ + data, + onChange, + fieldErrors, +}: { + data: FormData + onChange: (field: keyof FormData, value: string) => void + fieldErrors: Record +}) { + return ( + +
+ onChange('firstName', v)} + error={fieldErrors.firstName} + /> + onChange('lastName', v)} + error={fieldErrors.lastName} + /> +
+ onChange('email', v)} + error={fieldErrors.email} + /> + onChange('phone', v)} + error={fieldErrors.phone} + /> + onChange('password', v)} + error={fieldErrors.password} + /> + onChange('confirmPassword', v)} + error={fieldErrors.confirmPassword} + /> +
+ ) +} + +function Step2({ + data, + onChange, +}: { + data: FormData + onChange: (field: keyof FormData, value: string) => void +}) { + return ( + +
+ onChange('upi', v)} + /> + onChange('studentId', v)} + /> +
+ onChange('areaOfStudy', v)} + /> + onChange('yearOfUniversity', v)} + options={[ + { value: '1', label: 'Year 1' }, + { value: '2', label: 'Year 2' }, + { value: '3', label: 'Year 3' }, + { value: '4', label: 'Year 4' }, + { value: '5+', label: 'Year 5+' }, + { value: 'postgrad', label: 'Postgraduate' }, + ]} + /> +
+ ) +} + +function Step3({ + data, + onChange, +}: { + data: FormData + onChange: (field: keyof FormData, value: string) => void +}) { + return ( + +
+ onChange('gender', v)} + options={[ + { value: 'male', label: 'Male' }, + { value: 'female', label: 'Female' }, + { value: 'non-binary', label: 'Non-binary' }, + { value: 'prefer-not-to-say', label: 'Prefer not to say' }, + ]} + /> + onChange('ethnicity', v)} + options={[ + { value: 'chinese', label: 'Chinese' }, + { value: 'malay', label: 'Malay' }, + { value: 'indian', label: 'Indian' }, + { value: 'eurasian', label: 'Eurasian' }, + { value: 'other', label: 'Other' }, + ]} + /> +
+ onChange('returningMember', v)} + options={[ + { value: 'yes', label: 'Yes' }, + { value: 'no', label: 'No' }, + ]} + /> +
+ ) +} + +function Step4({ onPay, isLoading }: { onPay: () => void; isLoading: boolean }) { + return ( + +
+

+ $6 is required to be a SSA member, the fee includes: +

+
    +
  • Goodies and discounts from SSA sponsors when you show them your membership sticker
  • +
  • Please be sure to collect your MEMBERSHIP CARD from the team.
  • +
+
+ + +
+
+
+ ) +} + function SignupForm() { const searchParams = useSearchParams() const wasCancelled = searchParams.get('cancelled') === 'true' + const [step, setStep] = useState(1) + const [formData, setFormData] = useState(initialFormData) const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) const [fieldErrors, setFieldErrors] = useState>({}) - const validate = (data: { - name: string - email: string - password: string - confirmPassword: string - phone: string - }) => { + function handleChange(field: keyof FormData, value: string) { + setFormData((prev) => ({ ...prev, [field]: value })) + } + + function handleNext() { + if (step < TOTAL_STEPS) setStep((s) => s + 1) + } + + function handleBack() { + if (step > 1) setStep((s) => s - 1) + } + + const validate = () => { const errors: Record = {} - if (!data.name.trim()) errors.name = 'Name is required' - if (!data.email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) { + if (!formData.firstName.trim()) errors.firstName = 'First name is required' + if (!formData.lastName.trim()) errors.lastName = 'Last name is required' + if (!formData.email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { errors.email = 'Valid email is required' } - if (data.password.length < 8) errors.password = 'Password must be at least 8 characters' - if (data.password !== data.confirmPassword) errors.confirmPassword = 'Passwords do not match' - if (!data.phone.trim()) errors.phone = 'Phone number is required' + if (!formData.phone.trim()) errors.phone = 'Phone number is required' + if (formData.password.length < 8) errors.password = 'Password must be at least 8 characters' + if (formData.password !== formData.confirmPassword) errors.confirmPassword = 'Passwords do not match' return errors } - const handleSubmit = async (e: FormEvent) => { - e.preventDefault() + const handlePay = async () => { setError(null) setFieldErrors({}) - const form = e.currentTarget - const data = { - name: (form.elements.namedItem('name') as HTMLInputElement).value, - email: (form.elements.namedItem('email') as HTMLInputElement).value, - password: (form.elements.namedItem('password') as HTMLInputElement).value, - confirmPassword: (form.elements.namedItem('confirmPassword') as HTMLInputElement).value, - phone: (form.elements.namedItem('phone') as HTMLInputElement).value, - } - - const errors = validate(data) + const errors = validate() if (Object.keys(errors).length > 0) { setFieldErrors(errors) + setStep(1) return } @@ -55,10 +388,17 @@ function SignupForm() { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - name: data.name, - email: data.email, - password: data.password, - phone: data.phone, + name: `${formData.firstName} ${formData.lastName}`, + email: formData.email, + password: formData.password, + phone: formData.phone, + upi: formData.upi, + studentId: formData.studentId, + areaOfStudy: formData.areaOfStudy, + yearOfUniversity: formData.yearOfUniversity, + gender: formData.gender, + ethnicity: formData.ethnicity, + returningMember: formData.returningMember === 'yes', }), }) @@ -77,70 +417,55 @@ function SignupForm() { } } - const inputClass = - 'w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ssa-red' - return ( -
-
-

Become a Member

-

Join the Singapore Students' Association

- - {wasCancelled && ( -
- Payment was cancelled. You can try again below. -
- )} +
+
+
- {error && ( -
- {error} -
- )} + {wasCancelled && ( +
+ Payment was cancelled. You can try again below. +
+ )} -
-
- - - {fieldErrors.name &&

{fieldErrors.name}

} -
+ {error && ( +
+ {error} +
+ )} -
- - - {fieldErrors.email &&

{fieldErrors.email}

} -
+ -
- - - {fieldErrors.phone &&

{fieldErrors.phone}

} -
- -
- - - {fieldErrors.password &&

{fieldErrors.password}

} -
+ {step === 1 && } + {step === 2 && } + {step === 3 && } + {step === 4 && } -
- - - {fieldErrors.confirmPassword && ( -

{fieldErrors.confirmPassword}

+
+ {step > 1 ? ( + + ) : ( +
+ )} + {step < TOTAL_STEPS && ( + )}
- - +
-
+
) } From eb79aad6290d9c52006174d604a228032e6d4085 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 29 Apr 2026 10:20:14 +1200 Subject: [PATCH 03/22] feature(stripe): changed sign up logic so members get created in CMS payload only after the member has paid --- cms/src/app/stripe/checkout/route.ts | 51 ++++++------ cms/src/app/stripe/webhook/route.ts | 48 +++++++++-- web/src/app/signup/page.tsx | 116 ++++++++++++++++++++++----- web/src/app/signup/success/page.tsx | 7 +- 4 files changed, 166 insertions(+), 56 deletions(-) diff --git a/cms/src/app/stripe/checkout/route.ts b/cms/src/app/stripe/checkout/route.ts index 918bf8a..99e03eb 100644 --- a/cms/src/app/stripe/checkout/route.ts +++ b/cms/src/app/stripe/checkout/route.ts @@ -2,6 +2,16 @@ import { NextRequest } from 'next/server' import configPromise from '@payload-config' import { getPayload } from 'payload' import Stripe from 'stripe' +import { createCipheriv, randomBytes } from 'crypto' + +function encryptPassword(password: string, hexKey: string): string { + const key = Buffer.from(hexKey, 'hex') + const iv = randomBytes(12) + const cipher = createCipheriv('aes-256-gcm', key, iv) + const encrypted = Buffer.concat([cipher.update(password, 'utf8'), cipher.final()]) + const tag = cipher.getAuthTag() + return Buffer.concat([iv, tag, encrypted]).toString('base64') +} export const POST = async (request: NextRequest) => { let body: { name?: string; email?: string; password?: string; phone?: string } @@ -19,49 +29,40 @@ export const POST = async (request: NextRequest) => { const stripeSecretKey = process.env.STRIPE_SECRET_KEY const priceId = process.env.STRIPE_PRICE_ID const webUrl = process.env.WEB_URL || 'http://localhost:3000' + const encryptionKey = process.env.SIGNUP_ENCRYPTION_KEY if (!stripeSecretKey || !priceId) { return Response.json({ error: 'Stripe not configured' }, { status: 500 }) } + if (!encryptionKey || encryptionKey.length !== 64) { + return Response.json({ error: 'Signup encryption not configured' }, { status: 500 }) + } const payload = await getPayload({ config: configPromise }) - let memberId: number - try { - const member = await payload.create({ - collection: 'members', - data: { - name, - email, - password, - phone, - status: 'pending', - }, - }) - memberId = member.id - } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Failed to create member' - if (message.toLowerCase().includes('duplicate') || message.toLowerCase().includes('unique')) { - return Response.json({ error: 'An account with this email already exists' }, { status: 409 }) - } - return Response.json({ error: message }, { status: 400 }) + // Check for duplicate email before sending the user to Stripe + const existing = await payload.find({ + collection: 'members', + where: { email: { equals: email } }, + limit: 1, + }) + if (existing.totalDocs > 0) { + return Response.json({ error: 'An account with this email already exists' }, { status: 409 }) } const stripe = new Stripe(stripeSecretKey, { apiVersion: '2026-04-22.dahlia' }) let customerId: string try { - const customer = await stripe.customers.create({ - email, - name, - metadata: { memberId: String(memberId) }, - }) + const customer = await stripe.customers.create({ email, name }) customerId = customer.id } catch (err: unknown) { const message = err instanceof Error ? err.message : 'Failed to create Stripe customer' return Response.json({ error: message }, { status: 502 }) } + const encryptedPassword = encryptPassword(password, encryptionKey) + try { const session = await stripe.checkout.sessions.create({ mode: 'payment', @@ -69,7 +70,7 @@ export const POST = async (request: NextRequest) => { line_items: [{ price: priceId, quantity: 1 }], success_url: `${webUrl}/signup/success?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${webUrl}/signup?cancelled=true`, - metadata: { memberId: String(memberId) }, + metadata: { name, email, phone, encryptedPassword }, }) return Response.json({ checkoutUrl: session.url }) } catch (err: unknown) { diff --git a/cms/src/app/stripe/webhook/route.ts b/cms/src/app/stripe/webhook/route.ts index a49e1c0..2e074fa 100644 --- a/cms/src/app/stripe/webhook/route.ts +++ b/cms/src/app/stripe/webhook/route.ts @@ -2,10 +2,23 @@ import { NextRequest } from 'next/server' import configPromise from '@payload-config' import { getPayload } from 'payload' import Stripe from 'stripe' +import { createDecipheriv } from 'crypto' + +function decryptPassword(encrypted: string, hexKey: string): string { + const key = Buffer.from(hexKey, 'hex') + const buf = Buffer.from(encrypted, 'base64') + const iv = buf.subarray(0, 12) + const tag = buf.subarray(12, 28) + const ciphertext = buf.subarray(28) + const decipher = createDecipheriv('aes-256-gcm', key, iv) + decipher.setAuthTag(tag) + return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8') +} export const POST = async (request: NextRequest) => { const stripeSecretKey = process.env.STRIPE_SECRET_KEY const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET + const encryptionKey = process.env.SIGNUP_ENCRYPTION_KEY if (!stripeSecretKey || !webhookSecret) { return Response.json({ error: 'Stripe not configured' }, { status: 500 }) @@ -36,28 +49,49 @@ export const POST = async (request: NextRequest) => { return Response.json({ received: true }) } - const memberId = session.metadata?.memberId + const { name, email, phone, encryptedPassword } = session.metadata ?? {} const stripeCustomerId = session.customer as string | null - if (!memberId) { - console.error('Stripe webhook: missing memberId in session metadata', { sessionId: session.id }) + if (!name || !email || !phone || !encryptedPassword) { + console.error('Stripe webhook: missing user data in session metadata', { sessionId: session.id }) return Response.json({ received: true }) } + if (!encryptionKey) { + console.error('Stripe webhook: SIGNUP_ENCRYPTION_KEY not configured') + return Response.json({ error: 'Encryption not configured' }, { status: 500 }) + } + + let password: string + try { + password = decryptPassword(encryptedPassword, encryptionKey) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to decrypt password' + console.error('Stripe webhook: failed to decrypt password', { sessionId: session.id, error: message }) + return Response.json({ error: message }, { status: 500 }) + } + const payload = await getPayload({ config: configPromise }) try { - await payload.update({ + await payload.create({ collection: 'members', - id: Number(memberId), data: { + name, + email, + password, + phone, status: 'active', ...(stripeCustomerId ? { stripeCustomerId } : {}), }, }) } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Failed to update member' - console.error('Stripe webhook: failed to update member', { memberId, error: message }) + const message = err instanceof Error ? err.message : 'Failed to create member' + // If the member already exists (duplicate webhook delivery), treat as success + if (message.toLowerCase().includes('duplicate') || message.toLowerCase().includes('unique')) { + return Response.json({ received: true }) + } + console.error('Stripe webhook: failed to create member', { email, error: message }) // Return 500 so Stripe retries with exponential backoff return Response.json({ error: message }, { status: 500 }) } diff --git a/web/src/app/signup/page.tsx b/web/src/app/signup/page.tsx index 81229dd..d80f671 100644 --- a/web/src/app/signup/page.tsx +++ b/web/src/app/signup/page.tsx @@ -23,8 +23,10 @@ function SignupForm() { if (!data.email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) { errors.email = 'Valid email is required' } - if (data.password.length < 8) errors.password = 'Password must be at least 8 characters' - if (data.password !== data.confirmPassword) errors.confirmPassword = 'Passwords do not match' + if (data.password.length < 8) + errors.password = 'Password must be at least 8 characters' + if (data.password !== data.confirmPassword) + errors.confirmPassword = 'Passwords do not match' if (!data.phone.trim()) errors.phone = 'Phone number is required' return errors } @@ -39,7 +41,9 @@ function SignupForm() { name: (form.elements.namedItem('name') as HTMLInputElement).value, email: (form.elements.namedItem('email') as HTMLInputElement).value, password: (form.elements.namedItem('password') as HTMLInputElement).value, - confirmPassword: (form.elements.namedItem('confirmPassword') as HTMLInputElement).value, + confirmPassword: ( + form.elements.namedItem('confirmPassword') as HTMLInputElement + ).value, phone: (form.elements.namedItem('phone') as HTMLInputElement).value, } @@ -84,7 +88,9 @@ function SignupForm() {

Become a Member

-

Join the Singapore Students' Association

+

+ Join the Singapore Students' Association +

{wasCancelled && (
@@ -98,36 +104,98 @@ function SignupForm() {
)} -
+
- - - {fieldErrors.name &&

{fieldErrors.name}

} + + + {fieldErrors.name && ( +

{fieldErrors.name}

+ )}
- - - {fieldErrors.email &&

{fieldErrors.email}

} + + + {fieldErrors.email && ( +

{fieldErrors.email}

+ )}
- - - {fieldErrors.phone &&

{fieldErrors.phone}

} + + + {fieldErrors.phone && ( +

{fieldErrors.phone}

+ )}
- - - {fieldErrors.password &&

{fieldErrors.password}

} + + + {fieldErrors.password && ( +

+ {fieldErrors.password} +

+ )}
- - + + {fieldErrors.confirmPassword && ( -

{fieldErrors.confirmPassword}

+

+ {fieldErrors.confirmPassword} +

)}
@@ -146,7 +214,13 @@ function SignupForm() { export default function SignupPage() { return ( - Loading...
}> + + Loading... + + } + > ) diff --git a/web/src/app/signup/success/page.tsx b/web/src/app/signup/success/page.tsx index c85b2cc..a7c6b9b 100644 --- a/web/src/app/signup/success/page.tsx +++ b/web/src/app/signup/success/page.tsx @@ -14,12 +14,13 @@ export default async function SignupSuccessPage({ searchParams }: Props) {

Welcome to SSA!

{hasSession ? (

- Your payment was successful and your membership is now active. Check your email for a - confirmation. + Your payment was successful and your membership is now active. Check + your email for a confirmation.

) : (

- If you completed your payment, your membership will be activated shortly. + If you completed your payment, your membership will be activated + shortly.

)} Date: Wed, 29 Apr 2026 13:39:21 +1200 Subject: [PATCH 04/22] chore/running lint --- web/src/app/signup/page.tsx | 57 +++++++++++++++++++++++++++++-------- 1 file changed, 45 insertions(+), 12 deletions(-) diff --git a/web/src/app/signup/page.tsx b/web/src/app/signup/page.tsx index dd1657c..0d53b57 100644 --- a/web/src/app/signup/page.tsx +++ b/web/src/app/signup/page.tsx @@ -40,7 +40,10 @@ const initialFormData: FormData = { function ProgressBar({ step }: { step: number }) { const progress = (step / TOTAL_STEPS) * 100 return ( -
+
+

{title} @@ -307,7 +319,13 @@ function Step3({ ) } -function Step4({ onPay, isLoading }: { onPay: () => void; isLoading: boolean }) { +function Step4({ + onPay, + isLoading, +}: { + onPay: () => void + isLoading: boolean +}) { return (
@@ -315,7 +333,10 @@ function Step4({ onPay, isLoading }: { onPay: () => void; isLoading: boolean }) $6 is required to be a SSA member, the fee includes:

    -
  • Goodies and discounts from SSA sponsors when you show them your membership sticker
  • +
  • + Goodies and discounts from SSA sponsors when you show them your + membership sticker +
  • Please be sure to collect your MEMBERSHIP CARD from the team.
@@ -362,12 +383,17 @@ function SignupForm() { const errors: Record = {} if (!formData.firstName.trim()) errors.firstName = 'First name is required' if (!formData.lastName.trim()) errors.lastName = 'Last name is required' - if (!formData.email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { + if ( + !formData.email.trim() || + !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email) + ) { errors.email = 'Valid email is required' } if (!formData.phone.trim()) errors.phone = 'Phone number is required' - if (formData.password.length < 8) errors.password = 'Password must be at least 8 characters' - if (formData.password !== formData.confirmPassword) errors.confirmPassword = 'Passwords do not match' + if (formData.password.length < 8) + errors.password = 'Password must be at least 8 characters' + if (formData.password !== formData.confirmPassword) + errors.confirmPassword = 'Passwords do not match' return errors } @@ -418,10 +444,12 @@ function SignupForm() { } return ( -
+
- {wasCancelled && (
Payment was cancelled. You can try again below. @@ -436,7 +464,13 @@ function SignupForm() { - {step === 1 && } + {step === 1 && ( + + )} {step === 2 && } {step === 3 && } {step === 4 && } @@ -462,7 +496,6 @@ function SignupForm() { )}
-
From 5fe52374cbf6d38f3d901304f37568db8f9ccf44 Mon Sep 17 00:00:00 2001 From: Richman Tan Date: Fri, 1 May 2026 15:44:01 +1200 Subject: [PATCH 05/22] fix: changes --- cms/src/app/(payload)/admin/importMap.js | 2 +- cms/src/app/stripe/checkout/route.ts | 26 +++---- cms/src/app/stripe/webhook/route.ts | 55 ++++--------- cms/src/collections/Members.ts | 2 +- web/src/app/signup/page.tsx | 98 +++++++++++++++++------- 5 files changed, 94 insertions(+), 89 deletions(-) diff --git a/cms/src/app/(payload)/admin/importMap.js b/cms/src/app/(payload)/admin/importMap.js index eb1ae47..f410558 100644 --- a/cms/src/app/(payload)/admin/importMap.js +++ b/cms/src/app/(payload)/admin/importMap.js @@ -1,5 +1,5 @@ import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc' export const importMap = { - "@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 + '@payloadcms/next/rsc#CollectionCards': CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1, } diff --git a/cms/src/app/stripe/checkout/route.ts b/cms/src/app/stripe/checkout/route.ts index f0db826..09478a1 100644 --- a/cms/src/app/stripe/checkout/route.ts +++ b/cms/src/app/stripe/checkout/route.ts @@ -2,16 +2,6 @@ import { NextRequest } from 'next/server' import configPromise from '@payload-config' import { getPayload } from 'payload' import Stripe from 'stripe' -import { createCipheriv, randomBytes } from 'crypto' - -function encryptPassword(password: string, hexKey: string): string { - const key = Buffer.from(hexKey, 'hex') - const iv = randomBytes(12) - const cipher = createCipheriv('aes-256-gcm', key, iv) - const encrypted = Buffer.concat([cipher.update(password, 'utf8'), cipher.final()]) - const tag = cipher.getAuthTag() - return Buffer.concat([iv, tag, encrypted]).toString('base64') -} export const POST = async (request: NextRequest) => { let body: { @@ -41,14 +31,10 @@ export const POST = async (request: NextRequest) => { const stripeSecretKey = process.env.STRIPE_SECRET_KEY const priceId = process.env.STRIPE_PRICE_ID const webUrl = process.env.WEB_URL || 'http://localhost:3000' - const encryptionKey = process.env.SIGNUP_ENCRYPTION_KEY if (!stripeSecretKey || !priceId) { return Response.json({ error: 'Stripe not configured' }, { status: 500 }) } - if (!encryptionKey || encryptionKey.length !== 64) { - return Response.json({ error: 'Signup encryption not configured' }, { status: 500 }) - } const payload = await getPayload({ config: configPromise }) @@ -91,8 +77,6 @@ export const POST = async (request: NextRequest) => { return Response.json({ error: message }, { status: 502 }) } - const encryptedPassword = encryptPassword(password, encryptionKey) - try { const session = await stripe.checkout.sessions.create({ mode: 'payment', @@ -100,8 +84,16 @@ export const POST = async (request: NextRequest) => { line_items: [{ price: priceId, quantity: 1 }], success_url: `${webUrl}/signup/success?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${webUrl}/signup?cancelled=true`, - metadata: { name, email, phone, encryptedPassword }, + metadata: { memberId: String(memberId) }, }) + + if (!session.url) { + return Response.json( + { error: 'Stripe did not provide a checkout URL for the created session' }, + { status: 502 }, + ) + } + return Response.json({ checkoutUrl: session.url }) } catch (err: unknown) { const message = err instanceof Error ? err.message : 'Failed to create Stripe checkout session' diff --git a/cms/src/app/stripe/webhook/route.ts b/cms/src/app/stripe/webhook/route.ts index 2e074fa..872323e 100644 --- a/cms/src/app/stripe/webhook/route.ts +++ b/cms/src/app/stripe/webhook/route.ts @@ -2,23 +2,10 @@ import { NextRequest } from 'next/server' import configPromise from '@payload-config' import { getPayload } from 'payload' import Stripe from 'stripe' -import { createDecipheriv } from 'crypto' - -function decryptPassword(encrypted: string, hexKey: string): string { - const key = Buffer.from(hexKey, 'hex') - const buf = Buffer.from(encrypted, 'base64') - const iv = buf.subarray(0, 12) - const tag = buf.subarray(12, 28) - const ciphertext = buf.subarray(28) - const decipher = createDecipheriv('aes-256-gcm', key, iv) - decipher.setAuthTag(tag) - return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8') -} export const POST = async (request: NextRequest) => { const stripeSecretKey = process.env.STRIPE_SECRET_KEY const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET - const encryptionKey = process.env.SIGNUP_ENCRYPTION_KEY if (!stripeSecretKey || !webhookSecret) { return Response.json({ error: 'Stripe not configured' }, { status: 500 }) @@ -49,49 +36,33 @@ export const POST = async (request: NextRequest) => { return Response.json({ received: true }) } - const { name, email, phone, encryptedPassword } = session.metadata ?? {} - const stripeCustomerId = session.customer as string | null + const { memberId: rawMemberId } = session.metadata ?? {} + const stripeCustomerId = + typeof session.customer === 'string' ? session.customer : session.customer?.id ?? null - if (!name || !email || !phone || !encryptedPassword) { - console.error('Stripe webhook: missing user data in session metadata', { sessionId: session.id }) + const memberId = Number(rawMemberId) + if (!Number.isFinite(memberId)) { + console.error('Stripe webhook: invalid or missing memberId in session metadata', { + sessionId: session.id, + rawMemberId, + }) return Response.json({ received: true }) } - if (!encryptionKey) { - console.error('Stripe webhook: SIGNUP_ENCRYPTION_KEY not configured') - return Response.json({ error: 'Encryption not configured' }, { status: 500 }) - } - - let password: string - try { - password = decryptPassword(encryptedPassword, encryptionKey) - } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Failed to decrypt password' - console.error('Stripe webhook: failed to decrypt password', { sessionId: session.id, error: message }) - return Response.json({ error: message }, { status: 500 }) - } - const payload = await getPayload({ config: configPromise }) try { - await payload.create({ + await payload.update({ collection: 'members', + id: memberId, data: { - name, - email, - password, - phone, status: 'active', ...(stripeCustomerId ? { stripeCustomerId } : {}), }, }) } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Failed to create member' - // If the member already exists (duplicate webhook delivery), treat as success - if (message.toLowerCase().includes('duplicate') || message.toLowerCase().includes('unique')) { - return Response.json({ received: true }) - } - console.error('Stripe webhook: failed to create member', { email, error: message }) + const message = err instanceof Error ? err.message : 'Failed to update member' + console.error('Stripe webhook: failed to update member', { memberId, error: message }) // Return 500 so Stripe retries with exponential backoff return Response.json({ error: message }, { status: 500 }) } diff --git a/cms/src/collections/Members.ts b/cms/src/collections/Members.ts index fe83aca..f7bc4fa 100644 --- a/cms/src/collections/Members.ts +++ b/cms/src/collections/Members.ts @@ -4,7 +4,7 @@ export const Members: CollectionConfig = { slug: 'members', auth: true, access: { - create: () => true, + create: () => false, }, admin: { useAsTitle: 'name', diff --git a/web/src/app/signup/page.tsx b/web/src/app/signup/page.tsx index 0d53b57..83361bd 100644 --- a/web/src/app/signup/page.tsx +++ b/web/src/app/signup/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, Suspense } from 'react' +import { useState, useId, Suspense } from 'react' import { useSearchParams } from 'next/navigation' const TOTAL_STEPS = 4 @@ -69,18 +69,22 @@ function InputField({ type?: string error?: string }) { + const id = useId() return (
-
@@ -94,6 +98,7 @@ function SelectField({ value, onChange, options, + error, }: { label: string required?: boolean @@ -101,16 +106,21 @@ function SelectField({ value: string onChange: (v: string) => void options: { value: string; label: string }[] + error?: string }) { + const id = useId() return (
-
) } @@ -222,9 +233,11 @@ function Step1({ function Step2({ data, onChange, + fieldErrors, }: { data: FormData onChange: (field: keyof FormData, value: string) => void + fieldErrors: Record }) { return ( @@ -235,6 +248,7 @@ function Step2({ placeholder="abcd123" value={data.upi} onChange={(v) => onChange('upi', v)} + error={fieldErrors.upi} /> onChange('studentId', v)} + error={fieldErrors.studentId} />
onChange('areaOfStudy', v)} + error={fieldErrors.areaOfStudy} /> onChange('yearOfUniversity', v)} + error={fieldErrors.yearOfUniversity} options={[ { value: '1', label: 'Year 1' }, { value: '2', label: 'Year 2' }, @@ -272,9 +289,11 @@ function Step2({ function Step3({ data, onChange, + fieldErrors, }: { data: FormData onChange: (field: keyof FormData, value: string) => void + fieldErrors: Record }) { return ( @@ -284,6 +303,7 @@ function Step3({ required value={data.gender} onChange={(v) => onChange('gender', v)} + error={fieldErrors.gender} options={[ { value: 'male', label: 'Male' }, { value: 'female', label: 'Female' }, @@ -296,6 +316,7 @@ function Step3({ required value={data.ethnicity} onChange={(v) => onChange('ethnicity', v)} + error={fieldErrors.ethnicity} options={[ { value: 'chinese', label: 'Chinese' }, { value: 'malay', label: 'Malay' }, @@ -310,6 +331,7 @@ function Step3({ required value={data.returningMember} onChange={(v) => onChange('returningMember', v)} + error={fieldErrors.returningMember} options={[ { value: 'yes', label: 'Yes' }, { value: 'no', label: 'No' }, @@ -371,7 +393,42 @@ function SignupForm() { setFormData((prev) => ({ ...prev, [field]: value })) } + function validateStep(s: number): Record { + const errors: Record = {} + if (s === 1) { + if (!formData.firstName.trim()) errors.firstName = 'First name is required' + if (!formData.lastName.trim()) errors.lastName = 'Last name is required' + if ( + !formData.email.trim() || + !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email) + ) { + errors.email = 'Valid email is required' + } + if (!formData.phone.trim()) errors.phone = 'Phone number is required' + if (formData.password.length < 8) + errors.password = 'Password must be at least 8 characters' + if (formData.password !== formData.confirmPassword) + errors.confirmPassword = 'Passwords do not match' + } else if (s === 2) { + if (!formData.upi.trim()) errors.upi = 'UPI is required' + if (!formData.studentId.trim()) errors.studentId = 'Student ID is required' + if (!formData.areaOfStudy.trim()) errors.areaOfStudy = 'Area of study is required' + if (!formData.yearOfUniversity) errors.yearOfUniversity = 'Year of university is required' + } else if (s === 3) { + if (!formData.gender) errors.gender = 'Gender is required' + if (!formData.ethnicity) errors.ethnicity = 'Ethnicity is required' + if (!formData.returningMember) errors.returningMember = 'This field is required' + } + return errors + } + function handleNext() { + const errors = validateStep(step) + if (Object.keys(errors).length > 0) { + setFieldErrors(errors) + return + } + setFieldErrors({}) if (step < TOTAL_STEPS) setStep((s) => s + 1) } @@ -379,31 +436,16 @@ function SignupForm() { if (step > 1) setStep((s) => s - 1) } - const validate = () => { - const errors: Record = {} - if (!formData.firstName.trim()) errors.firstName = 'First name is required' - if (!formData.lastName.trim()) errors.lastName = 'Last name is required' - if ( - !formData.email.trim() || - !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email) - ) { - errors.email = 'Valid email is required' - } - if (!formData.phone.trim()) errors.phone = 'Phone number is required' - if (formData.password.length < 8) - errors.password = 'Password must be at least 8 characters' - if (formData.password !== formData.confirmPassword) - errors.confirmPassword = 'Passwords do not match' - return errors - } - const handlePay = async () => { setError(null) - setFieldErrors({}) - const errors = validate() - if (Object.keys(errors).length > 0) { - setFieldErrors(errors) + const allErrors = { + ...validateStep(1), + ...validateStep(2), + ...validateStep(3), + } + if (Object.keys(allErrors).length > 0) { + setFieldErrors(allErrors) setStep(1) return } @@ -471,8 +513,8 @@ function SignupForm() { fieldErrors={fieldErrors} /> )} - {step === 2 && } - {step === 3 && } + {step === 2 && } + {step === 3 && } {step === 4 && }
From e8146076aa12141eaa3f0503edd6c35d1fbabd71 Mon Sep 17 00:00:00 2001 From: Richman Tan Date: Fri, 1 May 2026 16:02:56 +1200 Subject: [PATCH 06/22] chore: fix prettier formatting in signup page Co-Authored-By: Claude Sonnet 4.6 --- web/src/app/signup/page.tsx | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/web/src/app/signup/page.tsx b/web/src/app/signup/page.tsx index 83361bd..f602cb8 100644 --- a/web/src/app/signup/page.tsx +++ b/web/src/app/signup/page.tsx @@ -396,7 +396,8 @@ function SignupForm() { function validateStep(s: number): Record { const errors: Record = {} if (s === 1) { - if (!formData.firstName.trim()) errors.firstName = 'First name is required' + if (!formData.firstName.trim()) + errors.firstName = 'First name is required' if (!formData.lastName.trim()) errors.lastName = 'Last name is required' if ( !formData.email.trim() || @@ -411,13 +412,17 @@ function SignupForm() { errors.confirmPassword = 'Passwords do not match' } else if (s === 2) { if (!formData.upi.trim()) errors.upi = 'UPI is required' - if (!formData.studentId.trim()) errors.studentId = 'Student ID is required' - if (!formData.areaOfStudy.trim()) errors.areaOfStudy = 'Area of study is required' - if (!formData.yearOfUniversity) errors.yearOfUniversity = 'Year of university is required' + if (!formData.studentId.trim()) + errors.studentId = 'Student ID is required' + if (!formData.areaOfStudy.trim()) + errors.areaOfStudy = 'Area of study is required' + if (!formData.yearOfUniversity) + errors.yearOfUniversity = 'Year of university is required' } else if (s === 3) { if (!formData.gender) errors.gender = 'Gender is required' if (!formData.ethnicity) errors.ethnicity = 'Ethnicity is required' - if (!formData.returningMember) errors.returningMember = 'This field is required' + if (!formData.returningMember) + errors.returningMember = 'This field is required' } return errors } @@ -513,8 +518,20 @@ function SignupForm() { fieldErrors={fieldErrors} /> )} - {step === 2 && } - {step === 3 && } + {step === 2 && ( + + )} + {step === 3 && ( + + )} {step === 4 && }
From 926eab56d990b4b8c2941a6742151ebec660c2f6 Mon Sep 17 00:00:00 2001 From: Richman Tan <160602954+Richman-Tan@users.noreply.github.com> Date: Fri, 1 May 2026 16:08:58 +1200 Subject: [PATCH 07/22] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- cms/.env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/.env.example b/cms/.env.example index 0bb83ad..5eae80e 100644 --- a/cms/.env.example +++ b/cms/.env.example @@ -1,4 +1,4 @@ -DATABASE_URL=mongodb://127.0.0.1/your-database-name +DATABASE_URL=postgresql://127.0.0.1:5432/your-database-name PAYLOAD_SECRET=YOUR_SECRET_HERE # Stripe — copy keys from Stripe Dashboard > Developers From 75d0fc458f09d643a39190e8daaafc09ade26401 Mon Sep 17 00:00:00 2001 From: Richman Tan <160602954+Richman-Tan@users.noreply.github.com> Date: Fri, 1 May 2026 16:09:02 +1200 Subject: [PATCH 08/22] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- web/src/app/api/checkout/route.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/web/src/app/api/checkout/route.ts b/web/src/app/api/checkout/route.ts index cc84640..f929324 100644 --- a/web/src/app/api/checkout/route.ts +++ b/web/src/app/api/checkout/route.ts @@ -26,6 +26,18 @@ export const POST = async (request: NextRequest) => { return Response.json({ error: message }, { status: 502 }) } - const data = await cmsResponse.json() + const cmsBody = await cmsResponse.text() + let data: unknown + + if (!cmsBody) { + data = { error: 'Empty CMS response' } + } else { + try { + data = JSON.parse(cmsBody) + } catch { + data = { error: cmsBody } + } + } + return Response.json(data, { status: cmsResponse.status }) } From d615decdd3b72071622401c22115d5932ec7a5eb Mon Sep 17 00:00:00 2001 From: Richman Tan <160602954+Richman-Tan@users.noreply.github.com> Date: Fri, 1 May 2026 16:09:29 +1200 Subject: [PATCH 09/22] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- web/src/app/signup/success/page.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/src/app/signup/success/page.tsx b/web/src/app/signup/success/page.tsx index a7c6b9b..90ea159 100644 --- a/web/src/app/signup/success/page.tsx +++ b/web/src/app/signup/success/page.tsx @@ -14,8 +14,9 @@ export default async function SignupSuccessPage({ searchParams }: Props) {

Welcome to SSA!

{hasSession ? (

- Your payment was successful and your membership is now active. Check - your email for a confirmation. + We received your signup and are confirming your payment details. + Your membership will be activated once confirmation is complete. + Check your email for updates.

) : (

From 5cdeb65ca9eced8aa7fd603f0701a0b1c503eedd Mon Sep 17 00:00:00 2001 From: Richman Tan <160602954+Richman-Tan@users.noreply.github.com> Date: Fri, 1 May 2026 16:10:36 +1200 Subject: [PATCH 10/22] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- web/src/app/signup/page.tsx | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/web/src/app/signup/page.tsx b/web/src/app/signup/page.tsx index f602cb8..c4149df 100644 --- a/web/src/app/signup/page.tsx +++ b/web/src/app/signup/page.tsx @@ -444,14 +444,25 @@ function SignupForm() { const handlePay = async () => { setError(null) + const step1Errors = validateStep(1) + const step2Errors = validateStep(2) + const step3Errors = validateStep(3) const allErrors = { - ...validateStep(1), - ...validateStep(2), - ...validateStep(3), + ...step1Errors, + ...step2Errors, + ...step3Errors, } if (Object.keys(allErrors).length > 0) { setFieldErrors(allErrors) - setStep(1) + + if (Object.keys(step1Errors).length > 0) { + setStep(1) + } else if (Object.keys(step2Errors).length > 0) { + setStep(2) + } else { + setStep(3) + } + return } From 0403ea927b6ce5eb967e3f4df8ffc81da8f08950 Mon Sep 17 00:00:00 2001 From: Richman Tan Date: Fri, 1 May 2026 16:13:50 +1200 Subject: [PATCH 11/22] refactor: improve member creation logic and error handling in Stripe checkout --- cms/src/app/stripe/checkout/route.ts | 75 ++++++++++++++++++---------- 1 file changed, 49 insertions(+), 26 deletions(-) diff --git a/cms/src/app/stripe/checkout/route.ts b/cms/src/app/stripe/checkout/route.ts index 09478a1..1a60537 100644 --- a/cms/src/app/stripe/checkout/route.ts +++ b/cms/src/app/stripe/checkout/route.ts @@ -37,42 +37,59 @@ export const POST = async (request: NextRequest) => { } const payload = await getPayload({ config: configPromise }) + const stripe = new Stripe(stripeSecretKey, { apiVersion: '2026-04-22.dahlia' }) + // Reuse an existing pending member so a transient Stripe failure doesn't + // permanently block the email from retrying. let memberId: number - try { - const member = await payload.create({ - collection: 'members', - data: { - name, - email, - password, - phone, - upi, - studentId, - areaOfStudy, - yearOfUniversity, - gender, - ethnicity, - returningMember: returningMember ?? false, - status: 'pending', - }, - }) - memberId = member.id - } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Failed to create member' - if (message.toLowerCase().includes('duplicate') || message.toLowerCase().includes('unique')) { - return Response.json({ error: 'An account with this email already exists' }, { status: 409 }) + let memberCreatedHere = false + + const existing = await payload.find({ + collection: 'members', + where: { and: [{ email: { equals: email } }, { status: { equals: 'pending' } }] }, + limit: 1, + }) + + if (existing.docs.length > 0) { + memberId = existing.docs[0].id + } else { + try { + const member = await payload.create({ + collection: 'members', + data: { + name, + email, + password, + phone, + upi, + studentId, + areaOfStudy, + yearOfUniversity, + gender, + ethnicity, + returningMember: returningMember ?? false, + status: 'pending', + }, + }) + memberId = member.id + memberCreatedHere = true + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to create member' + if (message.toLowerCase().includes('duplicate') || message.toLowerCase().includes('unique')) { + return Response.json({ error: 'An account with this email already exists' }, { status: 409 }) + } + return Response.json({ error: message }, { status: 400 }) } - return Response.json({ error: message }, { status: 400 }) } - const stripe = new Stripe(stripeSecretKey, { apiVersion: '2026-04-22.dahlia' }) - let customerId: string try { const customer = await stripe.customers.create({ email, name }) customerId = customer.id } catch (err: unknown) { + if (memberCreatedHere) { + await payload.delete({ collection: 'members', id: memberId }).catch(() => {}) + } const message = err instanceof Error ? err.message : 'Failed to create Stripe customer' return Response.json({ error: message }, { status: 502 }) } @@ -88,6 +105,9 @@ export const POST = async (request: NextRequest) => { }) if (!session.url) { + if (memberCreatedHere) { + await payload.delete({ collection: 'members', id: memberId }).catch(() => {}) + } return Response.json( { error: 'Stripe did not provide a checkout URL for the created session' }, { status: 502 }, @@ -96,6 +116,9 @@ export const POST = async (request: NextRequest) => { return Response.json({ checkoutUrl: session.url }) } catch (err: unknown) { + if (memberCreatedHere) { + await payload.delete({ collection: 'members', id: memberId }).catch(() => {}) + } const message = err instanceof Error ? err.message : 'Failed to create Stripe checkout session' return Response.json({ error: message }, { status: 502 }) } From 03f27ef0dadeaae435aa9f81bba4937efdd70e0a Mon Sep 17 00:00:00 2001 From: Richman Tan <160602954+Richman-Tan@users.noreply.github.com> Date: Fri, 1 May 2026 16:16:38 +1200 Subject: [PATCH 12/22] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- cms/src/app/stripe/checkout/route.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/cms/src/app/stripe/checkout/route.ts b/cms/src/app/stripe/checkout/route.ts index 1a60537..ae3184c 100644 --- a/cms/src/app/stripe/checkout/route.ts +++ b/cms/src/app/stripe/checkout/route.ts @@ -23,9 +23,24 @@ export const POST = async (request: NextRequest) => { return Response.json({ error: 'Invalid JSON body' }, { status: 400 }) } - const { name, email, password, phone, upi, studentId, areaOfStudy, yearOfUniversity, gender, ethnicity, returningMember } = body + const { + name, + email, + password, + phone, + upi, + studentId, + areaOfStudy, + yearOfUniversity, + gender, + ethnicity, + returningMember, + } = body if (!name || !email || !password || !phone) { - return Response.json({ error: 'Missing required fields: name, email, password, phone' }, { status: 400 }) + return Response.json( + { error: 'Missing required fields: name, email, password, phone' }, + { status: 400 }, + ) } const stripeSecretKey = process.env.STRIPE_SECRET_KEY From 3fc48776d68cf2d39661b2c4541bfbf8ca2e24e0 Mon Sep 17 00:00:00 2001 From: Richman Tan <160602954+Richman-Tan@users.noreply.github.com> Date: Fri, 1 May 2026 16:16:58 +1200 Subject: [PATCH 13/22] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- cms/src/app/stripe/checkout/route.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cms/src/app/stripe/checkout/route.ts b/cms/src/app/stripe/checkout/route.ts index ae3184c..23648b7 100644 --- a/cms/src/app/stripe/checkout/route.ts +++ b/cms/src/app/stripe/checkout/route.ts @@ -71,6 +71,10 @@ export const POST = async (request: NextRequest) => { try { const member = await payload.create({ collection: 'members', + // This is a trusted server-side route that creates only a pending + // member record for the Stripe checkout flow, so bypass collection + // create access here explicitly. + overrideAccess: true, data: { name, email, From 7c58b971a613ed9a820bc1460d8c51f979792189 Mon Sep 17 00:00:00 2001 From: Richman Tan <160602954+Richman-Tan@users.noreply.github.com> Date: Fri, 1 May 2026 16:17:15 +1200 Subject: [PATCH 14/22] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- web/src/app/signup/success/page.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/web/src/app/signup/success/page.tsx b/web/src/app/signup/success/page.tsx index 90ea159..b72d4e6 100644 --- a/web/src/app/signup/success/page.tsx +++ b/web/src/app/signup/success/page.tsx @@ -1,12 +1,11 @@ import Link from 'next/link' interface Props { - searchParams: Promise<{ session_id?: string }> + searchParams: { session_id?: string } } -export default async function SignupSuccessPage({ searchParams }: Props) { - const params = await searchParams - const hasSession = Boolean(params.session_id) +export default function SignupSuccessPage({ searchParams }: Props) { + const hasSession = Boolean(searchParams.session_id) return (

From dc5ca6c4d083b4477473de640d3fe8472c420488 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 04:27:19 +0000 Subject: [PATCH 15/22] fix: apply review 4209899554 - validation, pending member update, Stripe button Agent-Logs-Url: https://github.com/UoaWDCC/ssa/sessions/c67c821e-aeb4-4238-a4bb-1c8d46139541 Co-authored-by: Richman-Tan <160602954+Richman-Tan@users.noreply.github.com> --- cms/src/app/stripe/checkout/route.ts | 39 +++++++++++++++++++++++++--- web/src/app/signup/page.tsx | 4 +-- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/cms/src/app/stripe/checkout/route.ts b/cms/src/app/stripe/checkout/route.ts index 23648b7..816e44a 100644 --- a/cms/src/app/stripe/checkout/route.ts +++ b/cms/src/app/stripe/checkout/route.ts @@ -36,9 +36,24 @@ export const POST = async (request: NextRequest) => { ethnicity, returningMember, } = body - if (!name || !email || !password || !phone) { + if ( + !name || + !email || + !password || + !phone || + !upi || + !studentId || + !areaOfStudy || + !yearOfUniversity || + !gender || + !ethnicity || + returningMember === undefined + ) { return Response.json( - { error: 'Missing required fields: name, email, password, phone' }, + { + error: + 'Missing required fields: name, email, password, phone, upi, studentId, areaOfStudy, yearOfUniversity, gender, ethnicity, returningMember', + }, { status: 400 }, ) } @@ -67,6 +82,24 @@ export const POST = async (request: NextRequest) => { if (existing.docs.length > 0) { memberId = existing.docs[0].id + // Update the existing pending member with the latest submitted details + // so a retry after correcting input always uses the most recent data. + await payload.update({ + collection: 'members', + id: memberId, + overrideAccess: true, + data: { + name, + phone, + upi, + studentId, + areaOfStudy, + yearOfUniversity, + gender, + ethnicity, + returningMember, + }, + }) } else { try { const member = await payload.create({ @@ -86,7 +119,7 @@ export const POST = async (request: NextRequest) => { yearOfUniversity, gender, ethnicity, - returningMember: returningMember ?? false, + returningMember, status: 'pending', }, }) diff --git a/web/src/app/signup/page.tsx b/web/src/app/signup/page.tsx index c4149df..7bd53a7 100644 --- a/web/src/app/signup/page.tsx +++ b/web/src/app/signup/page.tsx @@ -370,9 +370,9 @@ function Step4({ > {isLoading ? 'Processing...' : 'Pay'} - +

From 33ffb9744523f30f01631fddf04028e5617ffee7 Mon Sep 17 00:00:00 2001 From: Richman Tan Date: Fri, 1 May 2026 16:54:42 +1200 Subject: [PATCH 16/22] refactor: enhance SelectField component with dropdown icon and improve accessibility --- web/src/app/signup/page.tsx | 62 ++++++++++++++++++++++--------------- 1 file changed, 37 insertions(+), 25 deletions(-) diff --git a/web/src/app/signup/page.tsx b/web/src/app/signup/page.tsx index 7bd53a7..f953c5a 100644 --- a/web/src/app/signup/page.tsx +++ b/web/src/app/signup/page.tsx @@ -115,24 +115,36 @@ function SelectField({ {label} {required && *} - onChange(e.target.value)} + required={required} + aria-invalid={!!error} + className="w-full rounded-lg px-3 py-2 pr-8 text-sm outline-none border border-transparent focus:border-ssa-red bg-white appearance-none" + style={{ color: value ? '#0a0805' : '#9ca3af' }} + > + - ))} - + {options.map((o) => ( + + ))} + + +
{error &&

{error}

}
) @@ -161,7 +173,7 @@ function CardSection({ ) } -function Step1({ +function ContactStep({ data, onChange, fieldErrors, @@ -230,7 +242,7 @@ function Step1({ ) } -function Step2({ +function UniInfoStep({ data, onChange, fieldErrors, @@ -286,7 +298,7 @@ function Step2({ ) } -function Step3({ +function AdditionalInfoStep({ data, onChange, fieldErrors, @@ -341,7 +353,7 @@ function Step3({ ) } -function Step4({ +function PaymentStep({ onPay, isLoading, }: { @@ -523,27 +535,27 @@ function SignupForm() { {step === 1 && ( - )} {step === 2 && ( - )} {step === 3 && ( - )} - {step === 4 && } + {step === 4 && }
{step > 1 ? ( From 2ca3790c71edb6e4150c0537d05d1a25e8bc6a18 Mon Sep 17 00:00:00 2001 From: Richman Tan Date: Fri, 1 May 2026 16:56:26 +1200 Subject: [PATCH 17/22] chore: fix prettier formatting in signup page Co-Authored-By: Claude Sonnet 4.6 --- web/src/app/signup/page.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/web/src/app/signup/page.tsx b/web/src/app/signup/page.tsx index f953c5a..526f93c 100644 --- a/web/src/app/signup/page.tsx +++ b/web/src/app/signup/page.tsx @@ -142,7 +142,13 @@ function SelectField({ fill="none" aria-hidden="true" > - +
{error &&

{error}

} @@ -555,7 +561,9 @@ function SignupForm() { fieldErrors={fieldErrors} /> )} - {step === 4 && } + {step === 4 && ( + + )}
{step > 1 ? ( From be9c12b9074ae98600706062799e99efc45c2ba9 Mon Sep 17 00:00:00 2001 From: Richman Tan Date: Fri, 1 May 2026 18:10:10 +1200 Subject: [PATCH 18/22] feat: implement multi-step signup form with validation and payment processing --- .../signup/_components/AdditionalInfoStep.tsx | 58 ++ .../app/signup/_components/CardSection.tsx | 22 + .../app/signup/_components/ContactStep.tsx | 72 ++ web/src/app/signup/_components/InputField.tsx | 42 ++ .../app/signup/_components/PaymentStep.tsx | 39 ++ .../app/signup/_components/ProgressBar.tsx | 16 + .../app/signup/_components/SelectField.tsx | 68 ++ web/src/app/signup/_components/SignupForm.tsx | 199 ++++++ .../app/signup/_components/UniInfoStep.tsx | 60 ++ web/src/app/signup/_components/types.ts | 33 + web/src/app/signup/page.tsx | 619 +----------------- 11 files changed, 627 insertions(+), 601 deletions(-) create mode 100644 web/src/app/signup/_components/AdditionalInfoStep.tsx create mode 100644 web/src/app/signup/_components/CardSection.tsx create mode 100644 web/src/app/signup/_components/ContactStep.tsx create mode 100644 web/src/app/signup/_components/InputField.tsx create mode 100644 web/src/app/signup/_components/PaymentStep.tsx create mode 100644 web/src/app/signup/_components/ProgressBar.tsx create mode 100644 web/src/app/signup/_components/SelectField.tsx create mode 100644 web/src/app/signup/_components/SignupForm.tsx create mode 100644 web/src/app/signup/_components/UniInfoStep.tsx create mode 100644 web/src/app/signup/_components/types.ts diff --git a/web/src/app/signup/_components/AdditionalInfoStep.tsx b/web/src/app/signup/_components/AdditionalInfoStep.tsx new file mode 100644 index 0000000..46efe66 --- /dev/null +++ b/web/src/app/signup/_components/AdditionalInfoStep.tsx @@ -0,0 +1,58 @@ +import type { FormData } from './types' +import CardSection from './CardSection' +import SelectField from './SelectField' + +export default function AdditionalInfoStep({ + data, + onChange, + fieldErrors, +}: { + data: FormData + onChange: (field: keyof FormData, value: string) => void + fieldErrors: Record +}) { + return ( + +
+ onChange('gender', v)} + error={fieldErrors.gender} + options={[ + { value: 'male', label: 'Male' }, + { value: 'female', label: 'Female' }, + { value: 'non-binary', label: 'Non-binary' }, + { value: 'prefer-not-to-say', label: 'Prefer not to say' }, + ]} + /> + onChange('ethnicity', v)} + error={fieldErrors.ethnicity} + options={[ + { value: 'chinese', label: 'Chinese' }, + { value: 'malay', label: 'Malay' }, + { value: 'indian', label: 'Indian' }, + { value: 'eurasian', label: 'Eurasian' }, + { value: 'other', label: 'Other' }, + ]} + /> +
+ onChange('returningMember', v)} + error={fieldErrors.returningMember} + options={[ + { value: 'yes', label: 'Yes' }, + { value: 'no', label: 'No' }, + ]} + /> +
+ ) +} diff --git a/web/src/app/signup/_components/CardSection.tsx b/web/src/app/signup/_components/CardSection.tsx new file mode 100644 index 0000000..08a542e --- /dev/null +++ b/web/src/app/signup/_components/CardSection.tsx @@ -0,0 +1,22 @@ +export default function CardSection({ + title, + children, +}: { + title: string + children: React.ReactNode +}) { + return ( +
+
+

+ {title} +

+
+
+ {children} +
+ ) +} diff --git a/web/src/app/signup/_components/ContactStep.tsx b/web/src/app/signup/_components/ContactStep.tsx new file mode 100644 index 0000000..f36b0d9 --- /dev/null +++ b/web/src/app/signup/_components/ContactStep.tsx @@ -0,0 +1,72 @@ +import type { FormData } from './types' +import CardSection from './CardSection' +import InputField from './InputField' + +export default function ContactStep({ + data, + onChange, + fieldErrors, +}: { + data: FormData + onChange: (field: keyof FormData, value: string) => void + fieldErrors: Record +}) { + return ( + +
+ onChange('firstName', v)} + error={fieldErrors.firstName} + /> + onChange('lastName', v)} + error={fieldErrors.lastName} + /> +
+ onChange('email', v)} + error={fieldErrors.email} + /> + onChange('phone', v)} + error={fieldErrors.phone} + /> + onChange('password', v)} + error={fieldErrors.password} + /> + onChange('confirmPassword', v)} + error={fieldErrors.confirmPassword} + /> +
+ ) +} diff --git a/web/src/app/signup/_components/InputField.tsx b/web/src/app/signup/_components/InputField.tsx new file mode 100644 index 0000000..cba963f --- /dev/null +++ b/web/src/app/signup/_components/InputField.tsx @@ -0,0 +1,42 @@ +'use client' + +import { useId } from 'react' + +export default function InputField({ + label, + required, + placeholder, + value, + onChange, + type = 'text', + error, +}: { + label: string + required?: boolean + placeholder?: string + value: string + onChange: (v: string) => void + type?: string + error?: string +}) { + const id = useId() + return ( +
+ + onChange(e.target.value)} + required={required} + aria-invalid={!!error} + className="w-full rounded-lg px-3 py-2 text-sm text-gray-900 outline-none border border-transparent focus:border-ssa-red bg-white placeholder:text-gray-400" + /> + {error &&

{error}

} +
+ ) +} diff --git a/web/src/app/signup/_components/PaymentStep.tsx b/web/src/app/signup/_components/PaymentStep.tsx new file mode 100644 index 0000000..c63b1a4 --- /dev/null +++ b/web/src/app/signup/_components/PaymentStep.tsx @@ -0,0 +1,39 @@ +import CardSection from './CardSection' + +export default function PaymentStep({ + onPay, + isLoading, +}: { + onPay: () => void + isLoading: boolean +}) { + return ( + +
+

+ $6 is required to be a SSA member, the fee includes: +

+
    +
  • + Goodies and discounts from SSA sponsors when you show them your + membership sticker +
  • +
  • Please be sure to collect your MEMBERSHIP CARD from the team.
  • +
+
+ +

+ Powered by Stripe +

+
+
+
+ ) +} diff --git a/web/src/app/signup/_components/ProgressBar.tsx b/web/src/app/signup/_components/ProgressBar.tsx new file mode 100644 index 0000000..fa97025 --- /dev/null +++ b/web/src/app/signup/_components/ProgressBar.tsx @@ -0,0 +1,16 @@ +import { TOTAL_STEPS } from './types' + +export default function ProgressBar({ step }: { step: number }) { + const progress = (step / TOTAL_STEPS) * 100 + return ( +
+
+
+ ) +} diff --git a/web/src/app/signup/_components/SelectField.tsx b/web/src/app/signup/_components/SelectField.tsx new file mode 100644 index 0000000..aa65274 --- /dev/null +++ b/web/src/app/signup/_components/SelectField.tsx @@ -0,0 +1,68 @@ +'use client' + +import { useId } from 'react' + +export default function SelectField({ + label, + required, + placeholder, + value, + onChange, + options, + error, +}: { + label: string + required?: boolean + placeholder?: string + value: string + onChange: (v: string) => void + options: { value: string; label: string }[] + error?: string +}) { + const id = useId() + return ( +
+ +
+ + +
+ {error &&

{error}

} +
+ ) +} diff --git a/web/src/app/signup/_components/SignupForm.tsx b/web/src/app/signup/_components/SignupForm.tsx new file mode 100644 index 0000000..e0af2dd --- /dev/null +++ b/web/src/app/signup/_components/SignupForm.tsx @@ -0,0 +1,199 @@ +'use client' + +import { useState } from 'react' +import { useSearchParams } from 'next/navigation' +import { TOTAL_STEPS, initialFormData, type FormData } from './types' +import ProgressBar from './ProgressBar' +import ContactStep from './ContactStep' +import UniInfoStep from './UniInfoStep' +import AdditionalInfoStep from './AdditionalInfoStep' +import PaymentStep from './PaymentStep' + +export default function SignupForm() { + const searchParams = useSearchParams() + const wasCancelled = searchParams.get('cancelled') === 'true' + + const [step, setStep] = useState(1) + const [formData, setFormData] = useState(initialFormData) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const [fieldErrors, setFieldErrors] = useState>({}) + + function handleChange(field: keyof FormData, value: string) { + setFormData((prev) => ({ ...prev, [field]: value })) + } + + function validateStep(s: number): Record { + const errors: Record = {} + if (s === 1) { + if (!formData.firstName.trim()) + errors.firstName = 'First name is required' + if (!formData.lastName.trim()) errors.lastName = 'Last name is required' + if ( + !formData.email.trim() || + !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email) + ) { + errors.email = 'Valid email is required' + } + if (!formData.phone.trim()) errors.phone = 'Phone number is required' + if (formData.password.length < 8) + errors.password = 'Password must be at least 8 characters' + if (formData.password !== formData.confirmPassword) + errors.confirmPassword = 'Passwords do not match' + } else if (s === 2) { + if (!formData.upi.trim()) errors.upi = 'UPI is required' + if (!formData.studentId.trim()) + errors.studentId = 'Student ID is required' + if (!formData.areaOfStudy.trim()) + errors.areaOfStudy = 'Area of study is required' + if (!formData.yearOfUniversity) + errors.yearOfUniversity = 'Year of university is required' + } else if (s === 3) { + if (!formData.gender) errors.gender = 'Gender is required' + if (!formData.ethnicity) errors.ethnicity = 'Ethnicity is required' + if (!formData.returningMember) + errors.returningMember = 'This field is required' + } + return errors + } + + function handleNext() { + const errors = validateStep(step) + if (Object.keys(errors).length > 0) { + setFieldErrors(errors) + return + } + setFieldErrors({}) + if (step < TOTAL_STEPS) setStep((s) => s + 1) + } + + function handleBack() { + if (step > 1) setStep((s) => s - 1) + } + + const handlePay = async () => { + setError(null) + + const step1Errors = validateStep(1) + const step2Errors = validateStep(2) + const step3Errors = validateStep(3) + const allErrors = { ...step1Errors, ...step2Errors, ...step3Errors } + if (Object.keys(allErrors).length > 0) { + setFieldErrors(allErrors) + if (Object.keys(step1Errors).length > 0) { + setStep(1) + } else if (Object.keys(step2Errors).length > 0) { + setStep(2) + } else { + setStep(3) + } + return + } + + setIsLoading(true) + try { + const response = await fetch('/api/checkout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: `${formData.firstName} ${formData.lastName}`, + email: formData.email, + password: formData.password, + phone: formData.phone, + upi: formData.upi, + studentId: formData.studentId, + areaOfStudy: formData.areaOfStudy, + yearOfUniversity: formData.yearOfUniversity, + gender: formData.gender, + ethnicity: formData.ethnicity, + returningMember: formData.returningMember === 'yes', + }), + }) + + const result = await response.json() + + if (!response.ok || !result.checkoutUrl) { + setError(result.error ?? 'Something went wrong. Please try again.') + return + } + + window.location.href = result.checkoutUrl + } catch { + setError('Network error. Please check your connection and try again.') + } finally { + setIsLoading(false) + } + } + + return ( +
+
+
+ {wasCancelled && ( +
+ Payment was cancelled. You can try again below. +
+ )} + + {error && ( +
+ {error} +
+ )} + + + + {step === 1 && ( + + )} + {step === 2 && ( + + )} + {step === 3 && ( + + )} + {step === 4 && ( + + )} + +
+ {step > 1 ? ( + + ) : ( +
+ )} + {step < TOTAL_STEPS && ( + + )} +
+
+
+
+ ) +} diff --git a/web/src/app/signup/_components/UniInfoStep.tsx b/web/src/app/signup/_components/UniInfoStep.tsx new file mode 100644 index 0000000..840dd3b --- /dev/null +++ b/web/src/app/signup/_components/UniInfoStep.tsx @@ -0,0 +1,60 @@ +import type { FormData } from './types' +import CardSection from './CardSection' +import InputField from './InputField' +import SelectField from './SelectField' + +export default function UniInfoStep({ + data, + onChange, + fieldErrors, +}: { + data: FormData + onChange: (field: keyof FormData, value: string) => void + fieldErrors: Record +}) { + return ( + +
+ onChange('upi', v)} + error={fieldErrors.upi} + /> + onChange('studentId', v)} + error={fieldErrors.studentId} + /> +
+ onChange('areaOfStudy', v)} + error={fieldErrors.areaOfStudy} + /> + onChange('yearOfUniversity', v)} + error={fieldErrors.yearOfUniversity} + options={[ + { value: '1', label: 'Year 1' }, + { value: '2', label: 'Year 2' }, + { value: '3', label: 'Year 3' }, + { value: '4', label: 'Year 4' }, + { value: '5+', label: 'Year 5+' }, + { value: 'postgrad', label: 'Postgraduate' }, + ]} + /> +
+ ) +} diff --git a/web/src/app/signup/_components/types.ts b/web/src/app/signup/_components/types.ts new file mode 100644 index 0000000..e4ee540 --- /dev/null +++ b/web/src/app/signup/_components/types.ts @@ -0,0 +1,33 @@ +export const TOTAL_STEPS = 4 + +export type FormData = { + firstName: string + lastName: string + email: string + phone: string + password: string + confirmPassword: string + upi: string + studentId: string + areaOfStudy: string + yearOfUniversity: string + gender: string + ethnicity: string + returningMember: string +} + +export const initialFormData: FormData = { + firstName: '', + lastName: '', + email: '', + phone: '', + password: '', + confirmPassword: '', + upi: '', + studentId: '', + areaOfStudy: '', + yearOfUniversity: '', + gender: '', + ethnicity: '', + returningMember: '', +} diff --git a/web/src/app/signup/page.tsx b/web/src/app/signup/page.tsx index 526f93c..7b77eeb 100644 --- a/web/src/app/signup/page.tsx +++ b/web/src/app/signup/page.tsx @@ -1,607 +1,24 @@ -'use client' +import { Suspense } from 'react' +import Hero from '@/components/Hero' +import SignupForm from './_components/SignupForm' -import { useState, useId, Suspense } from 'react' -import { useSearchParams } from 'next/navigation' - -const TOTAL_STEPS = 4 - -type FormData = { - firstName: string - lastName: string - email: string - phone: string - password: string - confirmPassword: string - upi: string - studentId: string - areaOfStudy: string - yearOfUniversity: string - gender: string - ethnicity: string - returningMember: string -} - -const initialFormData: FormData = { - firstName: '', - lastName: '', - email: '', - phone: '', - password: '', - confirmPassword: '', - upi: '', - studentId: '', - areaOfStudy: '', - yearOfUniversity: '', - gender: '', - ethnicity: '', - returningMember: '', -} - -function ProgressBar({ step }: { step: number }) { - const progress = (step / TOTAL_STEPS) * 100 - return ( -
-
-
- ) -} - -function InputField({ - label, - required, - placeholder, - value, - onChange, - type = 'text', - error, -}: { - label: string - required?: boolean - placeholder?: string - value: string - onChange: (v: string) => void - type?: string - error?: string -}) { - const id = useId() - return ( -
- - onChange(e.target.value)} - required={required} - aria-invalid={!!error} - className="w-full rounded-lg px-3 py-2 text-sm text-gray-900 outline-none border border-transparent focus:border-ssa-red bg-white placeholder:text-gray-400" - /> - {error &&

{error}

} -
- ) -} - -function SelectField({ - label, - required, - placeholder, - value, - onChange, - options, - error, -}: { - label: string - required?: boolean - placeholder?: string - value: string - onChange: (v: string) => void - options: { value: string; label: string }[] - error?: string -}) { - const id = useId() - return ( -
- -
- - -
- {error &&

{error}

} -
- ) -} - -function CardSection({ - title, - children, -}: { - title: string - children: React.ReactNode -}) { - return ( -
-
-

- {title} -

-
-
- {children} -
- ) -} - -function ContactStep({ - data, - onChange, - fieldErrors, -}: { - data: FormData - onChange: (field: keyof FormData, value: string) => void - fieldErrors: Record -}) { - return ( - -
- onChange('firstName', v)} - error={fieldErrors.firstName} - /> - onChange('lastName', v)} - error={fieldErrors.lastName} - /> -
- onChange('email', v)} - error={fieldErrors.email} - /> - onChange('phone', v)} - error={fieldErrors.phone} - /> - onChange('password', v)} - error={fieldErrors.password} - /> - onChange('confirmPassword', v)} - error={fieldErrors.confirmPassword} - /> -
- ) -} - -function UniInfoStep({ - data, - onChange, - fieldErrors, -}: { - data: FormData - onChange: (field: keyof FormData, value: string) => void - fieldErrors: Record -}) { - return ( - -
- onChange('upi', v)} - error={fieldErrors.upi} - /> - onChange('studentId', v)} - error={fieldErrors.studentId} - /> -
- onChange('areaOfStudy', v)} - error={fieldErrors.areaOfStudy} - /> - onChange('yearOfUniversity', v)} - error={fieldErrors.yearOfUniversity} - options={[ - { value: '1', label: 'Year 1' }, - { value: '2', label: 'Year 2' }, - { value: '3', label: 'Year 3' }, - { value: '4', label: 'Year 4' }, - { value: '5+', label: 'Year 5+' }, - { value: 'postgrad', label: 'Postgraduate' }, - ]} - /> -
- ) -} - -function AdditionalInfoStep({ - data, - onChange, - fieldErrors, -}: { - data: FormData - onChange: (field: keyof FormData, value: string) => void - fieldErrors: Record -}) { +export default function SignupPage() { return ( - -
- onChange('gender', v)} - error={fieldErrors.gender} - options={[ - { value: 'male', label: 'Male' }, - { value: 'female', label: 'Female' }, - { value: 'non-binary', label: 'Non-binary' }, - { value: 'prefer-not-to-say', label: 'Prefer not to say' }, - ]} - /> - onChange('ethnicity', v)} - error={fieldErrors.ethnicity} - options={[ - { value: 'chinese', label: 'Chinese' }, - { value: 'malay', label: 'Malay' }, - { value: 'indian', label: 'Indian' }, - { value: 'eurasian', label: 'Eurasian' }, - { value: 'other', label: 'Other' }, - ]} - /> -
- onChange('returningMember', v)} - error={fieldErrors.returningMember} - options={[ - { value: 'yes', label: 'Yes' }, - { value: 'no', label: 'No' }, - ]} +
+ - - ) -} - -function PaymentStep({ - onPay, - isLoading, -}: { - onPay: () => void - isLoading: boolean -}) { - return ( - -
-

- $6 is required to be a SSA member, the fee includes: -

-
    -
  • - Goodies and discounts from SSA sponsors when you show them your - membership sticker -
  • -
  • Please be sure to collect your MEMBERSHIP CARD from the team.
  • -
-
- -

- Powered by Stripe -

-
-
-
- ) -} - -function SignupForm() { - const searchParams = useSearchParams() - const wasCancelled = searchParams.get('cancelled') === 'true' - - const [step, setStep] = useState(1) - const [formData, setFormData] = useState(initialFormData) - const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState(null) - const [fieldErrors, setFieldErrors] = useState>({}) - - function handleChange(field: keyof FormData, value: string) { - setFormData((prev) => ({ ...prev, [field]: value })) - } - - function validateStep(s: number): Record { - const errors: Record = {} - if (s === 1) { - if (!formData.firstName.trim()) - errors.firstName = 'First name is required' - if (!formData.lastName.trim()) errors.lastName = 'Last name is required' - if ( - !formData.email.trim() || - !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email) - ) { - errors.email = 'Valid email is required' - } - if (!formData.phone.trim()) errors.phone = 'Phone number is required' - if (formData.password.length < 8) - errors.password = 'Password must be at least 8 characters' - if (formData.password !== formData.confirmPassword) - errors.confirmPassword = 'Passwords do not match' - } else if (s === 2) { - if (!formData.upi.trim()) errors.upi = 'UPI is required' - if (!formData.studentId.trim()) - errors.studentId = 'Student ID is required' - if (!formData.areaOfStudy.trim()) - errors.areaOfStudy = 'Area of study is required' - if (!formData.yearOfUniversity) - errors.yearOfUniversity = 'Year of university is required' - } else if (s === 3) { - if (!formData.gender) errors.gender = 'Gender is required' - if (!formData.ethnicity) errors.ethnicity = 'Ethnicity is required' - if (!formData.returningMember) - errors.returningMember = 'This field is required' - } - return errors - } - - function handleNext() { - const errors = validateStep(step) - if (Object.keys(errors).length > 0) { - setFieldErrors(errors) - return - } - setFieldErrors({}) - if (step < TOTAL_STEPS) setStep((s) => s + 1) - } - - function handleBack() { - if (step > 1) setStep((s) => s - 1) - } - - const handlePay = async () => { - setError(null) - - const step1Errors = validateStep(1) - const step2Errors = validateStep(2) - const step3Errors = validateStep(3) - const allErrors = { - ...step1Errors, - ...step2Errors, - ...step3Errors, - } - if (Object.keys(allErrors).length > 0) { - setFieldErrors(allErrors) - - if (Object.keys(step1Errors).length > 0) { - setStep(1) - } else if (Object.keys(step2Errors).length > 0) { - setStep(2) - } else { - setStep(3) - } - - return - } - - setIsLoading(true) - try { - const response = await fetch('/api/checkout', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name: `${formData.firstName} ${formData.lastName}`, - email: formData.email, - password: formData.password, - phone: formData.phone, - upi: formData.upi, - studentId: formData.studentId, - areaOfStudy: formData.areaOfStudy, - yearOfUniversity: formData.yearOfUniversity, - gender: formData.gender, - ethnicity: formData.ethnicity, - returningMember: formData.returningMember === 'yes', - }), - }) - - const result = await response.json() - - if (!response.ok || !result.checkoutUrl) { - setError(result.error ?? 'Something went wrong. Please try again.') - return - } - - window.location.href = result.checkoutUrl - } catch { - setError('Network error. Please check your connection and try again.') - } finally { - setIsLoading(false) - } - } - - return ( -
-
-
- {wasCancelled && ( -
- Payment was cancelled. You can try again below. -
- )} - - {error && ( -
- {error} -
- )} - - - - {step === 1 && ( - - )} - {step === 2 && ( - - )} - {step === 3 && ( - - )} - {step === 4 && ( - - )} - -
- {step > 1 ? ( - - ) : ( -
- )} - {step < TOTAL_STEPS && ( - - )} + + Loading...
-
-
-
- ) -} - -export default function SignupPage() { - return ( - - Loading... -
- } - > - - + } + > + + +
) } From 13f304dc81ff2f945eb9d02d88a968ba9897a56a Mon Sep 17 00:00:00 2001 From: Richman Tan Date: Sat, 2 May 2026 21:43:48 +1200 Subject: [PATCH 19/22] feat: refactor signup components and add new input fields with improved structure --- web/src/app/globals.css | 1 + .../signup/_components/AdditionalInfoStep.tsx | 4 ++-- .../app/signup/_components/CardSection.tsx | 22 ------------------- .../app/signup/_components/ContactStep.tsx | 4 ++-- .../app/signup/_components/ProgressBar.tsx | 16 -------------- web/src/app/signup/_components/SignupForm.tsx | 14 +++++------- .../app/signup/_components/UniInfoStep.tsx | 6 ++--- web/src/components/CardSection.tsx | 17 ++++++++++++++ .../_components => components}/InputField.tsx | 0 .../PaymentStep.tsx | 6 ++--- web/src/components/ProgressBar.tsx | 11 ++++++++++ .../SelectField.tsx | 3 +-- 12 files changed, 44 insertions(+), 60 deletions(-) delete mode 100644 web/src/app/signup/_components/CardSection.tsx delete mode 100644 web/src/app/signup/_components/ProgressBar.tsx create mode 100644 web/src/components/CardSection.tsx rename web/src/{app/signup/_components => components}/InputField.tsx (100%) rename web/src/{app/signup/_components => components}/PaymentStep.tsx (84%) create mode 100644 web/src/components/ProgressBar.tsx rename web/src/{app/signup/_components => components}/SelectField.tsx (88%) diff --git a/web/src/app/globals.css b/web/src/app/globals.css index b883311..b0be952 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -10,6 +10,7 @@ --color-foreground: var(--foreground); --color-ssa-red: #f85b76; --color-ssa-red-light: #ff879c; + --color-ssa-red-pale: #ffb3bf; --color-ssa-yellow: #ffe6b6; --color-ssa-yellow-light: #fff7e9; --color-ssa-white: #fefcf9; diff --git a/web/src/app/signup/_components/AdditionalInfoStep.tsx b/web/src/app/signup/_components/AdditionalInfoStep.tsx index 46efe66..0b8c620 100644 --- a/web/src/app/signup/_components/AdditionalInfoStep.tsx +++ b/web/src/app/signup/_components/AdditionalInfoStep.tsx @@ -1,6 +1,6 @@ import type { FormData } from './types' -import CardSection from './CardSection' -import SelectField from './SelectField' +import CardSection from '@/components/CardSection' +import SelectField from '@/components/SelectField' export default function AdditionalInfoStep({ data, diff --git a/web/src/app/signup/_components/CardSection.tsx b/web/src/app/signup/_components/CardSection.tsx deleted file mode 100644 index 08a542e..0000000 --- a/web/src/app/signup/_components/CardSection.tsx +++ /dev/null @@ -1,22 +0,0 @@ -export default function CardSection({ - title, - children, -}: { - title: string - children: React.ReactNode -}) { - return ( -
-
-

- {title} -

-
-
- {children} -
- ) -} diff --git a/web/src/app/signup/_components/ContactStep.tsx b/web/src/app/signup/_components/ContactStep.tsx index f36b0d9..5e6b188 100644 --- a/web/src/app/signup/_components/ContactStep.tsx +++ b/web/src/app/signup/_components/ContactStep.tsx @@ -1,6 +1,6 @@ import type { FormData } from './types' -import CardSection from './CardSection' -import InputField from './InputField' +import CardSection from '@/components/CardSection' +import InputField from '@/components/InputField' export default function ContactStep({ data, diff --git a/web/src/app/signup/_components/ProgressBar.tsx b/web/src/app/signup/_components/ProgressBar.tsx deleted file mode 100644 index fa97025..0000000 --- a/web/src/app/signup/_components/ProgressBar.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { TOTAL_STEPS } from './types' - -export default function ProgressBar({ step }: { step: number }) { - const progress = (step / TOTAL_STEPS) * 100 - return ( -
-
-
- ) -} diff --git a/web/src/app/signup/_components/SignupForm.tsx b/web/src/app/signup/_components/SignupForm.tsx index e0af2dd..de607a6 100644 --- a/web/src/app/signup/_components/SignupForm.tsx +++ b/web/src/app/signup/_components/SignupForm.tsx @@ -3,11 +3,11 @@ import { useState } from 'react' import { useSearchParams } from 'next/navigation' import { TOTAL_STEPS, initialFormData, type FormData } from './types' -import ProgressBar from './ProgressBar' +import ProgressBar from '@/components/ProgressBar' import ContactStep from './ContactStep' import UniInfoStep from './UniInfoStep' import AdditionalInfoStep from './AdditionalInfoStep' -import PaymentStep from './PaymentStep' +import PaymentStep from '@/components/PaymentStep' export default function SignupForm() { const searchParams = useSearchParams() @@ -126,10 +126,7 @@ export default function SignupForm() { } return ( -
+
{wasCancelled && ( @@ -144,7 +141,7 @@ export default function SignupForm() {
)} - + {step === 1 && ( Next diff --git a/web/src/app/signup/_components/UniInfoStep.tsx b/web/src/app/signup/_components/UniInfoStep.tsx index 840dd3b..ebcb7de 100644 --- a/web/src/app/signup/_components/UniInfoStep.tsx +++ b/web/src/app/signup/_components/UniInfoStep.tsx @@ -1,7 +1,7 @@ import type { FormData } from './types' -import CardSection from './CardSection' -import InputField from './InputField' -import SelectField from './SelectField' +import CardSection from '@/components/CardSection' +import InputField from '@/components/InputField' +import SelectField from '@/components/SelectField' export default function UniInfoStep({ data, diff --git a/web/src/components/CardSection.tsx b/web/src/components/CardSection.tsx new file mode 100644 index 0000000..d3df3a4 --- /dev/null +++ b/web/src/components/CardSection.tsx @@ -0,0 +1,17 @@ +export default function CardSection({ + title, + children, +}: { + title: string + children: React.ReactNode +}) { + return ( +
+
+

{title}

+
+
+ {children} +
+ ) +} diff --git a/web/src/app/signup/_components/InputField.tsx b/web/src/components/InputField.tsx similarity index 100% rename from web/src/app/signup/_components/InputField.tsx rename to web/src/components/InputField.tsx diff --git a/web/src/app/signup/_components/PaymentStep.tsx b/web/src/components/PaymentStep.tsx similarity index 84% rename from web/src/app/signup/_components/PaymentStep.tsx rename to web/src/components/PaymentStep.tsx index c63b1a4..7428a1d 100644 --- a/web/src/app/signup/_components/PaymentStep.tsx +++ b/web/src/components/PaymentStep.tsx @@ -15,8 +15,7 @@ export default function PaymentStep({

  • - Goodies and discounts from SSA sponsors when you show them your - membership sticker + Goodies and discounts from SSA sponsors when you show them your membership sticker
  • Please be sure to collect your MEMBERSHIP CARD from the team.
@@ -24,8 +23,7 @@ export default function PaymentStep({ diff --git a/web/src/components/ProgressBar.tsx b/web/src/components/ProgressBar.tsx new file mode 100644 index 0000000..6f6b752 --- /dev/null +++ b/web/src/components/ProgressBar.tsx @@ -0,0 +1,11 @@ +export default function ProgressBar({ step, total }: { step: number; total: number }) { + const progress = (step / total) * 100 + return ( +
+
+
+ ) +} diff --git a/web/src/app/signup/_components/SelectField.tsx b/web/src/components/SelectField.tsx similarity index 88% rename from web/src/app/signup/_components/SelectField.tsx rename to web/src/components/SelectField.tsx index aa65274..64557b0 100644 --- a/web/src/app/signup/_components/SelectField.tsx +++ b/web/src/components/SelectField.tsx @@ -33,8 +33,7 @@ export default function SelectField({ onChange={(e) => onChange(e.target.value)} required={required} aria-invalid={!!error} - className="w-full rounded-lg px-3 py-2 pr-8 text-sm outline-none border border-transparent focus:border-ssa-red bg-white appearance-none" - style={{ color: value ? '#0a0805' : '#9ca3af' }} + className={`w-full rounded-lg px-3 py-2 pr-8 text-sm outline-none border border-transparent focus:border-ssa-red bg-white appearance-none ${value ? 'text-ssa-black' : 'text-gray-400'}`} >