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 diff --git a/cms/src/app/stripe/checkout/route.ts b/cms/src/app/stripe/checkout/route.ts index 99e03eb..b6982ba 100644 --- a/cms/src/app/stripe/checkout/route.ts +++ b/cms/src/app/stripe/checkout/route.ts @@ -2,67 +2,171 @@ 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 } + 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 - if (!name || !email || !password || !phone) { - return Response.json({ error: 'Missing required fields: name, email, password, phone' }, { status: 400 }) + const { + name, + email, + password, + phone, + upi, + studentId, + areaOfStudy, + yearOfUniversity, + gender, + ethnicity, + returningMember, + } = body + if ( + !name || + !email || + !password || + !phone || + !upi || + !studentId || + !areaOfStudy || + !yearOfUniversity || + !gender || + !ethnicity || + returningMember === undefined + ) { + return Response.json( + { + error: + 'Missing required fields: name, email, password, phone, upi, studentId, areaOfStudy, yearOfUniversity, gender, ethnicity, returningMember', + }, + { 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' - 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 }) + 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 + let memberCreatedHere = false + let existingStripeCustomerId: string | null | undefined - // Check for duplicate email before sending the user to Stripe const existing = await payload.find({ collection: 'members', - where: { email: { equals: email } }, + where: { and: [{ email: { equals: email } }, { status: { equals: 'pending' } }] }, 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' }) + if (existing.docs.length > 0) { + const existingDoc = existing.docs[0] + memberId = existingDoc.id + existingStripeCustomerId = existingDoc.stripeCustomerId + // Update the existing pending member with the latest submitted details, + // including password so a retry after correcting credentials works correctly. + await payload.update({ + collection: 'members', + id: memberId, + overrideAccess: true, + data: { + name, + phone, + password, + upi, + studentId, + areaOfStudy, + yearOfUniversity, + gender, + ethnicity, + returningMember, + }, + }) + } else { + 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, + password, + phone, + upi, + studentId, + areaOfStudy, + yearOfUniversity, + gender, + ethnicity, + returningMember, + 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 }) + } + } + // Reuse the existing Stripe customer if this pending member already has one. + // This prevents orphaned customers from accumulating on retries. let customerId: string - try { - 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 }) + let customerCreatedHere = false + if (existingStripeCustomerId) { + customerId = existingStripeCustomerId + } else { + try { + const customer = await stripe.customers.create({ email, name }) + customerId = customer.id + customerCreatedHere = true + // Persist the customer ID so future retries can reuse it. + await payload + .update({ + collection: 'members', + id: memberId, + overrideAccess: true, + data: { stripeCustomerId: customerId }, + }) + .catch(() => {}) + } 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 }) + } } - const encryptedPassword = encryptPassword(password, encryptionKey) - try { const session = await stripe.checkout.sessions.create({ mode: 'payment', @@ -70,10 +174,30 @@ 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) { + if (memberCreatedHere) { + await payload.delete({ collection: 'members', id: memberId }).catch(() => {}) + if (customerCreatedHere) { + await stripe.customers.del(customerId).catch(() => {}) + } + } + 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) { + if (memberCreatedHere) { + await payload.delete({ collection: 'members', id: memberId }).catch(() => {}) + if (customerCreatedHere) { + await stripe.customers.del(customerId).catch(() => {}) + } + } 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 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 acfa431..10fb5ff 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', @@ -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/api/checkout/route.ts b/web/src/app/api/checkout/route.ts index cc84640..781deff 100644 --- a/web/src/app/api/checkout/route.ts +++ b/web/src/app/api/checkout/route.ts @@ -26,6 +26,19 @@ 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 { + console.error('[api/checkout] CMS returned non-JSON body:', cmsBody) + data = { error: 'Checkout service error. Please try again.' } + } + } + return Response.json(data, { status: cmsResponse.status }) } diff --git a/web/src/app/globals.css b/web/src/app/globals.css index b883311..684b98a 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -10,6 +10,8 @@ --color-foreground: var(--foreground); --color-ssa-red: #f85b76; --color-ssa-red-light: #ff879c; + --color-ssa-red-lighter: #ffb3bf; + --color-stripe-purple: #635bff; --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 new file mode 100644 index 0000000..0b8c620 --- /dev/null +++ b/web/src/app/signup/_components/AdditionalInfoStep.tsx @@ -0,0 +1,58 @@ +import type { FormData } from './types' +import CardSection from '@/components/CardSection' +import SelectField from '@/components/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/ContactStep.tsx b/web/src/app/signup/_components/ContactStep.tsx new file mode 100644 index 0000000..102b121 --- /dev/null +++ b/web/src/app/signup/_components/ContactStep.tsx @@ -0,0 +1,84 @@ +import type { FormData } from './types' +import CardSection from '@/components/CardSection' +import InputField from '@/components/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/SignupForm.tsx b/web/src/app/signup/_components/SignupForm.tsx new file mode 100644 index 0000000..fe2bf54 --- /dev/null +++ b/web/src/app/signup/_components/SignupForm.tsx @@ -0,0 +1,206 @@ +'use client' + +import { useState } from 'react' +import { useSearchParams } from 'next/navigation' +import { TOTAL_STEPS, initialFormData, type FormData } from './types' +import ProgressBar from '@/components/ProgressBar' +import ContactStep from './ContactStep' +import UniInfoStep from './UniInfoStep' +import AdditionalInfoStep from './AdditionalInfoStep' +import PaymentStep from '@/components/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 })) + setFieldErrors((prev) => { + if (!prev[field]) return prev + const next = { ...prev } + delete next[field] + return next + }) + } + + 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 || + !/[a-zA-Z]/.test(formData.password) || + !/[0-9]/.test(formData.password) + ) + errors.password = + 'Password must be at least 8 characters and include a letter and a number' + 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..ebcb7de --- /dev/null +++ b/web/src/app/signup/_components/UniInfoStep.tsx @@ -0,0 +1,60 @@ +import type { FormData } from './types' +import CardSection from '@/components/CardSection' +import InputField from '@/components/InputField' +import SelectField from '@/components/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 d80f671..7b77eeb 100644 --- a/web/src/app/signup/page.tsx +++ b/web/src/app/signup/page.tsx @@ -1,227 +1,24 @@ -'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' +import { Suspense } from 'react' +import Hero from '@/components/Hero' +import SignupForm from './_components/SignupForm' +export default function SignupPage() { return ( -
-
-

Become a Member

-

- Join the Singapore Students' Association -

- - {wasCancelled && ( -
- Payment was cancelled. You can try again below. -
- )} - - {error && ( -
- {error} +
+ + + Loading...
- )} - -
-
- - - {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 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.

) : (

diff --git a/web/src/components/CardSection.tsx b/web/src/components/CardSection.tsx new file mode 100644 index 0000000..06b6f3b --- /dev/null +++ b/web/src/components/CardSection.tsx @@ -0,0 +1,19 @@ +export default function CardSection({ + title, + children, +}: { + title: string + children: React.ReactNode +}) { + return ( +

+
+

+ {title} +

+
+
+ {children} +
+ ) +} diff --git a/web/src/components/InputField.tsx b/web/src/components/InputField.tsx new file mode 100644 index 0000000..204f914 --- /dev/null +++ b/web/src/components/InputField.tsx @@ -0,0 +1,54 @@ +'use client' + +import { useId } from 'react' + +export default function InputField({ + label, + required, + placeholder, + value, + onChange, + type = 'text', + error, + autoComplete, + name, +}: { + label: string + required?: boolean + placeholder?: string + value: string + onChange: (v: string) => void + type?: string + error?: string + autoComplete?: string + name?: string +}) { + const id = useId() + const errorId = useId() + return ( +
+ + onChange(e.target.value)} + required={required} + aria-invalid={!!error} + aria-describedby={error ? errorId : undefined} + autoComplete={autoComplete} + 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/components/PaymentStep.tsx b/web/src/components/PaymentStep.tsx new file mode 100644 index 0000000..2b4b3fb --- /dev/null +++ b/web/src/components/PaymentStep.tsx @@ -0,0 +1,38 @@ +import CardSection from './CardSection' + +export default function PaymentStep({ + onPay, + isLoading, +}: { + onPay: () => void + isLoading: boolean +}) { + return ( + +
+

+ $6 is required to be an 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/components/ProgressBar.tsx b/web/src/components/ProgressBar.tsx new file mode 100644 index 0000000..a03018c --- /dev/null +++ b/web/src/components/ProgressBar.tsx @@ -0,0 +1,24 @@ +export default function ProgressBar({ + step, + total, +}: { + step: number + total: number +}) { + const progress = (step / total) * 100 + return ( +
+
+
+ ) +} diff --git a/web/src/components/SelectField.tsx b/web/src/components/SelectField.tsx new file mode 100644 index 0000000..b4af2ca --- /dev/null +++ b/web/src/components/SelectField.tsx @@ -0,0 +1,73 @@ +'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() + const errorId = useId() + return ( +
+ +
+ + +
+ {error && ( +

+ {error} +

+ )} +
+ ) +}