diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index 2d8626dd..ef0f811b 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -63,14 +63,19 @@ async def logout( request: Request, credentials: HTTPAuthorizationCredentials = Depends(security), auth_service: AuthService = Depends(get_auth_service), + user_service: UserService = Depends(get_user_service), ): try: - user_id = request.state.user_id - if not user_id: + auth_id = request.state.user_id # This is actually the Firebase auth_id + if not auth_id: raise HTTPException(status_code=401, detail="Authentication required") + # Convert Firebase auth_id to database user_id (UUID) + user_id = await user_service.get_user_id_by_auth_id(auth_id) auth_service.revoke_tokens(user_id) return {"message": "Successfully logged out"} + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/routes/contact.py b/backend/app/routes/contact.py new file mode 100644 index 00000000..200d3da9 --- /dev/null +++ b/backend/app/routes/contact.py @@ -0,0 +1,70 @@ +import logging + +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy.orm import Session + +from app.middleware.auth import has_roles +from app.models import User +from app.schemas.contact import ContactRequest, ContactResponse +from app.schemas.user import UserRole +from app.utilities.constants import LOGGER_NAME +from app.utilities.db_utils import get_db + +log = logging.getLogger(LOGGER_NAME("contact")) + +router = APIRouter( + prefix="/contact", + tags=["contact"], +) + + +@router.post("/submit", response_model=ContactResponse) +async def submit_contact_form( + contact_data: ContactRequest, + request: Request, + db: Session = Depends(get_db), + authorized: bool = has_roles([UserRole.PARTICIPANT, UserRole.VOLUNTEER, UserRole.ADMIN]), +): + """ + Submit a contact form message from a user. + + This endpoint receives contact form submissions from participants or volunteers + and sends the message to the admin team. + + Args: + contact_data: The contact form data (name, email, message) + request: The FastAPI request object (contains user_id from auth middleware) + db: Database session + + Returns: + ContactResponse with success status and message + """ + try: + # Get current user from auth middleware + current_user_auth_id = request.state.user_id + current_user = db.query(User).filter(User.auth_id == current_user_auth_id).first() + + if not current_user: + raise HTTPException(status_code=401, detail="User not found") + + # Log the contact form submission + log.info( + f"Contact form submission from user {current_user.id} " + f"(name: {contact_data.name}, email: {contact_data.email})" + ) + log.info(f"Message: {contact_data.message}") + + # TODO: Send email to admin team + # This will be implemented in a future update + # For now, we just log the message and return success + + return ContactResponse( + success=True, + message="Your message has been sent successfully. A staff member will get back to you as soon as possible.", + ) + + except HTTPException: + raise + except Exception as e: + log.error(f"Error submitting contact form: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to submit contact form") diff --git a/backend/app/routes/user_data.py b/backend/app/routes/user_data.py index e03e53e9..cb68d278 100644 --- a/backend/app/routes/user_data.py +++ b/backend/app/routes/user_data.py @@ -3,10 +3,12 @@ from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, ConfigDict -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, joinedload from app.middleware.auth import has_roles from app.models import Experience, Treatment, User, UserData +from app.models.User import Language +from app.models.VolunteerData import VolunteerData from app.schemas.user import UserRole from app.utilities.db_utils import get_db @@ -62,6 +64,7 @@ class UserDataResponse(BaseModel): has_kids: Optional[str] = None other_ethnic_group: Optional[str] = None gender_identity_custom: Optional[str] = None + timezone: Optional[str] = None # Cancer Experience diagnosis: Optional[str] = None @@ -84,6 +87,9 @@ class UserDataResponse(BaseModel): # Availability (list of availability templates) availability: List[AvailabilityTemplateResponse] = [] + # Volunteer Data (for volunteers) + volunteer_experience: Optional[str] = None + # ===== Endpoints ===== @@ -106,7 +112,9 @@ async def get_my_user_data( try: # Get current user from auth middleware current_user_auth_id = request.state.user_id - current_user = db.query(User).filter(User.auth_id == current_user_auth_id).first() + current_user = ( + db.query(User).options(joinedload(User.volunteer_data)).filter(User.auth_id == current_user_auth_id).first() + ) if not current_user: raise HTTPException(status_code=401, detail="User not found") @@ -128,6 +136,11 @@ async def get_my_user_data( if template.is_active ] + # Get volunteer_data.experience if user is a volunteer + volunteer_experience = None + if current_user.volunteer_data: + volunteer_experience = current_user.volunteer_data.experience + # Build response with all fields and resolved relationships response = UserDataResponse( # Personal Information @@ -147,6 +160,7 @@ async def get_my_user_data( has_kids=user_data.has_kids, other_ethnic_group=user_data.other_ethnic_group, gender_identity_custom=user_data.gender_identity_custom, + timezone=user_data.timezone, # Cancer Experience diagnosis=user_data.diagnosis, date_of_diagnosis=user_data.date_of_diagnosis.isoformat() if user_data.date_of_diagnosis else None, @@ -166,6 +180,8 @@ async def get_my_user_data( caring_for_someone=user_data.caring_for_someone, # Availability availability=availability_templates, + # Volunteer Data + volunteer_experience=volunteer_experience, ) return response @@ -192,7 +208,9 @@ async def update_my_user_data( try: # Get current user from auth middleware current_user_auth_id = request.state.user_id - current_user = db.query(User).filter(User.auth_id == current_user_auth_id).first() + current_user = ( + db.query(User).options(joinedload(User.volunteer_data)).filter(User.auth_id == current_user_auth_id).first() + ) if not current_user: raise HTTPException(status_code=401, detail="User not found") @@ -300,6 +318,32 @@ async def update_my_user_data( if experience: user_data.loved_one_experiences.append(experience) + # Update user language (stored on User model, not UserData) + if "language" in update_data: + try: + language_value = update_data["language"] + if language_value in ["en", "fr"]: + current_user.language = Language(language_value) + except (ValueError, AttributeError): + pass # Invalid language value, skip + + # Update user timezone (stored on UserData model) + if "timezone" in update_data: + user_data.timezone = update_data["timezone"] + + # Handle volunteer_experience update if provided + if "volunteer_experience" in update_data: + volunteer_data = db.query(VolunteerData).filter(VolunteerData.user_id == current_user.id).first() + if volunteer_data: + volunteer_data.experience = update_data["volunteer_experience"] + else: + # Create volunteer_data if it doesn't exist + volunteer_data = VolunteerData( + user_id=current_user.id, + experience=update_data["volunteer_experience"], + ) + db.add(volunteer_data) + db.commit() db.refresh(user_data) @@ -314,6 +358,11 @@ async def update_my_user_data( if template.is_active ] + # Get volunteer_data.experience if user is a volunteer + volunteer_experience = None + if current_user.volunteer_data: + volunteer_experience = current_user.volunteer_data.experience + response = UserDataResponse( first_name=user_data.first_name, last_name=user_data.last_name, @@ -330,6 +379,7 @@ async def update_my_user_data( has_kids=user_data.has_kids, other_ethnic_group=user_data.other_ethnic_group, gender_identity_custom=user_data.gender_identity_custom, + timezone=user_data.timezone, diagnosis=user_data.diagnosis, date_of_diagnosis=user_data.date_of_diagnosis.isoformat() if user_data.date_of_diagnosis else None, treatments=[treatment.name for treatment in user_data.treatments], @@ -345,6 +395,7 @@ async def update_my_user_data( has_blood_cancer=user_data.has_blood_cancer, caring_for_someone=user_data.caring_for_someone, availability=availability_templates, + volunteer_experience=volunteer_experience, ) return response diff --git a/backend/app/schemas/contact.py b/backend/app/schemas/contact.py new file mode 100644 index 00000000..fda7b999 --- /dev/null +++ b/backend/app/schemas/contact.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel + + +class ContactRequest(BaseModel): + """Schema for contact form submission""" + + name: str + email: str + message: str + + +class ContactResponse(BaseModel): + """Schema for contact form response""" + + success: bool + message: str diff --git a/backend/app/schemas/match.py b/backend/app/schemas/match.py index 58a19403..e6575eaa 100644 --- a/backend/app/schemas/match.py +++ b/backend/app/schemas/match.py @@ -47,6 +47,7 @@ class MatchVolunteerSummary(BaseModel): first_name: Optional[str] = None last_name: Optional[str] = None email: str + phone: Optional[str] = None pronouns: Optional[List[str]] = None diagnosis: Optional[str] = None age: Optional[int] = None diff --git a/backend/app/seeds/users.py b/backend/app/seeds/users.py index dc6ce43d..a5b9b1eb 100644 --- a/backend/app/seeds/users.py +++ b/backend/app/seeds/users.py @@ -1,7 +1,7 @@ """Seed users data for testing matching functionality.""" import uuid -from datetime import date +from datetime import date, time from sqlalchemy import delete from sqlalchemy.orm import Session @@ -131,6 +131,7 @@ def seed_users(session: Session) -> None: "date_of_diagnosis": date(2018, 4, 20), # Survivor "has_blood_cancer": "yes", "caring_for_someone": "no", + "timezone": "EST", }, "treatments": [ TreatmentId.CHEMOTHERAPY, @@ -143,6 +144,12 @@ def seed_users(session: Session) -> None: ExperienceId.FATIGUE, ExperienceId.RETURNING_TO_WORK, ], + "volunteer_experience": "My journey with blood cancer started when I was about twelve years old and getting treatment for the first time was extremely stress-inducing. My journey with blood cancer started when I was about twelve years old and getting treatment for the first time was extremely stress-inducing.", + "availability_templates": [ + {"day_of_week": 1, "start_time": time(14, 0), "end_time": time(16, 0)}, # Tuesday 2-4pm + {"day_of_week": 3, "start_time": time(14, 0), "end_time": time(17, 0)}, # Thursday 2-5pm + {"day_of_week": 4, "start_time": time(10, 0), "end_time": time(12, 0)}, # Friday 10am-12pm + ], }, { "role": "volunteer", @@ -165,9 +172,16 @@ def seed_users(session: Session) -> None: "date_of_diagnosis": date(2020, 8, 15), # Survivor "has_blood_cancer": "yes", "caring_for_someone": "no", + "timezone": "PST", }, "treatments": [3, 6], # Chemotherapy, Radiation (matching Sarah's preferences) "experiences": [1, 3, 4], # Brain Fog, Feeling Overwhelmed, Fatigue (same as Sarah!) + "volunteer_experience": "I was diagnosed with ALL at 34 and underwent intensive chemotherapy and radiation. The hardest part was balancing treatment with being a mother. I want to help others navigate this difficult journey and share what I learned.", + "availability_templates": [ + {"day_of_week": 0, "start_time": time(16, 0), "end_time": time(18, 0)}, # Monday 4-6pm + {"day_of_week": 2, "start_time": time(11, 0), "end_time": time(13, 0)}, # Wednesday 11am-1pm + {"day_of_week": 4, "start_time": time(13, 0), "end_time": time(15, 0)}, # Friday 1-3pm + ], }, { "role": "volunteer", @@ -190,9 +204,16 @@ def seed_users(session: Session) -> None: "date_of_diagnosis": date(2020, 2, 14), "has_blood_cancer": "yes", "caring_for_someone": "no", + "timezone": "EST", }, "treatments": [3, 6], # Chemotherapy, Radiation "experiences": [10, 11, 7], # Anxiety/Depression, PTSD, Returning to work + "volunteer_experience": "Fighting Hodgkin Lymphoma taught me resilience I never knew I had. The mental health challenges were just as tough as the physical ones. I'm here to listen and support anyone going through similar struggles.", + "availability_templates": [ + {"day_of_week": 1, "start_time": time(9, 0), "end_time": time(11, 0)}, # Tuesday 9-11am + {"day_of_week": 3, "start_time": time(14, 0), "end_time": time(16, 0)}, # Thursday 2-4pm + {"day_of_week": 5, "start_time": time(10, 0), "end_time": time(12, 0)}, # Saturday 10am-12pm + ], }, # High-matching volunteers for Sarah Johnson { @@ -216,9 +237,15 @@ def seed_users(session: Session) -> None: "date_of_diagnosis": date(2019, 5, 10), # Survivor "has_blood_cancer": "yes", "caring_for_someone": "no", + "timezone": "EST", }, "treatments": [3, 6], # Chemotherapy, Radiation (matching Sarah's preferences) "experiences": [1, 3, 4], # Brain Fog, Feeling Overwhelmed, Fatigue (same as Sarah!) + "volunteer_experience": "As a working mother of two, being diagnosed with leukemia turned my world upside down. Brain fog and fatigue made everyday tasks feel impossible. Now in remission, I'm passionate about helping others find their strength during treatment.", + "availability_templates": [ + {"day_of_week": 2, "start_time": time(14, 0), "end_time": time(17, 0)}, # Wednesday 2-5pm + {"day_of_week": 3, "start_time": time(16, 0), "end_time": time(18, 0)}, # Thursday 4-6pm + ], }, { "role": "volunteer", @@ -241,9 +268,15 @@ def seed_users(session: Session) -> None: "date_of_diagnosis": date(2021, 3, 18), # Survivor "has_blood_cancer": "yes", "caring_for_someone": "no", + "timezone": "MST", }, "treatments": [3, 6], # Chemotherapy, Radiation (matching Sarah's preferences) "experiences": [1, 3, 4, 10], # Brain Fog, Feeling Overwhelmed, Fatigue, Anxiety/Depression + "volunteer_experience": "My cancer journey came with intense anxiety and depression alongside the physical symptoms. Through therapy and support groups, I learned to cope with the overwhelming emotions. I want to be that supportive voice for others facing the same battles.", + "availability_templates": [ + {"day_of_week": 0, "start_time": time(10, 0), "end_time": time(12, 0)}, # Monday 10am-12pm + {"day_of_week": 4, "start_time": time(14, 30), "end_time": time(16, 30)}, # Friday 2:30-4:30pm + ], }, { "role": "volunteer", @@ -266,9 +299,16 @@ def seed_users(session: Session) -> None: "date_of_diagnosis": date(2018, 9, 25), # Survivor "has_blood_cancer": "yes", "caring_for_someone": "no", + "timezone": "CST", }, "treatments": [3, 6, 7], # Chemotherapy, Radiation, Maintenance Chemo "experiences": [1, 4, 5], # Brain Fog, Feeling Overwhelmed, Fatigue (same as Sarah!) + "volunteer_experience": "Seven years ago, ALL changed everything. The fatigue was relentless, and brain fog made me feel like a stranger in my own mind. But I made it through, and now I want to offer hope and practical advice to anyone starting this journey.", + "availability_templates": [ + {"day_of_week": 1, "start_time": time(15, 0), "end_time": time(17, 0)}, # Tuesday 3-5pm + {"day_of_week": 3, "start_time": time(10, 0), "end_time": time(12, 0)}, # Thursday 10am-12pm + {"day_of_week": 5, "start_time": time(14, 0), "end_time": time(16, 0)}, # Saturday 2-4pm + ], }, # Test Case 3: Participant who is a caregiver wanting caregiver volunteers { @@ -434,6 +474,22 @@ def seed_users(session: Session) -> None: ) session.add(volunteer_data) + # Add availability templates for volunteers (if specified) + # Skip yashkothari@uwblueprint.org so they can set their own availability + if ( + user_info.get("availability_templates") + and user_info["user_data"]["email"] != "yashkothari@uwblueprint.org" + ): + for template_info in user_info["availability_templates"]: + availability_template = AvailabilityTemplate( + user_id=user.id, + day_of_week=template_info["day_of_week"], + start_time=template_info["start_time"], + end_time=template_info["end_time"], + is_active=True, + ) + session.add(availability_template) + created_users.append((user, user_info["role"])) print(f"Added {user_info['role']}: {user.first_name} {user.last_name}") diff --git a/backend/app/server.py b/backend/app/server.py index 70dd89f3..d647ca72 100644 --- a/backend/app/server.py +++ b/backend/app/server.py @@ -11,6 +11,7 @@ from .routes import ( auth, availability, + contact, intake, match, matching, @@ -90,6 +91,7 @@ async def lifespan(_: FastAPI): app.include_router(send_email.router) app.include_router(task.router) app.include_router(test.router) +app.include_router(contact.router) @app.get("/") diff --git a/backend/app/services/email/amazon_ses_provider.py b/backend/app/services/email/amazon_ses_provider.py index 354f4d8a..ce291b23 100644 --- a/backend/app/services/email/amazon_ses_provider.py +++ b/backend/app/services/email/amazon_ses_provider.py @@ -82,4 +82,5 @@ def get_email_service_provider() -> IEmailServiceProvider: aws_secret_key=os.getenv("AWS_SECRET_KEY"), region=os.getenv("AWS_REGION"), source_email=os.getenv("SES_SOURCE_EMAIL"), + is_sandbox=os.getenv("IS_SANDBOX", "True").lower() == "true", ) diff --git a/backend/app/services/implementations/match_service.py b/backend/app/services/implementations/match_service.py index e82010f4..20814385 100644 --- a/backend/app/services/implementations/match_service.py +++ b/backend/app/services/implementations/match_service.py @@ -345,9 +345,12 @@ async def cancel_match_by_participant( if acting_participant_id and match.participant_id != acting_participant_id: raise HTTPException(status_code=403, detail="Cannot modify another participant's match") - self._clear_confirmed_time(match) self._set_match_status(match, "cancelled_by_participant") + # TODO: send particpant an email saying that the match has been cancelled + # Soft-delete the match when cancelled (cleans up time blocks and sets deleted_at) + self._delete_match(match) + self.db.flush() self.db.commit() self.db.refresh(match) @@ -665,6 +668,7 @@ def _build_match_detail(self, match: Match) -> MatchDetailResponse: diagnosis = None age: Optional[int] = None timezone: Optional[str] = None + phone: Optional[str] = None treatments: List[str] = [] experiences: List[str] = [] overview: Optional[str] = None @@ -674,6 +678,7 @@ def _build_match_detail(self, match: Match) -> MatchDetailResponse: pronouns = volunteer_data.pronouns diagnosis = volunteer_data.diagnosis timezone = volunteer_data.timezone + phone = volunteer_data.phone if volunteer_data.date_of_birth: age = self._calculate_age(volunteer_data.date_of_birth) @@ -693,6 +698,7 @@ def _build_match_detail(self, match: Match) -> MatchDetailResponse: first_name=volunteer.first_name, last_name=volunteer.last_name, email=volunteer.email, + phone=phone, pronouns=pronouns, diagnosis=diagnosis, age=age, diff --git a/frontend/src/APIClients/authAPIClient.ts b/frontend/src/APIClients/authAPIClient.ts index 08879ca7..b59022e7 100644 --- a/frontend/src/APIClients/authAPIClient.ts +++ b/frontend/src/APIClients/authAPIClient.ts @@ -1,3 +1,4 @@ +import { signOut as firebaseSignOut } from 'firebase/auth'; import { AuthenticatedUser, UserCreateResponse, @@ -149,12 +150,20 @@ export const logout = async (): Promise => { const bearerToken = `Bearer ${getLocalStorageObjProperty(AUTHENTICATED_USER_KEY, 'accessToken')}`; try { + // Call backend to revoke tokens await baseAPIClient.post('/auth/logout', {}, { headers: { Authorization: bearerToken } }); - localStorage.removeItem(AUTHENTICATED_USER_KEY); return true; } catch (error) { - console.error('Logout error:', error); + console.error('Backend logout error:', error); return false; + } finally { + // Always clear localStorage and sign out from Firebase regardless of backend API success/failure + localStorage.removeItem(AUTHENTICATED_USER_KEY); + try { + await firebaseSignOut(auth); + } catch (firebaseError) { + console.error('Firebase sign out error:', firebaseError); + } } }; diff --git a/frontend/src/APIClients/contactAPIClient.ts b/frontend/src/APIClients/contactAPIClient.ts new file mode 100644 index 00000000..d30b1f65 --- /dev/null +++ b/frontend/src/APIClients/contactAPIClient.ts @@ -0,0 +1,20 @@ +import baseAPIClient from './baseAPIClient'; + +export interface ContactRequest { + name: string; + email: string; + message: string; +} + +export interface ContactResponse { + success: boolean; + message: string; +} + +/** + * Submit a contact form message + */ +export const submitContactForm = async (contactData: ContactRequest): Promise => { + const response = await baseAPIClient.post('/contact/submit', contactData); + return response.data; +}; diff --git a/frontend/src/APIClients/participantMatchAPIClient.ts b/frontend/src/APIClients/participantMatchAPIClient.ts index 9e39343b..ec73a2be 100644 --- a/frontend/src/APIClients/participantMatchAPIClient.ts +++ b/frontend/src/APIClients/participantMatchAPIClient.ts @@ -30,4 +30,46 @@ export const participantMatchAPIClient = { }); return response.data; }, + + /** + * Schedule a match by selecting a time block + * @param matchId The match ID + * @param timeBlockId The selected time block ID + * @returns Updated match details + */ + scheduleMatch: async (matchId: number, timeBlockId: number): Promise => { + const response = await baseAPIClient.post(`/matches/${matchId}/schedule`, { + timeBlockId, + }); + return response.data; + }, + + /** + * Cancel a match as a participant + * @param matchId The match ID + * @returns Updated match details + */ + cancelMatch: async (matchId: number): Promise => { + const response = await baseAPIClient.post(`/matches/${matchId}/cancel`); + return response.data; + }, + + /** + * Request new times for a match + * @param matchId The match ID + * @param timeRanges Array of time ranges (start_time and end_time) + * @returns Updated match details + */ + requestNewTimes: async ( + matchId: number, + timeRanges: Array<{ startTime: string; endTime: string }>, + ): Promise => { + const response = await baseAPIClient.post(`/matches/${matchId}/request-new-times`, { + suggestedNewTimes: timeRanges.map((range) => ({ + start_time: range.startTime, + end_time: range.endTime, + })), + }); + return response.data; + }, }; diff --git a/frontend/src/APIClients/userDataAPIClient.ts b/frontend/src/APIClients/userDataAPIClient.ts index a0870d6c..c95476b8 100644 --- a/frontend/src/APIClients/userDataAPIClient.ts +++ b/frontend/src/APIClients/userDataAPIClient.ts @@ -26,6 +26,7 @@ export interface UserDataResponse { hasKids?: string; otherEthnicGroup?: string; genderIdentityCustom?: string; + timezone?: string; // Cancer Experience diagnosis?: string; @@ -47,6 +48,9 @@ export interface UserDataResponse { // Availability availability?: AvailabilityTemplateResponse[]; + + // Volunteer Data (for volunteers) + volunteerExperience?: string; } export interface FormSubmissionResponse { diff --git a/frontend/src/components/dashboard/BloodCancerExperience.tsx b/frontend/src/components/dashboard/BloodCancerExperience.tsx index 68596478..227b6190 100644 --- a/frontend/src/components/dashboard/BloodCancerExperience.tsx +++ b/frontend/src/components/dashboard/BloodCancerExperience.tsx @@ -1,19 +1,12 @@ import React, { useState } from 'react'; -import { Box, Heading, Text, VStack, HStack, Button, Stack, Flex } from '@chakra-ui/react'; -import { FiHeart, FiInfo } from 'react-icons/fi'; +import { Box, Text, VStack, HStack, Flex } from '@chakra-ui/react'; +import { FiHeart } from 'react-icons/fi'; import ProfileTextInput from './ProfileTextInput'; -import ProfileDropdown from './ProfileDropdown'; -import ProfileMultiSelectDropdown from './ProfileMultiSelectDropdown'; import ProfileHeader from './ProfileHeader'; import ActionButton from './EditButton'; +import ReadOnlyDiagnosisField from './ReadOnlyDiagnosisField'; import { Checkbox } from '@/components/ui/checkbox'; -import { Tooltip } from '@/components/ui/tooltip'; -import { - DIAGNOSIS_DROPDOWN_OPTIONS, - TREATMENT_OPTIONS, - EXPERIENCE_OPTIONS, - COLORS, -} from '@/constants/form'; +import { TREATMENT_OPTIONS, EXPERIENCE_OPTIONS, COLORS } from '@/constants/form'; interface BloodCancerExperienceProps { cancerExperience: { @@ -48,6 +41,7 @@ interface BloodCancerExperienceProps { onEditExperiences: () => void; onEditLovedOneTreatments?: () => void; onEditLovedOneExperiences?: () => void; + hasBloodCancer?: boolean; } const BloodCancerExperience: React.FC = ({ @@ -59,6 +53,7 @@ const BloodCancerExperience: React.FC = ({ onEditExperiences, onEditLovedOneTreatments, onEditLovedOneExperiences, + hasBloodCancer = false, }) => { const [isEditingTreatments, setIsEditingTreatments] = useState(false); const [isEditingExperiences, setIsEditingExperiences] = useState(false); @@ -136,365 +131,128 @@ const BloodCancerExperience: React.FC = ({ Blood cancer experience information - - - setCancerExperience((prev) => ({ ...prev, diagnosis: selectedValues })) - } - options={DIAGNOSIS_DROPDOWN_OPTIONS} - maxSelections={3} - flex="1" - /> + {/* Only show user's cancer info if they have cancer */} + {hasBloodCancer && ( + <> + + 0 ? cancerExperience.diagnosis.join(', ') : '' + } + /> + + - - setCancerExperience((prev) => ({ ...prev, dateOfDiagnosis: e.target.value })) - } - placeholder="DD/MM/YYYY" - flex="1" - /> - - - - - - - Treatments you have done - - { - if (isEditingTreatments) { - await onEditTreatments(); - } - setIsEditingTreatments(!isEditingTreatments); - }} - > - {isEditingTreatments ? 'Save' : 'Edit'} - - - - {isEditingTreatments ? ( - - - - You can select a{' '} - - maximum of 2 - - . - - - {treatmentOptionsWithOther.map((treatment) => { - const isSelected = cancerExperience.treatments.includes(treatment); - const isDisabled = !isSelected && cancerExperience.treatments.length >= 2; - - return ( - - !isDisabled && handleTreatmentToggle(treatment)} - > - handleTreatmentToggle(treatment)} - /> - - {treatment} - - - {treatment === 'Other' && isSelected && ( - - setOtherTreatment(e.target.value)} - placeholder="Please specify..." - /> - - )} - - ); - })} - - ) : ( - - {cancerExperience.treatments.map((treatment, index) => ( - + + + - {treatment} - - ))} - - )} - - - - - - Experiences you had - - { - if (isEditingExperiences) { - await onEditExperiences(); - } - setIsEditingExperiences(!isEditingExperiences); - }} - > - {isEditingExperiences ? 'Save' : 'Edit'} - - - - {isEditingExperiences ? ( - - - + { + if (isEditingTreatments) { + await onEditTreatments(); + } + setIsEditingTreatments(!isEditingTreatments); + }} > - You can select a{' '} - - maximum of 5 - - . - - - {experienceOptionsWithOther.map((experience) => { - const isSelected = cancerExperience.experiences.includes(experience); - const isDisabled = !isSelected && cancerExperience.experiences.length >= 5; + {isEditingTreatments ? 'Save' : 'Edit'} + + - return ( - - !isDisabled && handleExperienceToggle(experience)} + {isEditingTreatments ? ( + + + - handleExperienceToggle(experience)} - /> - - {experience} + You can select a{' '} + + maximum of 2 - - {experience === 'Other' && isSelected && ( - - setOtherExperience(e.target.value)} - placeholder="Please specify..." - /> - - )} - - ); - })} - - ) : ( - - {cancerExperience.experiences.map((experience, index) => ( - - {experience} - - ))} - - )} - - - - - {/* Loved One's Blood Cancer Experience */} - {lovedOneCancerExperience && ( - - - - - - - - - Loved One's Diagnosis - - - - - - - - - - {lovedOneCancerExperience.diagnosis || 'Not provided'} - - - - - - - - + . + + + {treatmentOptionsWithOther.map((treatment) => { + const isSelected = cancerExperience.treatments.includes(treatment); + const isDisabled = !isSelected && cancerExperience.treatments.length >= 2; - - - - - Loved One's Date of Diagnosis - - - - - - - - - - - - - - + return ( + + !isDisabled && handleTreatmentToggle(treatment)} + > + handleTreatmentToggle(treatment)} + /> + + {treatment} + + + {treatment === 'Other' && isSelected && ( + + setOtherTreatment(e.target.value)} + placeholder="Please specify..." + /> + + )} + + ); + })} + + ) : ( + + {cancerExperience.treatments.map((treatment, index) => ( + + {treatment} + + ))} + + )} - - - - - - - + + = ({ color={COLORS.veniceBlue} fontFamily="'Open Sans', sans-serif" > - Treatments Loved One Has Done + Experiences you had - - { - if (isEditingLovedOneTreatments && onEditLovedOneTreatments) { - await onEditLovedOneTreatments(); - } - setIsEditingLovedOneTreatments(!isEditingLovedOneTreatments); - }} - > - {isEditingLovedOneTreatments ? 'Save' : 'Edit'} - - + { + if (isEditingExperiences) { + await onEditExperiences(); + } + setIsEditingExperiences(!isEditingExperiences); + }} + > + {isEditingExperiences ? 'Save' : 'Edit'} + + + + {isEditingExperiences ? ( + + + + You can select a{' '} + + maximum of 5 + + . + + + {experienceOptionsWithOther.map((experience) => { + const isSelected = cancerExperience.experiences.includes(experience); + const isDisabled = !isSelected && cancerExperience.experiences.length >= 5; + + return ( + + !isDisabled && handleExperienceToggle(experience)} + > + handleExperienceToggle(experience)} + /> + + {experience} + + + {experience === 'Other' && isSelected && ( + + setOtherExperience(e.target.value)} + placeholder="Please specify..." + /> + + )} + + ); + })} + + ) : ( + + {cancerExperience.experiences.map((experience, index) => ( + + {experience} + + ))} + + )} + + + + )} + + {/* Loved One's Blood Cancer Experience */} + {lovedOneCancerExperience && ( + + - {isEditingLovedOneTreatments ? ( - - - + + + + + + + + + + - You can select a{' '} - - maximum of 2 + Treatments Loved One Has Done + + + { + if (isEditingLovedOneTreatments && onEditLovedOneTreatments) { + await onEditLovedOneTreatments(); + } + setIsEditingLovedOneTreatments(!isEditingLovedOneTreatments); + }} + > + {isEditingLovedOneTreatments ? 'Save' : 'Edit'} + + + + {isEditingLovedOneTreatments ? ( + + + + You can select a{' '} + + maximum of 2 + + . - . - - - {treatmentOptionsWithOther.map((treatment) => { - const isSelected = lovedOneCancerExperience.treatments.includes(treatment); - const isDisabled = - !isSelected && lovedOneCancerExperience.treatments.length >= 2; + + {treatmentOptionsWithOther.map((treatment) => { + const isSelected = lovedOneCancerExperience.treatments.includes(treatment); + const isDisabled = + !isSelected && lovedOneCancerExperience.treatments.length >= 2; + + return ( + + !isDisabled && handleLovedOneTreatmentToggle(treatment)} + > + handleLovedOneTreatmentToggle(treatment)} + /> + + {treatment} + + + {treatment === 'Other' && isSelected && ( + + setOtherLovedOneTreatment(e.target.value)} + placeholder="Please specify..." + /> + + )} + + ); + })} + + ) : ( + + {lovedOneCancerExperience.treatments.map((treatment, index) => ( + + {treatment} + + ))} + + )} + + + + + + + + Experiences Loved One Had + + + { + if (isEditingLovedOneExperiences && onEditLovedOneExperiences) { + await onEditLovedOneExperiences(); + } + setIsEditingLovedOneExperiences(!isEditingLovedOneExperiences); + }} + > + {isEditingLovedOneExperiences ? 'Save' : 'Edit'} + + - return ( - + {isEditingLovedOneExperiences ? ( + + + + You can select a{' '} + + maximum of 5 + + . + + + {lovedOneExperienceOptions.map((experience) => { + const isSelected = lovedOneCancerExperience.experiences.includes(experience); + const isDisabled = + !isSelected && lovedOneCancerExperience.experiences.length >= 5; + + return ( !isDisabled && handleLovedOneTreatmentToggle(treatment)} + onClick={() => !isDisabled && handleLovedOneExperienceToggle(experience)} > handleLovedOneTreatmentToggle(treatment)} + onChange={() => handleLovedOneExperienceToggle(experience)} /> = ({ color="#495D6C" fontFamily="'Open Sans', sans-serif" > - {treatment} + {experience} - {treatment === 'Other' && isSelected && ( - - setOtherLovedOneTreatment(e.target.value)} - placeholder="Please specify..." - /> - - )} - - ); - })} - - ) : ( - - {lovedOneCancerExperience.treatments.map((treatment, index) => ( - - {treatment} - - ))} - - )} - - - - - - - - Experiences Loved One Had - - - { - if (isEditingLovedOneExperiences && onEditLovedOneExperiences) { - await onEditLovedOneExperiences(); - } - setIsEditingLovedOneExperiences(!isEditingLovedOneExperiences); - }} - > - {isEditingLovedOneExperiences ? 'Save' : 'Edit'} - - - - {isEditingLovedOneExperiences ? ( - - - - You can select a{' '} - - maximum of 5 - - . - - - {lovedOneExperienceOptions.map((experience) => { - const isSelected = lovedOneCancerExperience.experiences.includes(experience); - const isDisabled = - !isSelected && lovedOneCancerExperience.experiences.length >= 5; - - return ( - !isDisabled && handleLovedOneExperienceToggle(experience)} + ); + })} + + ) : ( + + {lovedOneCancerExperience.experiences.map((experience, index) => ( + - handleLovedOneExperienceToggle(experience)} - /> - - {experience} - - - ); - })} - - ) : ( - - {lovedOneCancerExperience.experiences.map((experience, index) => ( - - {experience} - - ))} - - )} - - - - )} + {experience} + + ))} + + )} + + + + )} + ); }; diff --git a/frontend/src/components/dashboard/EditProfileModal.tsx b/frontend/src/components/dashboard/EditProfileModal.tsx index ad3087cb..1e2a5a17 100644 --- a/frontend/src/components/dashboard/EditProfileModal.tsx +++ b/frontend/src/components/dashboard/EditProfileModal.tsx @@ -5,7 +5,6 @@ import { Box, Heading, Text, VStack, HStack, Button, Image } from '@chakra-ui/re import PersonalDetails from '@/components/dashboard/PersonalDetails'; import BloodCancerExperience from '@/components/dashboard/BloodCancerExperience'; import ActionButton from '@/components/dashboard/EditButton'; -import { COLORS } from '@/constants/form'; import { useAuth } from '@/contexts/AuthContext'; import { getUserData, @@ -13,6 +12,7 @@ import { updateMyAvailability, AvailabilityTemplateResponse, } from '@/APIClients/userDataAPIClient'; +import { extractTimezoneAbbreviation, getTimezoneDisplayName } from '@/utils/timezoneUtils'; interface EditProfileModalProps { isOpen: boolean; @@ -24,15 +24,25 @@ const EditProfileModal: React.FC = ({ isOpen, onClose }) const [isEditingAvailability, setIsEditingAvailability] = useState(false); const [loading, setLoading] = useState(true); const [savingAvailability, setSavingAvailability] = useState(false); + const [hasBloodCancer, setHasBloodCancer] = useState(false); // Personal details state for profile - const [personalDetails, setPersonalDetails] = useState({ + const [personalDetails, setPersonalDetails] = useState<{ + name: string; + email: string; + birthday: string; + gender: string; + pronouns: string; + timezone: string; + overview: string; + preferredLanguage?: string; + }>({ name: '', email: '', birthday: '', gender: '', pronouns: '', - timezone: 'Eastern Standard Time (EST)', + timezone: 'EST', overview: '', }); @@ -125,6 +135,7 @@ const EditProfileModal: React.FC = ({ isOpen, onClose }) // Populate personal details (using camelCase after axios conversion) const formattedBirthday = formatDate(userData.dateOfBirth); const formattedPronouns = userData.pronouns?.join(', ') || 'Not provided'; + const userLanguage = user?.language || undefined; setPersonalDetails({ name: @@ -135,17 +146,34 @@ const EditProfileModal: React.FC = ({ isOpen, onClose }) birthday: formattedBirthday, gender: userData.genderIdentity || 'Not provided', pronouns: formattedPronouns, - timezone: 'Eastern Standard Time (EST)', // TODO: Add timezone field to backend - overview: 'Not provided', // TODO: Add overview field to backend + timezone: userData.timezone ? getTimezoneDisplayName(userData.timezone) : 'EST', + overview: + userData.volunteerExperience && userData.volunteerExperience.trim() + ? userData.volunteerExperience + : 'Not provided', + preferredLanguage: + userLanguage === 'fr' ? 'fr' : userLanguage === 'en' ? 'en' : undefined, }); - // Populate cancer experience - setCancerExperience({ - diagnosis: userData.diagnosis ? [userData.diagnosis] : [], - dateOfDiagnosis: userData.dateOfDiagnosis || '', - treatments: userData.treatments || [], - experiences: userData.experiences || [], - }); + // Determine if user has blood cancer + const hasBloodCancerValue = userData.hasBloodCancer; + const userHasBloodCancer = + hasBloodCancerValue === true || + (hasBloodCancerValue !== undefined && + hasBloodCancerValue !== null && + String(hasBloodCancerValue).toLowerCase() === 'yes'); + + setHasBloodCancer(userHasBloodCancer); + + // Populate cancer experience (only if user has cancer) + if (userHasBloodCancer) { + setCancerExperience({ + diagnosis: userData.diagnosis ? [userData.diagnosis] : [], + dateOfDiagnosis: userData.dateOfDiagnosis || '', + treatments: userData.treatments || [], + experiences: userData.experiences || [], + }); + } // Populate loved one details if caring for someone if (userData.caringForSomeone) { @@ -191,15 +219,13 @@ const EditProfileModal: React.FC = ({ isOpen, onClose }) // Save handler for PersonalDetails const handleSavePersonalDetail = async (field: string, value: string) => { - const updateData: Partial = {}; + const updateData: Partial> = {}; // Map frontend field names to backend snake_case (axios will convert to camelCase on send) if (field === 'name') { const [firstName, ...lastNameParts] = value.split(' '); updateData.first_name = firstName || ''; updateData.last_name = lastNameParts.join(' ') || ''; - } else if (field === 'email') { - updateData.email = value; } else if (field === 'birthday') { // Convert DD/MM/YYYY to YYYY-MM-DD for backend try { @@ -212,6 +238,13 @@ const EditProfileModal: React.FC = ({ isOpen, onClose }) updateData.gender_identity = value; } else if (field === 'pronouns') { updateData.pronouns = value.split(',').map((p) => p.trim()); + } else if (field === 'overview') { + updateData.volunteer_experience = value; + } else if (field === 'timezone') { + // Extract abbreviation from full name (e.g., "Eastern Standard Time (EST)" -> "EST") + updateData.timezone = extractTimezoneAbbreviation(value); + } else if (field === 'preferredLanguage') { + updateData.language = value; // Backend expects 'language' field } else if (field === 'lovedOneBirthday') { // Loved one's age/birthday - store as is since backend expects lovedOneAge as string updateData.loved_one_age = value; @@ -247,7 +280,7 @@ const EditProfileModal: React.FC = ({ isOpen, onClose }) const handleSaveLovedOneTreatments = async () => { if (!lovedOneCancerExperience) return; const result = await updateUserData({ - loved_one_treatments: lovedOneCancerExperience.treatments, + lovedOneTreatments: lovedOneCancerExperience.treatments, }); if (!result) { alert('Failed to save loved one treatments'); @@ -258,7 +291,7 @@ const EditProfileModal: React.FC = ({ isOpen, onClose }) const handleSaveLovedOneExperiences = async () => { if (!lovedOneCancerExperience) return; const result = await updateUserData({ - loved_one_experiences: lovedOneCancerExperience.experiences, + lovedOneExperiences: lovedOneCancerExperience.experiences, }); if (!result) { alert('Failed to save loved one experiences'); @@ -447,6 +480,7 @@ const EditProfileModal: React.FC = ({ isOpen, onClose }) lovedOneDetails={lovedOneDetails} setLovedOneDetails={setLovedOneDetails} onSave={handleSavePersonalDetail} + isVolunteer={true} /> = ({ isOpen, onClose }) onEditExperiences={handleSaveExperiences} onEditLovedOneTreatments={handleSaveLovedOneTreatments} onEditLovedOneExperiences={handleSaveLovedOneExperiences} + hasBloodCancer={hasBloodCancer} /> diff --git a/frontend/src/components/dashboard/PersonalDetails.tsx b/frontend/src/components/dashboard/PersonalDetails.tsx index 483fe516..2c59bf94 100644 --- a/frontend/src/components/dashboard/PersonalDetails.tsx +++ b/frontend/src/components/dashboard/PersonalDetails.tsx @@ -1,10 +1,10 @@ import React, { useState } from 'react'; -import { Box, Text, HStack, Stack, VStack, Flex, Textarea, Button } from '@chakra-ui/react'; +import { Box, VStack, Flex, Button } from '@chakra-ui/react'; import { FiHeart } from 'react-icons/fi'; import ProfileTextInput from './ProfileTextInput'; import ProfileDropdown from './ProfileDropdown'; import ProfileHeader from './ProfileHeader'; -import { GENDER_DROPDOWN_OPTIONS, TIMEZONE_OPTIONS, COLORS } from '@/constants/form'; +import { GENDER_DROPDOWN_OPTIONS, TIMEZONE_OPTIONS } from '@/constants/form'; import { validateEmail, validateBirthday, validatePronouns } from '@/utils/validationUtils'; interface PersonalDetailsProps { @@ -16,6 +16,7 @@ interface PersonalDetailsProps { pronouns: string; timezone: string; overview: string; + preferredLanguage?: string | undefined; }; lovedOneDetails?: { birthday: string; @@ -30,6 +31,7 @@ interface PersonalDetailsProps { pronouns: string; timezone: string; overview: string; + preferredLanguage?: string; }> >; setLovedOneDetails?: React.Dispatch< @@ -39,6 +41,7 @@ interface PersonalDetailsProps { } | null> >; onSave?: (field: string, value: string) => Promise; + isVolunteer?: boolean; } const PersonalDetails: React.FC = ({ @@ -47,6 +50,7 @@ const PersonalDetails: React.FC = ({ setPersonalDetails, setLovedOneDetails, onSave, + isVolunteer = false, }) => { const [editingField, setEditingField] = useState(null); const [saving, setSaving] = useState(false); @@ -69,7 +73,17 @@ const PersonalDetails: React.FC = ({ validation = validateBirthday(value, 18); // Require at least 18 years old break; case 'lovedOneBirthday': - validation = validateBirthday(value, 0); // No age requirement for loved one + // Age field - just validate it's not empty and is a reasonable number + if (!value || !value.trim()) { + validation = { isValid: false, error: 'Age is required' }; + } else { + const ageNum = parseInt(value, 10); + if (isNaN(ageNum) || ageNum < 0 || ageNum > 1000) { + validation = { isValid: false, error: 'Please enter a valid age (0-1000)' }; + } else { + validation = { isValid: true }; + } + } break; case 'pronouns': validation = validatePronouns(value); @@ -95,11 +109,12 @@ const PersonalDetails: React.FC = ({ }; const handleBlur = (fieldName: string) => { - let value; + let value: string; if (fieldName === 'lovedOneBirthday') { value = lovedOneDetails?.birthday || ''; } else { - value = personalDetails[fieldName as keyof typeof personalDetails]; + const fieldValue = personalDetails[fieldName as keyof typeof personalDetails]; + value = typeof fieldValue === 'string' ? fieldValue : ''; } validateField(fieldName, value); }; @@ -110,11 +125,12 @@ const PersonalDetails: React.FC = ({ return; } - let value; + let value: string; if (editingField === 'lovedOneBirthday') { value = lovedOneDetails?.birthday || ''; } else { - value = personalDetails[editingField as keyof typeof personalDetails]; + const fieldValue = personalDetails[editingField as keyof typeof personalDetails]; + value = typeof fieldValue === 'string' ? fieldValue : ''; } // Validate before saving @@ -163,7 +179,7 @@ const PersonalDetails: React.FC = ({ fontSize="0.875rem" _hover={{ bg: '#044d52' }} _active={{ bg: '#033e42' }} - isDisabled={saving} + disabled={saving} > {saving ? 'Saving...' : 'Save'} @@ -193,7 +209,7 @@ const PersonalDetails: React.FC = ({ fontSize="0.875rem" _hover={{ bg: '#044d52' }} _active={{ bg: '#033e42' }} - isDisabled={saving} + disabled={saving} > {saving ? 'Saving...' : 'Save'} @@ -222,7 +238,7 @@ const PersonalDetails: React.FC = ({ fontSize="0.875rem" _hover={{ bg: '#044d52' }} _active={{ bg: '#033e42' }} - isDisabled={saving} + disabled={saving} > {saving ? 'Saving...' : 'Save'} @@ -235,30 +251,9 @@ const PersonalDetails: React.FC = ({ label="Email Address" value={personalDetails.email} onChange={(e) => setPersonalDetails((prev) => ({ ...prev, email: e.target.value }))} - onFocus={() => handleInputFocus('email')} - onBlur={() => handleBlur('email')} + readOnly={true} error={errors.email} /> - {editingField === 'email' && ( - - - - )} = ({ setPersonalDetails((prev) => ({ ...prev, timezone: e.target.value }))} + onChange={(e) => { + setPersonalDetails((prev) => ({ ...prev, timezone: e.target.value })); + handleInputFocus('timezone'); + }} + onFocus={() => handleInputFocus('timezone')} + onBlur={() => handleBlur('timezone')} options={TIMEZONE_OPTIONS.map((option) => ({ value: option.value, label: `${option.label} • ${new Date().toLocaleTimeString('en-US', { timeZone: - option.value === 'Eastern Standard Time (EST)' - ? 'America/New_York' - : option.value === 'Pacific Standard Time (PST)' - ? 'America/Los_Angeles' - : option.value === 'Central Standard Time (CST)' - ? 'America/Chicago' - : option.value === 'Mountain Standard Time (MST)' - ? 'America/Denver' - : 'America/New_York', + option.value === 'Newfoundland Standard Time (NST)' + ? 'America/St_Johns' + : option.value === 'Atlantic Standard Time (AST)' + ? 'America/Halifax' + : option.value === 'Eastern Standard Time (EST)' + ? 'America/New_York' + : option.value === 'Pacific Standard Time (PST)' + ? 'America/Los_Angeles' + : option.value === 'Central Standard Time (CST)' + ? 'America/Chicago' + : option.value === 'Mountain Standard Time (MST)' + ? 'America/Denver' + : 'America/New_York', hour12: true, hour: 'numeric', minute: '2-digit', })}`, }))} /> + {editingField === 'timezone' && ( + + + + )} + { + setPersonalDetails((prev) => ({ ...prev, preferredLanguage: e.target.value })); + handleInputFocus('preferredLanguage'); + }} + onFocus={() => handleInputFocus('preferredLanguage')} + onBlur={() => handleBlur('preferredLanguage')} + options={[ + { value: 'en', label: 'English' }, + { value: 'fr', label: 'Français' }, + ]} + /> + {editingField === 'preferredLanguage' && ( + + + + )} - - setPersonalDetails((prev) => ({ ...prev, overview: e.target.value }))} - isTextarea={true} - rows={2} - helperText="Explain your story! Participants will be able to learn more about you." - onFocus={() => handleInputFocus('overview')} - /> - {editingField === 'overview' && ( - - - - )} - + {/* Overview section - only for volunteers */} + {isVolunteer && ( + + setPersonalDetails((prev) => ({ ...prev, overview: e.target.value }))} + isTextarea={true} + rows={2} + helperText="Explain your story! Participants will be able to learn more about you." + onFocus={() => handleInputFocus('overview')} + /> + {editingField === 'overview' && ( + + + + )} + + )} {/* Loved One Section */} {lovedOneDetails && ( @@ -332,7 +393,7 @@ const PersonalDetails: React.FC = ({ setLovedOneDetails && @@ -343,7 +404,7 @@ const PersonalDetails: React.FC = ({ onFocus={() => handleInputFocus('lovedOneBirthday')} onBlur={() => handleBlur('lovedOneBirthday')} error={errors.lovedOneBirthday} - placeholder="DD/MM/YYYY" + placeholder="Age" icon={} /> {editingField === 'lovedOneBirthday' && ( @@ -360,7 +421,7 @@ const PersonalDetails: React.FC = ({ fontSize="0.875rem" _hover={{ bg: '#044d52' }} _active={{ bg: '#033e42' }} - isDisabled={saving} + disabled={saving} > {saving ? 'Saving...' : 'Save'} diff --git a/frontend/src/components/dashboard/ProfileCard.tsx b/frontend/src/components/dashboard/ProfileCard.tsx index 1d23f680..6e8773ed 100644 --- a/frontend/src/components/dashboard/ProfileCard.tsx +++ b/frontend/src/components/dashboard/ProfileCard.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Box, Text, VStack, HStack, Button } from '@chakra-ui/react'; import { Avatar } from '@/components/ui/avatar'; -import Badge from './badge'; +import Badge from './Badge'; import { COLORS } from '@/constants/form'; interface ProfileCardProps { diff --git a/frontend/src/components/dashboard/ProfileDropdown.tsx b/frontend/src/components/dashboard/ProfileDropdown.tsx index c4da0207..2dc5e754 100644 --- a/frontend/src/components/dashboard/ProfileDropdown.tsx +++ b/frontend/src/components/dashboard/ProfileDropdown.tsx @@ -9,6 +9,8 @@ interface ProfileDropdownProps { options: readonly { readonly value: string; readonly label: string }[]; flex?: string; icon?: React.ReactNode; + onFocus?: () => void; + onBlur?: () => void; } const ProfileDropdown: React.FC = ({ @@ -18,6 +20,8 @@ const ProfileDropdown: React.FC = ({ options, flex = '1', icon, + onFocus, + onBlur, }) => { const styledLabel = ( @@ -72,10 +76,12 @@ const ProfileDropdown: React.FC = ({ onFocus={(e) => { e.target.style.borderColor = '#319795'; e.target.style.boxShadow = '0 0 0 2px rgba(49, 151, 149, 0.2)'; + onFocus?.(); }} onBlur={(e) => { e.target.style.borderColor = '#D5D7DA'; e.target.style.boxShadow = 'none'; + onBlur?.(); }} > {options.map((option) => ( diff --git a/frontend/src/components/dashboard/ProfileTextInput.tsx b/frontend/src/components/dashboard/ProfileTextInput.tsx index eaddd413..0a316572 100644 --- a/frontend/src/components/dashboard/ProfileTextInput.tsx +++ b/frontend/src/components/dashboard/ProfileTextInput.tsx @@ -16,6 +16,7 @@ interface ProfileTextInputProps { error?: string; onBlur?: () => void; icon?: React.ReactNode; + readOnly?: boolean; } const ProfileTextInput: React.FC = ({ @@ -31,6 +32,7 @@ const ProfileTextInput: React.FC = ({ error, onBlur, icon, + readOnly = false, }) => { const styledLabel = ( @@ -71,7 +73,7 @@ const ProfileTextInput: React.FC = ({ ); const inputStyles = { - background: 'white', + background: readOnly ? '#F6F6F6' : 'white', borderColor: error ? '#EF4444' : '#D5D7DA', fontFamily: "'Open Sans', sans-serif", border: `1px solid ${error ? '#EF4444' : '#D5D7DA'}`, @@ -84,10 +86,11 @@ const ProfileTextInput: React.FC = ({ fontWeight: 400, lineHeight: '24px', letterSpacing: '0%', - color: '#181D27', + color: readOnly ? '#9E9E9E' : '#181D27', width: '100%', outline: 'none', transition: 'border-color 0.2s ease, box-shadow 0.2s ease', + cursor: readOnly ? 'not-allowed' : 'text', }; if (isTextarea) { @@ -132,21 +135,26 @@ const ProfileTextInput: React.FC = ({ value={value} onChange={onChange} placeholder={placeholder} + readOnly={readOnly} style={{ ...inputStyles, height: '44px', }} onFocus={(e) => { - e.target.style.borderColor = error ? '#EF4444' : '#319795'; - e.target.style.boxShadow = error - ? '0 0 0 2px rgba(239, 68, 68, 0.2)' - : '0 0 0 2px rgba(49, 151, 149, 0.2)'; - onFocus?.(); + if (!readOnly) { + e.target.style.borderColor = error ? '#EF4444' : '#319795'; + e.target.style.boxShadow = error + ? '0 0 0 2px rgba(239, 68, 68, 0.2)' + : '0 0 0 2px rgba(49, 151, 149, 0.2)'; + onFocus?.(); + } }} onBlur={(e) => { - e.target.style.borderColor = error ? '#EF4444' : '#D5D7DA'; - e.target.style.boxShadow = 'none'; - onBlur?.(); + if (!readOnly) { + e.target.style.borderColor = error ? '#EF4444' : '#D5D7DA'; + e.target.style.boxShadow = 'none'; + onBlur?.(); + } }} /> {error && ( diff --git a/frontend/src/components/dashboard/ReadOnlyDiagnosisField.tsx b/frontend/src/components/dashboard/ReadOnlyDiagnosisField.tsx new file mode 100644 index 00000000..0735ab86 --- /dev/null +++ b/frontend/src/components/dashboard/ReadOnlyDiagnosisField.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { Box, Text, Flex } from '@chakra-ui/react'; +import { FiHeart, FiInfo } from 'react-icons/fi'; +import { Tooltip } from '@/components/ui/tooltip'; +import { COLORS } from '@/constants/form'; + +interface ReadOnlyDiagnosisFieldProps { + label: string; + value: string; + showHeartIcon?: boolean; + fullWidth?: boolean; +} + +const ReadOnlyDiagnosisField: React.FC = ({ + label, + value, + showHeartIcon = false, + fullWidth = false, +}) => ( + + {showHeartIcon ? ( + + + + {label} + + + ) : ( + + {label} + + )} + + + {value || 'Not provided'} + + + + + + +); + +export default ReadOnlyDiagnosisField; diff --git a/frontend/src/components/dashboard/TimeScheduler.tsx b/frontend/src/components/dashboard/TimeScheduler.tsx index 619700be..885c0da6 100644 --- a/frontend/src/components/dashboard/TimeScheduler.tsx +++ b/frontend/src/components/dashboard/TimeScheduler.tsx @@ -11,6 +11,8 @@ const TimeScheduler: React.FC = ({ onTimeSlotsChange, initialTimeSlots = [], readOnly = false, + visibleDays, + selectedDaysDates, }) => { const [selectedTimeSlots, setSelectedTimeSlots] = useState(initialTimeSlots); const [isDragging, setIsDragging] = useState(false); @@ -162,6 +164,34 @@ const TimeScheduler: React.FC = ({ return dayOrder.indexOf(a) - dayOrder.indexOf(b); }); + // Filter days if visibleDays prop is provided + const getVisibleDays = () => { + if (visibleDays && visibleDays.length > 0) { + // Map full day names to abbreviated day names + const dayNameMap: { [key: string]: string } = { + Monday: 'Mon', + Tuesday: 'Tues', + Wednesday: 'Wed', + Thursday: 'Thu', + Friday: 'Fri', + Saturday: 'Sat', + Sunday: 'Sun', + }; + return visibleDays.map((fullDay) => dayNameMap[fullDay] || fullDay); + } + return days; + }; + + const getVisibleDaysFull = () => { + if (visibleDays && visibleDays.length > 0) { + return visibleDays; + } + return daysFull; + }; + + const filteredDays = getVisibleDays(); + const filteredDaysFull = getVisibleDaysFull(); + const renderScheduleGrid = () => ( {/* Header Row */} @@ -179,22 +209,32 @@ const TimeScheduler: React.FC = ({ > EST - {days.map((day) => ( - - {day} - - ))} + {filteredDays.map((day, index) => { + // Format day header with date if we have selected days dates + const dayHeader = + visibleDays && + visibleDays.length > 0 && + selectedDaysDates && + selectedDaysDates.length > index + ? `${day}, ${selectedDaysDates[index].toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}` + : day; + return ( + + {dayHeader} + + ); + })} {showAvailability && } @@ -220,7 +260,7 @@ const TimeScheduler: React.FC = ({ {/* Day Cells */} - {daysFull.map((dayFull, dayIndex) => ( + {filteredDaysFull.map((dayFull, dayIndex) => ( = router.push(path); }; - return ( - - {/* Sidebar positioned with equal gaps */} - - {/* Logo */} - - Leukemia & Lymphoma Society of Canada - + const handleSignOut = async () => { + await logout(); + }; - {/* Navigation */} - - {navigationItems.map((item) => ( - - ))} - - + Leukemia & Lymphoma Society of Canada + + + {/* Navigation */} + + {navigationItems.map((item) => ( + + ))} - {/* Profile Icon positioned at top right */} - { - e.preventDefault(); - e.stopPropagation(); - setIsEditProfileOpen(true); - }} - _hover={{ opacity: 0.8 }} - transition="opacity 0.2s" - > - - + {/* Sign Out */} + + + - {/* Main Content centered */} - - {children} - + {/* Main Content */} + + + + {/* Profile Icon positioned at top right */} + { + e.preventDefault(); + e.stopPropagation(); + setIsEditProfileOpen(true); + }} + _hover={{ opacity: 0.8 }} + transition="opacity 0.2s" + flexShrink={0} + ml={4} + > + + + + {children} + + + {/* Edit Profile Modal */} setIsEditProfileOpen(false)} /> diff --git a/frontend/src/components/dashboard/types.ts b/frontend/src/components/dashboard/types.ts index 8b10a0d5..6e789aa7 100644 --- a/frontend/src/components/dashboard/types.ts +++ b/frontend/src/components/dashboard/types.ts @@ -9,6 +9,8 @@ export interface TimeSchedulerProps { onTimeSlotsChange?: (timeSlots: TimeSlot[]) => void; initialTimeSlots?: TimeSlot[]; readOnly?: boolean; + visibleDays?: string[]; // Optional: filter to show only specific days (full day names like "Monday") + selectedDaysDates?: Date[]; // Optional: dates for selected days (for formatting headers) } export interface TimeSchedulerRef { diff --git a/frontend/src/components/intake/demographic-cancer-form.tsx b/frontend/src/components/intake/demographic-cancer-form.tsx index 120fb54d..5d35a62a 100644 --- a/frontend/src/components/intake/demographic-cancer-form.tsx +++ b/frontend/src/components/intake/demographic-cancer-form.tsx @@ -565,167 +565,172 @@ export function DemographicCancerForm({ - {/* Cancer Experience Section */} - - - Your Cancer Experience - + {/* Cancer Experience Section - Only show if user has blood cancer */} + {hasBloodCancer === 'yes' && ( + + + Your Cancer Experience + + + + {formType === 'volunteer' + ? 'This information can also be taken into account when matching you with a service user.' + : 'This information can also be taken into account when matching you with a volunteer.'} + + + + {/* Diagnosis and Date */} + + + ( + + )} + /> + - - {formType === 'volunteer' - ? 'This information can also be taken into account when matching you with a service user.' - : 'This information can also be taken into account when matching you with a volunteer.'} - + + ( + + + + )} + /> + + - - {/* Diagnosis and Date */} - - - ( - - )} - /> - + {/* Treatment and Experience Sections Side by Side */} + + {/* Treatment Section */} + + + Which of the following treatments have you done? + + + You can select a maximum of 2. + - - ( - - ( + - + )} + /> + {errors.treatments && ( + + {errors.treatments.message} + )} - /> - - - - {/* Treatment and Experience Sections Side by Side */} - - {/* Treatment Section */} - - - Which of the following treatments have you done? - - - You can select a maximum of 2. - + - ( - - )} - /> - {errors.treatments && ( - - {errors.treatments.message} + {/* Experience Section */} + + + Which of the following do you have experience with? + + + You can select a maximum of 5. - )} - - - {/* Experience Section */} - - - Which of the following do you have experience with? - - - You can select a maximum of 5. - - ( - + ( + + )} + /> + {errors.experiences && ( + + {errors.experiences.message} + )} - /> - {errors.experiences && ( - - {errors.experiences.message} - - )} - - - - + + + + + )} {/* Submit Button */} diff --git a/frontend/src/components/participant/AccountSettings.tsx b/frontend/src/components/participant/AccountSettings.tsx new file mode 100644 index 00000000..f866cc89 --- /dev/null +++ b/frontend/src/components/participant/AccountSettings.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { Box, Text, VStack, Button } from '@chakra-ui/react'; +import ProfileHeader from '@/components/dashboard/ProfileHeader'; + +const AccountSettings: React.FC = () => { + const handleBecomeVolunteer = () => { + // TODO: Implement become volunteer functionality + alert( + 'Become volunteer functionality will be implemented soon. Please contact support for assistance.', + ); + }; + + const handleOptOut = () => { + // TODO: Implement opt-out functionality + alert('Opt-out functionality will be implemented soon. Please contact support for assistance.'); + }; + + return ( + + Account settings + + + {/* Preferred Language - This is now in Personal Details, but keeping structure for future fields */} + + {/* Becoming a Volunteer */} + + + Becoming a Volunteer + + + Complete the volunteer application to express your interest and confirm these details + are correct. Once submitted, we'll follow up by email with next steps. + + + + + {/* Opt Out */} + + + Opt Out of the First Connections Program + + + Your experience is important to us. By opting out you are removing yourself from the + matching algorithm and cannot be connected with a potential volunteer. When you are + ready to participate again, please sign back in and click the Opt In. You do not need to + re-register or create a new profile. + + + + + + ); +}; + +export default AccountSettings; diff --git a/frontend/src/components/participant/CancelCallConfirmationModal.tsx b/frontend/src/components/participant/CancelCallConfirmationModal.tsx new file mode 100644 index 00000000..47ac5d69 --- /dev/null +++ b/frontend/src/components/participant/CancelCallConfirmationModal.tsx @@ -0,0 +1,133 @@ +import { Box, Button, Flex, Text, VStack } from '@chakra-ui/react'; +import { Icon } from '@chakra-ui/react'; +import { FiAlertCircle } from 'react-icons/fi'; + +interface CancelCallConfirmationModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + isCancelling?: boolean; +} + +export function CancelCallConfirmationModal({ + isOpen, + onClose, + onConfirm, + isCancelling = false, +}: CancelCallConfirmationModalProps) { + if (!isOpen) { + return null; + } + + return ( + + + + {/* Warning Icon */} + + + + + {/* Text Content */} + + + Are you sure you want to cancel your call? + + + You can request new matches if you'd like to connect with other volunteers. + + + + {/* Action Buttons */} + + + + + + + + ); +} diff --git a/frontend/src/components/participant/CancelCallSuccessModal.tsx b/frontend/src/components/participant/CancelCallSuccessModal.tsx new file mode 100644 index 00000000..f1c4e0e0 --- /dev/null +++ b/frontend/src/components/participant/CancelCallSuccessModal.tsx @@ -0,0 +1,105 @@ +import { Box, Button, Flex, Text, VStack } from '@chakra-ui/react'; +import { Icon } from '@chakra-ui/react'; +import { FiCheckCircle } from 'react-icons/fi'; + +interface CancelCallSuccessModalProps { + isOpen: boolean; + onClose: () => void; +} + +export function CancelCallSuccessModal({ isOpen, onClose }: CancelCallSuccessModalProps) { + if (!isOpen) { + return null; + } + + return ( + + + + {/* Success Icon */} + + + + + {/* Text Content */} + + + Your call is cancelled + + + We've notified the participant about the cancellation. + + + + {/* Action Button */} + + + + + + + ); +} diff --git a/frontend/src/components/participant/ConfirmedMatchCard.tsx b/frontend/src/components/participant/ConfirmedMatchCard.tsx index 673a61c5..16a48c9e 100644 --- a/frontend/src/components/participant/ConfirmedMatchCard.tsx +++ b/frontend/src/components/participant/ConfirmedMatchCard.tsx @@ -1,6 +1,8 @@ -import { Box, Button, Flex, Text, VStack } from '@chakra-ui/react'; +import { Box, Button, Flex, Text, VStack, HStack } from '@chakra-ui/react'; import { Match } from '@/types/matchTypes'; import { formatDateRelative, formatDateShort, formatTime } from '@/utils/dateUtils'; +import { Avatar } from '@/components/ui/avatar'; +import Badge from '@/components/dashboard/Badge'; interface ConfirmedMatchCardProps { match: Match; @@ -23,13 +25,6 @@ export function ConfirmedMatchCard({ const dateShort = formatDateShort(chosenTimeBlock.startTime); const timeFormatted = formatTime(chosenTimeBlock.startTime); - // Get initials from first and last name - const getInitials = () => { - const first = volunteer.firstName?.[0] || ''; - const last = volunteer.lastName?.[0] || ''; - return `${first}${last}`.toUpperCase(); - }; - // Format pronouns for display const pronounsText = volunteer.pronouns && volunteer.pronouns.length > 0 ? volunteer.pronouns.join('/') : null; @@ -77,171 +72,165 @@ export function ConfirmedMatchCard({ flex={1} bg="white" border="1px solid" - borderColor="#E2E8F0" - borderRadius="7px" - p={6} - boxShadow="0px 1px 3px 0px rgba(0, 0, 0, 0.1), 0px 1px 2px 0px rgba(0, 0, 0, 0.06)" + borderColor="#D5D7DA" + borderRadius="8px" + p={7} + boxShadow="0 1px 2px 0 rgba(0, 0, 0, 0.05)" > - + {/* Volunteer Info */} - + {/* Avatar */} - - - {getInitials()} - - + {/* Name, pronouns, and info badges */} - + {/* Name and pronouns */} - - + + {volunteer.firstName} {volunteer.lastName} {pronounsText && ( - + {pronounsText} )} - + {/* Info badges */} - + {typeof volunteer.age === 'number' && ( - - - 👤 Current Age: {volunteer.age} - - + Current Age: {volunteer.age} + )} - - - 🕐 Time Zone: {volunteerTimezone} - - + Time Zone: {volunteerTimezone} + {volunteer.diagnosis && ( - - - 🎗️ {volunteer.diagnosis} - - + {volunteer.diagnosis} + )} - + - + {/* Overview Section */} {volunteer.overview && ( - - + + Overview - + {volunteer.overview} )} {/* Treatment Information Section */} - {volunteer.treatments.length > 0 && ( - - + {volunteer.treatments && volunteer.treatments.length > 0 && ( + + Treatment Information - - {volunteer.treatments.map((treatment) => ( - - - {treatment} - - + + {volunteer.treatments.map((treatment: string, index: number) => ( + + {treatment} + ))} - + )} {/* Experience Information Section */} - {volunteer.experiences.length > 0 && ( - - + {volunteer.experiences && volunteer.experiences.length > 0 && ( + + Experience Information - - {volunteer.experiences.map((experience) => ( - - - {experience} - - + + {volunteer.experiences.map((experience: string, index: number) => ( + + {experience} + ))} - + )} {/* Action Buttons */} - + {onCancelCall && ( + ))} - {/* Sign Out - in same panel */} - - - Sign Out - - - + {/* Sign Out */} + ); diff --git a/frontend/src/components/participant/DaySelectionCalendar.tsx b/frontend/src/components/participant/DaySelectionCalendar.tsx new file mode 100644 index 00000000..819e7746 --- /dev/null +++ b/frontend/src/components/participant/DaySelectionCalendar.tsx @@ -0,0 +1,264 @@ +import React from 'react'; +import { Box, Flex, Text, VStack } from '@chakra-ui/react'; + +interface DaySelectionCalendarProps { + selectedDays: Date[]; + onDaysChange: (days: Date[]) => void; + maxDays?: number; // Maximum number of days to allow selection (default: 8) +} + +const DAYS_OF_WEEK = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; +const MONTHS = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +]; + +export function DaySelectionCalendar({ + selectedDays, + onDaysChange, + maxDays = 7, +}: DaySelectionCalendarProps) { + // Get the next 7 days starting from tomorrow + const getAvailableDays = (): Date[] => { + const days: Date[] = []; + const today = new Date(); + today.setHours(0, 0, 0, 0); // Reset to start of day + const tomorrow = new Date(today); + tomorrow.setDate(today.getDate() + 1); // Start from tomorrow + + for (let i = 0; i < maxDays; i++) { + const date = new Date(tomorrow); + date.setDate(tomorrow.getDate() + i); + days.push(date); + } + return days; + }; + + const availableDays = getAvailableDays(); + + const isDaySelected = (date: Date): boolean => { + return selectedDays.some( + (selected) => + selected.getDate() === date.getDate() && + selected.getMonth() === date.getMonth() && + selected.getFullYear() === date.getFullYear(), + ); + }; + + const toggleDay = (date: Date) => { + const isSelected = isDaySelected(date); + if (isSelected) { + onDaysChange(selectedDays.filter((d) => d.getTime() !== date.getTime())); + } else { + if (selectedDays.length < maxDays) { + onDaysChange([...selectedDays, date].sort((a, b) => a.getTime() - b.getTime())); + } + } + }; + + const formatDayLabel = (date: Date): string => { + return date.getDate().toString(); + }; + + const formatMonthYear = (): string => { + const firstDay = availableDays[0]; + const lastDay = availableDays[availableDays.length - 1]; + const firstMonth = MONTHS[firstDay.getMonth()]; + const firstYear = firstDay.getFullYear(); + const lastMonth = MONTHS[lastDay.getMonth()]; + const lastYear = lastDay.getFullYear(); + + if (firstMonth === lastMonth && firstYear === lastYear) { + return `${firstMonth} ${firstYear}`; + } + return `${firstMonth} ${firstYear} - ${lastMonth} ${lastYear}`; + }; + + // Group days by calendar week (rows) + const groupByWeek = (): Date[][] => { + const weeks: Date[][] = []; + let currentWeek: Date[] = []; + + availableDays.forEach((date) => { + const dayOfWeek = date.getDay(); + + // If it's Sunday and we have dates in currentWeek, start a new week + if (dayOfWeek === 0 && currentWeek.length > 0) { + weeks.push(currentWeek); + currentWeek = []; + } + + currentWeek.push(date); + }); + + // Add the last week if it has dates + if (currentWeek.length > 0) { + weeks.push(currentWeek); + } + + return weeks; + }; + + const weeks = groupByWeek(); + + // Create a map of date to its position in the week for quick lookup + const dateToWeekPosition: { [key: string]: { weekIndex: number; dayIndex: number } } = {}; + weeks.forEach((week, weekIndex) => { + week.forEach((date, dayIndex) => { + const dateKey = date.toISOString().split('T')[0]; + dateToWeekPosition[dateKey] = { weekIndex, dayIndex }; + }); + }); + + return ( + + {/* Month/Year Header */} + + {formatMonthYear()} + + + {/* Calendar Grid */} + + {/* Day of week headers */} + + {DAYS_OF_WEEK.map((dayName) => ( + + + {dayName} + + + ))} + + + {/* Calendar rows (weeks) */} + + {weeks.map((week, weekIndex) => ( + + {DAYS_OF_WEEK.map((dayName, dayIndex) => { + // Find the date for this day of week in this week + const dateForCell = week.find((date) => date.getDay() === dayIndex); + + if (!dateForCell) { + // Empty cell - might need to show next month indicator + const isFirstWeek = weekIndex === 0; + const shouldShowNextMonth = + isFirstWeek && + dayIndex === 0 && + availableDays[availableDays.length - 1].getDate() > 7; + + return ( + + {shouldShowNextMonth && ( + + {MONTHS[availableDays[availableDays.length - 1].getMonth()].substring( + 0, + 3, + )} + + )} + + ); + } + + const selected = isDaySelected(dateForCell); + // Create a unique key using the date's ISO string to ensure uniqueness + const dateKey = dateForCell.toISOString().split('T')[0]; + return ( + { + // Create a new Date object to ensure we're working with a fresh instance + const dateToToggle = new Date(dateForCell); + toggleDay(dateToToggle); + }} + _hover={{ + opacity: 1, + }} + display="flex" + alignItems="center" + justifyContent="center" + minW="37px" + minH="52px" + > + + {formatDayLabel(dateForCell)} + + {selected && ( + + )} + + ); + })} + + ))} + + + {/* Divider line */} + + + + ); +} diff --git a/frontend/src/components/participant/ParticipantEditProfileModal.tsx b/frontend/src/components/participant/ParticipantEditProfileModal.tsx new file mode 100644 index 00000000..f8f42942 --- /dev/null +++ b/frontend/src/components/participant/ParticipantEditProfileModal.tsx @@ -0,0 +1,363 @@ +import React, { useState, useEffect } from 'react'; +import { Box, Heading, Text, VStack, HStack, Image } from '@chakra-ui/react'; +import PersonalDetails from '@/components/dashboard/PersonalDetails'; +import BloodCancerExperience from '@/components/dashboard/BloodCancerExperience'; +import AccountSettings from '@/components/participant/AccountSettings'; +import { useAuth } from '@/contexts/AuthContext'; +import { getUserData, updateUserData } from '@/APIClients/userDataAPIClient'; +import { Language } from '@/types/authTypes'; + +interface ParticipantEditProfileModalProps { + isOpen: boolean; + onClose: () => void; +} + +const ParticipantEditProfileModal: React.FC = ({ + isOpen, + onClose, +}) => { + const { user, loading: authLoading } = useAuth(); + const [loading, setLoading] = useState(true); + + // Personal details state for profile + const [personalDetails, setPersonalDetails] = useState({ + name: '', + email: '', + birthday: '', + gender: '', + pronouns: '', + timezone: 'Eastern Standard Time (EST)', + overview: '', + preferredLanguage: 'en' as Language, + }); + + // Blood cancer experience state for profile + const [cancerExperience, setCancerExperience] = useState({ + diagnosis: [] as string[], + dateOfDiagnosis: '', + treatments: [] as string[], + experiences: [] as string[], + }); + + // Loved one details state + const [lovedOneDetails, setLovedOneDetails] = useState<{ + birthday: string; + gender: string; + } | null>(null); + + // Loved one cancer experience state + const [lovedOneCancerExperience, setLovedOneCancerExperience] = useState<{ + diagnosis: string; + dateOfDiagnosis: string; + treatments: string[]; + experiences: string[]; + } | null>(null); + + // Track if user has blood cancer + const [hasBloodCancer, setHasBloodCancer] = useState(false); + + // Load user data from API + useEffect(() => { + if (!isOpen) return; + + // Wait for auth to be ready + if (authLoading) return; + + const loadUserData = async () => { + setLoading(true); + try { + const userData = await getUserData(); + + if (userData) { + // Format date from ISO (YYYY-MM-DD) to display format (DD/MM/YYYY) + const formatDate = (isoDate: string | undefined | null): string => { + if (!isoDate) { + return 'Not provided'; + } + try { + const date = new Date(isoDate); + const day = date.getDate().toString().padStart(2, '0'); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const year = date.getFullYear(); + const formatted = `${day}/${month}/${year}`; + return formatted; + } catch (error) { + console.error('formatDate error:', error); + return 'Not provided'; + } + }; + + // Determine if user has blood cancer + const hasBloodCancerValue = userData.hasBloodCancer; + const caringForSomeoneValue = userData.caringForSomeone; + const userHasBloodCancer = + hasBloodCancerValue === true || + (hasBloodCancerValue !== undefined && + hasBloodCancerValue !== null && + String(hasBloodCancerValue).toLowerCase() === 'yes'); + const userCaringForSomeone = + caringForSomeoneValue === true || + (caringForSomeoneValue !== undefined && + caringForSomeoneValue !== null && + String(caringForSomeoneValue).toLowerCase() === 'yes'); + + setHasBloodCancer(userHasBloodCancer); + + // Populate personal details (using camelCase after axios conversion) + const formattedBirthday = formatDate(userData.dateOfBirth); + const formattedPronouns = userData.pronouns?.join(', ') || 'Not provided'; + const userLanguage = user?.language || 'en'; + + setPersonalDetails({ + name: + `${userData.firstName || ''} ${userData.lastName || ''}`.trim() || + user?.email || + 'Not provided', + email: userData.email || user?.email || 'Not provided', + birthday: formattedBirthday, + gender: userData.genderIdentity || 'Not provided', + pronouns: formattedPronouns, + timezone: 'Eastern Standard Time (EST)', // TODO: Add timezone field to backend + overview: 'Not provided', // Participants don't have overview + preferredLanguage: userLanguage === 'fr' ? Language.FRENCH : Language.ENGLISH, + }); + + // Populate cancer experience (only if user has cancer) + if (userHasBloodCancer) { + setCancerExperience({ + diagnosis: userData.diagnosis ? [userData.diagnosis] : [], + dateOfDiagnosis: userData.dateOfDiagnosis || '', + treatments: userData.treatments || [], + experiences: userData.experiences || [], + }); + } + + // Populate loved one details if caring for someone + if (userCaringForSomeone) { + const lovedOneBirthday = userData.lovedOneAge || 'Not provided'; + const lovedOneGender = userData.lovedOneGenderIdentity || 'Not provided'; + + setLovedOneDetails({ + birthday: lovedOneBirthday, + gender: lovedOneGender, + }); + + // Populate loved one cancer experience + setLovedOneCancerExperience({ + diagnosis: userData.lovedOneDiagnosis || 'Not provided', + dateOfDiagnosis: userData.lovedOneDateOfDiagnosis || 'Not provided', + treatments: userData.lovedOneTreatments || [], + experiences: userData.lovedOneExperiences || [], + }); + } + } + } catch (error) { + console.error('❌ Error loading user data:', error); + } finally { + setLoading(false); + } + }; + + loadUserData(); + }, [isOpen, authLoading, user]); + + // Save handler for PersonalDetails + const handleSavePersonalDetail = async (field: string, value: string) => { + const updateData: Partial> = {}; + + // Map frontend field names to backend snake_case (axios will convert to camelCase on send) + if (field === 'name') { + const [firstName, ...lastNameParts] = value.split(' '); + updateData.first_name = firstName || ''; + updateData.last_name = lastNameParts.join(' ') || ''; + } else if (field === 'birthday') { + // Convert DD/MM/YYYY to YYYY-MM-DD for backend + try { + const [day, month, year] = value.split('/'); + updateData.date_of_birth = `${year}-${month}-${day}`; + } catch { + updateData.date_of_birth = value; + } + } else if (field === 'gender') { + updateData.gender_identity = value; + } else if (field === 'pronouns') { + updateData.pronouns = value.split(',').map((p) => p.trim()); + } else if (field === 'timezone') { + updateData.timezone = value; + } else if (field === 'preferredLanguage') { + updateData.language = value; // Backend expects 'language' field + } else if (field === 'lovedOneBirthday') { + // Loved one's age/birthday - store as is since backend expects lovedOneAge as string + updateData.loved_one_age = value; + } + + const result = await updateUserData(updateData); + if (!result) { + throw new Error('Failed to update'); + } + }; + + // Save handler for treatments + const handleSaveTreatments = async () => { + const result = await updateUserData({ + treatments: cancerExperience.treatments, + }); + if (!result) { + alert('Failed to save treatments'); + } + }; + + // Save handler for experiences + const handleSaveExperiences = async () => { + const result = await updateUserData({ + experiences: cancerExperience.experiences, + }); + if (!result) { + alert('Failed to save experiences'); + } + }; + + // Save handler for loved one treatments + const handleSaveLovedOneTreatments = async () => { + if (!lovedOneCancerExperience) return; + const result = await updateUserData({ + lovedOneTreatments: lovedOneCancerExperience.treatments, + }); + if (!result) { + alert('Failed to save loved one treatments'); + } + }; + + // Save handler for loved one experiences + const handleSaveLovedOneExperiences = async () => { + if (!lovedOneCancerExperience) return; + const result = await updateUserData({ + lovedOneExperiences: lovedOneCancerExperience.experiences, + }); + if (!result) { + alert('Failed to save loved one experiences'); + } + }; + + if (!isOpen) return null; + + // Show loading while auth initializes or data loads + if (authLoading || loading) { + return ( + + + + Loading... + + + + ); + } + + return ( + + + + + Back + + Back + + + + + Edit Profile + + + + { + if (typeof updater === 'function') { + setPersonalDetails((prev) => { + const updated = updater({ + ...prev, + preferredLanguage: prev.preferredLanguage === Language.FRENCH ? 'fr' : 'en', + }); + return { + ...updated, + preferredLanguage: (updated.preferredLanguage === 'fr' + ? Language.FRENCH + : Language.ENGLISH) as Language, + }; + }); + } else { + setPersonalDetails({ + ...updater, + preferredLanguage: (updater.preferredLanguage === 'fr' + ? Language.FRENCH + : Language.ENGLISH) as Language, + }); + } + }} + lovedOneDetails={lovedOneDetails} + setLovedOneDetails={setLovedOneDetails} + onSave={handleSavePersonalDetail} + isVolunteer={false} + /> + + + + + + + ); +}; + +export default ParticipantEditProfileModal; diff --git a/frontend/src/components/participant/RequestNewMatchesModal.tsx b/frontend/src/components/participant/RequestNewMatchesModal.tsx index 974fcf2d..3b0a9577 100644 --- a/frontend/src/components/participant/RequestNewMatchesModal.tsx +++ b/frontend/src/components/participant/RequestNewMatchesModal.tsx @@ -134,6 +134,7 @@ export function RequestNewMatchesModal({ onClick={handleSubmit} loading={isSubmitting} loadingText="Submitting..." + disabled={isSubmitting} px={6} > Submit Request diff --git a/frontend/src/components/participant/RequestNewTimesSuccessModal.tsx b/frontend/src/components/participant/RequestNewTimesSuccessModal.tsx new file mode 100644 index 00000000..20a34c6c --- /dev/null +++ b/frontend/src/components/participant/RequestNewTimesSuccessModal.tsx @@ -0,0 +1,106 @@ +import { Box, Button, Flex, Text, VStack } from '@chakra-ui/react'; +import { Icon } from '@chakra-ui/react'; +import { FiCheckCircle } from 'react-icons/fi'; + +interface RequestNewTimesSuccessModalProps { + isOpen: boolean; + onClose: () => void; +} + +export function RequestNewTimesSuccessModal({ isOpen, onClose }: RequestNewTimesSuccessModalProps) { + if (!isOpen) { + return null; + } + + return ( + + + + {/* Success Icon */} + + + + + {/* Text Content */} + + + Success! + + + Your new availability has been sent. Please wait until your volunteer confirms a new + time. If no dates work for your volunteer, then you will be matched with a new one. + + + + {/* Action Button */} + + + + + + + ); +} diff --git a/frontend/src/components/participant/ViewContactDetailsModal.tsx b/frontend/src/components/participant/ViewContactDetailsModal.tsx new file mode 100644 index 00000000..0df3ccaa --- /dev/null +++ b/frontend/src/components/participant/ViewContactDetailsModal.tsx @@ -0,0 +1,166 @@ +import { Box, Button, Flex, Text, VStack, HStack } from '@chakra-ui/react'; +import { Match } from '@/types/matchTypes'; +import Image from 'next/image'; + +interface ViewContactDetailsModalProps { + isOpen: boolean; + match: Match | null; + onClose: () => void; +} + +export function ViewContactDetailsModal({ isOpen, match, onClose }: ViewContactDetailsModalProps) { + if (!isOpen || !match) { + return null; + } + + const { volunteer } = match; + const volunteerName = `${volunteer.firstName || ''} ${volunteer.lastName || ''}`.trim(); + const displayName = + volunteer.firstName && volunteer.lastName + ? `${volunteer.firstName} ${volunteer.lastName[0]}.` + : volunteerName; + const phoneNumber = volunteer.phone || 'Not available'; + + return ( + + + + {/* Phone Icon */} + + Phone + + + {/* Content */} + + {/* Text and supporting text */} + + + Your call is set! + + + You will get a call from your volunteer at your scheduled time. + + + + {/* Contact Details */} + + {/* Name */} + + + Name + + + {displayName} + + + + {/* Phone Number */} + + + Phone Number + + + {phoneNumber} + + + + + {/* Action Button */} + + + + + + + + ); +} diff --git a/frontend/src/components/participant/VolunteerCard.tsx b/frontend/src/components/participant/VolunteerCard.tsx index 53ee47cc..6ab05631 100644 --- a/frontend/src/components/participant/VolunteerCard.tsx +++ b/frontend/src/components/participant/VolunteerCard.tsx @@ -1,5 +1,9 @@ -import { Box, Button, Flex, Text, VStack } from '@chakra-ui/react'; +import { Box, Button, HStack, Text, VStack } from '@chakra-ui/react'; import { Match } from '@/types/matchTypes'; +import { Avatar } from '@/components/ui/avatar'; +import Badge from '@/components/dashboard/Badge'; +import { COLORS } from '@/constants/form'; +import { FiLoader } from 'react-icons/fi'; interface VolunteerCardProps { match: Match; @@ -9,192 +13,205 @@ interface VolunteerCardProps { export function VolunteerCard({ match, onSchedule }: VolunteerCardProps) { const { volunteer } = match; - // Get initials from first and last name - const getInitials = () => { - const first = volunteer.firstName?.[0] || ''; - const last = volunteer.lastName?.[0] || ''; - return `${first}${last}`.toUpperCase(); - }; + // Format full name + const fullName = `${volunteer.firstName || ''} ${volunteer.lastName || ''}`.trim(); // Format pronouns for display const pronounsText = - volunteer.pronouns && volunteer.pronouns.length > 0 ? volunteer.pronouns.join('/') : null; + volunteer.pronouns && volunteer.pronouns.length > 0 ? volunteer.pronouns.join('/') : ''; - // Format timezone (remove underscores, capitalize) - const formatTimezone = (tz: string) => { - return tz.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()); - }; - - // Get volunteer timezone or use placeholder - const volunteerTimezone = volunteer.timezone ? formatTimezone(volunteer.timezone) : 'TBD'; + const isRequestingNewTimes = match.matchStatus === 'requesting_new_times'; return ( - - {/* Header with avatar, name, and info badges */} - - {/* Avatar */} - - - {getInitials()} + {/* Pending Badge - Top Right */} + {isRequestingNewTimes && ( + + + + + Pending - + + + )} + + + {/* Avatar */} + - {/* Name, pronouns, and info badges */} - - {/* Name and pronouns */} - - - {volunteer.firstName} {volunteer.lastName} + {/* Volunteer Info */} + + + + {fullName} {pronounsText && ( - + {pronounsText} )} - + - {/* Info badges */} - + {typeof volunteer.age === 'number' && ( - - - 👤 Current Age: {volunteer.age} - - + Current Age: {volunteer.age} + )} + {volunteer.timezone && ( + Time Zone: {volunteer.timezone} )} - - - 🕐 Time Zone: {volunteerTimezone} - - {volunteer.diagnosis && ( - - - 🎗️ {volunteer.diagnosis} - - + {volunteer.diagnosis} )} - + - + {/* Overview Section */} {volunteer.overview && ( - - + + Overview - + {volunteer.overview} )} - {/* Treatment Information Section */} - {volunteer.treatments.length > 0 && ( - - + {/* Treatment Information */} + {volunteer.treatments && volunteer.treatments.length > 0 && ( + + Treatment Information - - {volunteer.treatments.map((treatment) => ( - - - {treatment} - - + + {volunteer.treatments.map((treatment: string, index: number) => ( + + {treatment} + ))} - + )} - {/* Experience Information Section */} - {volunteer.experiences.length > 0 && ( - - + {/* Experience Information */} + {volunteer.experiences && volunteer.experiences.length > 0 && ( + + Experience Information - - {volunteer.experiences.map((experience) => ( - - - {experience} - - + + {volunteer.experiences.map((experience: string, index: number) => ( + + {experience} + ))} - + )} - - {/* Schedule call button */} - {onSchedule && ( - - - - )} + + {/* Schedule call button - Positioned at bottom right */} + {onSchedule && !isRequestingNewTimes && ( + + )} ); } diff --git a/frontend/src/components/shared/ContactForm.tsx b/frontend/src/components/shared/ContactForm.tsx new file mode 100644 index 00000000..fcc3a1ef --- /dev/null +++ b/frontend/src/components/shared/ContactForm.tsx @@ -0,0 +1,292 @@ +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/router'; +import { Box, Button, Grid, HStack, Input, Textarea, Text, VStack } from '@chakra-ui/react'; +import { Field } from '@/components/ui/field'; +import { InputGroup } from '@/components/ui/input-group'; +import { submitContactForm } from '@/APIClients/contactAPIClient'; +import { getUserData, UserDataResponse } from '@/APIClients/userDataAPIClient'; +import { ContactSuccessModal } from './ContactSuccessModal'; + +interface ContactFormProps { + redirectPath: string; // Where to redirect after success (e.g., '/participant/dashboard' or '/volunteer/dashboard') +} + +export function ContactForm({ redirectPath }: ContactFormProps) { + const router = useRouter(); + + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [message, setMessage] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [showSuccessModal, setShowSuccessModal] = useState(false); + const [error, setError] = useState(null); + + // Pre-fill user data on mount + useEffect(() => { + const loadUserData = async () => { + try { + const userData: UserDataResponse | null = await getUserData(); + if (userData) { + const fullName = [userData.firstName, userData.lastName].filter(Boolean).join(' '); + if (fullName) { + setName(fullName); + } + if (userData.email) { + setEmail(userData.email); + } + } + } catch (error) { + console.error('Error loading user data:', error); + // Continue without pre-filling if there's an error + } + }; + + loadUserData(); + }, []); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + // Validate form + if (!name.trim() || !email.trim() || !message.trim()) { + setError('Please fill in all fields'); + return; + } + + setError(null); + setIsLoading(true); + + try { + const response = await submitContactForm({ + name: name.trim(), + email: email.trim(), + message: message.trim(), + }); + + if (response.success) { + // Clear the message field + setMessage(''); + // Show success modal + setShowSuccessModal(true); + } else { + setError('Failed to send message. Please try again later.'); + } + } catch (err) { + console.error('Error submitting contact form:', err); + setError('An error occurred. Please try again.'); + } finally { + setIsLoading(false); + } + }; + + const handleCancel = () => { + router.push(redirectPath); + }; + + const handleSuccessModalClose = () => { + setShowSuccessModal(false); + router.push(redirectPath); + }; + + return ( + <> + + + {/* Header */} + + + Get in touch! + + + Any questions? Fill out this contact form and a staff member will get back to you as + soon as possible. + + + + {/* Error Message */} + {error && ( + + + {error} + + + )} + + {/* Form */} +
+ + {/* Personal details section */} + + + Personal details + + + + {/* Name field */} + + Name + + } + > + + + + + + {/* Email field */} + + Email Address + + } + > + + + + + + + + {/* Message section */} + + + Message + + +