From a22c09fd931a60397324e8af094a2edbe728b9de Mon Sep 17 00:00:00 2001 From: Tejashri Majage Date: Fri, 31 Jul 2026 12:43:24 +0530 Subject: [PATCH 1/2] PS-7215 : Proposed Change to the Complete Profile Banner (After center and batch assign) --- package-lock.json | 9 +- package.json | 2 +- src/Routes/Public/DashboardStack.js | 6 + src/Routes/SCPUser/SCPUserStack.js | 6 + src/Routes/Youthnet/YouthNetStack.js | 6 + .../CompleteProfileBanner.js | 102 +++++ src/components/CustomRadioCard/RadioButton.js | 15 +- .../CustomTextField/CustomTextField.js | 97 +++-- .../DropdownSelect/DropdownSelect.js | 33 +- src/context/locales/en.json | 4 + src/screens/Dashboard/Courses/Courses.js | 2 + .../Preference/SCPDashboard/SCPDashboard.js | 2 + .../Profile/CompleteProfileFormScreen.js | 318 ++++++++++++++++ src/screens/Profile/ProfileFormShared.js | 322 ++++++++++++++++ src/screens/Profile/ProfileUpdateForm.js | 352 +----------------- src/screens/Profile/ProfileUpdateScreen.js | 24 +- src/screens/YouthNet/L1Courses.js | 2 + src/utils/JsHelper/Helper.js | 140 ++++++- 18 files changed, 1015 insertions(+), 427 deletions(-) create mode 100644 src/components/CompleteProfileBanner/CompleteProfileBanner.js create mode 100644 src/screens/Profile/CompleteProfileFormScreen.js create mode 100644 src/screens/Profile/ProfileFormShared.js diff --git a/package-lock.json b/package-lock.json index b4e9cb1c..721388cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@react-native-async-storage/async-storage": "^1.24.0", "@react-native-clipboard/clipboard": "^1.14.2", "@react-native-community/cli": "^13.6.9", - "@react-native-community/datetimepicker": "github:react-native-community/datetimepicker", + "@react-native-community/datetimepicker": "8.2.0", "@react-native-community/netinfo": "^11.3.2", "@react-native-firebase/analytics": "^21.0.0", "@react-native-firebase/app": "^21.5.0", @@ -4824,14 +4824,15 @@ } }, "node_modules/@react-native-community/datetimepicker": { - "version": "9.1.0", - "resolved": "git+ssh://git@github.com/react-native-community/datetimepicker.git#81b90986646a3b43515b523367aea435730b04fe", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@react-native-community/datetimepicker/-/datetimepicker-8.2.0.tgz", + "integrity": "sha512-qrUPhiBvKGuG9Y+vOqsc56RPFcHa1SU2qbAMT0hfGkoFIj3FodE0VuPVrEa8fgy7kcD5NQmkZIKgHOBLV0+hWg==", "license": "MIT", "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { - "expo": ">=52.0.0", + "expo": ">=50.0.0", "react": "*", "react-native": "*", "react-native-windows": "*" diff --git a/package.json b/package.json index 1b25ed74..49f8adad 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "@react-native-async-storage/async-storage": "^1.24.0", "@react-native-clipboard/clipboard": "^1.14.2", "@react-native-community/cli": "^13.6.9", - "@react-native-community/datetimepicker": "github:react-native-community/datetimepicker", + "@react-native-community/datetimepicker": "8.2.0", "@react-native-community/netinfo": "^11.3.2", "@react-native-firebase/analytics": "^21.0.0", "@react-native-firebase/app": "^21.5.0", diff --git a/src/Routes/Public/DashboardStack.js b/src/Routes/Public/DashboardStack.js index 23196f2c..693d5ee5 100644 --- a/src/Routes/Public/DashboardStack.js +++ b/src/Routes/Public/DashboardStack.js @@ -4,6 +4,7 @@ import Courses from '../../screens/Dashboard/Courses/Courses'; import ViewAllContent from '../../screens/Dashboard/ViewAllContent'; import CourseContentList from '../../screens/Dashboard/Courses/CourseContentList'; import UnitList from '../../screens/Dashboard/Courses/UnitList'; +import CompleteProfileFormScreen from '../../screens/Profile/CompleteProfileFormScreen'; const Stack = createNativeStackNavigator(); @@ -28,6 +29,11 @@ const DashboardStack = ({ CopilotStopped, customProp = null }) => { component={ViewAllContent} options={{ lazy: true }} // Lazily load LoadingScreen /> + ); }; diff --git a/src/Routes/SCPUser/SCPUserStack.js b/src/Routes/SCPUser/SCPUserStack.js index cd9f7d45..31388d54 100644 --- a/src/Routes/SCPUser/SCPUserStack.js +++ b/src/Routes/SCPUser/SCPUserStack.js @@ -8,6 +8,7 @@ import FullAttendance from '../../screens/Dashboard/Calendar/FullAttendance'; import TimeTable from '../../screens/Dashboard/Calendar/TimeTable'; import PreviousClassMaterialFullView from '../../screens/Dashboard/Preference/SCPDashboard/PreviousClassMaterialFullView'; import SubjectDetails from '../../screens/Dashboard/Preference/SCPDashboard/SubjectDetails'; +import CompleteProfileFormScreen from '../../screens/Profile/CompleteProfileFormScreen'; //for deep link import CourseContentList from '@src/screens/Dashboard/Courses/CourseContentList'; @@ -58,6 +59,11 @@ const SCPUserStack = () => { component={SubjectDetails} options={{ lazy: true }} // Lazily load LoadingScreen /> + {/* //for deep link course */} { component={UnitList} options={{ lazy: true }} // Lazily load LoadingScreen /> + ); }; diff --git a/src/components/CompleteProfileBanner/CompleteProfileBanner.js b/src/components/CompleteProfileBanner/CompleteProfileBanner.js new file mode 100644 index 00000000..9858f174 --- /dev/null +++ b/src/components/CompleteProfileBanner/CompleteProfileBanner.js @@ -0,0 +1,102 @@ +import React, { useCallback, useState } from 'react'; +import { StyleSheet, TouchableOpacity, View } from 'react-native'; +import { useFocusEffect, useNavigation } from '@react-navigation/native'; +import GlobalText from '@components/GlobalText/GlobalText'; +import { useTranslation } from '@context/LanguageContext'; +import globalStyles from '../../utils/Helper/Style'; +import { + buildUserDetailsObject, + getDataFromStorage, + getMergedProfileSchema, + getMissingProfileFields, +} from '../../utils/JsHelper/Helper'; + +const CompleteProfileBanner = () => { + const { t } = useTranslation(); + const navigation = useNavigation(); + const [banner, setBanner] = useState({ + visible: false, + tenantId: null, + missingFields: [], + }); + + const checkProfileCompletion = useCallback(async () => { + try { + const tenantData = JSON.parse((await getDataFromStorage('tenantData')) || 'null'); + const tenantId = tenantData?.[0]?.tenantId; + if (!tenantId) { + setBanner({ visible: false, tenantId: null, missingFields: [] }); + return; + } + + const schema = await getMergedProfileSchema(tenantId); + const profileData = JSON.parse((await getDataFromStorage('profileData')) || 'null'); + const userDetails = buildUserDetailsObject(profileData, schema); + const { missingFields, isComplete } = getMissingProfileFields(schema, userDetails); + + setBanner({ visible: !isComplete, tenantId, missingFields }); + } catch { + // Fail closed - don't nag the user if we couldn't determine completeness. + setBanner({ visible: false, tenantId: null, missingFields: [] }); + } + }, []); + + useFocusEffect( + useCallback(() => { + checkProfileCompletion(); + }, [checkProfileCompletion]) + ); + + if (!banner.visible) { + return null; + } + + return ( + + + {t('complete_profile_banner_message')} + + + navigation.navigate('CompleteProfileForm', { + tenantId: banner.tenantId, + missingFields: banner.missingFields, + }) + } + > + {t('complete_profile_button')} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + backgroundColor: '#FCE7B8', + borderRadius: 16, + marginVertical: 15, + padding: 15, + }, + message: { + marginBottom: 10, + }, + button: { + alignSelf: 'center', + borderRadius: 20, + backgroundColor: '#F0A809', + paddingHorizontal: 14, + height: 36, + justifyContent: 'center', + alignItems: 'center', + }, + buttonText: { + textAlign: 'center', + fontWeight: '700', + fontFamily: 'Roboto-Black', + fontSize: 13, + color: '#1F1B13', + }, +}); + +export default CompleteProfileBanner; diff --git a/src/components/CustomRadioCard/RadioButton.js b/src/components/CustomRadioCard/RadioButton.js index 1350e7ed..bf74a1ef 100644 --- a/src/components/CustomRadioCard/RadioButton.js +++ b/src/components/CustomRadioCard/RadioButton.js @@ -4,7 +4,6 @@ import { StyleSheet, Text, View, - ScrollView, TouchableOpacity, Image, Dimensions, @@ -39,7 +38,7 @@ const RadioButton = ({ field, formData, handleValue, errors }) => { - + { {errors[field.name] && ( {errors[field.name]} )} - + ); @@ -88,13 +87,13 @@ const RadioButton = ({ field, formData, handleValue, errors }) => { const styles = StyleSheet.create({ card: { - backgroundColor: '#fff', - padding: 20, - marginVertical: 10, - marginHorizontal: 10, + backgroundColor: '#b51212', + padding:10, + marginVertical: 8, + marginHorizontal: 8, borderRadius: 8, elevation: 3, - width: '44%', + //width: '44%', }, radioContainer: { flexDirection: 'row', diff --git a/src/components/CustomTextField/CustomTextField.js b/src/components/CustomTextField/CustomTextField.js index 2c2ddfbc..adfc7e80 100644 --- a/src/components/CustomTextField/CustomTextField.js +++ b/src/components/CustomTextField/CustomTextField.js @@ -33,52 +33,51 @@ const CustomTextField = ({ }; return ( - handleValue(field.name, text.trim())} - secureTextEntry={secureTextEntry} - autoCapitalize={autoCapitalize} // Disable auto-capitalization - keyboardType={keyboardType} // Opens numeric keyboard by default - editable={editable} - /> - {text && ( - handleCopyLink(formData[field.name])} - > - - - )} - - + {t(field.label.toLowerCase())} + {!field?.isRequired && + !['guardian_name', 'guardian_relation'].includes(field.name) && + `(${t('optional')})`} + + + - {t(field.label.toLowerCase())} - {!field?.isRequired && - !['guardian_name', 'guardian_relation'].includes(field.name) && - `(${t('optional')})`} - + value={formData[field.name] || ''} + onChangeText={(text) => handleValue(field.name, text.trim())} + secureTextEntry={secureTextEntry} + autoCapitalize={autoCapitalize} // Disable auto-capitalization + keyboardType={keyboardType} // Opens numeric keyboard by default + editable={editable} + /> + {text && ( + handleCopyLink(formData[field.name])} + > + + + )} {errors[field.name] && ( @@ -86,8 +85,7 @@ const CustomTextField = ({ style={{ color: 'red', alignSelf: 'flex-start', - marginBottom: 10, - marginTop: -20, + marginTop: 4, fontFamily: 'Poppins-Regular', }} > @@ -131,17 +129,14 @@ const styles = StyleSheet.create({ fontSize: 16, fontFamily: 'Poppins-Regular', }, - overlap: { - top: -62, - left: 13, - // top: -76, - // left: -120, - backgroundColor: 'white', + inputRow: { + width: '100%', }, text: { color: '#4D4639', paddingLeft: 2, - fontFamily: 'Poppins-Regular', paddingRight: 2, + marginBottom: 6, + fontFamily: 'Poppins-Regular', }, }); diff --git a/src/components/DropdownSelect/DropdownSelect.js b/src/components/DropdownSelect/DropdownSelect.js index fbb060c3..b2e202a7 100644 --- a/src/components/DropdownSelect/DropdownSelect.js +++ b/src/components/DropdownSelect/DropdownSelect.js @@ -13,12 +13,19 @@ import { useTranslation } from '../../context/LanguageContext'; import GlobalText from '@components/GlobalText/GlobalText'; -const DropdownSelect = ({ field, errors, options, formData, handleValue }) => { +const DropdownSelect = ({ + field, + errors, + options, + formData, + handleValue, + editable = true, +}) => { const [isDropdownOpen, setIsDropdownOpen] = useState(false); const { t } = useTranslation(); const toggleDropdown = () => { - if (options && options.length > 0) { + if (editable && options && options.length > 0) { setIsDropdownOpen(!isDropdownOpen); } }; @@ -61,22 +68,24 @@ const DropdownSelect = ({ field, errors, options, formData, handleValue }) => { onPress={toggleDropdown} style={[ styles.dropdownButton, - { borderColor: errors[field.name] ? 'red' : '#DADADA' }, + { + borderColor: errors[field.name] ? 'red' : '#DADADA', + backgroundColor: editable ? 'white' : '#F5F5F5', + }, ]} > {labelArray.includes(field.label) ? ( - + {t(formData[field.name]?.label)} ) : ( - + {t(formData[field.name]?.label?.toLowerCase())} )} - {/* - {t(formData[field.name]?.label?.toLowerCase())} - */} - + {editable && ( + + )} {isDropdownOpen && ( @@ -115,7 +124,6 @@ const styles = StyleSheet.create({ marginBottom: 10, width: '95%', alignSelf: 'center', - top: -10, }, dropdownButton: { flexDirection: 'row', @@ -127,12 +135,9 @@ const styles = StyleSheet.create({ borderRadius: 5, }, label: { - // position: 'absolute', - top: 15, - left: 15, backgroundColor: 'white', paddingHorizontal: 5, - zIndex: 1, + marginBottom: 6, alignSelf: 'flex-start', // Allow the label to adjust to its content width }, selectedValue: { diff --git a/src/context/locales/en.json b/src/context/locales/en.json index 66d02b50..c6bed2a8 100644 --- a/src/context/locales/en.json +++ b/src/context/locales/en.json @@ -299,6 +299,10 @@ "skilling_center_near_you": "Skilling Center Near You", "open_on_maps": "Open on Maps", "edit_profile": "Edit Profile", + "complete_profile_banner_message": "Complete your profile so we can guide you better on your learning journey", + "complete_profile_button": "Complete Profile", + "complete_profile_title": "Complete Your Profile", + "complete_profile_intro": "Help us with your background & other details", "change_username": "Change Username", "change_password": "Change Password", "completed_courses_certificates": "Completed Courses & Certificates", diff --git a/src/screens/Dashboard/Courses/Courses.js b/src/screens/Dashboard/Courses/Courses.js index 7bb725d4..9790f16f 100644 --- a/src/screens/Dashboard/Courses/Courses.js +++ b/src/screens/Dashboard/Courses/Courses.js @@ -48,6 +48,7 @@ import globalStyles from '../../../utils/Helper/Style'; import GlobalText from '@components/GlobalText/GlobalText'; import AppUpdatePopup from '../../../components/AppUpdate/AppUpdatePopup'; import AttemptAssessmentButton from '../../../components/AttemptAssessmentButton/AttemptAssessmentButton'; +import CompleteProfileBanner from '../../../components/CompleteProfileBanner/CompleteProfileBanner'; import PrimaryButton from '../../../components/PrimaryButton/PrimaryButton'; import InterestModal from './InterestModal'; import InterestModalError from './InterestModalError'; @@ -441,6 +442,7 @@ const Courses = ({ route, CopilotStopped, customProp = null }) => { ) : ( <> + diff --git a/src/screens/Dashboard/Preference/SCPDashboard/SCPDashboard.js b/src/screens/Dashboard/Preference/SCPDashboard/SCPDashboard.js index c7f3a259..21c2fbfa 100644 --- a/src/screens/Dashboard/Preference/SCPDashboard/SCPDashboard.js +++ b/src/screens/Dashboard/Preference/SCPDashboard/SCPDashboard.js @@ -36,6 +36,7 @@ import { } from '../../../../utils/API/AuthService'; import ActiveLoading from '../../../LoadingScreen/ActiveLoading'; import BackButtonHandler from '../../../../components/BackNavigation/BackButtonHandler'; +import CompleteProfileBanner from '../../../../components/CompleteProfileBanner/CompleteProfileBanner'; import GlobalText from '@components/GlobalText/GlobalText'; import AppUpdatePopup from '../../../../components/AppUpdate/AppUpdatePopup'; @@ -232,6 +233,7 @@ const SCPDashboard = (props) => { } style={styles.view2} > + diff --git a/src/screens/Profile/CompleteProfileFormScreen.js b/src/screens/Profile/CompleteProfileFormScreen.js new file mode 100644 index 00000000..3bab20be --- /dev/null +++ b/src/screens/Profile/CompleteProfileFormScreen.js @@ -0,0 +1,318 @@ +import React, { useEffect, useState } from 'react'; +import { + Image, + KeyboardAvoidingView, + Modal, + Platform, + ScrollView, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native'; +import { useNavigation, useRoute } from '@react-navigation/native'; +import Ionicons from 'react-native-vector-icons/Ionicons'; +import SecondaryHeader from '../../components/Layout/SecondaryHeader'; +import GlobalText from '@components/GlobalText/GlobalText'; +import PrimaryButton from '../../components/PrimaryButton/PrimaryButton'; +import globalStyles from '../../utils/Helper/Style'; +import { useTranslation } from '@context/LanguageContext'; +import { useInternet } from '@context/NetworkContext'; +import ActiveLoading from '../LoadingScreen/ActiveLoading'; +import lightning from '../../assets/images/png/lightning.png'; +import { + buildUserDetailsObject, + getDataFromStorage, + getMergedProfileSchema, + getMissingProfileFields, + logEventFunction, + setDataInStorage, +} from '../../utils/JsHelper/Helper'; +import { + getGeoLocation, + getProfileDetails, + updateUser, +} from '../../utils/API/AuthService'; +import { transformPayload } from './TransformPayload'; +import { + renderProfileField, + reorderFamilyFieldsAfterSelector, + validateProfileFields, +} from './ProfileFormShared'; + +const FAMILY_NAME_FIELDS = ['father_name', 'mother_name', 'spouse_name']; + +const CompleteProfileFormScreen = () => { + const { t } = useTranslation(); + const navigation = useNavigation(); + const route = useRoute(); + const { isConnected } = useInternet(); + + const [loading, setLoading] = useState(true); + const [filteredSchema, setFilteredSchema] = useState([]); + const [formData, setFormData] = useState({}); + const [errors, setErrors] = useState({}); + const [modal, setModal] = useState(false); + const [err, setErr] = useState(); + const [stateData, setStateData] = useState([]); + const [districtData, setDistrictData] = useState([]); + const [blockData, setBlockData] = useState([]); + const [villageData, setVillageData] = useState([]); + + useEffect(() => { + const loadSchema = async () => { + const tenantData = JSON.parse((await getDataFromStorage('tenantData')) || 'null'); + const tenantId = route.params?.tenantId || tenantData?.[0]?.tenantId; + const schema = await getMergedProfileSchema(tenantId); + const profileData = JSON.parse((await getDataFromStorage('profileData')) || 'null'); + const userDetails = buildUserDetailsObject(profileData, schema); + + let missingFields = route.params?.missingFields; + if (!missingFields) { + missingFields = getMissingProfileFields(schema, userDetails).missingFields; + } + + const fieldsToShow = new Set(missingFields); + if (fieldsToShow.has('family_member_details')) { + // The relation is still unknown, so keep all three name fields in the + // schema - once the user picks one, isFieldVisible() shows only that one. + FAMILY_NAME_FIELDS.forEach((name) => fieldsToShow.add(name)); + } + + setFilteredSchema(schema.filter((field) => fieldsToShow.has(field.name))); + // Seed with the user's already-saved values (not just the missing ones) so + // conditional visibility that depends on a field NOT being edited here - + // e.g. mobile/guardian fields depending on a dob already on file - still + // resolves correctly. + setFormData(userDetails); + setLoading(false); + }; + + loadSchema(); + + const logOpen = async () => { + await logEventFunction({ + eventName: 'complete_profile_form_view', + method: 'on-view', + screenName: 'CompleteProfileForm', + }); + }; + logOpen(); + }, []); + + useEffect(() => { + if (filteredSchema.some((field) => field.name === 'state') && stateData.length === 0) { + getGeoLocation({ payload: { offset: 0, fieldName: 'state' } }).then((data) => + setStateData(data?.values || []) + ); + } + }, [filteredSchema]); + + useEffect(() => { + if (!filteredSchema.some((field) => field.name === 'district')) { + return; + } + const stateValue = formData?.state?.value; + if (!stateValue) { + return; + } + getGeoLocation({ + payload: { offset: 0, fieldName: 'district', controllingfieldfk: [stateValue] }, + }).then((data) => setDistrictData(data?.values || [])); + }, [formData?.state]); + + useEffect(() => { + if (!filteredSchema.some((field) => field.name === 'block')) { + return; + } + const districtValue = formData?.district?.value; + if (!districtValue) { + return; + } + getGeoLocation({ + payload: { offset: 0, fieldName: 'block', controllingfieldfk: [districtValue] }, + }).then((data) => setBlockData(data?.values || [])); + }, [formData?.district]); + + useEffect(() => { + if (!filteredSchema.some((field) => field.name === 'village')) { + return; + } + const blockValue = formData?.block?.value; + if (!blockValue) { + return; + } + getGeoLocation({ + payload: { offset: 0, fieldName: 'village', controllingfieldfk: [blockValue] }, + }).then((data) => setVillageData(data?.values || [])); + }, [formData?.block]); + + const handleInputChange = (name, value) => { + setFormData((prev) => ({ ...prev, [name]: value })); + setErrors((prev) => ({ ...prev, [name]: '' })); + }; + + const handleSubmit = async () => { + const familyType = (() => { + const raw = formData?.family_member_details; + return raw && typeof raw === 'object' ? raw.value : raw; + })(); + + const dataToValidate = { ...formData }; + if (familyType) { + ['father_name', 'mother_name', 'spouse_name'] + .filter((name) => name !== `${familyType}_name`) + .forEach((name) => { + dataToValidate[name] = ''; + }); + } + + const newErrors = validateProfileFields(filteredSchema, dataToValidate, t); + setErrors(newErrors); + if (Object.keys(newErrors).length > 0) { + return; + } + + setLoading(true); + const payload = await transformPayload(dataToValidate); + const user_id = await getDataFromStorage('userId'); + const register = await updateUser({ payload, user_id }); + + if (!isConnected) { + setLoading(false); + } else if (register?.params?.status === 'failed') { + setLoading(false); + setModal(true); + setErr(register?.params?.err); + } else { + const profileData = await getProfileDetails({ userId: user_id }); + await setDataInStorage('profileData', JSON.stringify(profileData)); + navigation.goBack(); + } + }; + + if (loading) { + return ; + } + + const orderedSchema = reorderFamilyFieldsAfterSelector(filteredSchema); + const geoOptions = { stateData, districtData, blockData, villageData }; + + return ( + <> + + + navigation.goBack()} style={styles.backButton}> + + + + {t('complete_profile_title')} + + + + + + + 🧐📝 {t('complete_profile_intro')} + + + {orderedSchema.map((field) => { + const content = renderProfileField(field, { + formData, + errors, + handleInputChange, + geoOptions, + }); + if (!content) { + return null; + } + return ( + + {content} + + ); + })} + + + + + + {modal && ( + + + {err && ( + + + + Error: {err} + + setModal(false)} /> + + )} + + + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: 'white', + }, + titleRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 20, + paddingVertical: 15, + }, + backButton: { + marginRight: 10, + }, + title: { + flex: 1, + textAlign: 'center', + marginRight: 34, + }, + card: { + margin: 20, + padding: 20, + borderRadius: 20, + backgroundColor: 'white', + borderWidth: 1, + borderColor: '#EEE6DA', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.08, + shadowRadius: 8, + elevation: 3, + }, + intro: { + marginBottom: 15, + }, + fieldList: { + marginBottom: 20, + }, + fieldContainer: { + width: '100%', + marginBottom: 10, + }, + modalContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.5)', + }, + alertBox: { + width: 350, + backgroundColor: 'white', + borderRadius: 20, + alignItems: 'center', + padding: 10, + }, +}); + +export default CompleteProfileFormScreen; diff --git a/src/screens/Profile/ProfileFormShared.js b/src/screens/Profile/ProfileFormShared.js new file mode 100644 index 00000000..3ed731ed --- /dev/null +++ b/src/screens/Profile/ProfileFormShared.js @@ -0,0 +1,322 @@ +import React from 'react'; +import CustomTextField from '../../components/CustomTextField/CustomTextField'; +import CustomCards from '@components/CustomCard/CustomCard'; +import RadioButton from '@components/CustomRadioCard/RadioButton'; +import DropdownSelect from '@components/DropdownSelect/DropdownSelect'; +import CustomPasswordTextField from '@components/CustomPasswordComponent/CustomPasswordComponent'; +import DateTimePicker from '@components/DateTimePicker/DateTimePicker'; +import { calculateAge } from '../../utils/JsHelper/Helper'; + +const NON_EDITABLE_NAME_FIELDS = [ + 'first_name', + 'last_name', + 'firstName', + 'lastName', + 'state', + 'district', + 'block', + 'village', +]; +const ALWAYS_HIDDEN_FIELDS = [ + 'username', + 'password', + 'confirm_password', + 'is_volunteer', +]; +const FAMILY_NAME_FIELDS = ['father_name', 'mother_name', 'spouse_name']; + +const getFamilyType = (formData) => { + const raw = formData?.family_member_details; + return raw && typeof raw === 'object' ? raw.value : raw; +}; + +const getPhoneType = (formData) => { + const raw = formData?.phone_type_accessible; + return raw && typeof raw === 'object' ? raw.value : raw; +}; + +const getAgeFromFormData = (formData) => { + const dob = formData?.dob || ''; + if (!dob) { + return null; + } + try { + const age = calculateAge(dob); + return age !== null && age !== undefined && !isNaN(age) ? parseInt(age, 10) : null; + } catch { + return null; + } +}; + +// Whether `field` should be shown/considered given the rest of the form's current +// values - age-based guardian/mobile visibility, the selected family member +// relation, and phone ownership. Shared so the full Edit Profile form and the +// Complete Profile mini-form apply identical conditional logic. +export const isFieldVisible = (field, formData) => { + const ageValue = getAgeFromFormData(formData); + const isAge18OrAbove = ageValue !== null && ageValue >= 18; + const isAgeBelow18 = ageValue !== null && ageValue < 18; + + if ( + ['guardian_relation', 'guardian_name', 'parent_phone'].includes(field.name) && + isAge18OrAbove + ) { + return false; + } + if ( + ['phone_num', 'phone_number', 'mobile'].includes(field.name) && + isAgeBelow18 + ) { + return false; + } + + const familyType = getFamilyType(formData); + if (!familyType && FAMILY_NAME_FIELDS.includes(field.name)) { + return false; + } + if (familyType === 'spouse' && ['father_name', 'mother_name'].includes(field.name)) { + return false; + } + if (familyType === 'father' && ['spouse_name', 'mother_name'].includes(field.name)) { + return false; + } + if (familyType === 'mother' && ['father_name', 'spouse_name'].includes(field.name)) { + return false; + } + + if (field.name === 'own_phone_check' && getPhoneType(formData) === 'nophone') { + return false; + } + + if (ALWAYS_HIDDEN_FIELDS.includes(field.name)) { + return false; + } + + return true; +}; + +// Reorders `schema` so father_name/mother_name/spouse_name render immediately +// after family_member_details, regardless of their original position. +export const reorderFamilyFieldsAfterSelector = (schema) => { + const familyDetailsIndex = schema.findIndex( + (f) => f.name === 'family_member_details' + ); + if (familyDetailsIndex === -1) { + return schema; + } + + const nameFields = schema.filter((f) => FAMILY_NAME_FIELDS.includes(f.name)); + let orderedSchema = schema.filter((f) => !FAMILY_NAME_FIELDS.includes(f.name)); + const insertAt = + orderedSchema.findIndex((f) => f.name === 'family_member_details') + 1; + orderedSchema.splice(insertAt, 0, ...nameFields); + + return orderedSchema; +}; + +// Renders the single input component for `field` (no wrapping container - callers +// own their own layout), or null if the field is currently hidden per +// isFieldVisible(). Shared between ProfileUpdateForm and CompleteProfileFormScreen +// so both screens use identical field components and behavior. +export const renderProfileField = ( + field, + { formData, errors, handleInputChange, geoOptions = {} } +) => { + if (!isFieldVisible(field, formData)) { + return null; + } + + switch (field.type) { + case 'text': + return ( + + ); + case 'email': + return ( + + ); + case 'numeric': + return ( + + ); + case 'radio': + return ( + + ); + case 'select': + return ( + + ); + case 'drop_down': + return ( + + ); + case 'password': + case 'confirm_password': + return ( + + ); + case 'date': + return ( + + ); + default: + return null; + } +}; + +// Validates `fieldNames` (defaults to every field in `schema`) against `formData`, +// applying the same required/minLength/maxLength/pattern rules - and the same +// conditional skips (age, family member selection, phone ownership) - as the full +// Edit Profile form. Returns an { [fieldName]: message } errors object. +export const validateProfileFields = (schema, formData, t, fieldNames) => { + const targetFields = fieldNames || schema?.map((f) => f.name) || []; + const newErrors = {}; + const age = calculateAge(formData?.dob || ''); + const ageValue = age ? parseInt(age, 10) : null; + const familyType = getFamilyType(formData); + const phoneType = getPhoneType(formData); + + targetFields.forEach((fieldName) => { + const field = schema?.find((f) => f.name === fieldName); + if (!field) { + return; + } + + const fieldValue = formData[field.name]; + let value = ''; + if (fieldValue !== null && fieldValue !== undefined) { + if (typeof fieldValue === 'object' && !Array.isArray(fieldValue)) { + value = fieldValue.value !== undefined ? String(fieldValue.value) : ''; + } else if (Array.isArray(fieldValue)) { + value = + fieldValue.length > 0 + ? String(fieldValue[0]?.value || fieldValue[0] || '') + : ''; + } else { + value = String(fieldValue); + } + } + + if ( + [ + 'confirm_password', + 'password', + 'program', + 'username', + 'is_volunteer', + 'family_member_details', + ].includes(field.name) + ) { + return; + } + if ( + ['guardian_name', 'guardian_relation', 'parent_phone'].includes(field.name) && + ageValue !== null && + ageValue >= 18 + ) { + return; + } + if (familyType === 'mother' && ['father_name', 'spouse_name'].includes(field.name)) { + return; + } + if (familyType === 'father' && ['mother_name', 'spouse_name'].includes(field.name)) { + return; + } + if (familyType === 'spouse' && ['father_name', 'mother_name'].includes(field.name)) { + return; + } + if (!familyType && FAMILY_NAME_FIELDS.includes(field.name)) { + return; + } + if (field.name === 'mobile' && ageValue !== null && ageValue < 18) { + return; + } + if (field.name === 'own_phone_check' && phoneType === 'nophone') { + return; + } + + if (field.isRequired && !value) { + newErrors[field.name] = `${t(field.name)} ${t('is_required')}`; + } else if (field.minLength && value.length < field.minLength && value) { + newErrors[field.name] = `${t('min_validation') + .replace('{field}', t(field.name)) + .replace('{length}', field.minLength)}`; + } else if (field.maxLength && value.length > field.maxLength && value) { + newErrors[field.name] = `${t('max_validation') + .replace('{field}', t(field.name)) + .replace('{length}', field.maxLength)}`; + } else if ( + field.pattern && + value && + !new RegExp(field.pattern.replace(/^\/|\/$/g, '')).test(value) + ) { + newErrors[field.name] = `${t(field.name)} ${t('is_invalid')}.`; + } + }); + + return newErrors; +}; diff --git a/src/screens/Profile/ProfileUpdateForm.js b/src/screens/Profile/ProfileUpdateForm.js index 3c3ec0c4..184458a8 100644 --- a/src/screens/Profile/ProfileUpdateForm.js +++ b/src/screens/Profile/ProfileUpdateForm.js @@ -8,8 +8,6 @@ import { Modal, Image, } from 'react-native'; -import CustomTextField from '../../components/CustomTextField/CustomTextField'; -import CustomCards from '@components/CustomCard/CustomCard'; import { logEventFunction } from '@src/utils/JsHelper/Helper'; import { useTranslation } from '@context/LanguageContext'; import { useInternet } from '@context/NetworkContext'; @@ -23,7 +21,6 @@ import globalStyles from '../../utils/Helper/Style'; import GlobalText from '@components/GlobalText/GlobalText'; import lightning from '../../assets/images/png/lightning.png'; import { - calculateAge, createNewObject, getDataFromStorage, setDataInStorage, @@ -34,10 +31,11 @@ import { updateUser, } from '../../utils/API/AuthService'; import { useNavigation } from '@react-navigation/native'; -import RadioButton from '@components/CustomRadioCard/RadioButton'; -import DropdownSelect from '@components/DropdownSelect/DropdownSelect'; -import CustomPasswordTextField from '@components/CustomPasswordComponent/CustomPasswordComponent'; -import DateTimePicker from '@components/DateTimePicker/DateTimePicker'; +import { + renderProfileField, + reorderFamilyFieldsAfterSelector, + validateProfileFields, +} from './ProfileFormShared'; const ProfileUpdateForm = ({ fields }) => { const { t } = useTranslation(); @@ -331,108 +329,7 @@ const ProfileUpdateForm = ({ fields }) => { const validateFields = () => { const pageFields = pages[currentPage]; - const newErrors = {}; - - pageFields.forEach((fieldName) => { - const field = schema?.find((f) => f.name === fieldName); - const age = calculateAge(formData?.dob || ''); - - if (field) { - // Extract value - handle both object and string formats - const fieldValue = formData[field.name]; - let value = ''; - - if (fieldValue !== null && fieldValue !== undefined) { - if (typeof fieldValue === 'object' && !Array.isArray(fieldValue)) { - // If it's an object, extract the value property - value = fieldValue.value !== undefined ? String(fieldValue.value) : ''; - } else if (Array.isArray(fieldValue)) { - // If it's an array, join the values or use first value - value = fieldValue.length > 0 ? String(fieldValue[0]?.value || fieldValue[0] || '') : ''; - } else { - // If it's a string or number, convert to string - value = String(fieldValue); - } - } - - if ( - [ - 'confirm_password', - 'password', - 'program', - 'username', - 'is_volunteer', - 'family_member_details', - ].includes(field.name) - ) { - return; // Skip validation for these fields - } - if ( - ['guardian_name', 'guardian_relation', 'parent_phone'].includes( - field.name - ) && - age && - parseInt(age, 10) >= 18 - ) { - return; // Skip validation for these fields - } - - // Skip validation for family member fields that are hidden - const rawFamilyType1 = formData?.family_member_details; - const familyType = (rawFamilyType1 && typeof rawFamilyType1 === 'object') ? rawFamilyType1.value : rawFamilyType1; - if (familyType === 'mother' && field.name === 'father_name') { - return; // Skip validation for hidden fields - } - if (familyType === 'mother' && field.name === 'spouse_name') { - return; // Skip validation for hidden fields - } - if (familyType === 'father' && field.name === 'mother_name') { - return; // Skip validation for hidden fields - } - if (familyType === 'father' && field.name === 'spouse_name') { - return; // Skip validation for hidden fields - } - if (familyType === 'spouse' && field.name === 'father_name') { - return; // Skip validation for hidden fields - } - if (familyType === 'spouse' && field.name === 'mother_name') { - return; // Skip validation for hidden fields - } - if (!familyType && ['father_name', 'mother_name', 'spouse_name'].includes(field.name)) { - return; // Skip validation for hidden fields - } - - // Skip validation for mobile field when age is below 18 - const ageValue = age ? parseInt(age, 10) : null; - if (field.name === 'mobile' && ageValue !== null && ageValue < 18) { - return; // Skip validation for hidden mobile field - } - - // Skip own_phone_check when phone_type_accessible is nophone (field is hidden) - const phoneTypeValue = formData?.phone_type_accessible?.value || formData?.phone_type_accessible; - if (field.name === 'own_phone_check' && phoneTypeValue === 'nophone') { - return; - } - - if (field.isRequired && !value) { - newErrors[field.name] = `${t(field.name)} ${t('is_required')}`; - } else if (field.minLength && value.length < field.minLength && value) { - newErrors[field.name] = `${t('min_validation') - .replace('{field}', t(field.name)) - .replace('{length}', field.minLength)}`; - } else if (field.maxLength && value.length > field.maxLength && value) { - newErrors[field.name] = `${t('max_validation') - .replace('{field}', t(field.name)) - .replace('{length}', field.maxLength)}`; - } else if ( - field.pattern && - value && - !new RegExp(field.pattern.replace(/^\/|\/$/g, '')).test(value) - ) { - newErrors[field.name] = `${t(field.name)} ${t('is_invalid')}.`; - } - } - }); + const newErrors = validateProfileFields(schema, formData, t, pageFields); setErrors(newErrors); console.log('ProfileUpdateForm validation errors:', JSON.stringify(newErrors)); @@ -440,237 +337,28 @@ const ProfileUpdateForm = ({ fields }) => { }; const renderField = (field) => { - const dob = formData?.dob || ''; - let age = null; - let ageValue = null; - - // Calculate age if DOB exists - if (dob) { - try { - age = calculateAge(dob); - ageValue = age !== null && age !== undefined && !isNaN(age) ? parseInt(age, 10) : null; - } catch (error) { - console.log('Error calculating age:', error); - ageValue = null; - } - } - - const isAge18OrAbove = ageValue !== null && ageValue >= 18; - const isAgeBelow18 = ageValue !== null && ageValue < 18; - - // Debug logging for mobile field visibility - if (field.name === 'mobile' || field.name === 'phone_num' || field.name === 'phone_number') { - console.log(`Field: ${field.name}, DOB: ${dob}, Age: ${ageValue}, isAgeBelow18: ${isAgeBelow18}`); - } - - // Hide guardian fields for users 18 or above - if ( - (field.name === 'guardian_relation' || - field.name === 'guardian_name' || - field.name === 'parent_phone') && - isAge18OrAbove - ) { - return null; - } - - // Hide phone/mobile number field when age is below 18 - // Show mobile for users 18 or above, hide it when age is below 18 - if ( - (field.name === 'phone_num' || - field.name === 'phone_number' || - field.name === 'mobile') && - isAgeBelow18 - ) { - return null; - } - - // Family member details conditional logic - const rawFamilyType2 = formData?.family_member_details; - const familyType = (rawFamilyType2 && typeof rawFamilyType2 === 'object') ? rawFamilyType2.value : rawFamilyType2; - console.log('familyType:', familyType, 'field.name:', field.name, 'formData.family_member_details:', formData?.family_member_details); - - // If no family_member_details is selected, hide all family name fields - if ( - !familyType && - ['father_name', 'mother_name', 'spouse_name'].includes(field.name) - ) { - console.log('Hiding field (no familyType):', field.name); - return null; - } - - // If spouse is selected, hide father_name and mother_name - if (familyType === 'spouse' && (field.name === 'father_name' || field.name === 'mother_name')) { - console.log('Hiding field (spouse selected):', field.name); - return null; - } - - // If father is selected, hide spouse_name and mother_name - if (familyType === 'father' && (field.name === 'spouse_name' || field.name === 'mother_name')) { - console.log('Hiding field (father selected):', field.name); - return null; - } - - // If mother is selected, hide father_name and spouse_name - if (familyType === 'mother' && (field.name === 'father_name' || field.name === 'spouse_name')) { - console.log('Hiding field (mother selected):', field.name); - return null; - } - - // Hide "Does this phone belong to you?" when "No Phone" is selected. - const phoneTypeValue = formData?.phone_type_accessible?.value || formData?.phone_type_accessible; - if (field.name === 'own_phone_check' && phoneTypeValue === 'nophone') { - return null; - } + const geoOptions = { stateData, districtData, blockData, villageData }; + const content = renderProfileField(field, { + formData, + errors, + handleInputChange, + geoOptions, + }); - // if (field.name && !field?.isEditable) { - // return null; - // } - if ( - [ - 'username', - 'password', - 'confirm_password', - 'is_volunteer', - // 'state', - // 'district', - // 'block', - // 'village', - ].includes(field.name) - ) { + if (!content) { return null; } - switch (field.type) { - case 'text': - return ( - - - - ); - case 'email': - return ( - - - - ); - case 'numeric': - return ( - - - - ); - - case 'radio': - return ( - - - - ); - case 'select': - return ( - - - - ); - case 'drop_down': - return ( - - - - ); - case 'password': - case 'confirm_password': - return ( - - - - ); - case 'date': - return ( - - - - ); - default: - return null; - } + return ( + + {content} + + ); }; const renderPage = () => { const pageFields = pages[currentPage]; - const familyNameFields = ['father_name', 'mother_name', 'spouse_name']; - - // Move family name fields to appear immediately after family_member_details - const familyDetailsIndex = schema.findIndex( - (f) => f.name === 'family_member_details' - ); - let orderedSchema = [...schema]; - if (familyDetailsIndex !== -1) { - const nameFields = orderedSchema.filter((f) => - familyNameFields.includes(f.name) - ); - orderedSchema = orderedSchema.filter( - (f) => !familyNameFields.includes(f.name) - ); - const insertAt = - orderedSchema.findIndex((f) => f.name === 'family_member_details') + 1; - orderedSchema.splice(insertAt, 0, ...nameFields); - } + const orderedSchema = reorderFamilyFieldsAfterSelector(schema); return orderedSchema .filter((field) => pageFields?.includes(field.name)) diff --git a/src/screens/Profile/ProfileUpdateScreen.js b/src/screens/Profile/ProfileUpdateScreen.js index 20502168..3f4ea302 100644 --- a/src/screens/Profile/ProfileUpdateScreen.js +++ b/src/screens/Profile/ProfileUpdateScreen.js @@ -2,10 +2,9 @@ import React, { useEffect, useState } from 'react'; import { SafeAreaView, StyleSheet } from 'react-native'; import ProfileUpdateForm from './ProfileUpdateForm'; import NetworkAlert from '../../components/NetworkError/NetworkAlert'; -import { getStudentForm } from '../../utils/API/AuthService'; import { getDataFromStorage, - setDataInStorage, + getMergedProfileSchema, } from '../../utils/JsHelper/Helper'; import ActiveLoading from '../LoadingScreen/ActiveLoading'; // import Geolocation from 'react-native-geolocation-service'; //GeoLocation Comment @@ -24,24 +23,17 @@ const ProfileUpdateScreen = () => { }); }; const fetchData = async () => { - const data = await getStudentForm(); const tenantData = JSON.parse(await getDataFromStorage('tenantData')); const tenantId = tenantData?.[0]?.tenantId; - const programForm = await getStudentForm(tenantId); - setDataInStorage('studentProgramForm', JSON.stringify(programForm?.fields)); - const newSchema = [...data.fields, ...programForm.fields]; - const filteredSchema = newSchema.filter( - (field) => field.name !== 'center' && field.name !== 'batch' - ); - const updatedSchema = updateOrder(filteredSchema); - console.log("updatedSchema",updatedSchema); - if (data?.error) { - setNetworkError(true); - } else { - // const states = await fetchstates(); - setDataInStorage('studentForm', JSON.stringify(data?.fields)); + + try { + const filteredSchema = await getMergedProfileSchema(tenantId); + const updatedSchema = updateOrder(filteredSchema); + console.log("updatedSchema",updatedSchema); setMainSchema(updatedSchema); setNetworkError(false); + } catch { + setNetworkError(true); } setLoading(false); diff --git a/src/screens/YouthNet/L1Courses.js b/src/screens/YouthNet/L1Courses.js index 188a19d8..bada8868 100644 --- a/src/screens/YouthNet/L1Courses.js +++ b/src/screens/YouthNet/L1Courses.js @@ -42,6 +42,7 @@ import globalStyles from '@src/utils/Helper/Style'; import GlobalText from '@components/GlobalText/GlobalText'; import AppUpdatePopup from '@src/components/AppUpdate/AppUpdatePopup'; +import CompleteProfileBanner from '@src/components/CompleteProfileBanner/CompleteProfileBanner'; import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; import AntDesign from 'react-native-vector-icons/AntDesign'; @@ -237,6 +238,7 @@ const L1Courses = () => { // translucent={true} backgroundColor="transparent" /> + diff --git a/src/utils/JsHelper/Helper.js b/src/utils/JsHelper/Helper.js index 8a4aac1c..81ec8b18 100644 --- a/src/utils/JsHelper/Helper.js +++ b/src/utils/JsHelper/Helper.js @@ -1,6 +1,6 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { BackHandler, PermissionsAndroid } from 'react-native'; -import { getAccessToken } from '../API/AuthService'; +import { getAccessToken, getStudentForm } from '../API/AuthService'; import analytics from '@react-native-firebase/analytics'; import RNFS from 'react-native-fs'; import messaging from '@react-native-firebase/messaging'; @@ -603,6 +603,144 @@ export const createNewObjectTarget = (customFields, labels, profileView) => { return result; }; +// Merges the base ("common") student form with the current program's form, +// mirroring what ProfileUpdateScreen has always done, so Edit Profile and the +// Complete Profile banner always agree on what "all fields for this program" means. +export const getMergedProfileSchema = async (tenantId) => { + const base = await getStudentForm(); + const program = await getStudentForm(tenantId); + const baseFields = base?.fields || []; + const programFields = program?.fields || []; + + await setDataInStorage('studentForm', JSON.stringify(baseFields)); + await setDataInStorage('studentProgramForm', JSON.stringify(programFields)); + + return [...baseFields, ...programFields].filter( + (field) => field.name !== 'center' && field.name !== 'batch' + ); +}; + +// Builds the same flattened { fieldName: value } object ProfileUpdateForm builds +// from cached profileData + the merged schema's labels, for reuse by anything that +// needs to inspect the user's saved values (e.g. the profile-completeness check). +export const buildUserDetailsObject = (profileData, schema) => { + const finalResult = profileData?.getUserDetails?.[0]; + if (!finalResult) { + return {}; + } + + const keysToRemove = [ + 'customFields', + 'total_count', + 'status', + 'updatedAt', + 'createdAt', + 'updatedBy', + 'createdBy', + 'username', + ]; + const filteredResult = Object.keys(finalResult) + .filter((key) => !keysToRemove.includes(key)) + .reduce((obj, key) => { + obj[key] = finalResult[key]; + return obj; + }, {}); + + const requiredLabels = schema?.map((item) => ({ + label: item?.label, + name: item?.name, + })); + const userDetails = createNewObject(finalResult?.customFields, requiredLabels); + + return { ...userDetails, ...filteredResult }; +}; + +const extractProfileFieldValue = (fieldValue) => { + if (fieldValue === null || fieldValue === undefined) { + return ''; + } + if (Array.isArray(fieldValue)) { + return fieldValue.length > 0 + ? String(fieldValue[0]?.value ?? fieldValue[0] ?? '').trim() + : ''; + } + if (typeof fieldValue === 'object') { + return fieldValue.value !== undefined ? String(fieldValue.value).trim() : ''; + } + return String(fieldValue).trim(); +}; + +const ALWAYS_EXCLUDED_PROFILE_FIELDS = [ + 'username', + 'password', + 'confirm_password', + 'is_volunteer', + 'center', + 'batch', + 'program', + 'first_name', + 'firstName', + 'last_name', + 'lastName', +]; + +// Given the merged schema (§getMergedProfileSchema) and the user's saved profile +// data (§buildUserDetailsObject), returns which schema fields have no value yet. +// Mirrors ProfileUpdateForm's conditional visibility rules (age, family member +// selection, phone ownership) so a field the user could never be shown is never +// flagged as "missing". +export const getMissingProfileFields = (schema, userDetails) => { + const age = userDetails?.dob ? parseInt(calculateAge(userDetails.dob), 10) : null; + + const rawFamilyType = userDetails?.family_member_details; + const familyType = + rawFamilyType && typeof rawFamilyType === 'object' + ? rawFamilyType.value + : rawFamilyType; + const familyNameFieldForType = { + mother: 'mother_name', + father: 'father_name', + spouse: 'spouse_name', + }[familyType]; + + const rawPhoneType = userDetails?.phone_type_accessible; + const phoneType = + rawPhoneType && typeof rawPhoneType === 'object' + ? rawPhoneType.value + : rawPhoneType; + + const missingFields = (schema || []) + .filter((field) => field?.name) + .filter((field) => !ALWAYS_EXCLUDED_PROFILE_FIELDS.includes(field.name)) + .filter((field) => { + if ( + ['guardian_relation', 'guardian_name', 'parent_phone'].includes(field.name) && + age !== null && + age >= 18 + ) { + return false; + } + if ( + ['mobile', 'phone_num', 'phone_number'].includes(field.name) && + age !== null && + age < 18 + ) { + return false; + } + if (field.name === 'own_phone_check' && phoneType === 'nophone') { + return false; + } + if (['father_name', 'mother_name', 'spouse_name'].includes(field.name)) { + return field.name === familyNameFieldForType; + } + return true; + }) + .filter((field) => !extractProfileFieldValue(userDetails?.[field.name])) + .map((field) => field.name); + + return { missingFields, isComplete: missingFields.length === 0 }; +}; + export const categorizeEvents = async (events) => { const plannedSessions = []; const extraSessions = []; From b9e556246cb468f6deadbd67fc4fbb95cdd1397f Mon Sep 17 00:00:00 2001 From: Tejashri Majage Date: Fri, 7 Aug 2026 15:24:53 +0530 Subject: [PATCH 2/2] complete profile banner changes --- .../CompleteProfileBanner.js | 14 +- .../Profile/CompleteProfileFormScreen.js | 11 +- src/screens/Profile/ProfileFormShared.js | 88 +------- src/utils/JsHelper/Helper.js | 195 +++++++++++++----- 4 files changed, 177 insertions(+), 131 deletions(-) diff --git a/src/components/CompleteProfileBanner/CompleteProfileBanner.js b/src/components/CompleteProfileBanner/CompleteProfileBanner.js index 9858f174..5e10c1cc 100644 --- a/src/components/CompleteProfileBanner/CompleteProfileBanner.js +++ b/src/components/CompleteProfileBanner/CompleteProfileBanner.js @@ -9,6 +9,7 @@ import { getDataFromStorage, getMergedProfileSchema, getMissingProfileFields, + getProfileCompletionSchema, } from '../../utils/JsHelper/Helper'; const CompleteProfileBanner = () => { @@ -29,10 +30,17 @@ const CompleteProfileBanner = () => { return; } - const schema = await getMergedProfileSchema(tenantId); + // Completeness is judged only on the current program's required fields + // (see getProfileCompletionSchema). The user's saved values are still read + // against the full merged schema so common-form labels resolve correctly. + const mergedSchema = await getMergedProfileSchema(tenantId); + const completionSchema = await getProfileCompletionSchema(tenantId); const profileData = JSON.parse((await getDataFromStorage('profileData')) || 'null'); - const userDetails = buildUserDetailsObject(profileData, schema); - const { missingFields, isComplete } = getMissingProfileFields(schema, userDetails); + const userDetails = buildUserDetailsObject(profileData, mergedSchema); + const { missingFields, isComplete } = getMissingProfileFields( + completionSchema, + userDetails + ); setBanner({ visible: !isComplete, tenantId, missingFields }); } catch { diff --git a/src/screens/Profile/CompleteProfileFormScreen.js b/src/screens/Profile/CompleteProfileFormScreen.js index 3bab20be..f5e09531 100644 --- a/src/screens/Profile/CompleteProfileFormScreen.js +++ b/src/screens/Profile/CompleteProfileFormScreen.js @@ -24,6 +24,7 @@ import { getDataFromStorage, getMergedProfileSchema, getMissingProfileFields, + getProfileCompletionSchema, logEventFunction, setDataInStorage, } from '../../utils/JsHelper/Helper'; @@ -62,13 +63,21 @@ const CompleteProfileFormScreen = () => { const loadSchema = async () => { const tenantData = JSON.parse((await getDataFromStorage('tenantData')) || 'null'); const tenantId = route.params?.tenantId || tenantData?.[0]?.tenantId; + // `schema` is the full merged set - needed so we can still render a field + // like father_name that is not itself part of the completion scope. + // `completionSchema` (program form, required only) is what decides which + // fields count as missing, matching the web client. const schema = await getMergedProfileSchema(tenantId); const profileData = JSON.parse((await getDataFromStorage('profileData')) || 'null'); const userDetails = buildUserDetailsObject(profileData, schema); let missingFields = route.params?.missingFields; if (!missingFields) { - missingFields = getMissingProfileFields(schema, userDetails).missingFields; + const completionSchema = await getProfileCompletionSchema(tenantId); + missingFields = getMissingProfileFields( + completionSchema, + userDetails + ).missingFields; } const fieldsToShow = new Set(missingFields); diff --git a/src/screens/Profile/ProfileFormShared.js b/src/screens/Profile/ProfileFormShared.js index 3ed731ed..ad2a857a 100644 --- a/src/screens/Profile/ProfileFormShared.js +++ b/src/screens/Profile/ProfileFormShared.js @@ -5,24 +5,12 @@ import RadioButton from '@components/CustomRadioCard/RadioButton'; import DropdownSelect from '@components/DropdownSelect/DropdownSelect'; import CustomPasswordTextField from '@components/CustomPasswordComponent/CustomPasswordComponent'; import DateTimePicker from '@components/DateTimePicker/DateTimePicker'; -import { calculateAge } from '../../utils/JsHelper/Helper'; +import { + calculateAge, + isProfileFieldVisible, + PROFILE_NON_EDITABLE_FIELDS, +} from '../../utils/JsHelper/Helper'; -const NON_EDITABLE_NAME_FIELDS = [ - 'first_name', - 'last_name', - 'firstName', - 'lastName', - 'state', - 'district', - 'block', - 'village', -]; -const ALWAYS_HIDDEN_FIELDS = [ - 'username', - 'password', - 'confirm_password', - 'is_volunteer', -]; const FAMILY_NAME_FIELDS = ['father_name', 'mother_name', 'spouse_name']; const getFamilyType = (formData) => { @@ -35,65 +23,9 @@ const getPhoneType = (formData) => { return raw && typeof raw === 'object' ? raw.value : raw; }; -const getAgeFromFormData = (formData) => { - const dob = formData?.dob || ''; - if (!dob) { - return null; - } - try { - const age = calculateAge(dob); - return age !== null && age !== undefined && !isNaN(age) ? parseInt(age, 10) : null; - } catch { - return null; - } -}; - -// Whether `field` should be shown/considered given the rest of the form's current -// values - age-based guardian/mobile visibility, the selected family member -// relation, and phone ownership. Shared so the full Edit Profile form and the -// Complete Profile mini-form apply identical conditional logic. -export const isFieldVisible = (field, formData) => { - const ageValue = getAgeFromFormData(formData); - const isAge18OrAbove = ageValue !== null && ageValue >= 18; - const isAgeBelow18 = ageValue !== null && ageValue < 18; - - if ( - ['guardian_relation', 'guardian_name', 'parent_phone'].includes(field.name) && - isAge18OrAbove - ) { - return false; - } - if ( - ['phone_num', 'phone_number', 'mobile'].includes(field.name) && - isAgeBelow18 - ) { - return false; - } - - const familyType = getFamilyType(formData); - if (!familyType && FAMILY_NAME_FIELDS.includes(field.name)) { - return false; - } - if (familyType === 'spouse' && ['father_name', 'mother_name'].includes(field.name)) { - return false; - } - if (familyType === 'father' && ['spouse_name', 'mother_name'].includes(field.name)) { - return false; - } - if (familyType === 'mother' && ['father_name', 'spouse_name'].includes(field.name)) { - return false; - } - - if (field.name === 'own_phone_check' && getPhoneType(formData) === 'nophone') { - return false; - } - - if (ALWAYS_HIDDEN_FIELDS.includes(field.name)) { - return false; - } - - return true; -}; +// Re-exported from Helper so the forms, the banner's completeness check, and the +// mini-form all decide field visibility from one implementation. +export const isFieldVisible = isProfileFieldVisible; // Reorders `schema` so father_name/mother_name/spouse_name render immediately // after family_member_details, regardless of their original position. @@ -135,7 +67,7 @@ export const renderProfileField = ( formData={formData} handleValue={handleInputChange} errors={errors} - editable={!NON_EDITABLE_NAME_FIELDS.includes(field.name)} + editable={!PROFILE_NON_EDITABLE_FIELDS.includes(field.name)} /> ); case 'email': @@ -199,7 +131,7 @@ export const renderProfileField = ( errors={errors} formData={formData} handleValue={handleInputChange} - editable={!NON_EDITABLE_NAME_FIELDS.includes(field.name)} + editable={!PROFILE_NON_EDITABLE_FIELDS.includes(field.name)} /> ); case 'password': diff --git a/src/utils/JsHelper/Helper.js b/src/utils/JsHelper/Helper.js index 81ec8b18..3b8beb83 100644 --- a/src/utils/JsHelper/Helper.js +++ b/src/utils/JsHelper/Helper.js @@ -603,19 +603,51 @@ export const createNewObjectTarget = (customFields, labels, profileView) => { return result; }; -// Merges the base ("common") student form with the current program's form, -// mirroring what ProfileUpdateScreen has always done, so Edit Profile and the -// Complete Profile banner always agree on what "all fields for this program" means. -export const getMergedProfileSchema = async (tenantId) => { +// Fetches the two Form Read responses that make up a learner's profile: +// the base ("common") form collected at registration, and the current program's +// own form. Both are cached to storage because TransformPayload reads them back +// when building the update payload. +export const getProfileFormSchemas = async (tenantId) => { const base = await getStudentForm(); const program = await getStudentForm(tenantId); - const baseFields = base?.fields || []; + const commonFields = base?.fields || []; const programFields = program?.fields || []; - await setDataInStorage('studentForm', JSON.stringify(baseFields)); + await setDataInStorage('studentForm', JSON.stringify(commonFields)); await setDataInStorage('studentProgramForm', JSON.stringify(programFields)); - return [...baseFields, ...programFields].filter( + return { commonFields, programFields }; +}; + +// Everything a learner can edit: common form + current program's form. Used by +// the full Edit Profile screen, and by the Complete Profile mini-form when it +// needs to render a field (e.g. a family member's name) that is not itself part +// of the completion scope below. +export const getMergedProfileSchema = async (tenantId) => { + const { commonFields, programFields } = await getProfileFormSchemas(tenantId); + + return [...commonFields, ...programFields].filter( + (field) => field.name !== 'center' && field.name !== 'batch' + ); +}; + +// The fields that determine whether a profile counts as "complete" for the +// current program - deliberately narrower than getMergedProfileSchema. +// +// Scope is the PROGRAM form only, matching the web client: common-form fields +// (name, dob, gender, state/district/block/village, preferred language) are +// collected during registration, so they must never hold this banner open. +// Both required and optional program fields count - Camp to Club's two fields +// are optional and web still asks for them. +// +// `center`/`batch` are dropped here (admin-assigned, not user-supplied); the +// remaining exclusions - unsupported types like `consent_file`, and the +// name-based list in PROFILE_COMPLETENESS_OPTIONAL_FIELDS - are applied by +// getMissingProfileFields. +export const getProfileCompletionSchema = async (tenantId) => { + const { programFields } = await getProfileFormSchemas(tenantId); + + return programFields.filter( (field) => field.name !== 'center' && field.name !== 'batch' ); }; @@ -670,71 +702,136 @@ const extractProfileFieldValue = (fieldValue) => { return String(fieldValue).trim(); }; -const ALWAYS_EXCLUDED_PROFILE_FIELDS = [ +// Field types the profile forms know how to render (see renderProfileField in +// screens/Profile/ProfileFormShared.js). A field of any other type cannot be +// filled in through the app, so it must never be counted as "missing" - doing so +// would show a Complete Profile banner that opens a form with nothing in it. +export const PROFILE_SUPPORTED_FIELD_TYPES = [ + 'text', + 'email', + 'numeric', + 'radio', + 'select', + 'drop_down', + 'password', + 'confirm_password', + 'date', +]; + +// Rendered, but read-only - set at registration or derived elsewhere. The user +// cannot change these here, so they must never be counted as "missing" either. +export const PROFILE_NON_EDITABLE_FIELDS = [ + 'first_name', + 'firstName', + 'last_name', + 'lastName', + 'state', + 'district', + 'block', + 'village', +]; + +// Never rendered at all - internal/system fields. +export const PROFILE_ALWAYS_HIDDEN_FIELDS = [ 'username', 'password', 'confirm_password', 'is_volunteer', +]; + +// Fully editable in the forms, but genuinely optional - a user may legitimately +// have no value for these, so leaving them blank must not keep the Complete +// Profile banner up forever. +export const PROFILE_COMPLETENESS_OPTIONAL_FIELDS = [ + 'middle_name', + 'middleName', +]; + +const ALWAYS_EXCLUDED_PROFILE_FIELDS = [ + ...PROFILE_ALWAYS_HIDDEN_FIELDS, + ...PROFILE_NON_EDITABLE_FIELDS, + ...PROFILE_COMPLETENESS_OPTIONAL_FIELDS, 'center', 'batch', 'program', - 'first_name', - 'firstName', - 'last_name', - 'lastName', ]; -// Given the merged schema (§getMergedProfileSchema) and the user's saved profile -// data (§buildUserDetailsObject), returns which schema fields have no value yet. -// Mirrors ProfileUpdateForm's conditional visibility rules (age, family member -// selection, phone ownership) so a field the user could never be shown is never -// flagged as "missing". -export const getMissingProfileFields = (schema, userDetails) => { - const age = userDetails?.dob ? parseInt(calculateAge(userDetails.dob), 10) : null; +// Whether `field` should be rendered at all given the rest of the form's current +// values. Single source of truth shared by the Edit Profile form, the Complete +// Profile mini-form, and the banner's completeness check, so the three can't +// drift apart. Note "visible" != "editable": first_name/state/etc. are visible +// but read-only (see PROFILE_NON_EDITABLE_FIELDS). +export const isProfileFieldVisible = (field, formData) => { + if (PROFILE_ALWAYS_HIDDEN_FIELDS.includes(field?.name)) { + return false; + } + + const dob = formData?.dob || ''; + let age = null; + if (dob) { + try { + const parsed = calculateAge(dob); + age = + parsed !== null && parsed !== undefined && !isNaN(parsed) + ? parseInt(parsed, 10) + : null; + } catch { + age = null; + } + } + + if ( + ['guardian_relation', 'guardian_name', 'parent_phone'].includes(field?.name) && + age !== null && + age >= 18 + ) { + return false; + } + if ( + ['mobile', 'phone_num', 'phone_number'].includes(field?.name) && + age !== null && + age < 18 + ) { + return false; + } - const rawFamilyType = userDetails?.family_member_details; + const rawFamilyType = formData?.family_member_details; const familyType = rawFamilyType && typeof rawFamilyType === 'object' ? rawFamilyType.value : rawFamilyType; - const familyNameFieldForType = { - mother: 'mother_name', - father: 'father_name', - spouse: 'spouse_name', - }[familyType]; + if (['father_name', 'mother_name', 'spouse_name'].includes(field?.name)) { + if (!familyType) { + return false; + } + return field.name === `${familyType}_name`; + } - const rawPhoneType = userDetails?.phone_type_accessible; + const rawPhoneType = formData?.phone_type_accessible; const phoneType = rawPhoneType && typeof rawPhoneType === 'object' ? rawPhoneType.value : rawPhoneType; + if (field?.name === 'own_phone_check' && phoneType === 'nophone') { + return false; + } + return true; +}; + +// Given the merged schema (§getMergedProfileSchema) and the user's saved profile +// data (§buildUserDetailsObject), returns which schema fields have no value yet. +// +// A field only counts as "missing" if the user can actually fill it in through +// the Complete Profile form: it must render (supported type + currently visible) +// and be editable. Anything else would produce a banner the user can never clear, +// because the form it opens would have no usable input for that field. +export const getMissingProfileFields = (schema, userDetails) => { const missingFields = (schema || []) .filter((field) => field?.name) .filter((field) => !ALWAYS_EXCLUDED_PROFILE_FIELDS.includes(field.name)) - .filter((field) => { - if ( - ['guardian_relation', 'guardian_name', 'parent_phone'].includes(field.name) && - age !== null && - age >= 18 - ) { - return false; - } - if ( - ['mobile', 'phone_num', 'phone_number'].includes(field.name) && - age !== null && - age < 18 - ) { - return false; - } - if (field.name === 'own_phone_check' && phoneType === 'nophone') { - return false; - } - if (['father_name', 'mother_name', 'spouse_name'].includes(field.name)) { - return field.name === familyNameFieldForType; - } - return true; - }) + .filter((field) => PROFILE_SUPPORTED_FIELD_TYPES.includes(field.type)) + .filter((field) => isProfileFieldVisible(field, userDetails)) .filter((field) => !extractProfileFieldValue(userDetails?.[field.name])) .map((field) => field.name);