Created
January 4, 2026 05:18
-
-
Save ManiruzzamanAkash/5907645ebd2b1341a78ac350b0678c5b to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { useState, useEffect, useRef, useCallback } from "react"; | |
| import ArrowDownIcon from "../../../../IconComponents/ArrowDownIcon"; | |
| import ArrowUpIcon from "../../../../IconComponents/ArrowUpIcon"; | |
| import TabSelector from "../../../components/TabSelector"; | |
| import axios from "@utils/axios"; | |
| import { useForm } from "react-hook-form"; | |
| import { notifyError, notifySuccess } from "../../../components/Notification"; | |
| import CommentForm from "../../components/tools/CommentForm"; | |
| import TextEditorForCourse from "../../../../components/text-editor/text-editor-for-course"; | |
| import useDraftManager from "../../../../hooks/useDraftManager"; | |
| import { UnsavedChangesNotice } from "../../../../components/drafts/unsaved-changes"; | |
| import NoSessionIcon from "@components/sessions/no-session-icon"; | |
| // Update the button styles with consistent text colors | |
| const buttonStyles = { | |
| active: "bg-defaultPrimary text-white", | |
| default: "bg-defaultPrimary-30 text-defaultPrimary", | |
| hover: "hover:bg-defaultPrimary hover:text-white", | |
| action: "transition-colors", | |
| }; | |
| export default function ListView({ | |
| currentPage, | |
| sessions, | |
| lastPage, | |
| moreHandler, | |
| onCancelAppointment, | |
| loadingMore, | |
| activeTab, | |
| onTabChange, | |
| }) { | |
| const [selectedSession, setSelectedSession] = useState(null); | |
| const [sessionOpenDetails, setSessionOpenDetails] = useState(null); | |
| const [openCoachingPlan, setOpenCoachingPlan] = useState(false); | |
| const [openNotes, setOpenNotes] = useState(false); | |
| const [openTools, setOpenTools] = useState(false); | |
| const [coachNote, setCoachNote] = useState(""); | |
| const [privateNote, setPrivateNote] = useState(""); | |
| const [isCancelling, setIsCancelling] = useState(false); | |
| const sessionsContainerRef = useRef(null); | |
| useEffect(() => { | |
| // Check URL parameters for session to expand | |
| const urlParams = new URLSearchParams(window.location.search); | |
| const sessionToExpand = urlParams.get("session"); | |
| const shouldExpand = urlParams.get("expand"); | |
| const section = urlParams.get("section"); | |
| // Reset all section states first | |
| setOpenCoachingPlan(false); | |
| setOpenNotes(false); | |
| setOpenTools(false); | |
| if (sessionToExpand && shouldExpand && sessions.length > 0) { | |
| const session = sessions.find((s) => s.uuid === sessionToExpand); | |
| if (session) { | |
| setSessionOpenDetails(session.id); | |
| setSelectedSession(session); | |
| // Open the appropriate section based on the section parameter | |
| switch (section) { | |
| case "coaching_plan": | |
| setOpenTools(false); // Close other sections | |
| setOpenNotes(false); | |
| setOpenCoachingPlan(true); // Open QuestionsPreview | |
| setTimeout(() => { | |
| document | |
| .querySelector(".coaching-plan-section") | |
| ?.scrollIntoView({ behavior: "smooth" }); | |
| }, 100); | |
| break; | |
| case "notes": | |
| setOpenTools(false); // Close other sections | |
| setOpenCoachingPlan(false); | |
| setOpenNotes(true); // Open NotesPreview | |
| setTimeout(() => { | |
| document | |
| .querySelector(".coach-notes-section") | |
| ?.scrollIntoView({ behavior: "smooth" }); | |
| }, 100); | |
| break; | |
| case "tools": | |
| setOpenCoachingPlan(false); // Close other sections | |
| setOpenNotes(false); | |
| setOpenTools(true); // Open ToolsPreview | |
| setTimeout(() => { | |
| document | |
| .querySelector(".tools-section") | |
| ?.scrollIntoView({ behavior: "smooth" }); | |
| }, 100); | |
| break; | |
| } | |
| } | |
| } | |
| // Cleanup function | |
| return () => { | |
| setOpenCoachingPlan(false); | |
| setOpenNotes(false); | |
| setOpenTools(false); | |
| }; | |
| }, [sessions]); | |
| // Replace the filteredSessions logic - we don't need to filter by date anymore | |
| // since the backend will return the correct sessions based on activeTab | |
| const sessionsList = sessions || []; | |
| // If we have a session to expand, ensure all sessions are loaded | |
| useEffect(() => { | |
| const urlParams = new URLSearchParams(window.location.search); | |
| const sessionToExpand = urlParams.get("session"); | |
| if (sessionToExpand && sessions.length > 0) { | |
| const session = sessions.find((s) => s.uuid === sessionToExpand); | |
| if (!session && currentPage < lastPage) { | |
| // If we haven't found the session and there are more pages, load more | |
| moreHandler(); | |
| } | |
| } | |
| }, [sessions, currentPage, lastPage]); | |
| const statusClasses = (session) => { | |
| return " text-gray-900"; | |
| }; | |
| const toggleSessionDetails = (event, session) => { | |
| event.stopPropagation(); | |
| if (sessionOpenDetails === session.id) { | |
| setSessionOpenDetails(null); | |
| setSelectedSession(null); | |
| } else { | |
| setSessionOpenDetails(session.id); | |
| setSelectedSession(session); | |
| } | |
| }; | |
| const submitCoachNote = async (sessionId) => { | |
| if (!coachNote.trim()) { | |
| notifyError("Please enter a note"); | |
| return; | |
| } | |
| try { | |
| const response = await axios.post("/coach/session/notes/store", { | |
| session_id: sessionId, | |
| note: coachNote, | |
| type: "coach", | |
| }); | |
| if (response.data.status === "success") { | |
| notifySuccess("Note added successfully"); | |
| setCoachNote(""); | |
| // Refresh session data | |
| const updatedSession = await loadSessionData(sessionId); | |
| setSelectedSession(updatedSession); | |
| } | |
| } catch (error) { | |
| notifyError("Failed to add note"); | |
| console.error(error); | |
| } | |
| }; | |
| const submitPrivateNote = async (sessionId) => { | |
| if (!privateNote.trim()) { | |
| notifyError("Please enter a note"); | |
| return; | |
| } | |
| try { | |
| const response = await axios.post("/coach/session/notes/store", { | |
| session_id: sessionId, | |
| note: privateNote, | |
| type: "private", | |
| }); | |
| if (response.data.status === "success") { | |
| notifySuccess("Private note added successfully"); | |
| setPrivateNote(""); | |
| const updatedSession = await loadSessionData(sessionId); | |
| setSelectedSession(updatedSession); | |
| } | |
| } catch (error) { | |
| notifyError("Failed to add private note"); | |
| console.error(error); | |
| } | |
| }; | |
| const loadSessionData = async (sessionId) => { | |
| try { | |
| const response = await axios.get( | |
| `/client/sessions/${sessionId}/data` | |
| ); | |
| return response.data; | |
| } catch (error) { | |
| console.error("Failed to load session data:", error); | |
| return null; | |
| } | |
| }; | |
| // Preview Components | |
| const CoachingPlanPreview = ({ session }) => ( | |
| <div className="flex flex-col bg-white"> | |
| <button | |
| onClick={() => setOpenCoachingPlan(!openCoachingPlan)} | |
| className={`flex items-center justify-between w-full px-4 py-3 rounded-lg transition-all duration-200 | |
| ${ | |
| openCoachingPlan | |
| ? "bg-defaultPrimary text-white shadow-sm" | |
| : "bg-blue-50 text-defaultPrimary hover:bg-blue-100" | |
| }`} | |
| > | |
| <h1 className="normal-text font-semibold text-center items-center"> | |
| Coaching plan | |
| </h1> | |
| {openCoachingPlan ? ( | |
| <ArrowDownIcon | |
| className={`w-5 h-5 ${ | |
| openCoachingPlan ? "text-white" : "text-defaultPrimary" | |
| }`} | |
| /> | |
| ) : ( | |
| <ArrowUpIcon | |
| className={`w-5 h-5 ${ | |
| openCoachingPlan ? "text-white" : "text-defaultPrimary" | |
| }`} | |
| /> | |
| )} | |
| </button> | |
| {openCoachingPlan && ( | |
| <div className="mt-6 space-y-6"> | |
| {session?.coaching_plan?.questions?.map((question) => ( | |
| <div | |
| key={question.id} | |
| className="bg-white p-6 rounded-lg shadow-sm border border-gray-100" | |
| > | |
| <h1 className="normal-text font-semibold text-gray-900 mb-3"> | |
| {question.question} | |
| </h1> | |
| <p className="text-gray-700"> | |
| {question.answer || "No answer provided"} | |
| </p> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| const SessionCoachNote = ({ notes, className }) => ( | |
| <div className="flex flex-col bg-white"> | |
| <button | |
| onClick={() => setOpenNotes(!openNotes)} | |
| className={ | |
| "flex flex-row items-center justify-between w-full " + | |
| (openNotes | |
| ? "primary-button" | |
| : "secondary-button !bg-blue-50") | |
| } | |
| > | |
| <h1 | |
| className={ | |
| "text-center w-full normal-text " + | |
| (openNotes ? "" : "text-black") | |
| } | |
| > | |
| Coach Notes | |
| </h1> | |
| {openNotes ? ( | |
| <ArrowDownIcon className="w-4 h-4 text-gray-600" /> | |
| ) : ( | |
| <ArrowUpIcon className="w-4 h-4 text-gray-600" /> | |
| )} | |
| </button> | |
| {openNotes && ( | |
| <div className="flex flex-col gap-[24px] mt-[32px]"> | |
| <TextEditorForCourse | |
| value={notes} | |
| onChange={setCoachNote} | |
| placeholder="Add a coach note..." | |
| /> | |
| <button | |
| onClick={() => submitCoachNote(session.id)} | |
| className={`px-6 py-3 rounded-lg normal-text font-medium w-fit ${buttonStyles.action}`} | |
| > | |
| Add Note | |
| </button> | |
| {session?.coach_notes?.map((note) => ( | |
| <div key={note.id} className="bg-gray-50 p-4 rounded"> | |
| <div | |
| dangerouslySetInnerHTML={{ __html: note.note }} | |
| /> | |
| <div className="text-sm text-gray-500 mt-2"> | |
| {new Date(note.created_at).toLocaleString()} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| const NotesPreview = ({ session }) => { | |
| const [openNotes, setOpenNotes] = useState(false); | |
| const [isHovered, setIsHovered] = useState(false); | |
| const [isEditing, setIsEditing] = useState( | |
| !session.client_notes?.[0]?.note_json | |
| ); | |
| const [notes, setNotes] = useState({ | |
| my_current_focus: "", | |
| notes_from_session: "", | |
| action_items_committing_to: "", | |
| questions_arise_during_session: "", | |
| }); | |
| // Only initialize draft managers when notes section is open | |
| const { | |
| updateDraft: updateCurrentFocusDraft, | |
| clearDraft: clearCurrentFocusDraft, | |
| isDirty: hasCurrentFocusUnsavedChanges, | |
| } = useDraftManager( | |
| openNotes && session?.uuid | |
| ? `client_notes_current_focus:${session.uuid}` | |
| : null, | |
| "", | |
| { | |
| autoSaveDelay: 2000, | |
| enableLocalStorage: true, | |
| enableServerStorage: false, // don't store this on server. | |
| onRestored: (restoredData) => { | |
| if (restoredData && restoredData.trim()) { | |
| setNotes((prev) => ({ | |
| ...prev, | |
| my_current_focus: restoredData, | |
| })); | |
| } | |
| }, | |
| } | |
| ); | |
| const { | |
| updateDraft: updateSessionNotesDraft, | |
| clearDraft: clearSessionNotesDraft, | |
| isDirty: hasSessionNotesUnsavedChanges, | |
| } = useDraftManager( | |
| openNotes && session?.uuid | |
| ? `client_notes_session_notes:${session.uuid}` | |
| : null, | |
| "", | |
| { | |
| autoSaveDelay: 2000, | |
| enableLocalStorage: true, | |
| enableServerStorage: false, // don't store this on server. | |
| onRestored: (restoredData) => { | |
| if (restoredData && restoredData.trim()) { | |
| setNotes((prev) => ({ | |
| ...prev, | |
| notes_from_session: restoredData, | |
| })); | |
| } | |
| }, | |
| } | |
| ); | |
| const { | |
| updateDraft: updateActionItemsDraft, | |
| clearDraft: clearActionItemsDraft, | |
| isDirty: hasActionItemsUnsavedChanges, | |
| } = useDraftManager( | |
| openNotes && session?.uuid | |
| ? `client_notes_action_items:${session.uuid}` | |
| : null, | |
| "", | |
| { | |
| autoSaveDelay: 2000, | |
| enableLocalStorage: true, | |
| enableServerStorage: false, // don't store this on server. | |
| onRestored: (restoredData) => { | |
| if (restoredData && restoredData.trim()) { | |
| setNotes((prev) => ({ | |
| ...prev, | |
| action_items_committing_to: restoredData, | |
| })); | |
| } | |
| }, | |
| } | |
| ); | |
| const { | |
| updateDraft: updateQuestionsDraft, | |
| clearDraft: clearQuestionsDraft, | |
| isDirty: hasQuestionsUnsavedChanges, | |
| } = useDraftManager( | |
| openNotes && session?.uuid | |
| ? `client_notes_questions:${session.uuid}` | |
| : null, | |
| "", | |
| { | |
| autoSaveDelay: 2000, | |
| enableLocalStorage: true, | |
| enableServerStorage: false, // don't store this on server. | |
| onRestored: (restoredData) => { | |
| if (restoredData && restoredData.trim()) { | |
| setNotes((prev) => ({ | |
| ...prev, | |
| questions_arise_during_session: restoredData, | |
| })); | |
| } | |
| }, | |
| } | |
| ); | |
| useEffect(() => { | |
| if (session.client_notes?.[0]?.note_json) { | |
| setNotes(session.client_notes[0].note_json); | |
| setIsEditing(false); | |
| } else { | |
| setIsEditing(true); | |
| } | |
| }, [session]); | |
| const handleSubmit = async (field) => { | |
| if (!notes[field]) { | |
| notifyError("Please enter some content before saving."); | |
| return; | |
| } | |
| try { | |
| let response; | |
| const updatedNotes = { ...notes }; | |
| const sessionUuid = session.uuid; | |
| const isEditing = session.client_notes?.[0]?.id; | |
| if (isEditing) { | |
| response = await axios.put( | |
| `/client/sessions/${sessionUuid}/notes/${session.client_notes[0].id}`, | |
| updatedNotes | |
| ); | |
| } else { | |
| response = await axios.post( | |
| `/client/sessions/${sessionUuid}/notes`, | |
| updatedNotes | |
| ); | |
| } | |
| if (response.data.status === "success") { | |
| notifySuccess("Your note has been saved successfully!"); | |
| // Clear the appropriate draft after successful save | |
| switch (field) { | |
| case "my_current_focus": | |
| await clearCurrentFocusDraft(); | |
| break; | |
| case "notes_from_session": | |
| await clearSessionNotesDraft(); | |
| break; | |
| case "action_items_committing_to": | |
| await clearActionItemsDraft(); | |
| break; | |
| case "questions_arise_during_session": | |
| await clearQuestionsDraft(); | |
| break; | |
| } | |
| // If this is a new note, update the session data to include the new note | |
| if (!isEditing && response.data.data) { | |
| setSelectedSession((prev) => ({ | |
| ...prev, | |
| client_notes: [ | |
| response.data.data, | |
| ...(prev.client_notes || []), | |
| ], | |
| })); | |
| } | |
| } else { | |
| notifyError( | |
| response.data.message || "Failed to save note." | |
| ); | |
| } | |
| } catch (error) { | |
| console.error("Error saving notes:", error); | |
| notifyError( | |
| error.response?.data?.message || "Failed to save note." | |
| ); | |
| } | |
| }; | |
| // Handle draft updates for each field | |
| const handleNotesChange = (field, value) => { | |
| setNotes((prev) => ({ | |
| ...prev, | |
| [field]: value, | |
| })); | |
| // Only update appropriate draft if notes section is open | |
| if (session?.uuid && openNotes) { | |
| switch (field) { | |
| case "my_current_focus": | |
| updateCurrentFocusDraft(value); | |
| break; | |
| case "notes_from_session": | |
| updateSessionNotesDraft(value); | |
| break; | |
| case "action_items_committing_to": | |
| updateActionItemsDraft(value); | |
| break; | |
| case "questions_arise_during_session": | |
| updateQuestionsDraft(value); | |
| break; | |
| } | |
| } | |
| }; | |
| // Get the appropriate isDirty flag for each field | |
| const getUnsavedChangesFlag = (field) => { | |
| switch (field) { | |
| case "my_current_focus": | |
| return hasCurrentFocusUnsavedChanges; | |
| case "notes_from_session": | |
| return hasSessionNotesUnsavedChanges; | |
| case "action_items_committing_to": | |
| return hasActionItemsUnsavedChanges; | |
| case "questions_arise_during_session": | |
| return hasQuestionsUnsavedChanges; | |
| default: | |
| return false; | |
| } | |
| }; | |
| const renderField = (field, label) => { | |
| if (isEditing) { | |
| return ( | |
| <div className="w-full mb-6"> | |
| <h1 className="normal-text font-medium text-gray-900 mb-3 flex justify-between"> | |
| {label} | |
| {getUnsavedChangesFlag(field) && <UnsavedChangesNotice />} | |
| </h1> | |
| <div className="bg-white rounded-lg"> | |
| <TextEditorForCourse | |
| value={notes[field]} | |
| onChange={(value) => | |
| handleNotesChange(field, value) | |
| } | |
| // placeholder={`Enter your ${label.toLowerCase()}...`} | |
| /> | |
| </div> | |
| <div className="flex justify-end mt-4"> | |
| <button | |
| onClick={() => handleSubmit(field)} | |
| className={`px-6 py-2 rounded-lg normal-text font-medium ${buttonStyles.action}`} | |
| > | |
| Save | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| return notes[field] ? ( | |
| <div className="w-full mb-6"> | |
| <h1 className="normal-text font-semibold text-gray-800 mb-4"> | |
| {label} | |
| </h1> | |
| <div | |
| className="normal-text text-gray-800 leading-relaxed rendered-content" | |
| dangerouslySetInnerHTML={{ __html: notes[field] }} | |
| style={{ | |
| "& ul": { | |
| listStyle: "disc", | |
| paddingLeft: "2rem", | |
| marginTop: "0.5rem", | |
| marginBottom: "0.5rem", | |
| }, | |
| "& ol": { | |
| listStyle: "decimal", | |
| paddingLeft: "2rem", | |
| marginTop: "0.5rem", | |
| marginBottom: "0.5rem", | |
| }, | |
| "& li": { marginBottom: "0.25rem" }, | |
| "& p": { marginBottom: "0.5rem" }, | |
| }} | |
| /> | |
| <style jsx>{` | |
| .rendered-content ul { | |
| list-style: disc !important; | |
| padding-left: 2rem !important; | |
| margin: 0.5rem 0 !important; | |
| } | |
| .rendered-content ol { | |
| list-style: decimal !important; | |
| padding-left: 2rem !important; | |
| margin: 0.5rem 0 !important; | |
| } | |
| .rendered-content li { | |
| margin-bottom: 0.25rem !important; | |
| display: list-item !important; | |
| } | |
| .rendered-content p { | |
| margin-bottom: 0.5rem !important; | |
| } | |
| `}</style> | |
| </div> | |
| ) : null; | |
| }; | |
| return ( | |
| <div className="flex flex-col bg-white"> | |
| <button | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| setOpenNotes(!openNotes); | |
| }} | |
| onMouseEnter={() => setIsHovered(true)} | |
| onMouseLeave={() => setIsHovered(false)} | |
| className={ | |
| "flex flex-row items-center justify-between w-full px-4 py-3 rounded-lg transition-colors border border-defaultPrimary normal-text " + | |
| (openNotes | |
| ? buttonStyles.active | |
| : `${buttonStyles.default} ${buttonStyles.hover}`) | |
| } | |
| > | |
| <h1 className={`normal-text font-semibold text-center items-center ${!openNotes && isHovered ? "text-white" : ""}`}> | |
| My Private Notes | |
| </h1> | |
| {openNotes ? ( | |
| <ArrowDownIcon className="w-5 h-5 text-white" /> | |
| ) : ( | |
| <ArrowUpIcon className={`w-5 h-5 ${isHovered ? "text-white" : "text-defaultPrimary"}`} /> | |
| )} | |
| </button> | |
| {openNotes && ( | |
| <div className="flex flex-col gap-6 mt-6"> | |
| <div className="bg-blue-50/10 p-6 rounded-xl border border-blue-100"> | |
| {isEditing ? ( | |
| <div className="space-y-4"> | |
| {renderField( | |
| "my_current_focus", | |
| "My current focus and priorities" | |
| )} | |
| {renderField( | |
| "notes_from_session", | |
| "Notes from our session" | |
| )} | |
| {renderField( | |
| "action_items_committing_to", | |
| "Action items I'm committing to" | |
| )} | |
| {renderField( | |
| "questions_arise_during_session", | |
| "Questions that arise during session" | |
| )} | |
| <div className="flex justify-end mt-4"> | |
| <button | |
| onClick={() => setIsEditing(false)} | |
| className="px-6 py-2 rounded-lg normal-text font-medium text-gray-700 hover:bg-gray-100 border border-gray-300 ml-4" | |
| > | |
| Close | |
| </button> | |
| </div> | |
| </div> | |
| ) : ( | |
| <div className="space-y-3"> | |
| {renderField( | |
| "my_current_focus", | |
| "My current focus and priorities" | |
| )} | |
| {renderField( | |
| "notes_from_session", | |
| "Notes from our session" | |
| )} | |
| {renderField( | |
| "action_items_committing_to", | |
| "Action items I'm committing to" | |
| )} | |
| {renderField( | |
| "questions_arise_during_session", | |
| "Questions that arise during session" | |
| )} | |
| <button | |
| onClick={() => setIsEditing(true)} | |
| className={`px-6 py-3 rounded-lg normal-text font-medium ${buttonStyles.action}`} | |
| > | |
| Edit Notes | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| const QuestionsPreview = ({ session }) => { | |
| const [showEntries, setShowEntries] = useState(2); | |
| const [showCommentForm, setShowCommentForm] = useState(false); | |
| const [questionAnswers, setQuestionAnswers] = useState({}); | |
| const [unsavedQuestions, setUnsavedQuestions] = useState(new Set()); // Track which questions have unsaved changes | |
| const [isHovered, setIsHovered] = useState(false); | |
| // Draft management for coaching plan questions | |
| const { | |
| updateDraft: updateQuestionDraft, | |
| clearDraft: clearQuestionDraft, | |
| } = useDraftManager( | |
| openCoachingPlan && session?.uuid ? `coaching_plan_questions:${session.uuid}` : null, | |
| "", | |
| { | |
| autoSaveDelay: 2000, | |
| enableLocalStorage: true, | |
| enableServerStorage: false, | |
| onRestored: (restoredData) => { | |
| if (restoredData && restoredData.trim()) { | |
| try { | |
| const parsedData = JSON.parse(restoredData); | |
| setQuestionAnswers(parsedData); | |
| // Mark questions with content as unsaved | |
| const unsavedSet = new Set(); | |
| Object.entries(parsedData).forEach(([questionId, value]) => { | |
| if (value && value.trim()) { | |
| unsavedSet.add(parseInt(questionId)); | |
| } | |
| }); | |
| setUnsavedQuestions(unsavedSet); | |
| } catch (error) { | |
| console.error("Error parsing restored question data:", error); | |
| } | |
| } | |
| }, | |
| } | |
| ); | |
| const { | |
| register, | |
| handleSubmit, | |
| formState: { errors }, | |
| setValue, | |
| } = useForm({ | |
| defaultValues: { | |
| questions: session?.plan_details?.[0]?.questions?.reduce( | |
| (acc, question) => { | |
| acc[question.id] = { | |
| id: question.id, | |
| question: question.question, | |
| answer: "", | |
| }; | |
| return acc; | |
| }, | |
| {} | |
| ) || {}, | |
| }, | |
| }); | |
| // Handle answer changes with draft saving | |
| const handleAnswerChange = (questionId, value) => { | |
| const updatedAnswers = { | |
| ...questionAnswers, | |
| [questionId]: value | |
| }; | |
| setQuestionAnswers(updatedAnswers); | |
| setValue(`questions.${questionId}.answer`, value); | |
| // Track which questions have unsaved changes | |
| setUnsavedQuestions(prev => { | |
| const newSet = new Set(prev); | |
| if (value && value.trim()) { | |
| newSet.add(questionId); | |
| } else { | |
| newSet.delete(questionId); | |
| } | |
| return newSet; | |
| }); | |
| // Save to draft | |
| if (session?.uuid && openCoachingPlan) { | |
| updateQuestionDraft(JSON.stringify(updatedAnswers)); | |
| } | |
| }; | |
| const hasSubmittedAnswers = session?.plan_details?.[0]?.questions?.some( | |
| (question) => | |
| question.answer?.answer && question.answer?.answer !== "-" | |
| ); | |
| const onSubmit = async (data) => { | |
| // Ensure questions object exists and is not null/undefined | |
| const questions = data?.questions || {}; | |
| let haveAnyAnswers = | |
| Object.keys(questions)?.filter( | |
| (key) => questions[key]?.answer !== "" | |
| ) || []; | |
| if (haveAnyAnswers.length === 0) { | |
| notifyError( | |
| "Please answer at least one question to submit the plan." | |
| ); | |
| return; | |
| } | |
| const processedData = Object.keys(questions).reduce( | |
| (acc, key) => { | |
| if (questions[key].answer === "") { | |
| questions[key].answer = "-"; | |
| } | |
| acc.push(questions[key]); | |
| return acc; | |
| }, | |
| [] | |
| ); | |
| try { | |
| const url = `/client/sessions/${session.uuid}/plan`; | |
| const response = await axios.post( | |
| url, | |
| { | |
| questions: processedData, | |
| }, | |
| { | |
| headers: { | |
| "X-CSRF-TOKEN": document | |
| .querySelector('meta[name="csrf-token"]') | |
| .getAttribute("content"), | |
| }, | |
| } | |
| ); | |
| if (response.data.status === "success") { | |
| notifySuccess(response.data.message); | |
| // Clear draft after successful submission | |
| await clearQuestionDraft(); | |
| // Clear unsaved changes tracking | |
| setUnsavedQuestions(new Set()); | |
| setQuestionAnswers({}); | |
| const updatedSession = await loadSessionData(session.id); | |
| if (updatedSession) { | |
| setSelectedSession(updatedSession); | |
| } | |
| } else { | |
| notifyError(response.data.message); | |
| } | |
| } catch (error) { | |
| notifyError( | |
| error.response?.data?.message || | |
| "Unknown error occurred while saving session plan" | |
| ); | |
| console.error(error); | |
| } | |
| }; | |
| const submitComment = async () => { | |
| if (!comment) { | |
| notifyError("Please enter a comment before submitting."); | |
| return; | |
| } | |
| try { | |
| const url = `/client/sessions/${selectedSession.uuid}/comments`; | |
| const response = await axios.post(url, { | |
| comment, | |
| }); | |
| if (response.data.status === "success") { | |
| notifySuccess("Your comment has been added."); | |
| // Create new comment object with user data from response | |
| const newComment = { | |
| id: response.data.id, | |
| entry_details: response.data.comment, | |
| formatted_created_at: | |
| response.data.formatted_created_at, | |
| user: response.data.user, | |
| }; | |
| // Update local state with the new comment | |
| setSelectedSession((prevSession) => ({ | |
| ...prevSession, | |
| plan_comments: [ | |
| newComment, | |
| ...(prevSession.plan_comments || []), | |
| ], | |
| })); | |
| // Clear comment input | |
| setComment(""); | |
| } else { | |
| notifyError(response.data.message); | |
| } | |
| } catch (error) { | |
| notifyError("An error occurred while submitting the comment."); | |
| console.error(error); | |
| } | |
| }; | |
| return ( | |
| <div className="flex flex-col bg-white coaching-plan-section"> | |
| <button | |
| onClick={() => setOpenCoachingPlan(!openCoachingPlan)} | |
| onMouseEnter={() => setIsHovered(true)} | |
| onMouseLeave={() => setIsHovered(false)} | |
| className={ | |
| "flex flex-row items-center justify-between w-full px-4 py-3 rounded-lg transition-colors border border-defaultPrimary " + | |
| (openCoachingPlan | |
| ? buttonStyles.active | |
| : `${buttonStyles.default} ${buttonStyles.hover}`) | |
| } | |
| > | |
| <h1 className={`normal-text font-semibold text-center items-center ${!openCoachingPlan && isHovered ? "text-white" : ""}`}> | |
| Coaching Plan | |
| </h1> | |
| {openCoachingPlan ? ( | |
| <ArrowDownIcon className="w-5 h-5 text-white" /> | |
| ) : ( | |
| <ArrowUpIcon className={`w-5 h-5 ${isHovered ? "text-white" : "text-defaultPrimary"}`} /> | |
| )} | |
| </button> | |
| {openCoachingPlan && ( | |
| <div className="flex flex-col gap-6 mt-6"> | |
| {!hasSubmittedAnswers ? ( | |
| <form | |
| onSubmit={handleSubmit(onSubmit)} | |
| className="flex flex-col gap-6" | |
| > | |
| {session?.plan_details?.[0]?.questions?.map( | |
| (question) => ( | |
| <div | |
| key={question.id} | |
| className="flex flex-col gap-3" | |
| > | |
| <h1 className="normal-text font-medium text-gray-900 flex justify-between"> | |
| {question.question} | |
| {unsavedQuestions.has(question.id) && <UnsavedChangesNotice />} | |
| </h1> | |
| <textarea | |
| {...register( | |
| `questions.${question.id}.answer` | |
| )} | |
| value={questionAnswers[question.id] || ""} | |
| onChange={(e) => handleAnswerChange(question.id, e.target.value)} | |
| className="w-full rounded-lg border-gray-400 normal-text" | |
| rows="5" | |
| placeholder={`Enter your answer for: ${question.question}`} | |
| /> | |
| </div> | |
| ) | |
| )} | |
| <button | |
| type="submit" | |
| className={`px-6 py-3 rounded-lg normal-text font-medium w-fit ${buttonStyles.action}`} | |
| > | |
| Submit plan | |
| </button> | |
| </form> | |
| ) : ( | |
| <div className="flex flex-col gap-6"> | |
| {session?.plan_details?.[0]?.questions?.map( | |
| (question) => ( | |
| <div | |
| key={question.id} | |
| className="flex flex-col gap-3" | |
| > | |
| <h1 className="normal-text font-medium text-gray-900"> | |
| {question.question} | |
| </h1> | |
| <p className="normal-text text-gray-700"> | |
| {question?.answer?.answer || | |
| "No answer provided"} | |
| </p> | |
| </div> | |
| ) | |
| )} | |
| </div> | |
| )} | |
| {/* Add Comments Section */} | |
| <div className="mt-6"> | |
| <div className="flex items-center mb-4"> | |
| {!showCommentForm && ( | |
| <button | |
| onClick={() => setShowCommentForm(true)} | |
| className="flex items-center gap-2 text-defaultPrimary transition-colors normal-text font-semibold" | |
| > | |
| Add Comment | |
| </button> | |
| )} | |
| </div> | |
| {showCommentForm && ( | |
| <div className="mb-4"> | |
| <CommentForm | |
| submitUrl={`/client/sessions/${selectedSession.uuid}/comments`} | |
| closeHandler={() => | |
| setShowCommentForm(false) | |
| } | |
| onCommentAdded={(response) => { | |
| const newComment = { | |
| id: response.data.id, | |
| entry_details: | |
| response.data.comment, | |
| formatted_created_at: | |
| response.data | |
| .formatted_created_at, | |
| user: response.data.user, | |
| }; | |
| setSelectedSession( | |
| (prevSession) => ({ | |
| ...prevSession, | |
| plan_comments: [ | |
| newComment, | |
| ...(prevSession.plan_comments || | |
| []), | |
| ], | |
| }) | |
| ); | |
| setShowCommentForm(false); | |
| // Keep coaching plan section open | |
| setOpenCoachingPlan(true); | |
| }} | |
| /> | |
| </div> | |
| )} | |
| {/* Display Comments */} | |
| {session.plan_comments.length > 0 && ( | |
| <div className="space-y-4 sm:space-y-6"> | |
| {session.plan_comments | |
| .slice(0, showEntries) | |
| .map((comment, index) => ( | |
| <div | |
| key={comment.id || index} | |
| className="flex flex-col sm:flex-row items-start gap-1 sm:gap-3 p-3 sm:p-6 bg-blue-50 rounded-xl border border-blue-100" | |
| > | |
| <div className="flex-shrink-0 mb-3 sm:mb-0"> | |
| {comment.user.avatar ? ( | |
| <img | |
| src={ | |
| comment.user | |
| .avatar | |
| } | |
| alt={ | |
| comment.user | |
| .name | |
| } | |
| className="w-8 h-8 sm:w-10 sm:h-10 rounded-full border-2 border-white shadow-sm" | |
| /> | |
| ) : ( | |
| <div className="w-8 h-8 sm:w-10 sm:h-10 rounded-full bg-defaultPrimary text-white flex items-center justify-center font-medium text-lg shadow-sm"> | |
| {comment.user.name.charAt( | |
| 0 | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| <div className="flex-1 min-w-0"> | |
| <div className="flex flex-col mb-2 sm:mb-3"> | |
| <h3 className="normal-text font-medium text-gray-900 mb-1 sm:mb-0"> | |
| {comment.user.name} | |
| </h3> | |
| <span className="instructions-text text-gray-800"> | |
| { | |
| comment.formatted_created_at | |
| } | |
| </span> | |
| </div> | |
| <div | |
| className="normal-text text-gray-800 leading-relaxed break-words" | |
| dangerouslySetInnerHTML={{ | |
| __html: comment.entry_details, | |
| }} | |
| /> | |
| </div> | |
| </div> | |
| ))} | |
| {session.plan_comments.length > | |
| showEntries && ( | |
| <button | |
| onClick={() => | |
| setShowEntries(showEntries + 2) | |
| } | |
| className="text-defaultPrimary hover:text-blue-700 font-medium normal-text mt-2 sm:mt-4 flex items-center gap-2 mx-auto" | |
| > | |
| <span>Show More Comments</span> | |
| <svg | |
| className="w-4 h-4" | |
| fill="none" | |
| stroke="currentColor" | |
| viewBox="0 0 24 24" | |
| > | |
| <path | |
| strokeLinecap="round" | |
| strokeLinejoin="round" | |
| strokeWidth="2" | |
| d="M19 9l-7 7-7-7" | |
| /> | |
| </svg> | |
| </button> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| const CoachNotesPreview = ({ session }) => { | |
| const [showCommentForm, setShowCommentForm] = useState({}); | |
| const [showEntries, setShowEntries] = useState(5); | |
| const [isHovered, setIsHovered] = useState(false); | |
| const onCommentAddedHandler = (response, noteId) => { | |
| // Create new comment object with response data | |
| const newComment = { | |
| id: response.id, | |
| comment: response.comment, | |
| formatted_created_at: response.formatted_created_at, | |
| user: { | |
| name: | |
| response.user?.name || | |
| selectedSession.client?.name || | |
| "Anonymous", | |
| avatar: | |
| response.user?.avatar || selectedSession.client?.avatar, | |
| }, | |
| }; | |
| // Update the local state with the new comment | |
| setSelectedSession((prevSession) => { | |
| const updatedNotes = prevSession.coach_notes.map((n) => { | |
| if (n.id === noteId) { | |
| return { | |
| ...n, | |
| comments: [newComment, ...(n.comments || [])], | |
| }; | |
| } | |
| return n; | |
| }); | |
| return { | |
| ...prevSession, | |
| coach_notes: updatedNotes, | |
| }; | |
| }); | |
| // Only hide the comment form for this specific note | |
| setShowCommentForm((prev) => ({ | |
| ...prev, | |
| [noteId]: false, | |
| })); | |
| // Keep notes section open | |
| setOpenNotes(true); | |
| }; | |
| return ( | |
| <div className="flex flex-col bg-white coach-notes-section"> | |
| <button | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| setOpenNotes(!openNotes); | |
| }} | |
| onMouseEnter={() => setIsHovered(true)} | |
| onMouseLeave={() => setIsHovered(false)} | |
| className={ | |
| "flex flex-row items-center justify-between w-full px-4 py-3 rounded-lg transition-colors border border-defaultPrimary " + | |
| (openNotes | |
| ? buttonStyles.active | |
| : `${buttonStyles.default} ${buttonStyles.hover}`) | |
| } | |
| > | |
| <h1 className={`normal-text font-semibold text-center items-center ${!openNotes && isHovered ? "text-white" : ""}`}> | |
| Coach Notes{" "} | |
| {session?.coach_notes?.length > 0 && ( | |
| <span>({session?.coach_notes?.length || 0})</span> | |
| )} | |
| </h1> | |
| {openNotes ? ( | |
| <ArrowDownIcon className="w-5 h-5 text-white" /> | |
| ) : ( | |
| <ArrowUpIcon className={`w-5 h-5 ${isHovered ? "text-white" : "text-defaultPrimary"}`} /> | |
| )} | |
| </button> | |
| {openNotes && session?.coach_notes?.length > 0 && ( | |
| <div className="flex flex-col gap-6 mt-6"> | |
| {session.coach_notes | |
| .slice(0, showEntries) | |
| .map((note) => ( | |
| <div | |
| key={note.id} | |
| className="flex flex-col gap-4 bg-blue-50 p-6 rounded-xl border border-blue-100" | |
| > | |
| <div className="flex items-center justify-between"> | |
| <div className="flex items-center gap-4"> | |
| {note.user.avatar ? ( | |
| <img | |
| src={note.user.avatar} | |
| alt={note.user.name} | |
| className="w-10 h-10 rounded-full border-2 border-white shadow-sm" | |
| /> | |
| ) : ( | |
| <div className="w-10 h-10 rounded-full bg-defaultPrimary text-white flex items-center justify-center font-medium text-lg shadow-sm"> | |
| {note.user.name.charAt(0)} | |
| </div> | |
| )} | |
| <div className="flex flex-col"> | |
| <div className="normal-text font-medium text-gray-900"> | |
| {note.user.name} | |
| </div> | |
| <div className="instructions-text text-gray-600"> | |
| {note.formatted_created_at} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div | |
| className="prose max-w-none normal-text text-gray-800 leading-relaxed" | |
| dangerouslySetInnerHTML={{ | |
| __html: note.note, | |
| }} | |
| /> | |
| {!showCommentForm[note.id] && ( | |
| <button | |
| onClick={() => | |
| setShowCommentForm((prev) => ({ | |
| ...prev, | |
| [note.id]: true, | |
| })) | |
| } | |
| className="text-defaultPrimary hover:text-blue-700 font-medium normal-text w-max" | |
| > | |
| Add Comment | |
| </button> | |
| )} | |
| {showCommentForm[note.id] && ( | |
| <CommentForm | |
| submitUrl={`/comment/session/note/${note.id}`} | |
| closeHandler={() => | |
| setShowCommentForm((prev) => ({ | |
| ...prev, | |
| [note.id]: false, | |
| })) | |
| } | |
| onCommentAdded={(response) => { | |
| onCommentAddedHandler( | |
| response, | |
| note.id | |
| ); | |
| }} | |
| /> | |
| )} | |
| {note.comments && | |
| note.comments.length > 0 && ( | |
| <div className="mt-4 space-y-4"> | |
| {note.comments.map( | |
| (comment, index) => ( | |
| <div | |
| key={ | |
| comment.id || | |
| index | |
| } | |
| className="flex items-start gap-4 bg-white p-4 rounded-lg border border-gray-100" | |
| > | |
| <div className="flex-shrink-0"> | |
| {comment.user | |
| .avatar ? ( | |
| <img | |
| src={ | |
| comment | |
| .user | |
| .avatar | |
| } | |
| alt={ | |
| comment | |
| .user | |
| .name | |
| } | |
| className="w-8 h-8 rounded-full" | |
| /> | |
| ) : ( | |
| <div className="w-8 h-8 rounded-full bg-defaultPrimary text-white flex items-center justify-center font-medium"> | |
| {( | |
| comment | |
| .user | |
| ?.name || | |
| "Anonymous" | |
| ).charAt( | |
| 0 | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| <div className="flex-1"> | |
| <div className="flex flex-col"> | |
| <span className="normal-text font-medium text-gray-900"> | |
| {comment | |
| .user | |
| ?.name || | |
| "Anonymous"} | |
| </span> | |
| <span className="instructions-text text-gray-600"> | |
| { | |
| comment.formatted_created_at | |
| } | |
| </span> | |
| </div> | |
| <div | |
| className="normal-text text-gray-800 mt-2" | |
| dangerouslySetInnerHTML={{ | |
| __html: comment.comment | |
| }} | |
| /> | |
| </div> | |
| </div> | |
| ) | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| ))} | |
| {session.coach_notes.length > showEntries && ( | |
| <button | |
| onClick={() => setShowEntries(showEntries + 5)} | |
| className="text-defaultPrimary hover:text-blue-700 font-medium normal-text mt-4 flex items-center gap-2" | |
| > | |
| <span>Show More Notes</span> | |
| <svg | |
| className="w-4 h-4" | |
| fill="none" | |
| stroke="currentColor" | |
| viewBox="0 0 24 24" | |
| > | |
| <path | |
| strokeLinecap="round" | |
| strokeLinejoin="round" | |
| strokeWidth="2" | |
| d="M19 9l-7 7-7-7" | |
| /> | |
| </svg> | |
| </button> | |
| )} | |
| </div> | |
| )} | |
| {openNotes && | |
| (!session?.coach_notes || | |
| session.coach_notes.length === 0) && ( | |
| <div className="flex justify-center items-center p-6 normal-text"> | |
| No coach notes available | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| const ToolsPreview = ({ session }) => { | |
| const [isHovered, setIsHovered] = useState(false); | |
| const tools = [ | |
| { | |
| id: 1, | |
| title: "Action Items", | |
| icon: "/logos/action-item.svg", | |
| items: session.action_items || [], | |
| type: "action-item", | |
| path: "action-items", | |
| }, | |
| { | |
| id: 2, | |
| title: "Worksheets", | |
| icon: "/logos/worksheet.svg", | |
| items: session.worksheets || [], | |
| type: "worksheet", | |
| path: "worksheets", | |
| }, | |
| { | |
| id: 3, | |
| title: "Goals", | |
| icon: "/logos/goal.svg", | |
| items: session.goals || [], | |
| type: "goal", | |
| path: "goals", | |
| }, | |
| { | |
| id: 4, | |
| title: "Journals", | |
| icon: "/logos/journals.png", | |
| items: session.journals || [], | |
| type: "journal", | |
| path: "journals", | |
| }, | |
| { | |
| id: 5, | |
| title: "Resources", | |
| icon: "/logos/resources.png", | |
| items: session.resources || [], | |
| type: "resource", | |
| path: "resources", | |
| }, | |
| ]; | |
| return ( | |
| <div className="flex flex-col bg-white tools-section"> | |
| <button | |
| onClick={() => setOpenTools(!openTools)} | |
| onMouseEnter={() => setIsHovered(true)} | |
| onMouseLeave={() => setIsHovered(false)} | |
| className={ | |
| "flex flex-row items-center justify-between w-full px-4 py-3 rounded-lg transition-colors border border-defaultPrimary " + | |
| (openTools | |
| ? buttonStyles.active | |
| : `${buttonStyles.default} ${buttonStyles.hover}`) | |
| } | |
| > | |
| <h1 className={`normal-text font-semibold text-center items-center ${!openTools && isHovered ? "text-white" : ""}`}> | |
| Tools{" "} | |
| {tools?.length > 0 && ( | |
| <span> | |
| ( | |
| {tools?.reduce( | |
| (count, tool) => count + tool.items.length, | |
| 0 | |
| )} | |
| ) | |
| </span> | |
| )} | |
| </h1> | |
| {openTools ? ( | |
| <ArrowDownIcon className="w-5 h-5 text-white" /> | |
| ) : ( | |
| <ArrowUpIcon className={`w-5 h-5 ${isHovered ? "text-white" : "text-defaultPrimary"}`} /> | |
| )} | |
| </button> | |
| {openTools && ( | |
| <div className="flex flex-col gap-6 mt-6"> | |
| {tools.map( | |
| (tool) => | |
| tool.items.length > 0 && ( | |
| <div | |
| key={tool.id} | |
| className="bg-white rounded-lg border border-gray-400 p-6 hover:bg-blue-50" | |
| > | |
| <a | |
| href={`/client/coaching/pad/tools/${tool.path}/${tool.items[0]?.client_tool_uuid}`} | |
| className="flex items-center gap-3 mb-4 w-fit border-2 border-[#67bbf7] p-3 rounded-lg hover:bg-blue-50 transition-colors" | |
| > | |
| <img | |
| src={tool.icon} | |
| alt={tool.title} | |
| className="w-6 h-6" | |
| /> | |
| <h2 className="normal-text font-medium text-gray-900"> | |
| {tool.title} | |
| </h2> | |
| </a> | |
| <div className="flex flex-col gap-4"> | |
| {tool.items.map((item) => ( | |
| <a | |
| key={item.id} | |
| href={`/client/coaching/pad/tools/${tool.path}/${item.client_tool_uuid}`} | |
| className="text-defaultPrimary hover:text-[#0371c0] normal-text" | |
| > | |
| {item.name} | |
| </a> | |
| ))} | |
| </div> | |
| </div> | |
| ) | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| const SessionDetails = ({ session }) => { | |
| // Add debug logs | |
| console.log("Session data:", session); | |
| console.log("Coach notes:", session.coach_notes); | |
| console.log("Coach notes length:", session.coach_notes?.length); | |
| console.log( | |
| "Coach notes condition:", | |
| session.coach_notes && session.coach_notes.length > 0 | |
| ); | |
| return ( | |
| <div className="session-details"> | |
| {/* Coach Notes Section */} | |
| {session.coach_notes && session.coach_notes.length > 0 && ( | |
| <div className="coach-notes-section mb-6"> | |
| <h3 className="text-lg font-semibold mb-4"> | |
| Notes from Coach | |
| </h3> | |
| <div className="space-y-4"> | |
| {session.coach_notes.map((note) => { | |
| // Add debug log for each note | |
| console.log("Processing note:", note); | |
| return ( | |
| <div | |
| key={note.id} | |
| className="bg-gray-50 p-4 rounded-lg" | |
| > | |
| <div className="flex items-center justify-between mb-2"> | |
| <div className="flex items-center"> | |
| {note.user.avatar ? ( | |
| <img | |
| src={note.user.avatar} | |
| alt={note.user.name} | |
| className="w-8 h-8 rounded-full mr-3" | |
| /> | |
| ) : ( | |
| <div className="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center mr-3"> | |
| {note.user.name.charAt( | |
| 0 | |
| )} | |
| </div> | |
| )} | |
| <div> | |
| <div className="font-medium"> | |
| {note.user.name} | |
| </div> | |
| <div className="text-sm text-gray-500"> | |
| { | |
| note.formatted_created_at | |
| } | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div | |
| className="prose max-w-none" | |
| dangerouslySetInnerHTML={{ | |
| __html: note.note, | |
| }} | |
| /> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| )} | |
| {/* Coaching Plan Section */} | |
| {session.plan_details && session.plan_details.length > 0 && ( | |
| <div className="coaching-plan-section"> | |
| {/* ... existing coaching plan code ... */} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| // Use this for scroll handling | |
| const handleScroll = useCallback(() => { | |
| if (loadingMore || currentPage >= lastPage) return; | |
| // Calculate distance from bottom of window to bottom of document | |
| const scrollTop = window.scrollY || document.documentElement.scrollTop; | |
| const windowHeight = window.innerHeight; | |
| const documentHeight = Math.max( | |
| document.body.scrollHeight, | |
| document.body.offsetHeight, | |
| document.documentElement.clientHeight, | |
| document.documentElement.scrollHeight, | |
| document.documentElement.offsetHeight | |
| ); | |
| // Load more when user is within 200px of bottom | |
| const distanceFromBottom = documentHeight - (scrollTop + windowHeight); | |
| console.log( | |
| `Scroll debug: Distance from bottom: ${distanceFromBottom}px, Loading: ${loadingMore}` | |
| ); | |
| if (distanceFromBottom < 200 && !loadingMore && moreHandler) { | |
| console.log("Loading more sessions from scroll trigger"); | |
| moreHandler(); | |
| } | |
| }, [currentPage, lastPage, loadingMore, moreHandler]); | |
| useEffect(() => { | |
| // Debounce scroll event to improve performance | |
| const debouncedHandleScroll = debounce(handleScroll, 100); | |
| window.addEventListener("scroll", debouncedHandleScroll); | |
| // Check once after render in case the page isn't long enough to scroll | |
| setTimeout(() => handleScroll(), 100); | |
| return () => | |
| window.removeEventListener("scroll", debouncedHandleScroll); | |
| }, [handleScroll]); | |
| // Simple debounce function to limit scroll event handling | |
| function debounce(func, wait) { | |
| let timeout; | |
| return function () { | |
| const context = this; | |
| const args = arguments; | |
| clearTimeout(timeout); | |
| timeout = setTimeout(() => func.apply(context, args), wait); | |
| } | |
| } | |
| // If we have a session to expand, ensure it's visible | |
| useEffect(() => { | |
| const urlParams = new URLSearchParams(window.location.search); | |
| const sessionToExpand = urlParams.get("session"); | |
| if (sessionToExpand && sessions.length > 0) { | |
| const session = sessions.find((s) => s.uuid === sessionToExpand); | |
| if (!session && currentPage < lastPage && moreHandler) { | |
| // If we haven't found the session and there are more pages, load more | |
| moreHandler(); | |
| } | |
| } | |
| }, [sessions, currentPage, lastPage, moreHandler]); | |
| return ( | |
| <div | |
| className="flex flex-col gap-[24px] w-full" | |
| ref={sessionsContainerRef} | |
| > | |
| <div className="px-0 xl:px-4"> | |
| <TabSelector | |
| tabs={[ | |
| { | |
| id: "upcoming", | |
| text: "Upcoming Sessions", | |
| handler: () => onTabChange("upcoming"), | |
| }, | |
| { | |
| id: "past", | |
| text: "Past Sessions", | |
| handler: () => onTabChange("past"), | |
| }, | |
| ]} | |
| selectedTab={activeTab} | |
| /> | |
| </div> | |
| <div className="-mx-2 sm:-mx-4"> | |
| {sessionsList.map((session) => ( | |
| <div | |
| key={session.id} | |
| className="flex flex-col mb-3 sm:mb-4 sm:rounded-xl rounded-none border-b sm:border border-gray-400 bg-white group overflow-hidden" | |
| > | |
| <div | |
| className={`relative flex flex-col xl:flex-row justify-between items-start xl:items-center w-full cursor-pointer bg-white px-3 sm:px-6 py-3 sm:py-5 ${ | |
| sessionOpenDetails === session.id | |
| ? "bg-defaultPrimary/[0.05]" | |
| : "hover:bg-defaultPrimary/[0.02] transition-colors duration-200" | |
| }`} | |
| onClick={(event) => | |
| toggleSessionDetails(event, session) | |
| } | |
| > | |
| {/* Blue line indicator */} | |
| <div | |
| className={`absolute left-0 top-0 h-full w-0.5 xl:w-1 bg-defaultPrimary transition-all duration-300 ${ | |
| sessionOpenDetails === session.id | |
| ? "opacity-100" | |
| : "opacity-70 group-hover:opacity-100" | |
| }`} | |
| /> | |
| {/* Mobile/Tablet Layout */} | |
| <div className="flex flex-col w-full xl:hidden"> | |
| <div className="flex flex-col sm:flex-row sm:items-start justify-between"> | |
| <div className="flex-1 min-w-0 mb-3 sm:mb-0"> | |
| <div className="flex items-center justify-between mb-1"> | |
| <h3 className="text-gray-900 normal-text leading-5 truncate pr-3"> | |
| {session?.coach_name} | |
| </h3> | |
| {(session.session_status === | |
| "cancelled" || | |
| (session.session_status === | |
| "active" && | |
| !isCancelling && | |
| new Date( | |
| session.session_date_and_time | |
| ) > new Date())) && ( | |
| <div className="flex-shrink-0"> | |
| {session.session_status === | |
| "cancelled" ? ( | |
| <span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 border border-red-200 whitespace-nowrap"> | |
| <span className="w-1 h-1 rounded-full bg-red-600 mr-1"></span> | |
| Cancelled | |
| </span> | |
| ) : ( | |
| <button | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| onCancelAppointment(session.id); | |
| }} | |
| className="inline-flex items-center px-1.5 py-1 sm:px-2.5 sm:py-1.5 rounded-md text-xs font-medium text-red-600 hover:text-white hover:bg-red-600 border-0 sm:border sm:border-red-600 transition-all duration-300 gap-1.5 whitespace-nowrap opacity-0 group-hover:opacity-100 hover:shadow-sm active:scale-95 hover:scale-[1.02]" | |
| > | |
| <svg | |
| className="w-3 h-3 sm:w-3.5 sm:h-3.5 flex-shrink-0" | |
| fill="none" | |
| stroke="currentColor" | |
| viewBox="0 0 24 24" | |
| > | |
| <path | |
| strokeLinecap="round" | |
| strokeLinejoin="round" | |
| strokeWidth="2" | |
| d="M6 18L18 6M6 6l12 12" | |
| /> | |
| </svg> | |
| <span className="relative top-px"> | |
| Cancel | |
| </span> | |
| </button> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| <p className="text-gray-600 text-sm leading-5 mb-0.5"> | |
| {`${session.date_month_year} ${ | |
| session.formatted_start_time.split( | |
| " " | |
| )[0] | |
| } - ${session.formatted_end_time}`} | |
| </p> | |
| <p className="text-gray-500 text-sm leading-5 truncate"> | |
| {session.session_type} | |
| </p> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Desktop Layout */} | |
| <div className="hidden xl:flex flex-1 gap-2 sm:gap-4 md:gap-6 relative z-10"> | |
| <div className="flex items-start md:items-center w-full md:w-[18%] pl-4 sm:pl-8 min-w-0"> | |
| <div className="flex flex-col min-w-0 group-hover:translate-x-1 transition-transform duration-300"> | |
| <span | |
| className="text-gray-900 normal-text font-medium truncate" | |
| title={session?.coach_name} | |
| > | |
| {session?.coach_name} | |
| </span> | |
| </div> | |
| </div> | |
| <div className="flex items-center w-full md:w-1/3 justify-start md:justify-center font-medium text-gray-800 normal-text pl-4 md:pl-0 group-hover:translate-x-1 transition-transform duration-300"> | |
| <span className="truncate text-sm sm:text-base"> | |
| {`${session.date_month_year} ${ | |
| session.formatted_start_time.split( | |
| " " | |
| )[0] | |
| } - ${session.formatted_end_time}`} | |
| </span> | |
| </div> | |
| <div className="flex items-center w-full md:w-1/3 pl-4 md:pl-4 pr-8 sm:pr-12 text-gray-700 normal-text group-hover:translate-x-1 transition-transform duration-300"> | |
| <div className="flex items-center justify-between w-full"> | |
| <div className="line-clamp-2 overflow-hidden text-ellipsis"> | |
| {session?.session_type} | |
| </div> | |
| {session.session_status === | |
| "cancelled" ? ( | |
| <span className="inline-flex px-2 sm:px-3 py-1 rounded-full text-xs sm:text-sm font-medium transition-all duration-300 bg-red-100 text-red-800 whitespace-nowrap ml-2"> | |
| Cancelled | |
| </span> | |
| ) : ( | |
| session.session_status === | |
| "active" && | |
| !isCancelling && | |
| new Date( | |
| session.session_date_and_time | |
| ) > new Date() && ( | |
| <button | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| onCancelAppointment(session.id); | |
| }} | |
| className="inline-flex items-center px-1.5 py-1 sm:px-2.5 sm:py-1.5 rounded-md text-xs font-medium text-red-600 hover:text-white hover:bg-red-600 border-0 sm:border sm:border-red-600 transition-all duration-300 gap-1.5 whitespace-nowrap opacity-0 group-hover:opacity-100 hover:shadow-sm active:scale-95 hover:scale-[1.02]" | |
| disabled={isCancelling} | |
| > | |
| <svg | |
| className="w-3 h-3 sm:w-3.5 sm:h-3.5 flex-shrink-0" | |
| fill="none" | |
| stroke="currentColor" | |
| viewBox="0 0 24 24" | |
| > | |
| <path | |
| strokeLinecap="round" | |
| strokeLinejoin="round" | |
| strokeWidth="2" | |
| d="M6 18L18 6M6 6l12 12" | |
| /> | |
| </svg> | |
| <span className="relative top-px"> | |
| Cancel | |
| </span> | |
| </button> | |
| ) | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| {/* Arrow indicator - Adjusted for tablet */} | |
| <div | |
| className={`absolute right-2 sm:right-4 xl:right-6 top-1/2 -translate-y-1/2 transition-all duration-300 ${ | |
| sessionOpenDetails === session.id | |
| ? "rotate-180 text-defaultPrimary" | |
| : "text-gray-400 group-hover:text-gray-600" | |
| }`} | |
| > | |
| <svg | |
| className="w-4 sm:w-5 h-4 sm:h-5" | |
| fill="none" | |
| stroke="currentColor" | |
| viewBox="0 0 24 24" | |
| > | |
| <path | |
| strokeLinecap="round" | |
| strokeLinejoin="round" | |
| strokeWidth="2" | |
| d="M19 9l-7 7-7-7" | |
| /> | |
| </svg> | |
| </div> | |
| </div> | |
| {sessionOpenDetails === session.id && ( | |
| <div className="border-t border-gray-200 bg-white"> | |
| <div className="p-3 sm:p-5 xl:p-7"> | |
| <div className="flex flex-col gap-4 sm:gap-5 xl:gap-6 rounded-lg w-full"> | |
| <div className="space-y-4 sm:space-y-6 xl:space-y-8 w-full"> | |
| <div className="w-full hover:translate-x-1 transition-transform duration-300"> | |
| <CoachNotesPreview | |
| session={selectedSession} | |
| /> | |
| </div> | |
| <div className="w-full hover:translate-x-1 transition-transform duration-300"> | |
| <QuestionsPreview | |
| session={selectedSession} | |
| /> | |
| </div> | |
| <div className="w-full hover:translate-x-1 transition-transform duration-300"> | |
| <NotesPreview | |
| session={selectedSession} | |
| /> | |
| </div> | |
| <div className="w-full hover:translate-x-1 transition-transform duration-300"> | |
| <ToolsPreview | |
| session={selectedSession} | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ))} | |
| </div> | |
| {/* --- START EMPTY STATE --- */} | |
| {sessionsList.length === 0 && activeTab === "upcoming" && ( | |
| <div className="flex items-center justify-center w-full py-10 sm:py-24 bg-white rounded-none sm:rounded-2xl border-b sm:border border-gray-200 mx-0"> | |
| <div className="flex flex-col items-center gap-4 sm:gap-5 px-3 sm:px-0 max-w-md text-center"> | |
| <NoSessionIcon width={100} height={100} /> | |
| <div className="flex flex-col gap-[16px]"> | |
| <p className="normal-text font-semibold text-gray-600"> | |
| No new upcoming sessions. Book a new session! | |
| </p> | |
| <a | |
| href="/client/sessions/create" | |
| className={`btn-primary h-12 flex items-center justify-center`} | |
| > | |
| BOOK SESSION | |
| </a> | |
| </div> | |
| <div className="flex flex-col gap-[16px] mt-[10px]"> | |
| <p className="normal-text font-semibold text-gray-600"> | |
| Looking for your past sessions? Check out the | |
| Past Sessions tab. | |
| </p> | |
| <button | |
| className={`btn-primary-outline h-12 flex items-center justify-center`} | |
| onClick={() => onTabChange("past")} | |
| > | |
| PAST SESSIONS | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {sessionsList.length === 0 && activeTab === "past" && ( | |
| <div className="flex items-center justify-center w-full py-10 sm:py-24 bg-white rounded-none sm:rounded-2xl border-b sm:border border-gray-200 mx-0"> | |
| <div className="flex flex-col items-center gap-4 sm:gap-5 px-3 sm:px-0 max-w-md text-center"> | |
| <NoSessionIcon width={100} height={100} /> | |
| <div className="flex flex-col gap-[16px]"> | |
| <p className="normal-text font-semibold text-gray-600"> | |
| No past session found. Book a new session. | |
| </p> | |
| <a | |
| href="/client/sessions/create" | |
| className={`inline-flex items-center justify-center px-6 py-3 rounded-xl text-sm font-medium shadow-sm hover:shadow-md transition-all duration-300 ${buttonStyles.action}`} | |
| > | |
| BOOK SESSION | |
| </a> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* --- END EMPTY STATE --- */} | |
| {/* Loading indicator - Only show if loadingMore is true */} | |
| {/* {loadingMore && ( | |
| <div className="flex justify-center py-6"> | |
| <div className="bg-white px-6 py-4 rounded-lg shadow-sm border border-gray-200 flex items-center space-x-3"> | |
| <div className="flex items-center space-x-2"> | |
| <div | |
| className="w-3 h-3 rounded-full bg-[#0b8ce9] animate-bounce" | |
| style={{ animationDelay: "0.1s" }} | |
| ></div> | |
| <div | |
| className="w-3 h-3 rounded-full bg-[#0b8ce9] animate-bounce" | |
| style={{ animationDelay: "0.3s" }} | |
| ></div> | |
| <div | |
| className="w-3 h-3 rounded-full bg-[#0b8ce9] animate-bounce" | |
| style={{ animationDelay: "0.5s" }} | |
| ></div> | |
| </div> | |
| </div> | |
| </div> | |
| )} */} | |
| </div> | |
| ); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment