diff --git a/functions/api/participant-application.ts b/functions/api/participant-application.ts new file mode 100644 index 0000000..f0e08fe --- /dev/null +++ b/functions/api/participant-application.ts @@ -0,0 +1,456 @@ +type Env = { + TURNSTILE_SECRET_KEY?: string; + RESEND_API_KEY?: string; + CONTACT_FROM_EMAIL?: string; + CONTACT_TO_EMAIL?: string; +}; + +const jsonResponse = (data: Record, status = 200) => + new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json" }, + }); + +const fetchWithTimeout = async ( + input: RequestInfo | URL, + init: RequestInit, + timeoutMs: number, + label: string, +) => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(input, { ...init, signal: controller.signal }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown error"; + throw new Error(`${label} request failed: ${message}`); + } finally { + clearTimeout(timeout); + } +}; + +const readTextField = (value: string | null) => (value ?? "").trim(); +const toTitleCase = (value: string) => + value + .replace(/_/g, " ") + .replace(/\b\w/g, (char) => char.toUpperCase()); + +const parseFormFields = async (request: Request) => { + const contentType = request.headers.get("content-type") ?? ""; + const data = new Map(); + + const appendValue = (key: string, value: string | null) => { + const normalized = readTextField(value); + if (!normalized) return; + data.set(key, [...(data.get(key) ?? []), normalized]); + }; + + if (contentType.includes("application/x-www-form-urlencoded")) { + const bodyText = await request.text(); + const params = new URLSearchParams(bodyText); + for (const [key, value] of params.entries()) { + appendValue(key, value); + } + return data; + } + + const formData = await request.formData(); + for (const [key, value] of formData.entries()) { + appendValue(key, typeof value === "string" ? value : value.name); + } + return data; +}; + +const formatValue = (values: string[] | undefined) => (values ?? []).join(", "); + +const labelMap: Record = { + filled_by: "Who is filling in the form", + participant_first_names: "Participant first name(s)", + participant_last_name: "Participant last name", + preferred_name: "Preferred name or nickname", + preferred_pronouns: "Preferred pronouns", + date_of_birth: "Date of birth", + sex: "Sex", + languages_spoken: "Languages used every day", + address_line_1: "Address line 1", + address_line_2: "Address line 2", + postcode: "Postcode", + telephone: "Telephone", + mobile: "Mobile", + email: "Email", + activity_experience: "Previous activity experience", + allergies: "Allergies", + triggers: "Known triggers", + simple_instructions: "Would prefer simple instructions", + learning_disability: "Learning disability", + neurodiverse: "Neurodiverse", + sensitive_to_crowds: "Sensitive to strangers or crowds", + sensory_sensitivities: "Sensitive to light, temperature, noise or clothing", + anxiety_low_mood: "Anxiety, depression or low mood", + panic_attacks: "Panic attacks", + speech_difficulties: "Difficulty with speech or being understood", + communication_system: "Uses a communication system", + hearing_aid: "Hearing aid", + cochlear_implant: "Cochlear implant", + bsl: "Uses BSL", + blind: "Blind", + partially_sighted: "Partially sighted", + dexterity_difficulties: "Dexterity difficulties", + balance_problems: "Problems with balance", + weight_through_feet: "Can take weight through feet", + help_walking: "Needs help with walking", + steps_mounting_block: "Can walk up a few steps", + wheelchair_user: "Wheelchair user", + walking_aids: "Uses walking aids or supports", + prosthetic_limb: "Wears a prosthetic limb", + long_term_pain: "Long term pain", + breathing_stamina: "Breathing or stamina difficulties", + epilepsy: "Epilepsy or controlled by medication", + condition_details: "Disability or medical condition details", + participant_height: "Height", + height_unit: "Height unit", + participant_weight: "Weight", + weight_unit: "Weight unit", + interested_in_competing: "Interested in competing", + been_classified: "Has been classified", + classification_details: "Classification", + classification_date: "Classification date", + emergency_contact_name: "Emergency contact name and relationship", + emergency_contact_phone: "Emergency contact number", + emergency_contact_consent: "Emergency contact consent", + declaration_confirm: "Declaration confirmation", + provide_medical_details: "Agrees to provide further medical details if requested", + notify_changes: "Will notify RDA of changes", + risk_acknowledgement: "Accepts horse-related risk acknowledgement", + vaulting_acknowledgement: "Vaulting risk acknowledgement", + photos_consent: "Photo and video consent", + newsletter_consent: "Newsletter consent", + declaration_signature: "Signature name", + declaration_date: "Declaration date", + guardian_name: "Parent or legal guardian name", + guardian_relationship: "Relationship to applicant", + guardian_address_line_1: "Guardian address line 1", + guardian_address_line_2: "Guardian address line 2", + guardian_postcode: "Guardian postcode", + guardian_telephone: "Guardian telephone", + guardian_mobile: "Guardian mobile", +}; + +const requiredFields = [ + "filled_by", + "participant_first_names", + "participant_last_name", + "date_of_birth", + "address_line_1", + "postcode", + "email", + "emergency_contact_name", + "emergency_contact_phone", + "declaration_confirm", + "photos_consent", +]; + +const sectionOrder = [ + { + title: "Applicant", + fields: [ + "filled_by", + "participant_first_names", + "participant_last_name", + "preferred_name", + "preferred_pronouns", + "date_of_birth", + "sex", + "languages_spoken", + "address_line_1", + "address_line_2", + "postcode", + "telephone", + "mobile", + "email", + "activity_experience", + ], + }, + { + title: "Specific Information", + fields: [ + "allergies", + "triggers", + "simple_instructions", + "learning_disability", + "neurodiverse", + "sensitive_to_crowds", + "sensory_sensitivities", + "anxiety_low_mood", + "panic_attacks", + "speech_difficulties", + "communication_system", + "hearing_aid", + "cochlear_implant", + "bsl", + "blind", + "partially_sighted", + "dexterity_difficulties", + "balance_problems", + "weight_through_feet", + "help_walking", + "steps_mounting_block", + "wheelchair_user", + "walking_aids", + "prosthetic_limb", + "long_term_pain", + "breathing_stamina", + "epilepsy", + "condition_details", + "participant_height", + "height_unit", + "participant_weight", + "weight_unit", + ], + }, + { + title: "Additional Information", + fields: [ + "interested_in_competing", + "been_classified", + "classification_details", + "classification_date", + ], + }, + { + title: "Emergency Contact", + fields: [ + "emergency_contact_name", + "emergency_contact_phone", + "emergency_contact_consent", + ], + }, + { + title: "Declaration", + fields: [ + "declaration_confirm", + "provide_medical_details", + "notify_changes", + "risk_acknowledgement", + "vaulting_acknowledgement", + "photos_consent", + "newsletter_consent", + "declaration_signature", + "declaration_date", + ], + }, + { + title: "Parent or Legal Guardian", + fields: [ + "guardian_name", + "guardian_relationship", + "guardian_address_line_1", + "guardian_address_line_2", + "guardian_postcode", + "guardian_telephone", + "guardian_mobile", + ], + }, +]; + +const handlePost: PagesFunction = async ({ request, env }) => { + const fields = await parseFormFields(request); + + const missingRequired = requiredFields.filter( + (field) => !formatValue(fields.get(field)), + ); + if (missingRequired.length > 0) { + return jsonResponse( + { + ok: false, + error: "Missing required fields.", + details: missingRequired.map((field) => labelMap[field] ?? field).join(", "), + }, + 400, + ); + } + + if (!env.TURNSTILE_SECRET_KEY) { + return jsonResponse( + { ok: false, error: "Turnstile secret key not configured." }, + 500, + ); + } + + const turnstileToken = formatValue(fields.get("cf-turnstile-response")); + if (!turnstileToken) { + return jsonResponse( + { ok: false, error: "Turnstile verification failed." }, + 400, + ); + } + + const verifyBody = new URLSearchParams({ + secret: env.TURNSTILE_SECRET_KEY, + response: turnstileToken, + remoteip: request.headers.get("CF-Connecting-IP") ?? "", + }); + + const verifyResponse = await fetchWithTimeout( + "https://challenges.cloudflare.com/turnstile/v0/siteverify", + { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: verifyBody, + }, + 8000, + "Turnstile", + ); + const verifyResult = (await verifyResponse.json()) as { + success?: boolean; + "error-codes"?: string[]; + messages?: string[]; + hostname?: string; + action?: string; + }; + + if (!verifyResult.success) { + const details = [ + verifyResult["error-codes"]?.length + ? `codes=${verifyResult["error-codes"].join(",")}` + : "", + verifyResult.messages?.length + ? `messages=${verifyResult.messages.join(",")}` + : "", + verifyResult.hostname ? `hostname=${verifyResult.hostname}` : "", + verifyResult.action ? `action=${verifyResult.action}` : "", + ] + .filter(Boolean) + .join(" | ") + .slice(0, 500); + return jsonResponse( + { + ok: false, + error: "Turnstile verification failed.", + details: details || undefined, + }, + 400, + ); + } + + if (!env.RESEND_API_KEY) { + return jsonResponse( + { ok: false, error: "Email provider not configured." }, + 500, + ); + } + + const fromEmail = env.CONTACT_FROM_EMAIL; + if (!fromEmail) { + return jsonResponse( + { ok: false, error: "Sender email not configured." }, + 500, + ); + } + + const toEmail = env.CONTACT_TO_EMAIL ?? "calum@muir.in"; + const applicantName = [ + formatValue(fields.get("participant_first_names")), + formatValue(fields.get("participant_last_name")), + ] + .filter(Boolean) + .join(" "); + + const bodyText = sectionOrder + .map(({ title, fields: sectionFields }) => { + const lines = sectionFields + .map((field) => { + const value = formatValue(fields.get(field)); + if (!value) return null; + return `${labelMap[field] ?? toTitleCase(field)}: ${value}`; + }) + .filter(Boolean) + .join("\n"); + + return lines ? `${title}\n${lines}` : ""; + }) + .filter(Boolean) + .join("\n\n"); + + const extraFields = [...fields.keys()].filter( + (field) => + field !== "cf-turnstile-response" && + !sectionOrder.some((section) => section.fields.includes(field)), + ); + const extraText = extraFields + .map((field) => { + const value = formatValue(fields.get(field)); + return value ? `${labelMap[field] ?? toTitleCase(field)}: ${value}` : ""; + }) + .filter(Boolean) + .join("\n"); + + const emailPayload = { + from: fromEmail, + to: [toEmail], + reply_to: formatValue(fields.get("email")) || undefined, + subject: `New participant application${applicantName ? `: ${applicantName}` : ""}`, + text: [bodyText, extraText ? `Additional Fields\n${extraText}` : ""] + .filter(Boolean) + .join("\n\n"), + }; + + const response = await fetchWithTimeout( + "https://api.resend.com/emails", + { + method: "POST", + headers: { + Authorization: `Bearer ${env.RESEND_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(emailPayload), + }, + 8000, + "Resend", + ); + if (!response.ok) { + const contentType = response.headers.get("content-type") ?? ""; + const errorText = contentType.includes("application/json") + ? JSON.stringify(await response.json().catch(() => ({}))) + : await response.text(); + const errorCode = response.headers.get("x-ms-error-code"); + const details = [ + `${response.status} ${response.statusText}`.trim(), + errorCode ? `code=${errorCode}` : "", + errorText.trim(), + ] + .filter(Boolean) + .join(" | ") + .slice(0, 500); + return jsonResponse( + { + ok: false, + error: "Failed to send email.", + details, + }, + 502, + ); + } + + return jsonResponse({ ok: true }); +}; + +export const onRequest: PagesFunction = async (context) => { + try { + if (context.request.method !== "POST") { + return jsonResponse( + { ok: false, error: "Method not allowed." }, + 405, + ); + } + return await handlePost(context); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown error"; + return jsonResponse( + { ok: false, error: "Unhandled exception.", details: message }, + 500, + ); + } +}; diff --git a/functions/api/volunteer-application.ts b/functions/api/volunteer-application.ts new file mode 100644 index 0000000..eb111d6 --- /dev/null +++ b/functions/api/volunteer-application.ts @@ -0,0 +1,435 @@ +type Env = { + TURNSTILE_SECRET_KEY?: string; + RESEND_API_KEY?: string; + CONTACT_FROM_EMAIL?: string; + CONTACT_TO_EMAIL?: string; +}; + +const jsonResponse = (data: Record, status = 200) => + new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json" }, + }); + +const fetchWithTimeout = async ( + input: RequestInfo | URL, + init: RequestInit, + timeoutMs: number, + label: string, +) => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(input, { ...init, signal: controller.signal }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown error"; + throw new Error(`${label} request failed: ${message}`); + } finally { + clearTimeout(timeout); + } +}; + +const readTextField = (value: string | null) => (value ?? "").trim(); +const toTitleCase = (value: string) => + value + .replace(/_/g, " ") + .replace(/\b\w/g, (char) => char.toUpperCase()); + +const parseFormFields = async (request: Request) => { + const contentType = request.headers.get("content-type") ?? ""; + const data = new Map(); + + const appendValue = (key: string, value: string | null) => { + const normalized = readTextField(value); + if (!normalized) return; + data.set(key, [...(data.get(key) ?? []), normalized]); + }; + + if (contentType.includes("application/x-www-form-urlencoded")) { + const bodyText = await request.text(); + const params = new URLSearchParams(bodyText); + for (const [key, value] of params.entries()) { + appendValue(key, value); + } + return data; + } + + const formData = await request.formData(); + for (const [key, value] of formData.entries()) { + appendValue(key, typeof value === "string" ? value : value.name); + } + return data; +}; + +const formatValue = (values: string[] | undefined) => (values ?? []).join(", "); + +const labelMap: Record = { + group_name: "RDA Group Name", + charity_number: "Charity Number", + group_contact_name: "Group Contact Name", + group_contact_address: "Group Contact Address", + group_contact_email: "Group Contact Email", + group_contact_phone: "Group Contact Telephone", + volunteer_first_names: "Volunteer first name(s)", + volunteer_last_name: "Volunteer last name", + preferred_name: "Preferred name or nickname", + preferred_pronouns: "Preferred pronouns", + date_of_birth: "Date of birth", + sex: "Sex", + languages_spoken: "Languages used every day", + address_line_1: "Address line 1", + address_line_2: "Address line 2", + postcode: "Postcode", + telephone: "Telephone", + mobile: "Mobile", + email: "Email", + equine_experience: "Previous experience with equines", + disability_experience: "Experience volunteering or working with disabled people", + skills_qualifications: "Other skills and professional qualifications", + placement_considerations: "Placement considerations or support needs", + emergency_contact_name: "Emergency contact full name", + emergency_contact_relationship: "Emergency contact relationship", + emergency_contact_phone: "Emergency contact telephone number", + emergency_contact_consent: "Emergency contact consent", + reference_1_name: "Reference 1 full name", + reference_1_address: "Reference 1 address", + reference_1_email: "Reference 1 email", + reference_1_phone: "Reference 1 phone", + reference_2_name: "Reference 2 full name", + reference_2_address: "Reference 2 address", + reference_2_email: "Reference 2 email", + reference_2_phone: "Reference 2 phone", + declaration_truth: "Declaration of truth and accuracy", + declaration_notify_changes: "Will notify RDA of any changes", + declaration_risk: "Accepts activity risk and precautions", + declaration_codes: "Will adhere to the RDA Codes of Conduct", + declaration_horses: "Understands horses and ponies are unpredictable", + declaration_liability: "Accepts liability statement", + disclosure_consent: "Enhanced disclosure and safeguarding consent", + authority_checks: "Accepts authority and police record checks", + conviction_reporting: "Understands duty to report convictions involving children", + photos_consent: "Photo and video consent", + declaration_signature: "Signature name", + declaration_role: "Signer role", + declaration_date: "Declaration date", + guardian_name: "Parent or guardian name", + guardian_relationship: "Relationship to volunteer", + guardian_address_line_1: "Parent or guardian address line 1", + guardian_address_line_2: "Parent or guardian address line 2", + guardian_postcode: "Parent or guardian postcode", + guardian_telephone: "Parent or guardian telephone", + guardian_mobile: "Parent or guardian mobile", +}; + +const requiredFields = [ + "volunteer_first_names", + "volunteer_last_name", + "date_of_birth", + "address_line_1", + "postcode", + "email", + "emergency_contact_name", + "emergency_contact_relationship", + "emergency_contact_phone", + "reference_1_name", + "reference_1_email", + "reference_2_name", + "reference_2_email", + "declaration_truth", + "photos_consent", +]; + +const sectionOrder = [ + { + title: "Group Details", + fields: [ + "group_name", + "charity_number", + "group_contact_name", + "group_contact_address", + "group_contact_email", + "group_contact_phone", + ], + }, + { + title: "Volunteer Details", + fields: [ + "volunteer_first_names", + "volunteer_last_name", + "preferred_name", + "preferred_pronouns", + "date_of_birth", + "sex", + "languages_spoken", + "address_line_1", + "address_line_2", + "postcode", + "telephone", + "mobile", + "email", + ], + }, + { + title: "Specific Information", + fields: [ + "equine_experience", + "disability_experience", + "skills_qualifications", + "placement_considerations", + ], + }, + { + title: "Emergency Contact", + fields: [ + "emergency_contact_name", + "emergency_contact_relationship", + "emergency_contact_phone", + "emergency_contact_consent", + ], + }, + { + title: "References", + fields: [ + "reference_1_name", + "reference_1_address", + "reference_1_email", + "reference_1_phone", + "reference_2_name", + "reference_2_address", + "reference_2_email", + "reference_2_phone", + ], + }, + { + title: "Declaration", + fields: [ + "declaration_truth", + "declaration_notify_changes", + "declaration_risk", + "declaration_codes", + "declaration_horses", + "declaration_liability", + "disclosure_consent", + "authority_checks", + "conviction_reporting", + "photos_consent", + "declaration_signature", + "declaration_role", + "declaration_date", + ], + }, + { + title: "Parent or Guardian", + fields: [ + "guardian_name", + "guardian_relationship", + "guardian_address_line_1", + "guardian_address_line_2", + "guardian_postcode", + "guardian_telephone", + "guardian_mobile", + ], + }, +]; + +const handlePost: PagesFunction = async ({ request, env }) => { + const fields = await parseFormFields(request); + + const missingRequired = requiredFields.filter( + (field) => !formatValue(fields.get(field)), + ); + if (missingRequired.length > 0) { + return jsonResponse( + { + ok: false, + error: "Missing required fields.", + details: missingRequired.map((field) => labelMap[field] ?? field).join(", "), + }, + 400, + ); + } + + if (!env.TURNSTILE_SECRET_KEY) { + return jsonResponse( + { ok: false, error: "Turnstile secret key not configured." }, + 500, + ); + } + + const turnstileToken = formatValue(fields.get("cf-turnstile-response")); + if (!turnstileToken) { + return jsonResponse( + { ok: false, error: "Turnstile verification failed." }, + 400, + ); + } + + const verifyBody = new URLSearchParams({ + secret: env.TURNSTILE_SECRET_KEY, + response: turnstileToken, + remoteip: request.headers.get("CF-Connecting-IP") ?? "", + }); + + const verifyResponse = await fetchWithTimeout( + "https://challenges.cloudflare.com/turnstile/v0/siteverify", + { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: verifyBody, + }, + 8000, + "Turnstile", + ); + const verifyResult = (await verifyResponse.json()) as { + success?: boolean; + "error-codes"?: string[]; + messages?: string[]; + hostname?: string; + action?: string; + }; + + if (!verifyResult.success) { + const details = [ + verifyResult["error-codes"]?.length + ? `codes=${verifyResult["error-codes"].join(",")}` + : "", + verifyResult.messages?.length + ? `messages=${verifyResult.messages.join(",")}` + : "", + verifyResult.hostname ? `hostname=${verifyResult.hostname}` : "", + verifyResult.action ? `action=${verifyResult.action}` : "", + ] + .filter(Boolean) + .join(" | ") + .slice(0, 500); + return jsonResponse( + { + ok: false, + error: "Turnstile verification failed.", + details: details || undefined, + }, + 400, + ); + } + + if (!env.RESEND_API_KEY) { + return jsonResponse( + { ok: false, error: "Email provider not configured." }, + 500, + ); + } + + const fromEmail = env.CONTACT_FROM_EMAIL; + if (!fromEmail) { + return jsonResponse( + { ok: false, error: "Sender email not configured." }, + 500, + ); + } + + const toEmail = env.CONTACT_TO_EMAIL ?? "calum@muir.in"; + const volunteerName = [ + formatValue(fields.get("volunteer_first_names")), + formatValue(fields.get("volunteer_last_name")), + ] + .filter(Boolean) + .join(" "); + + const bodyText = sectionOrder + .map(({ title, fields: sectionFields }) => { + const lines = sectionFields + .map((field) => { + const value = formatValue(fields.get(field)); + if (!value) return null; + return `${labelMap[field] ?? toTitleCase(field)}: ${value}`; + }) + .filter(Boolean) + .join("\n"); + + return lines ? `${title}\n${lines}` : ""; + }) + .filter(Boolean) + .join("\n\n"); + + const extraFields = [...fields.keys()].filter( + (field) => + field !== "cf-turnstile-response" && + !sectionOrder.some((section) => section.fields.includes(field)), + ); + const extraText = extraFields + .map((field) => { + const value = formatValue(fields.get(field)); + return value ? `${labelMap[field] ?? toTitleCase(field)}: ${value}` : ""; + }) + .filter(Boolean) + .join("\n"); + + const emailPayload = { + from: fromEmail, + to: [toEmail], + reply_to: formatValue(fields.get("email")) || undefined, + subject: `New volunteer application${volunteerName ? `: ${volunteerName}` : ""}`, + text: [bodyText, extraText ? `Additional Fields\n${extraText}` : ""] + .filter(Boolean) + .join("\n\n"), + }; + + const response = await fetchWithTimeout( + "https://api.resend.com/emails", + { + method: "POST", + headers: { + Authorization: `Bearer ${env.RESEND_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(emailPayload), + }, + 8000, + "Resend", + ); + if (!response.ok) { + const contentType = response.headers.get("content-type") ?? ""; + const errorText = contentType.includes("application/json") + ? JSON.stringify(await response.json().catch(() => ({}))) + : await response.text(); + const errorCode = response.headers.get("x-ms-error-code"); + const details = [ + `${response.status} ${response.statusText}`.trim(), + errorCode ? `code=${errorCode}` : "", + errorText.trim(), + ] + .filter(Boolean) + .join(" | ") + .slice(0, 500); + return jsonResponse( + { + ok: false, + error: "Failed to send email.", + details, + }, + 502, + ); + } + + return jsonResponse({ ok: true }); +}; + +export const onRequest: PagesFunction = async (context) => { + try { + if (context.request.method !== "POST") { + return jsonResponse( + { ok: false, error: "Method not allowed." }, + 405, + ); + } + return await handlePost(context); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown error"; + return jsonResponse( + { ok: false, error: "Unhandled exception.", details: message }, + 500, + ); + } +}; diff --git a/public/application-form.js b/public/application-form.js new file mode 100644 index 0000000..cd5c616 --- /dev/null +++ b/public/application-form.js @@ -0,0 +1,70 @@ +(() => { + const form = document.querySelector("[data-application-form]"); + const message = document.querySelector("[data-form-message]"); + const submitButton = document.querySelector("[data-form-submit]"); + + if (!form || !message || !submitButton) return; + + form.addEventListener("submit", async (event) => { + event.preventDefault(); + if (submitButton.disabled) return; + + submitButton.disabled = true; + submitButton.textContent = "Submitting..."; + message.textContent = ""; + + try { + const formData = new FormData(form); + const body = new URLSearchParams(); + for (const [key, value] of formData.entries()) { + if (typeof value === "string") { + body.append(key, value); + } + } + + const response = await fetch(form.action, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body, + }); + + if (!response.ok) { + let errorMessage = "Request failed"; + const errorData = await response.json().catch(() => null); + if (errorData?.error) { + errorMessage = errorData.details + ? `${errorData.error} (${errorData.details})` + : errorData.error; + } else { + const errorText = await response.text().catch(() => ""); + if (errorText.trim()) { + errorMessage = `${response.status} ${response.statusText}: ${errorText}`; + } else { + errorMessage = `${response.status} ${response.statusText}`; + } + } + throw new Error(errorMessage); + } + + message.textContent = + "Thanks. Your participant application has been submitted."; + form.reset(); + if ( + window.turnstile && + typeof window.turnstile.reset === "function" + ) { + window.turnstile.reset(); + } + } catch (error) { + message.textContent = + error instanceof Error + ? error.message + : "Sorry, something went wrong. Please try again."; + } finally { + submitButton.disabled = false; + submitButton.textContent = "Submit application"; + } + }); +})(); diff --git a/src/pages/index.astro b/src/pages/index.astro index 2f083e2..ff36001 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -29,12 +29,25 @@ const cardData = [ href: "/support-us", }, { - title: "Contact", + title: "Contact Us", imageUrl: "/contact-640.webp", imageSrcSet: "/contact-320.webp 320w, /contact-480.webp 480w, /contact-640.webp 640w, /contact-960.webp 960w", href: "/contact", }, + { + title: "Apply to Volunteer", + imageUrl: "/Vols5-2880w-1024x768.avif", + imageSrcSet: "/Vols5-2880w-1024x768.avif 1024w", + href: "/volunteer-application", + }, + { + title: "Apply to Participate", + imageUrl: "/Samantha-and-Connolly-960.webp", + imageSrcSet: + "/Samantha-and-Connolly-480.webp 480w, /Samantha-and-Connolly-640.webp 640w, /Samantha-and-Connolly-960.webp 960w", + href: "/participant-application", + }, ]; --- @@ -169,7 +182,7 @@ const cardData = [ srcset={ card.imageSrcSet } - sizes="(min-width: 640px) 33vw, 320px" + sizes="(min-width: 1024px) 20vw, (min-width: 640px) 50vw, 320px" loading="lazy" decoding="async" /> diff --git a/src/pages/participant-application.astro b/src/pages/participant-application.astro new file mode 100644 index 0000000..863337e --- /dev/null +++ b/src/pages/participant-application.astro @@ -0,0 +1,896 @@ +--- +import BaseLayout from "../layouts/BaseLayout.astro"; +import SiteHeader from "../components/SiteHeader.astro"; +import SiteFooter from "../components/SiteFooter.astro"; +import { navigationItems } from "../lib/navigation"; + +const pageSeo = { + title: "Participant Application", + description: + "Apply to join Highland Group RDA with our participant application form. Share your details, support needs, and emergency contact information online.", + keywords: + "Highland Group RDA participant application, RDA application form, riding for the disabled application, Highland RDA participant form", + canonicalPath: "/participant-application", +}; + +const turnstileSiteKey = import.meta.env.PUBLIC_TURNSTILE_SITE_KEY ?? ""; + +const inputClass = + "mt-2 w-full rounded-md border border-black/15 bg-white px-3 py-2 text-black placeholder:text-black/35"; +const textareaClass = `${inputClass} min-h-[120px]`; +const selectClass = inputClass; +const legendClass = + "[font-family:'Merriweather',Helvetica] font-bold text-black text-xl sm:text-2xl"; +const helpTextClass = + "mt-2 [font-family:'Public_Sans',Helvetica] font-semibold text-sm leading-[1.6] text-black/70"; + +const supportQuestions = [ + ["allergies", "Do you have any allergies we need to be aware of?"], + [ + "triggers", + "Do you have any known triggers, physical, emotional or environmental?", + ], + [ + "simple_instructions", + "Would it help if we used very simple instructions?", + ], + ["learning_disability", "Do you have a learning disability?"], + ["neurodiverse", "Are you neurodiverse?"], + [ + "sensitive_to_crowds", + "Are you sensitive to strangers or crowds?", + ], + [ + "sensory_sensitivities", + "Are you sensitive to light, temperature, noise, clothing or hats?", + ], + [ + "anxiety_low_mood", + "Do you experience anxiety, depression or low mood?", + ], + ["panic_attacks", "Do you experience panic attacks?"], + [ + "speech_difficulties", + "Do you have difficulty with speech or making yourself understood?", + ], + [ + "communication_system", + "Do you use a communication system such as Makaton, PECS or AAC?", + ], + ["hearing_aid", "Do you wear a hearing aid?"], + ["cochlear_implant", "Do you have a cochlear implant?"], + ["bsl", "Do you use BSL to communicate?"], + ["blind", "Are you blind?"], + [ + "partially_sighted", + "Are you partially sighted beyond what glasses or lenses correct?", + ], + ["dexterity_difficulties", "Do you have dexterity difficulties?"], + ["balance_problems", "Do you have problems with balance?"], + [ + "weight_through_feet", + "Can you take weight through your feet, for example sit to stand?", + ], + ["help_walking", "Do you need any help with walking?"], + [ + "steps_mounting_block", + "Can you walk up a few steps, for example onto a mounting block?", + ], + ["wheelchair_user", "Are you a wheelchair user?"], + ["walking_aids", "Do you use any walking aids or supports?"], + ["prosthetic_limb", "Do you wear a prosthetic limb?"], + ["long_term_pain", "Do you experience long-term pain?"], + [ + "breathing_stamina", + "Do you have difficulties with breathing or stamina?", + ], + ["epilepsy", "Do you have epilepsy, and is it controlled by medication?"], +]; +--- + + + { + turnstileSiteKey && ( + + + +
+
+
+
+
+
+ +
+
+

+ Participant application +

+
+

+ Apply to join Highland Group RDA +

+

+ This online form follows the 2025 individual + participant application document and gathers the + details we need to understand support needs, + emergency contacts, and consent. +

+
+
+ + Donate via JustGiving + +
+
+ +
+
+
+
+
+

+ Before you begin +

+

+ Please complete the form in full. The + information you provide will be kept + confidential and used only by Highland Group + RDA in line with the Data Protection Act + 2018. +

+

+ Required fields are marked with + *. If a + question does not apply, leave it blank. +

+
+ +
+

Why we ask for this

+

+ Your details help us understand medical, + communication, and access needs so we can + match the right support, coach, and equine + safely. +

+

+ Height and weight are used for suitable + equine matching in line with welfare + policies and are handled confidentially by + the group coach. +

+
+
+ +
+ { + !turnstileSiteKey && ( +

+ Turnstile is not configured. Set + `PUBLIC_TURNSTILE_SITE_KEY` and redeploy + to enable the form. +

+ ) + } + +
+ Part 1. Your Details +

+ Participant details and primary contact + information. +

+ +
+ +
+ { + [ + "Participant", + "Parent", + "Guardian / Carer", + ].map((option) => ( + + )) + } +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+ { + ["Riding", "Carriage Driving", "Vaulting"].map( + (item) => ( + + ), + ) + } +
+
+
+ +
+ + Part 2. Specific Information About You + +

+ Tick all that apply. We may contact you for + more information where needed. +

+ +
+ { + supportQuestions.map(([name, label]) => ( + + )) + } +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + Part 3. Additional Information + + +
+
+ +
+ { + ["Yes", "No"].map((option) => ( + + )) + } +
+

+ Classification may not be relevant + for non-ridden activity. +

+
+
+ +
+ { + ["Yes", "No"].map((option) => ( + + )) + } +
+
+
+ +
+
+ + +
+
+ + +
+
+
+ +
+ + Emergency Contact Details + +

+ We need to know who to contact if the + participant becomes unwell or is injured + during RDA activities. +

+ +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + Part 4. Declaration + +

+ These confirmations reflect the wording in + the application document. +

+ +
+ + + + + +
+ +
+ +
+ { + ["Yes", "No"].map((option) => ( + + )) + } +
+

+ This covers images used for training, + publicity, websites, social media, + newsletters, and marketing materials. +

+
+ + + +
+
+ + +
+
+ + +
+
+
+ +
+ + Part 5. Parent or Legal Guardian + +

+ Complete this section if the form is being + submitted by a parent or legal guardian, or + if the participant is under 18. +

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+

+

+ +
+
+
+
+
+ + +
+
+
diff --git a/src/pages/volunteer-application.astro b/src/pages/volunteer-application.astro new file mode 100644 index 0000000..413192f --- /dev/null +++ b/src/pages/volunteer-application.astro @@ -0,0 +1,608 @@ +--- +import BaseLayout from "../layouts/BaseLayout.astro"; +import SiteHeader from "../components/SiteHeader.astro"; +import SiteFooter from "../components/SiteFooter.astro"; +import { navigationItems } from "../lib/navigation"; + +const pageSeo = { + title: "Volunteer Application", + description: + "Apply to volunteer with Highland Group RDA. Complete the volunteer application form with your details, experience, references, and declarations.", + keywords: + "Highland Group RDA volunteer application, RDA volunteer form, volunteer with horses Highlands, disabled riding charity volunteer", + canonicalPath: "/volunteer-application", +}; + +const turnstileSiteKey = import.meta.env.PUBLIC_TURNSTILE_SITE_KEY ?? ""; + +const inputClass = + "mt-2 w-full rounded-md border border-black/15 bg-white px-3 py-2 text-black placeholder:text-black/35"; +const textareaClass = `${inputClass} min-h-[120px]`; +const selectClass = inputClass; +const legendClass = + "[font-family:'Merriweather',Helvetica] font-bold text-black text-xl sm:text-2xl"; +const helpTextClass = + "mt-2 [font-family:'Public_Sans',Helvetica] font-semibold text-sm leading-[1.6] text-black/70"; + +const groupDetails = [ + ["RDA Group Name", "Highland Group RDA"], + ["Charity Number", "SC007357"], + ["Group Contact Name", "Steve Byford"], + [ + "Contact Address", + "Sandycroft, Reelig, Kirkhill, IV5 7PP", + ], + ["Contact Email", "info@highlandgrouprda.org.uk"], + ["Contact Telephone", "07759 327690"], +]; +--- + + + { + turnstileSiteKey && ( + + + +
+
+
+
+
+ +
+
+

+ Volunteer application +

+
+

+ Apply to volunteer with Highland Group RDA +

+

+ This online form follows the 2026 volunteer + application document and gathers your details, + relevant experience, references, emergency + contact, and declarations. +

+
+
+ + Donate via JustGiving + +
+
+ +
+
+
+
+
+

+ Group details +

+

+ This section is prefilled from the source + application document and identifies the RDA + group the form is being submitted to. +

+
+ { + groupDetails.map(([label, value]) => ( +
+
+ {label} +
+
+ {value} +
+
+ )) + } +
+
+ +
+

Before you begin

+

+ Please fill this in clearly and completely. + All information is treated as confidential + and used only for RDA volunteering + activities in line with the Data Protection + Act 2018. +

+

+ The form asks for two references from people + who are not related to you and have known + you for at least two years. +

+
+
+ +
+ + + + + + + + { + !turnstileSiteKey && ( +

+ Turnstile is not configured. Set + `PUBLIC_TURNSTILE_SITE_KEY` and redeploy + to enable the form. +

+ ) + } + +
+ Part 1. Your Details +

+ Details of the volunteer applicant. +

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + Part 2. Specific Information About You + +

+ This section helps place you in a suitable + volunteering role and identify any support + needs. +

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + Part 3. Emergency Contact Details + +

+ We need to know who to contact if you become + unwell or are injured while volunteering. +

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ References +

+ Please provide two references. They should + not be related to you and should ideally + know you in a professional capacity. +

+ +
+
+

+ Reference 1 +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+

+ Reference 2 +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ +
+ Part 4. Declaration +

+ These declarations reflect the wording in + the volunteer application document. +

+ +
+ + + + + + + + + +
+ +
+ +
+ {["Yes", "No"].map((option) => ( + + ))} +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ Parent or Guardian +

+ If you are under 18, this form must also be + signed by a parent or guardian. +

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+

+ +
+
+
+
+
+ + +
+
+