diff --git a/backend/app/models/UserData.py b/backend/app/models/UserData.py index ee8d17f4..ebc4d9aa 100644 --- a/backend/app/models/UserData.py +++ b/backend/app/models/UserData.py @@ -59,6 +59,7 @@ class UserData(Base): ethnic_group = Column(JSON, nullable=True) # Array of strings marital_status = Column(Text, nullable=True) has_kids = Column(Text, nullable=True) + timezone = Column(Text, nullable=True) # Cancer Experience diagnosis = Column(Text, nullable=True) diff --git a/backend/app/seeds/users.py b/backend/app/seeds/users.py index 80e1be00..ca86311a 100644 --- a/backend/app/seeds/users.py +++ b/backend/app/seeds/users.py @@ -33,7 +33,7 @@ def seed_users(session: Session) -> None: "gender_identity": "Woman", "pronouns": ["she", "her"], "ethnic_group": ["White/Caucasian"], - "marital_status": "Married", + "marital_status": "Married/Common Law", "has_kids": "Yes", "diagnosis": "Acute Lymphoblastic Leukemia", "date_of_diagnosis": date(2023, 8, 10), @@ -83,7 +83,7 @@ def seed_users(session: Session) -> None: "gender_identity": "Woman", "pronouns": ["she", "her"], "ethnic_group": ["Hispanic/Latino"], - "marital_status": "Married", + "marital_status": "Married/Common Law", "has_kids": "Yes", "has_blood_cancer": "No", "caring_for_someone": "Yes", @@ -115,7 +115,7 @@ def seed_users(session: Session) -> None: "gender_identity": "Man", "pronouns": ["he", "him"], "ethnic_group": ["White/Caucasian"], - "marital_status": "Married", + "marital_status": "Married/Common Law", "has_kids": "Yes", "diagnosis": "Acute Lymphoblastic Leukemia", "date_of_diagnosis": date(2018, 4, 20), # Survivor @@ -149,7 +149,7 @@ def seed_users(session: Session) -> None: "gender_identity": "Woman", # Same as Sarah "pronouns": ["she", "her"], "ethnic_group": ["Asian"], - "marital_status": "Married", # Same as Sarah + "marital_status": "Married/Common Law", # Same as Sarah "has_kids": "Yes", # Same as Sarah "diagnosis": "Acute Lymphoblastic Leukemia", # Same diagnosis as Sarah! "date_of_diagnosis": date(2020, 8, 15), # Survivor @@ -200,7 +200,7 @@ def seed_users(session: Session) -> None: "gender_identity": "Woman", # Same as Sarah "pronouns": ["she", "her"], "ethnic_group": ["Asian"], - "marital_status": "Married", # Same as Sarah + "marital_status": "Married/Common Law", # Same as Sarah "has_kids": "Yes", # Same as Sarah "diagnosis": "Acute Lymphoblastic Leukemia", # Same diagnosis as Sarah! "date_of_diagnosis": date(2019, 5, 10), # Survivor @@ -225,7 +225,7 @@ def seed_users(session: Session) -> None: "gender_identity": "Woman", # Same as Sarah "pronouns": ["she", "her"], "ethnic_group": ["Hispanic/Latino"], - "marital_status": "Married", # Same as Sarah + "marital_status": "Married/Common Law", # Same as Sarah "has_kids": "Yes", # Same as Sarah "diagnosis": "Acute Lymphoblastic Leukemia", # Same diagnosis as Sarah! "date_of_diagnosis": date(2021, 3, 18), # Survivor @@ -250,7 +250,7 @@ def seed_users(session: Session) -> None: "gender_identity": "Woman", # Same as Sarah "pronouns": ["she", "her"], "ethnic_group": ["White/Caucasian"], - "marital_status": "Married", # Same as Sarah + "marital_status": "Married/Common Law", # Same as Sarah "has_kids": "Yes", # Same as Sarah "diagnosis": "Acute Lymphoblastic Leukemia", # Same diagnosis as Sarah! "date_of_diagnosis": date(2018, 9, 25), # Survivor @@ -276,7 +276,7 @@ def seed_users(session: Session) -> None: "gender_identity": "Woman", "pronouns": ["she", "her"], "ethnic_group": ["White/Caucasian"], - "marital_status": "Married", + "marital_status": "Married/Common Law", "has_kids": "Yes", "has_blood_cancer": "No", # Not a cancer patient herself "caring_for_someone": "Yes", # Is a caregiver diff --git a/backend/app/services/implementations/intake_form_processor.py b/backend/app/services/implementations/intake_form_processor.py index 5d32f404..4d96529d 100644 --- a/backend/app/services/implementations/intake_form_processor.py +++ b/backend/app/services/implementations/intake_form_processor.py @@ -9,6 +9,9 @@ logger = logging.getLogger(__name__) +# Valid Canadian timezone abbreviations +VALID_TIMEZONES = {"NST", "AST", "EST", "CST", "MST", "PST"} + class IntakeFormProcessor: """ @@ -177,6 +180,13 @@ def _process_demographics(self, user_data: UserData, demographics: Dict[str, Any user_data.ethnic_group = demographics.get("ethnic_group", []) user_data.marital_status = self._trim_text(demographics.get("marital_status")) user_data.has_kids = demographics.get("has_kids") + + # Validate and set timezone + timezone = self._trim_text(demographics.get("timezone")) + if timezone and timezone not in VALID_TIMEZONES: + raise ValueError(f"Invalid timezone: {timezone}. Must be one of {sorted(VALID_TIMEZONES)}") + user_data.timezone = timezone + user_data.other_ethnic_group = self._trim_text(demographics.get("ethnic_group_custom")) user_data.gender_identity_custom = self._trim_text(demographics.get("gender_identity_custom")) diff --git a/backend/docs/intake_api.md b/backend/docs/intake_api.md index b7fd40e2..75f951cf 100644 --- a/backend/docs/intake_api.md +++ b/backend/docs/intake_api.md @@ -35,7 +35,7 @@ Create a new form submission and process it into structured data. "pronouns": ["array of strings (optional)"], "ethnicGroup": ["array of strings (optional)"], "maritalStatus": "string (optional)", - "hasKids": "yes|no|prefer not to say (optional)", + "hasKids": "Yes|No|Prefer not to answer (optional)", "ethnicGroupCustom": "string (optional)", "genderIdentityCustom": "string (optional)" }, diff --git a/backend/migrations/versions/2ccee7a88d08_add_timezone_column_to_user_data.py b/backend/migrations/versions/2ccee7a88d08_add_timezone_column_to_user_data.py new file mode 100644 index 00000000..b59d83db --- /dev/null +++ b/backend/migrations/versions/2ccee7a88d08_add_timezone_column_to_user_data.py @@ -0,0 +1,30 @@ +"""add_timezone_column_to_user_data + +Revision ID: 2ccee7a88d08 +Revises: 8d2cd99b9eb8 +Create Date: 2025-10-30 19:02:10.801071 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "2ccee7a88d08" +down_revision: Union[str, None] = "8d2cd99b9eb8" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("user_data", sa.Column("timezone", sa.Text(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("user_data", "timezone") + # ### end Alembic commands ### diff --git a/backend/tests/unit/test_intake_form_processor.py b/backend/tests/unit/test_intake_form_processor.py index 47f6a71e..15c958f7 100644 --- a/backend/tests/unit/test_intake_form_processor.py +++ b/backend/tests/unit/test_intake_form_processor.py @@ -158,8 +158,8 @@ def test_participant_with_cancer_only(db_session, test_user): "gender_identity": "Male", "pronouns": ["he", "him"], "ethnic_group": ["White"], - "marital_status": "Married", - "has_kids": "yes", + "marital_status": "Married/Common Law", + "has_kids": "Yes", }, "cancer_experience": { "diagnosis": "Leukemia", @@ -185,8 +185,8 @@ def test_participant_with_cancer_only(db_session, test_user): assert user_data.gender_identity == "Male" assert user_data.pronouns == ["he", "him"] assert user_data.ethnic_group == ["White"] - assert user_data.marital_status == "Married" - assert user_data.has_kids == "yes" + assert user_data.marital_status == "Married/Common Law" + assert user_data.has_kids == "Yes" # Assert - Cancer Experience assert user_data.diagnosis == "Leukemia" @@ -247,7 +247,7 @@ def test_volunteer_caregiver_experience_processing(db_session, test_user): "pronouns": ["she", "her"], "ethnic_group": ["Indigenous"], "marital_status": "Divorced", - "has_kids": "yes", + "has_kids": "Yes", }, "caregiver_experience": { "experiences": ["Anxiety", "Depression"], @@ -339,8 +339,8 @@ def test_form_submission_json_structure(db_session, test_user): "pronouns": ["they", "them"], "ethnic_group": ["Other", "Asian"], "ethnic_group_custom": "Mixed heritage - Filipino and Indigenous", - "marital_status": "Common-law", - "has_kids": "yes", + "marital_status": "Married/Common Law", + "has_kids": "Yes", }, "cancer_experience": { "diagnosis": "Ovarian Cancer", @@ -471,8 +471,8 @@ def test_participant_caregiver_without_cancer(db_session, test_user): "gender_identity": "Female", "pronouns": ["she", "her"], "ethnic_group": ["Black"], - "marital_status": "Married", - "has_kids": "yes", + "marital_status": "Married/Common Law", + "has_kids": "Yes", }, "loved_one": { "demographics": {"gender_identity": "Male", "age": "55-64"}, @@ -550,8 +550,8 @@ def test_participant_cancer_patient_and_caregiver(db_session, test_user): "pronouns": ["he", "him"], "ethnic_group": ["White", "Other"], "ethnic_group_custom": "Mixed European heritage", - "marital_status": "Married", - "has_kids": "yes", + "marital_status": "Married/Common Law", + "has_kids": "Yes", }, "cancer_experience": { "diagnosis": "Lymphoma", @@ -635,7 +635,7 @@ def test_participant_no_cancer_experience(db_session, test_user): "pronouns": ["she", "her"], "ethnic_group": ["Asian", "Indigenous"], "marital_status": "Single", - "has_kids": "no", + "has_kids": "No", }, # No cancer_experience, caregiver_experience, or loved_one sections } @@ -658,7 +658,7 @@ def test_participant_no_cancer_experience(db_session, test_user): assert "Asian" in user_data.ethnic_group assert "Indigenous" in user_data.ethnic_group assert user_data.marital_status == "Single" - assert user_data.has_kids == "no" + assert user_data.has_kids == "No" # Assert - No cancer-related data assert user_data.diagnosis is None @@ -770,8 +770,8 @@ def test_volunteer_cancer_patient_and_caregiver(db_session, test_user): "gender_identity": "Female", "pronouns": ["she", "her"], "ethnic_group": ["White"], - "marital_status": "Married", - "has_kids": "yes", + "marital_status": "Married/Common Law", + "has_kids": "Yes", }, "cancer_experience": { "diagnosis": "Breast Cancer", @@ -848,7 +848,7 @@ def test_volunteer_no_cancer_experience(db_session, test_user): "pronouns": ["he", "him"], "ethnic_group": ["White"], "marital_status": "Single", - "has_kids": "no", + "has_kids": "No", }, # No cancer_experience, caregiver_experience, or loved_one sections } @@ -870,7 +870,7 @@ def test_volunteer_no_cancer_experience(db_session, test_user): assert user_data.pronouns == ["he", "him"] assert user_data.ethnic_group == ["White"] assert user_data.marital_status == "Single" - assert user_data.has_kids == "no" + assert user_data.has_kids == "No" # Assert - No cancer-related data assert user_data.diagnosis is None diff --git a/frontend/src/components/intake/demographic-cancer-form.tsx b/frontend/src/components/intake/demographic-cancer-form.tsx index 57480c80..f3c2119b 100644 --- a/frontend/src/components/intake/demographic-cancer-form.tsx +++ b/frontend/src/components/intake/demographic-cancer-form.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect } from 'react'; import { Box, Heading, Button, VStack, HStack, Text, Input } from '@chakra-ui/react'; import { Controller, useForm } from 'react-hook-form'; import { FormField } from '@/components/ui/form-field'; @@ -7,6 +7,9 @@ import { CheckboxGroup } from '@/components/ui/checkbox-group'; import { COLORS, VALIDATION } from '@/constants/form'; import baseAPIClient from '@/APIClients/baseAPIClient'; import { IntakeExperience, IntakeTreatment } from '@/types/intakeTypes'; +import { detectCanadianTimezone } from '@/utils/timezoneUtils'; +import { SingleSelectDropdown } from '@/components/ui/single-select-dropdown'; +import { MultiSelectDropdown } from '@/components/ui/multi-select-dropdown'; // Reusable Select component to replace inline styling type StyledSelectProps = React.SelectHTMLAttributes & { @@ -48,36 +51,38 @@ interface DemographicCancerFormData { ethnicGroup: string[]; maritalStatus: string; hasKids: string; + timezone: string; diagnosis: string; dateOfDiagnosis: string; treatments: string[]; experiences: string[]; } -const DEFAULT_VALUES: DemographicCancerFormData = { +const getDefaultValues = (): DemographicCancerFormData => ({ genderIdentity: '', pronouns: [], ethnicGroup: [], maritalStatus: '', hasKids: '', + timezone: detectCanadianTimezone(), diagnosis: '', dateOfDiagnosis: '', treatments: [], experiences: [], -}; +}); const DIAGNOSIS_OPTIONS = [ - 'Acute Myeloid Leukaemia', - 'Acute Lymphoblastic Leukaemia', - 'Chronic Myeloid Leukaemia', - 'Chronic Lymphocytic Leukaemia', - 'Hodgkin Lymphoma', - 'Non-Hodgkin Lymphoma', - 'Multiple Myeloma', - 'Myelodysplastic Syndrome', - 'Myelofibrosis', - 'Aplastic Anemia', - 'Other', + 'Unknown', + 'Acute Myeloid Leukemia', + 'Acute Lymphoblastic Leukemia', + 'Acute Promyelocytic Leukemia', + 'Mixed Phenotype Leukemia', + 'Chronic Lymphocytic Leukemia/Small Lymphocytic Lymphoma', + 'Chronic Myeloid Leukemia', + 'Hairy Cell Leukemia', + 'Myeloma/Multiple Myeloma', + "Hodgkin's Lymphoma", + "Indolent/Low Grade Non-Hodgkin's Lymphoma", ]; interface DemographicCancerFormProps { @@ -106,167 +111,26 @@ const PRONOUNS_OPTIONS = [ 'Self-describe', ]; +const TIMEZONE_OPTIONS = ['NST', 'AST', 'EST', 'CST', 'MST', 'PST']; + +const MARITAL_STATUS_OPTIONS = ['Single', 'Married/Common Law', 'Divorced', 'Widowed']; + +const HAS_KIDS_OPTIONS = ['Yes', 'No', 'Prefer not to answer']; + const ETHNIC_OPTIONS = [ - 'Indigenous', - 'Arab', - 'Black', - 'Chinese', - 'Filipino', - 'Japanese', - 'Korean', - 'Latin American', + 'Black (including African and Caribbean descent)', + 'Middle Eastern, Western or Central Asian', + 'East Asian', 'South Asian', 'Southeast Asian', - 'West Asian', + 'Indigenous person from Canada', + 'Latin American', 'White', + 'Mixed Ethnicity (Individuals who identify with more than one racial/ethnic or cultural group)', 'Prefer not to answer', - 'Self-describe', + 'Another background/Prefer to self-describe (please specify):', ]; -// Multi-select dropdown component -const MultiSelectDropdown: React.FC<{ - options: string[]; - selectedValues: string[]; - onSelectionChange: (values: string[]) => void; - placeholder: string; -}> = ({ options, selectedValues, onSelectionChange, placeholder }) => { - const [isOpen, setIsOpen] = useState(false); - const dropdownRef = useRef(null); - - // Close dropdown when clicking outside - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setIsOpen(false); - } - }; - - if (isOpen) { - document.addEventListener('mousedown', handleClickOutside); - } - - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [isOpen]); - - const handleCheckboxChange = (option: string, checked: boolean) => { - if (checked) { - onSelectionChange([...selectedValues, option]); - } else { - onSelectionChange(selectedValues.filter((val) => val !== option)); - } - }; - - const displayText = selectedValues.length > 0 ? selectedValues.join(', ') : placeholder; - - return ( - - - - {isOpen && ( - - {options.map((option) => ( - { - e.stopPropagation(); - const isSelected = selectedValues.includes(option); - handleCheckboxChange(option, !isSelected); - }} - > - handleCheckboxChange(option, e.target.checked)} - style={{ - width: '18px', - height: '18px', - accentColor: COLORS.teal, - cursor: 'pointer', - }} - onClick={(e) => e.stopPropagation()} - /> - - {option} - - - ))} - - )} - - ); -}; - export function DemographicCancerForm({ formType, onNext, @@ -274,7 +138,7 @@ export function DemographicCancerForm({ caringForSomeone, }: DemographicCancerFormProps) { const { control, handleSubmit, formState, watch } = useForm({ - defaultValues: DEFAULT_VALUES, + defaultValues: getDefaultValues(), }); const { errors, isSubmitting } = formState; @@ -343,8 +207,14 @@ export function DemographicCancerForm({ pronouns: data.pronouns.includes('Self-describe') ? data.pronouns.map((p) => (p === 'Self-describe' ? pronounsCustom : p)) : data.pronouns, - ethnicGroup: data.ethnicGroup.includes('Self-describe') - ? data.ethnicGroup.map((e) => (e === 'Self-describe' ? ethnicGroupCustom : e)) + ethnicGroup: data.ethnicGroup.includes( + 'Another background/Prefer to self-describe (please specify):', + ) + ? data.ethnicGroup.map((e) => + e === 'Another background/Prefer to self-describe (please specify):' + ? ethnicGroupCustom + : e, + ) : data.ethnicGroup, }; @@ -427,14 +297,13 @@ export function DemographicCancerForm({ }, }} render={({ field }) => ( - - - {GENDER_IDENTITY_OPTIONS.map((option) => ( - - ))} - + )} /> @@ -450,11 +319,12 @@ export function DemographicCancerForm({ fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor="#d1d5db" borderRadius="6px" h="40px" - border="1px solid" px={3} + border="1px solid" + borderColor="#d1d5db" + boxShadow="0 1px 2px 0 rgba(0, 0, 0, 0.05)" _placeholder={{ color: '#9ca3af' }} _focus={{ borderColor: COLORS.teal, boxShadow: `0 0 0 3px ${COLORS.teal}20` }} /> @@ -487,6 +357,7 @@ export function DemographicCancerForm({ selectedValues={field.value || []} onSelectionChange={field.onChange} placeholder="Pronouns" + error={!!errors.pronouns} /> )} /> @@ -503,11 +374,12 @@ export function DemographicCancerForm({ fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor="#d1d5db" borderRadius="6px" h="40px" - border="1px solid" px={3} + border="1px solid" + borderColor="#d1d5db" + boxShadow="0 1px 2px 0 rgba(0, 0, 0, 0.05)" _placeholder={{ color: '#9ca3af' }} _focus={{ borderColor: COLORS.teal, boxShadow: `0 0 0 3px ${COLORS.teal}20` }} /> @@ -516,6 +388,28 @@ export function DemographicCancerForm({ )} + {/* Time Zone - Left aligned */} + + + + ( + + )} + /> + + + + {/* Ethnic or Cultural Group - Left aligned */} @@ -528,8 +422,13 @@ export function DemographicCancerForm({ if (!value || value.length === 0) { return 'Please select at least one ethnic or cultural group'; } - if (value.includes('Self-describe') && !ethnicGroupCustom.trim()) { - return 'Please specify your ethnic or cultural group when selecting Self-describe'; + if ( + value.includes( + 'Another background/Prefer to self-describe (please specify):', + ) && + !ethnicGroupCustom.trim() + ) { + return 'Please specify your ethnic or cultural group when selecting self-describe'; } return true; }, @@ -540,13 +439,16 @@ export function DemographicCancerForm({ selectedValues={field.value || []} onSelectionChange={field.onChange} placeholder="Ethnic or Cultural Group" + error={!!errors.ethnicGroup} /> )} /> - {ethnicGroup.includes('Self-describe') && ( + {ethnicGroup.includes( + 'Another background/Prefer to self-describe (please specify):', + ) && ( @@ -577,16 +480,13 @@ export function DemographicCancerForm({ control={control} rules={{ required: 'Marital status is required' }} render={({ field }) => ( - - - - - - - - - - + )} /> @@ -597,12 +497,13 @@ export function DemographicCancerForm({ control={control} rules={{ required: 'Please specify if you have kids' }} render={({ field }) => ( - - - - - - + )} /> @@ -643,14 +544,13 @@ export function DemographicCancerForm({ control={control} rules={{ required: 'Diagnosis is required' }} render={({ field }) => ( - - - {DIAGNOSIS_OPTIONS.map((option) => ( - - ))} - + )} /> @@ -678,7 +578,7 @@ export function DemographicCancerForm({ fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor={errors.dateOfDiagnosis ? 'red.500' : '#d1d5db'} + borderColor={errors.dateOfDiagnosis ? 'red.500' : undefined} borderRadius="6px" h="40px" _placeholder={{ color: '#9ca3af' }} @@ -803,15 +703,17 @@ interface BasicDemographicsFormData { ethnicGroup: string[]; maritalStatus: string; hasKids: string; + timezone: string; } -const BASIC_DEFAULT_VALUES: BasicDemographicsFormData = { +const getBasicDefaultValues = (): BasicDemographicsFormData => ({ genderIdentity: '', pronouns: [], ethnicGroup: [], maritalStatus: '', hasKids: '', -}; + timezone: detectCanadianTimezone(), +}); interface BasicDemographicsFormProps { formType?: 'participant' | 'volunteer'; @@ -825,7 +727,7 @@ export function BasicDemographicsForm({ formType, onNext }: BasicDemographicsFor formState: { errors, isSubmitting }, watch, } = useForm({ - defaultValues: BASIC_DEFAULT_VALUES, + defaultValues: getBasicDefaultValues(), }); // Local state for custom values @@ -847,8 +749,14 @@ export function BasicDemographicsForm({ formType, onNext }: BasicDemographicsFor pronouns: data.pronouns.includes('Self-describe') ? data.pronouns.map((p) => (p === 'Self-describe' ? pronounsCustom : p)) : data.pronouns, - ethnicGroup: data.ethnicGroup.includes('Self-describe') - ? data.ethnicGroup.map((e) => (e === 'Self-describe' ? ethnicGroupCustom : e)) + ethnicGroup: data.ethnicGroup.includes( + 'Another background/Prefer to self-describe (please specify):', + ) + ? data.ethnicGroup.map((e) => + e === 'Another background/Prefer to self-describe (please specify):' + ? ethnicGroupCustom + : e, + ) : data.ethnicGroup, }; @@ -931,14 +839,13 @@ export function BasicDemographicsForm({ formType, onNext }: BasicDemographicsFor }, }} render={({ field }) => ( - - - {GENDER_IDENTITY_OPTIONS.map((option) => ( - - ))} - + )} /> @@ -954,11 +861,12 @@ export function BasicDemographicsForm({ formType, onNext }: BasicDemographicsFor fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor="#d1d5db" borderRadius="6px" h="40px" - border="1px solid" px={3} + border="1px solid" + borderColor="#d1d5db" + boxShadow="0 1px 2px 0 rgba(0, 0, 0, 0.05)" _placeholder={{ color: '#9ca3af' }} _focus={{ borderColor: COLORS.teal, boxShadow: `0 0 0 3px ${COLORS.teal}20` }} /> @@ -991,6 +899,7 @@ export function BasicDemographicsForm({ formType, onNext }: BasicDemographicsFor selectedValues={field.value || []} onSelectionChange={field.onChange} placeholder="Pronouns" + error={!!errors.pronouns} /> )} /> @@ -1007,11 +916,12 @@ export function BasicDemographicsForm({ formType, onNext }: BasicDemographicsFor fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor="#d1d5db" borderRadius="6px" h="40px" - border="1px solid" px={3} + border="1px solid" + borderColor="#d1d5db" + boxShadow="0 1px 2px 0 rgba(0, 0, 0, 0.05)" _placeholder={{ color: '#9ca3af' }} _focus={{ borderColor: COLORS.teal, boxShadow: `0 0 0 3px ${COLORS.teal}20` }} /> @@ -1020,6 +930,28 @@ export function BasicDemographicsForm({ formType, onNext }: BasicDemographicsFor )} + {/* Time Zone - Left aligned */} + + + + ( + + )} + /> + + + + {/* Ethnic or Cultural Group */} @@ -1032,8 +964,13 @@ export function BasicDemographicsForm({ formType, onNext }: BasicDemographicsFor if (!value || value.length === 0) { return 'Please select at least one ethnic or cultural group'; } - if (value.includes('Self-describe') && !ethnicGroupCustom.trim()) { - return 'Please specify your ethnic or cultural group when selecting Self-describe'; + if ( + value.includes( + 'Another background/Prefer to self-describe (please specify):', + ) && + !ethnicGroupCustom.trim() + ) { + return 'Please specify your ethnic or cultural group when selecting self-describe'; } return true; }, @@ -1044,13 +981,16 @@ export function BasicDemographicsForm({ formType, onNext }: BasicDemographicsFor selectedValues={field.value || []} onSelectionChange={field.onChange} placeholder="Ethnic or Cultural Group" + error={!!errors.ethnicGroup} /> )} /> - {ethnicGroup.includes('Self-describe') && ( + {ethnicGroup.includes( + 'Another background/Prefer to self-describe (please specify):', + ) && ( @@ -1081,16 +1022,13 @@ export function BasicDemographicsForm({ formType, onNext }: BasicDemographicsFor control={control} rules={{ required: 'Marital status is required' }} render={({ field }) => ( - - - - - - - - - - + )} /> @@ -1101,12 +1039,13 @@ export function BasicDemographicsForm({ formType, onNext }: BasicDemographicsFor control={control} rules={{ required: 'Please specify if you have kids' }} render={({ field }) => ( - - - - - - + )} /> diff --git a/frontend/src/components/intake/loved-one-form.tsx b/frontend/src/components/intake/loved-one-form.tsx index a47559c9..b4779e88 100644 --- a/frontend/src/components/intake/loved-one-form.tsx +++ b/frontend/src/components/intake/loved-one-form.tsx @@ -7,40 +7,16 @@ import { CheckboxGroup } from '@/components/ui/checkbox-group'; import { COLORS, VALIDATION } from '@/constants/form'; import { IntakeExperience, IntakeTreatment } from '@/types/intakeTypes'; import baseAPIClient from '@/APIClients/baseAPIClient'; - -// Reusable Select component to replace inline styling -type StyledSelectProps = React.SelectHTMLAttributes & { - children: React.ReactNode; - error?: boolean; -}; - -const StyledSelect = React.forwardRef( - ({ children, error, style, ...props }, ref) => ( - - ), -); -StyledSelect.displayName = 'StyledSelect'; +import { SingleSelectDropdown } from '@/components/ui/single-select-dropdown'; + +const GENDER_IDENTITY_OPTIONS = [ + 'Male', + 'Female', + 'Non-binary', + 'Transgender', + 'Prefer not to answer', + 'Self-describe', +]; interface LovedOneFormData { genderIdentity: string; @@ -61,17 +37,17 @@ const DEFAULT_VALUES: LovedOneFormData = { }; const DIAGNOSIS_OPTIONS = [ - 'Acute Myeloid Leukaemia', - 'Acute Lymphoblastic Leukaemia', - 'Chronic Myeloid Leukaemia', - 'Chronic Lymphocytic Leukaemia', - 'Hodgkin Lymphoma', - 'Non-Hodgkin Lymphoma', - 'Multiple Myeloma', - 'Myelodysplastic Syndrome', - 'Myelofibrosis', - 'Aplastic Anemia', - 'Other', + 'Unknown', + 'Acute Myeloid Leukemia', + 'Acute Lymphoblastic Leukemia', + 'Acute Promyelocytic Leukemia', + 'Mixed Phenotype Leukemia', + 'Chronic Lymphocytic Leukemia/Small Lymphocytic Lymphoma', + 'Chronic Myeloid Leukemia', + 'Hairy Cell Leukemia', + 'Myeloma/Multiple Myeloma', + "Hodgkin's Lymphoma", + "Indolent/Low Grade Non-Hodgkin's Lymphoma", ]; interface LovedOneFormProps { @@ -205,15 +181,13 @@ export function LovedOneForm({ formType = 'participant', onSubmit }: LovedOneFor }, }} render={({ field }) => ( - - - - - - - - - + )} /> @@ -229,10 +203,8 @@ export function LovedOneForm({ formType = 'participant', onSubmit }: LovedOneFor fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor="#d1d5db" borderRadius="6px" h="40px" - border="1px solid" px={3} _placeholder={{ color: '#9ca3af' }} _focus={{ borderColor: COLORS.teal, boxShadow: `0 0 0 3px ${COLORS.teal}20` }} @@ -255,7 +227,7 @@ export function LovedOneForm({ formType = 'participant', onSubmit }: LovedOneFor fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor={errors.age ? 'red.500' : '#d1d5db'} + borderColor={errors.age ? 'red.500' : undefined} borderRadius="6px" h="40px" _placeholder={{ color: '#9ca3af' }} @@ -300,14 +272,13 @@ export function LovedOneForm({ formType = 'participant', onSubmit }: LovedOneFor control={control} rules={{ required: 'Diagnosis is required' }} render={({ field }) => ( - - - {DIAGNOSIS_OPTIONS.map((option) => ( - - ))} - + )} /> @@ -335,7 +306,7 @@ export function LovedOneForm({ formType = 'participant', onSubmit }: LovedOneFor fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor={errors.dateOfDiagnosis ? 'red.500' : '#d1d5db'} + borderColor={errors.dateOfDiagnosis ? 'red.500' : undefined} borderRadius="6px" h="40px" _placeholder={{ color: '#9ca3af' }} diff --git a/frontend/src/components/intake/personal-info-form.tsx b/frontend/src/components/intake/personal-info-form.tsx index cd2f0fae..22ac5609 100644 --- a/frontend/src/components/intake/personal-info-form.tsx +++ b/frontend/src/components/intake/personal-info-form.tsx @@ -8,6 +8,7 @@ import { ExperienceTypeSection } from '@/components/intake/experience-type-secti import { COLORS, PROVINCES, VALIDATION, ExperienceData, PersonalData } from '@/constants/form'; import { CustomRadio } from '@/components/CustomRadio'; import { useRouter } from 'next/router'; +import { SingleSelectDropdown } from '@/components/ui/single-select-dropdown'; interface PersonalInfoFormData { hasBloodCancer: 'yes' | 'no' | ''; @@ -153,7 +154,7 @@ export function PersonalInfoForm({ formType, onSubmit }: PersonalInfoFormProps) fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor={errors.firstName ? 'red.500' : '#d1d5db'} + borderColor={errors.firstName ? 'red.500' : undefined} borderRadius="6px" h="40px" _placeholder={{ color: '#9ca3af' }} @@ -178,7 +179,7 @@ export function PersonalInfoForm({ formType, onSubmit }: PersonalInfoFormProps) fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor={errors.lastName ? 'red.500' : '#d1d5db'} + borderColor={errors.lastName ? 'red.500' : undefined} borderRadius="6px" h="40px" _placeholder={{ color: '#9ca3af' }} @@ -206,7 +207,7 @@ export function PersonalInfoForm({ formType, onSubmit }: PersonalInfoFormProps) fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor={errors.dateOfBirth ? 'red.500' : '#d1d5db'} + borderColor={errors.dateOfBirth ? 'red.500' : undefined} borderRadius="6px" h="40px" _placeholder={{ color: '#9ca3af' }} @@ -236,7 +237,7 @@ export function PersonalInfoForm({ formType, onSubmit }: PersonalInfoFormProps) fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor={errors.phoneNumber ? 'red.500' : '#d1d5db'} + borderColor={errors.phoneNumber ? 'red.500' : undefined} borderRadius="6px" h="40px" _placeholder={{ color: '#9ca3af' }} @@ -269,7 +270,7 @@ export function PersonalInfoForm({ formType, onSubmit }: PersonalInfoFormProps) fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor={errors.postalCode ? 'red.500' : '#d1d5db'} + borderColor={errors.postalCode ? 'red.500' : undefined} borderRadius="6px" h="40px" _placeholder={{ color: '#9ca3af' }} @@ -293,7 +294,7 @@ export function PersonalInfoForm({ formType, onSubmit }: PersonalInfoFormProps) fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor={errors.city ? 'red.500' : '#d1d5db'} + borderColor={errors.city ? 'red.500' : undefined} borderRadius="6px" h="40px" _placeholder={{ color: '#9ca3af' }} @@ -314,28 +315,15 @@ export function PersonalInfoForm({ formType, onSubmit }: PersonalInfoFormProps) control={control} rules={{ required: 'Province is required' }} render={({ field }) => ( - - - + )} /> - - {PROVINCES.map((province) => ( - {/* Empty box to maintain two-column layout */} diff --git a/frontend/src/components/intake/volunteer-profile-form.tsx b/frontend/src/components/intake/volunteer-profile-form.tsx index 79e3af44..d4dd4edb 100644 --- a/frontend/src/components/intake/volunteer-profile-form.tsx +++ b/frontend/src/components/intake/volunteer-profile-form.tsx @@ -146,13 +146,14 @@ export function VolunteerProfileForm({ onNext, onBack }: VolunteerProfileFormPro fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor={errors.experience ? 'red.500' : '#d1d5db'} borderRadius="6px" minH="200px" resize="vertical" - border="1px solid" px={3} py={3} + border="1px solid" + borderColor={errors.experience ? 'red.500' : '#d1d5db'} + boxShadow="0 1px 2px 0 rgba(0, 0, 0, 0.05)" _placeholder={{ color: '#9ca3af' }} _focus={{ borderColor: COLORS.teal, diff --git a/frontend/src/components/intake/volunteer-references-form.tsx b/frontend/src/components/intake/volunteer-references-form.tsx index ccdb73a6..499e4757 100644 --- a/frontend/src/components/intake/volunteer-references-form.tsx +++ b/frontend/src/components/intake/volunteer-references-form.tsx @@ -126,10 +126,9 @@ export function VolunteerReferencesForm({ onNext, onBack }: VolunteerReferencesF fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor={errors.reference1?.fullName ? 'red.500' : '#d1d5db'} + borderColor={errors.reference1?.fullName ? 'red.500' : undefined} borderRadius="6px" h="40px" - border="1px solid" px={3} _placeholder={{ color: '#9ca3af' }} _focus={{ @@ -174,10 +173,9 @@ export function VolunteerReferencesForm({ onNext, onBack }: VolunteerReferencesF fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor={errors.reference1?.email ? 'red.500' : '#d1d5db'} + borderColor={errors.reference1?.email ? 'red.500' : undefined} borderRadius="6px" h="40px" - border="1px solid" px={3} _placeholder={{ color: '#9ca3af' }} _focus={{ @@ -221,10 +219,9 @@ export function VolunteerReferencesForm({ onNext, onBack }: VolunteerReferencesF fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor={errors.reference1?.phoneNumber ? 'red.500' : '#d1d5db'} + borderColor={errors.reference1?.phoneNumber ? 'red.500' : undefined} borderRadius="6px" h="40px" - border="1px solid" px={3} _placeholder={{ color: '#9ca3af' }} _focus={{ @@ -280,10 +277,9 @@ export function VolunteerReferencesForm({ onNext, onBack }: VolunteerReferencesF fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor={errors.reference2?.fullName ? 'red.500' : '#d1d5db'} + borderColor={errors.reference2?.fullName ? 'red.500' : undefined} borderRadius="6px" h="40px" - border="1px solid" px={3} _placeholder={{ color: '#9ca3af' }} _focus={{ @@ -328,10 +324,9 @@ export function VolunteerReferencesForm({ onNext, onBack }: VolunteerReferencesF fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor={errors.reference2?.email ? 'red.500' : '#d1d5db'} + borderColor={errors.reference2?.email ? 'red.500' : undefined} borderRadius="6px" h="40px" - border="1px solid" px={3} _placeholder={{ color: '#9ca3af' }} _focus={{ @@ -375,10 +370,9 @@ export function VolunteerReferencesForm({ onNext, onBack }: VolunteerReferencesF fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor={errors.reference2?.phoneNumber ? 'red.500' : '#d1d5db'} + borderColor={errors.reference2?.phoneNumber ? 'red.500' : undefined} borderRadius="6px" h="40px" - border="1px solid" px={3} _placeholder={{ color: '#9ca3af' }} _focus={{ @@ -420,13 +414,14 @@ export function VolunteerReferencesForm({ onNext, onBack }: VolunteerReferencesF fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor="#d1d5db" borderRadius="6px" minH="120px" resize="vertical" - border="1px solid" px={3} py={3} + border="1px solid" + borderColor="#d1d5db" + boxShadow="0 1px 2px 0 rgba(0, 0, 0, 0.05)" _placeholder={{ color: '#9ca3af' }} _focus={{ borderColor: COLORS.teal, diff --git a/frontend/src/components/ui/input-group.tsx b/frontend/src/components/ui/input-group.tsx index 3a36aaea..8f80c94c 100644 --- a/frontend/src/components/ui/input-group.tsx +++ b/frontend/src/components/ui/input-group.tsx @@ -27,6 +27,25 @@ export const InputGroup = React.forwardRef( const child = React.Children.only>(children); + const filteredProps = Object.fromEntries( + Object.entries(children.props).filter(([, value]) => value !== undefined), + ); + + const getBorderColor = (color: unknown): string => { + if (color === 'red.500' || color === 'red') return '#ef4444'; + if (typeof color === 'string' && color.startsWith('#')) return color; + return '#d1d5db'; + }; + + const borderColor = filteredProps.borderColor || '#d1d5db'; + const borderColorHex = getBorderColor(borderColor); + const mergedProps = { + ...filteredProps, + border: filteredProps.border || `1px solid ${borderColorHex}`, + borderColor: borderColor, + boxShadow: filteredProps.boxShadow || '0 1px 2px 0 rgba(0, 0, 0, 0.05)', + } as Record; + return ( {startElement && ( @@ -39,14 +58,12 @@ export const InputGroup = React.forwardRef( ps: `calc(var(--input-height) - ${startOffset})`, }), ...(endElement && { pe: `calc(var(--input-height) - ${endOffset})` }), - ...children.props, - borderRadius: 'md', - border: '1px solid', - borderColor: 'gray.300', - padding: '10px', - fontSize: 'md', - _focus: { borderColor: 'teal.500', boxShadow: '0 0 0 1px #319795' }, - _placeholder: { color: 'gray.400' }, + borderRadius: mergedProps.borderRadius || 'md', + padding: mergedProps.padding || '10px', + fontSize: mergedProps.fontSize || 'md', + _focus: mergedProps._focus || { borderColor: 'teal.500', boxShadow: '0 0 0 1px #319795' }, + _placeholder: mergedProps._placeholder || { color: 'gray.400' }, + ...mergedProps, })} {endElement && ( diff --git a/frontend/src/components/ui/multi-select-dropdown.tsx b/frontend/src/components/ui/multi-select-dropdown.tsx new file mode 100644 index 00000000..e82ea5f5 --- /dev/null +++ b/frontend/src/components/ui/multi-select-dropdown.tsx @@ -0,0 +1,214 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Box, Text } from '@chakra-ui/react'; +import { COLORS } from '@/constants/form'; + +interface MultiSelectDropdownProps { + options: string[]; + selectedValues: string[]; + onSelectionChange: (values: string[]) => void; + placeholder: string; + error?: boolean; +} + +export const MultiSelectDropdown: React.FC = ({ + options, + selectedValues, + onSelectionChange, + placeholder, + error, +}) => { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + const handleCheckboxChange = (option: string, checked: boolean) => { + if (checked) { + onSelectionChange([...selectedValues, option]); + } else { + onSelectionChange(selectedValues.filter((val) => val !== option)); + } + }; + + const handleRemoveChip = (e: React.MouseEvent, option: string) => { + e.stopPropagation(); + onSelectionChange(selectedValues.filter((val) => val !== option)); + }; + + return ( + + + + )) + ) : ( + + {placeholder} + + )} + + + {isOpen ? '▲' : '▼'} + + + + {isOpen && ( + + {options.map((option) => { + const isSelected = selectedValues.includes(option); + return ( + { + e.stopPropagation(); + handleCheckboxChange(option, !isSelected); + }} + borderBottom="1px solid #f3f4f6" + _last={{ borderBottom: 'none' }} + > + + + {option} + + + ); + })} + + )} + + ); +}; diff --git a/frontend/src/components/ui/single-select-dropdown.tsx b/frontend/src/components/ui/single-select-dropdown.tsx new file mode 100644 index 00000000..d009aaac --- /dev/null +++ b/frontend/src/components/ui/single-select-dropdown.tsx @@ -0,0 +1,204 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Box, Text } from '@chakra-ui/react'; +import { COLORS } from '@/constants/form'; + +interface SingleSelectDropdownProps { + options: string[]; + selectedValue: string; + onSelectionChange: (value: string) => void; + placeholder: string; + error?: boolean; +} + +export const SingleSelectDropdown: React.FC = ({ + options, + selectedValue, + onSelectionChange, + placeholder, + error, +}) => { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + const handleSelect = (option: string) => { + onSelectionChange(option); + setIsOpen(false); + }; + + const handleRemove = (e: React.MouseEvent) => { + e.stopPropagation(); + onSelectionChange(''); + }; + + return ( + + + + ) : ( + + {placeholder} + + )} + + + {isOpen ? '▲' : '▼'} + + + + {isOpen && ( + + {options.map((option) => ( + handleSelect(option)} + borderBottom="1px solid #f3f4f6" + _last={{ borderBottom: 'none' }} + > + + + {option} + + + ))} + + )} + + ); +}; diff --git a/frontend/src/constants/form.ts b/frontend/src/constants/form.ts index 5c1ca94b..33ebc56f 100644 --- a/frontend/src/constants/form.ts +++ b/frontend/src/constants/form.ts @@ -58,6 +58,7 @@ export interface IntakeFormData { ethnicGroup: string[]; maritalStatus: string; hasKids: string; + timezone: string; }; // User's Cancer Experience (if applicable) @@ -111,6 +112,7 @@ export interface DemographicsData { ethnicGroup: string[]; maritalStatus: string; hasKids: string; + timezone: string; } export interface CancerExperienceData { @@ -149,5 +151,6 @@ export const INITIAL_INTAKE_FORM_DATA: IntakeFormData = { ethnicGroup: [], maritalStatus: '', hasKids: '', + timezone: '', }, }; diff --git a/frontend/src/pages/participant/intake/index.tsx b/frontend/src/pages/participant/intake/index.tsx index 6d8fd31a..80bbed06 100644 --- a/frontend/src/pages/participant/intake/index.tsx +++ b/frontend/src/pages/participant/intake/index.tsx @@ -27,6 +27,7 @@ interface DemographicCancerFormData { ethnicGroup: string[]; maritalStatus: string; hasKids: string; + timezone: string; diagnosis: string; dateOfDiagnosis: string; treatments: string[]; @@ -49,6 +50,7 @@ interface BasicDemographicsFormData { ethnicGroup: string[]; maritalStatus: string; hasKids: string; + timezone: string; } export default function ParticipantIntakePage() { @@ -128,6 +130,7 @@ export default function ParticipantIntakePage() { ethnicGroup: data.ethnicGroup, maritalStatus: data.maritalStatus, hasKids: data.hasKids, + timezone: data.timezone, }, ...(prev.hasBloodCancer === 'yes' && { cancerExperience: { @@ -184,6 +187,7 @@ export default function ParticipantIntakePage() { ethnicGroup: data.ethnicGroup, maritalStatus: data.maritalStatus, hasKids: data.hasKids, + timezone: data.timezone, }, }; void advanceAfterUpdate(updated); @@ -194,7 +198,7 @@ export default function ParticipantIntakePage() { return ( - + {currentStepType === 'experience-personal' && ( diff --git a/frontend/src/pages/volunteer/intake/index.tsx b/frontend/src/pages/volunteer/intake/index.tsx index 28c42c9e..a03fcf44 100644 --- a/frontend/src/pages/volunteer/intake/index.tsx +++ b/frontend/src/pages/volunteer/intake/index.tsx @@ -27,6 +27,7 @@ interface DemographicCancerFormData { ethnicGroup: string[]; maritalStatus: string; hasKids: string; + timezone: string; diagnosis: string; dateOfDiagnosis: string; treatments: string[]; @@ -49,6 +50,7 @@ interface BasicDemographicsFormData { ethnicGroup: string[]; maritalStatus: string; hasKids: string; + timezone: string; } export default function VolunteerIntakePage() { @@ -137,6 +139,7 @@ export default function VolunteerIntakePage() { ethnicGroup: data.ethnicGroup, maritalStatus: data.maritalStatus, hasKids: data.hasKids, + timezone: data.timezone, }, ...(prev.hasBloodCancer === 'yes' && { cancerExperience: { @@ -193,6 +196,7 @@ export default function VolunteerIntakePage() { ethnicGroup: data.ethnicGroup, maritalStatus: data.maritalStatus, hasKids: data.hasKids, + timezone: data.timezone, }, }; void advanceAfterUpdate(updated); @@ -203,7 +207,7 @@ export default function VolunteerIntakePage() { return ( - + {currentStepType === 'experience-personal' && ( diff --git a/frontend/src/pages/volunteer/secondary.tsx b/frontend/src/pages/volunteer/secondary.tsx index 76633925..90fe6cbe 100644 --- a/frontend/src/pages/volunteer/secondary.tsx +++ b/frontend/src/pages/volunteer/secondary.tsx @@ -40,7 +40,7 @@ export default function VolunteerSecondary() { const handleInputBlur = (e: React.FocusEvent) => { e.target.style.borderColor = 'rgb(209 213 219)'; - e.target.style.boxShadow = 'none'; + e.target.style.boxShadow = '0 1px 2px 0 rgba(0, 0, 0, 0.05)'; }; const handleSubmit = async () => { @@ -178,9 +178,9 @@ export default function VolunteerSecondary() { }} placeholder="Type here...." className="w-3/5 h-56 p-4 border border-gray-300 rounded-lg resize-none outline-none bg-white text-gray-900 placeholder-gray-400" + style={{ boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)', fontSize: '16px' }} onFocus={handleInputFocus} onBlur={handleInputBlur} - style={{ fontSize: '16px' }} />
handleReferenceChange(0, 'name', e.target.value)} placeholder="John Doe" className="w-full p-3 border border-gray-300 rounded-lg outline-none bg-white text-gray-900" + style={{ boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)' }} onFocus={handleInputFocus} onBlur={handleInputBlur} /> @@ -262,6 +263,7 @@ export default function VolunteerSecondary() { onChange={(e) => handleReferenceChange(0, 'email', e.target.value)} placeholder="john.doe@gmail.com" className="w-full p-3 border border-gray-300 rounded-lg outline-none bg-white text-gray-900" + style={{ boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)' }} onFocus={handleInputFocus} onBlur={handleInputBlur} /> @@ -276,6 +278,7 @@ export default function VolunteerSecondary() { onChange={(e) => handleReferenceChange(0, 'phone', e.target.value)} placeholder="###-###-####" className="w-full p-3 border border-gray-300 rounded-lg outline-none bg-white text-gray-900" + style={{ boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)' }} onFocus={handleInputFocus} onBlur={handleInputBlur} /> @@ -295,6 +298,7 @@ export default function VolunteerSecondary() { onChange={(e) => handleReferenceChange(1, 'name', e.target.value)} placeholder="John Doe" className="w-full p-3 border border-gray-300 rounded-lg outline-none bg-white text-gray-900" + style={{ boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)' }} onFocus={handleInputFocus} onBlur={handleInputBlur} /> @@ -307,6 +311,7 @@ export default function VolunteerSecondary() { onChange={(e) => handleReferenceChange(1, 'email', e.target.value)} placeholder="john.doe@gmail.com" className="w-full p-3 border border-gray-300 rounded-lg outline-none bg-white text-gray-900" + style={{ boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)' }} onFocus={handleInputFocus} onBlur={handleInputBlur} /> @@ -321,6 +326,7 @@ export default function VolunteerSecondary() { onChange={(e) => handleReferenceChange(1, 'phone', e.target.value)} placeholder="###-###-####" className="w-full p-3 border border-gray-300 rounded-lg outline-none bg-white text-gray-900" + style={{ boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)' }} onFocus={handleInputFocus} onBlur={handleInputBlur} /> @@ -338,6 +344,7 @@ export default function VolunteerSecondary() { onChange={(e) => setAdditionalComments(e.target.value)} placeholder="Type here...." className="w-2/3 h-32 p-4 border border-gray-300 rounded-lg resize-none outline-none bg-white text-gray-900 placeholder-gray-400" + style={{ boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)' }} onFocus={handleInputFocus} onBlur={handleInputBlur} /> diff --git a/frontend/src/utils/timezoneUtils.ts b/frontend/src/utils/timezoneUtils.ts new file mode 100644 index 00000000..d8040c16 --- /dev/null +++ b/frontend/src/utils/timezoneUtils.ts @@ -0,0 +1,76 @@ +/** + * Detects the user's timezone and maps it to Canadian timezone abbreviations. + * Returns one of: NST, AST, EST, CST, MST, PST, or empty string if detection fails. + */ +export function detectCanadianTimezone(): string { + try { + // Use Intl API to get the IANA timezone identifier + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + // Map IANA timezone identifiers to Canadian timezone abbreviations + const timezoneMap: Record = { + // Newfoundland Standard Time + 'America/St_Johns': 'NST', + + // Atlantic Standard Time + 'America/Halifax': 'AST', + 'America/Moncton': 'AST', + 'America/Glace_Bay': 'AST', + 'America/Goose_Bay': 'AST', + 'America/Blanc-Sablon': 'AST', + + // Eastern Standard Time + 'America/Toronto': 'EST', + 'America/Montreal': 'EST', + 'America/Ottawa': 'EST', + 'America/Thunder_Bay': 'EST', + 'America/Nipigon': 'EST', + 'America/Rainy_River': 'EST', + 'America/Atikokan': 'EST', + + // Central Standard Time + 'America/Winnipeg': 'CST', // Manitoba uses Central Time + 'America/Regina': 'CST', + 'America/Swift_Current': 'CST', + + // Mountain Standard Time + 'America/Edmonton': 'MST', + 'America/Calgary': 'MST', + 'America/Yellowknife': 'MST', + 'America/Inuvik': 'MST', + 'America/Cambridge_Bay': 'MST', + 'America/Dawson_Creek': 'MST', + 'America/Fort_Nelson': 'MST', + + // Pacific Standard Time + 'America/Vancouver': 'PST', + 'America/Whitehorse': 'PST', + 'America/Dawson': 'PST', + }; + + // Check if we have a direct mapping + if (timezoneMap[timeZone]) { + return timezoneMap[timeZone]; + } + + // Fallback: Use timezone offset to estimate + // Get UTC offset in hours (accounting for DST) + const now = new Date(); + const offsetMinutes = now.getTimezoneOffset(); + const offsetHours = -offsetMinutes / 60; // Invert because getTimezoneOffset returns opposite sign + + // Map UTC offsets to Canadian timezones (accounting for DST) + // During DST, offsets are shifted by 1 hour, so we map to standard time equivalents + if (offsetHours === -3.5 || offsetHours === -2.5) return 'NST'; // Newfoundland (standard or daylight) + if (offsetHours === -4 || offsetHours === -3) return 'AST'; // Atlantic (standard or daylight) + if (offsetHours === -5 || offsetHours === -4) return 'EST'; // Eastern (standard or daylight) + if (offsetHours === -6 || offsetHours === -5) return 'CST'; // Central (standard or daylight) + if (offsetHours === -7 || offsetHours === -6) return 'MST'; // Mountain (standard or daylight) + if (offsetHours === -8 || offsetHours === -7) return 'PST'; // Pacific (standard or daylight) + + return ''; + } catch (error) { + console.warn('Unable to detect timezone:', error); + return ''; + } +}