// Adaptive Form for APPLICATION former SA form - Version 11 // The new lead Client Code to be transformed into bilingual support // Helper function to get URL parameters (add this outside the class) function getUrlParameter(name) { name = name.replace(/[\[]/, '\\[').replace(/[\]]/, '\\]'); const regex = new RegExp('[\\?&]' + name + '=([^&#]*)'); const results = regex.exec(location.search); return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' ')); } const TextAnimator = (function() { function createStyle() { if (!document.getElementById('text-animator-style')) { const style = document.createElement('style'); style.id = 'text-animator-style'; style.textContent = ` @keyframes quickFadeIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } } .text-animator-wrapper { display: inline; white-space: normal; } .text-animator-word { display: inline; opacity: 0; } .month-suggestions { background: white; border: 1px solid #ddd; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); max-height: 200px; overflow-y: auto; z-index: 1000; } .month-suggestion-item { padding: 8px 12px; cursor: pointer; border-bottom: 1px solid #f0f0f0; transition: background-color 0.2s ease; } .month-suggestion-item:last-child { border-bottom: none; } .month-suggestion-item:hover, .month-suggestion-item.highlighted { background-color: #f5f5f5; } `; document.head.appendChild(style); } } createStyle(); return { animate: function(element, text, options = {}) { const defaults = { wordDelay: 100, animationDuration: 0.5, commaPauseDuration: 320, // Duration of pause after comma sentencePauseDuration: 520 // Duration of pause after period, question mark, or exclamation point }; const settings = { ...defaults, ...options }; if (!element) return; // Split text into words const words = text.split(' '); // Clear the element element.innerHTML = ''; // Create a wrapper to hold the words const wrapper = document.createElement('span'); wrapper.className = 'text-animator-wrapper'; let totalDelay = 0; words.forEach((word, index) => { // Create and append word span const wordSpan = document.createElement('span'); // Add the space to the word itself (except for last word) wordSpan.textContent = index < words.length - 1 ? word + ' ' : word; wordSpan.className = 'text-animator-word'; // Check if the word ends with different types of punctuation const endsWithComma = /,$/.test(word); const endsWithSentence = /[.!?]$/.test(word); wordSpan.style.animation = `quickFadeIn ${settings.animationDuration}s ease-out ${totalDelay / 1000}s forwards`; wrapper.appendChild(wordSpan); // Increase total delay totalDelay += settings.wordDelay; // Add extra pause after punctuation if (endsWithComma) { totalDelay += settings.commaPauseDuration; } else if (endsWithSentence) { totalDelay += settings.sentencePauseDuration; } }); element.appendChild(wrapper); }, // Updated method to handle frame transitions handleFrameTransition: function(element, text, options = {}) { const wrapper = element.querySelector('.text-animator-wrapper'); if (wrapper && wrapper.textContent.trim() === text.trim()) { // If the text is the same, just reset the animation const words = wrapper.querySelectorAll('.text-animator-word'); let totalDelay = 0; words.forEach((word, index) => { word.style.animation = 'none'; word.offsetHeight; // Trigger reflow const endsWithComma = /,$/.test(word.textContent); const endsWithSentence = /[.!?]$/.test(word.textContent); word.style.animation = `quickFadeIn ${options.animationDuration || 0.2}s ease-out ${totalDelay / 1000}s forwards`; totalDelay += (options.wordDelay || 100); if (endsWithComma) { totalDelay += (options.commaPauseDuration || 200); } else if (endsWithSentence) { totalDelay += (options.sentencePauseDuration || 500); } }); } else { // If the text is different, use the regular animate method this.animate(element, text, options); } } }; })(); const SESSION_EXPIRATION_TIME = 10 * 60 * 1000; // 10 minutes in milliseconds // Month names and variations for text-based month entry const monthsLookup = { // German standard names 'januar': 1, 'februar': 2, 'märz': 3, 'april': 4, 'mai': 5, 'juni': 6, 'juli': 7, 'august': 8, 'september': 9, 'oktober': 10, 'november': 11, 'dezember': 12, // Common variations and abbreviations 'jan': 1, 'feb': 2, 'mär': 3, 'mrz': 3, 'apr': 4, 'jun': 6, 'jul': 7, 'aug': 8, 'sep': 9, 'sept': 9, 'okt': 10, 'oct': 10, 'nov': 11, 'dez': 12, 'dec': 12, // English month names for international support 'january': 1, 'february': 2, 'march': 3, 'april': 4, 'may': 5, 'june': 6, 'july': 7, 'august': 8, 'september': 9, 'october': 10, 'november': 11, 'december': 12 }; // Full month names for output const fullMonthNames = { 'en': ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'], 'de': ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'] }; // Function to check if a year is a leap year function isLeapYear(year) { return (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0); } // Enhanced date validation functions function isValidDay(day, month, year) { if (day < 1 || day > 31) return false; // Check days in month (account for leap years) const daysInMonth = [31, (isLeapYear(year) ? 29 : 28), 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; if (month > 0 && month <= 12) { return day <= daysInMonth[month - 1]; } return true; // If month is not valid yet, just do basic validation } function isValidMonth(month) { return month >= 1 && month <= 12; } // Function to find the closest matching month name function findClosestMonth(input) { input = input.toLowerCase().trim(); // Direct match in our dictionary if (monthsLookup[input] !== undefined) { return monthsLookup[input]; } // Try to match as a number const numValue = parseInt(input, 10); if (!isNaN(numValue) && numValue >= 1 && numValue <= 12) { return numValue; } // If input is very short (1-2 chars), don't try fuzzy matching if (input.length < 3) { return null; } // Simple fuzzy matching for misspellings // Look for the entry with most character matches let bestMatch = null; let highestMatchCount = 0; for (const month in monthsLookup) { if (month.length < 3) continue; // Skip abbreviated months for fuzzy matching let matchCount = 0; for (let i = 0; i < input.length && i < month.length; i++) { if (input[i] === month[i]) { matchCount++; } } // Weight by the length difference to prefer closer length matches const lengthDiff = Math.abs(month.length - input.length); const weightedMatch = matchCount - (lengthDiff * 0.5); if (weightedMatch > highestMatchCount) { highestMatchCount = weightedMatch; bestMatch = month; } } // Only return a match if it's reasonably close if (bestMatch && highestMatchCount > Math.min(3, input.length * 0.6)) { return monthsLookup[bestMatch]; } return null; } // Function to get month suggestions for autocomplete function getMonthSuggestions(input) { input = input.toLowerCase().trim(); if (input.length < 1) return []; const suggestions = []; // First, add German months that start with the input const germanMonthsFirst = ['januar', 'februar', 'märz', 'april', 'mai', 'juni', 'juli', 'august', 'september', 'oktober', 'november', 'dezember']; for (const month of germanMonthsFirst) { if (month.startsWith(input)) { suggestions.push({ display: month.charAt(0).toUpperCase() + month.slice(1), value: month, type: 'german' }); } } // Then add German months that start with the input const germanMonths = ['januar', 'februar', 'märz', 'april', 'mai', 'juni', 'juli', 'august', 'september', 'oktober', 'november', 'dezember']; for (const month of germanMonths) { if (month.startsWith(input)) { suggestions.push({ display: month.charAt(0).toUpperCase() + month.slice(1), value: month, type: 'german' }); } } // Limit to top 5 suggestions return suggestions.slice(0, 5); } class ChatBasedFormController { constructor() { // Core elements this.container = document.getElementById('contform'); this.nextButton = document.getElementById('nextcta'); this.backButton = document.getElementById('backcta'); // Add session expiration time as an instance property this.SESSION_EXPIRATION_TIME = SESSION_EXPIRATION_TIME; this.sessionId = null; this.chatHistory = this.loadChatHistoryFromStorage() || []; this.contextToken = null; // Animation & transition control this.isAnimating = false; this.currentModule = null; this.autoAdvanceTimer = null; // Bind event handlers this.handleNext = this.handleNext.bind(this); this.handleBack = this.handleBack.bind(this); // Add properties for delayed loading this.loadingTimer = null; this.loadingDelay = 800; // Delay in ms before showing loading animation this.knownSlowOperations = ['Initialisiere...', 'Lade Ihre Daten...']; // API endpoint this.apiBaseUrl = 'https://api.hems-app.com/api:fUxdXSnV/de/application'; this.nextEndpoint = '/next'; this.authState = { email: null, authMethod: null, isAuthenticated: false }; // Submission state management this.isSubmitting = false; this.hasSubmittedSuccessfully = false; this.submissionQueue = []; this.activeSubmissionId = null; this.processedSubmissions = new Set(); this.submissionMutex = false; // Event listener tracking for cleanup this.eventListeners = []; this.componentElements = new Set(); // Initialize debounced handlers after methods are defined this.initializeDebouncedHandlers(); // Add cleanup on page unload this.addTrackedEventListener(window, 'beforeunload', () => this.cleanup()); } // Initialize debounced handlers initializeDebouncedHandlers() { this.debouncedHandleNext = this.createDebouncedHandleNext(); } // Decode a message ID back to a step ID decodeMessageId(messageId) { if (!messageId || !messageId.startsWith('msg_')) return null; const parts = messageId.split('_'); if (parts.length < 4) return null; try { return atob(parts[3]); } catch (e) { console.error('Failed to decode message ID', e); return null; } } persistChatHistoryToStorage() { try { localStorage.setItem('chatHistory', JSON.stringify(this.chatHistory)); localStorage.setItem('sessionId', this.sessionId); localStorage.setItem('sessionTimestamp', Date.now().toString()); if (this.contextToken) { localStorage.setItem('contextToken', this.contextToken); } } catch (error) { console.warn('Failed to persist chat history to localStorage:', error); } } loadChatHistoryFromStorage() { try { const savedSessionId = localStorage.getItem('sessionId'); const savedContextToken = localStorage.getItem('contextToken'); const savedHistory = localStorage.getItem('chatHistory'); const sessionTimestamp = localStorage.getItem('sessionTimestamp'); console.log("Checking saved session:", { hasSessionId: !!savedSessionId, hasContextToken: !!savedContextToken, hasHistory: !!savedHistory, hasTimestamp: !!sessionTimestamp }); // Check for session expiration if (sessionTimestamp) { const currentTime = Date.now(); const timestamp = parseInt(sessionTimestamp, 10); const sessionAge = currentTime - timestamp; console.log(`Session age: ${Math.round(sessionAge / 1000 / 60)} minutes`); if (sessionAge > this.SESSION_EXPIRATION_TIME) { console.log("Session expired, creating new session"); // Session expired, clear localStorage and return null localStorage.removeItem('chatHistory'); localStorage.removeItem('sessionId'); localStorage.removeItem('contextToken'); localStorage.removeItem('sessionTimestamp'); return null; } } if (savedSessionId) { this.sessionId = savedSessionId; } if (savedContextToken) { this.contextToken = savedContextToken; } if (savedHistory) { return JSON.parse(savedHistory); } return null; } catch (error) { console.warn('Failed to load chat history from localStorage:', error); return null; } } // Initialize the chat-based form async initialize() { try { // Show loading indicator for initialization // this.showLoadingIndicator('Initialisiere...'); // Check if we have a valid local session const storedHistory = this.loadChatHistoryFromStorage(); if (!this.sessionId || !storedHistory || storedHistory.length === 0) { // No valid session, create a new one const sessionData = await this.createSession(); this.sessionId = sessionData.session_id; localStorage.setItem('sessionId', this.sessionId); localStorage.setItem('sessionTimestamp', Date.now().toString()); this.chatHistory = []; await this.startNewSession(); } else { // We have a valid session and history, continue from where we left off this.chatHistory = storedHistory; // Update timestamp on successful continuation localStorage.setItem('sessionTimestamp', Date.now().toString()); // Get the last message from history const lastMessage = this.chatHistory[this.chatHistory.length - 1]; // Display all messages from history await this.rebuildUIFromHistory(); //if (lastMessage) { // try { // const nextStepData = await this.requestNextStep(lastMessage.message_id, false); // if (nextStepData) { // // Process the next step normally // this.processNextStep(nextStepData); // } // } catch (error) { // console.error("Failed to continue session, creating new one:", error); // // Create a new session if continuation fails // await this.resetAndStartNewSession(); // } //} else { // // Something wrong with history, start new // await this.resetAndStartNewSession(); //} } // Set up event listeners with tracking this.addTrackedEventListener(this.nextButton, 'click', this.handleNext); this.addTrackedEventListener(this.backButton, 'click', this.handleBack); // Inject required styles this.injectStyles(); } catch (error) { console.error("Failed to initialize form:", error); // Create new session silently instead of showing error await this.resetAndStartNewSession(); } } setupRefreshPrevention() { window.addEventListener('beforeunload', (event) => { // Only show warning if user has started the conversation if (this.chatHistory.length > 1) { // Standard way to show a confirmation dialog before leaving const message = 'Wenn Sie die Seite verlassen, gehen Ihre Eingaben verloren. Möchten Sie wirklich fortfahren?'; event.returnValue = message; return message; } }); } async createSession() { try { // Get apptoken from URL parameters if it exists const apptoken = getUrlParameter('apptoken'); // Get city parameter from URL const cityIntent = getUrlParameter('location'); // Get additional parameters from URL const inviteParam = getUrlParameter('invite'); const pollParam = getUrlParameter('poll'); const tsParam = getUrlParameter('ts'); // Create form data const formData = new FormData(); if (apptoken) { formData.append('apptoken', apptoken); } // Add city parameter if it exists if (cityIntent) { formData.append('location', cityIntent); } // Add invite_id parameter if it exists if (inviteParam) { formData.append('invite_id', inviteParam); } // Add poll_id parameter if it exists if (pollParam) { formData.append('poll_id', pollParam); } // Add ts_id parameter if it exists if (tsParam) { formData.append('ts_id', tsParam); } const response = await fetch(`https://api.hems-app.com/api:fUxdXSnV/en/application/create_session`, { method: 'POST', mode: 'cors', credentials: 'include', body: formData }); if (!response.ok) { if (response.status === 429) { throw new Error('Rate limit exceeded. Please try again later.'); } throw new Error(`Failed to create session: ${response.statusText}`); } const sessionData = await response.json(); // Store contextToken if it's provided if (sessionData.contextToken) { this.contextToken = sessionData.contextToken; // The contextToken now contains the city_intent information // We don't need to separately store it } return sessionData; } catch (error) { console.error("Session creation failed:", error); throw error; } } // Add this method to your class debounce(func, wait) { let timeout; return (...args) => { clearTimeout(timeout); timeout = setTimeout(() => func.apply(this, args), wait); }; } // Generate unique submission ID generateSubmissionId() { return `sub_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } // Queue submission to prevent duplicates async queueSubmission(payload) { const submissionId = this.generateSubmissionId(); // Check if we already processed this payload const payloadHash = JSON.stringify(payload); if (this.processedSubmissions.has(payloadHash)) { console.log('Duplicate submission attempt blocked:', submissionId); return; } // Add to queue this.submissionQueue.push({ id: submissionId, payload, hash: payloadHash }); // Process queue if not already processing if (!this.submissionMutex) { await this.processSubmissionQueue(); } } // Process submission queue sequentially async processSubmissionQueue() { if (this.submissionMutex || this.submissionQueue.length === 0) { return; } this.submissionMutex = true; try { while (this.submissionQueue.length > 0) { const submission = this.submissionQueue.shift(); // Skip if already processed if (this.processedSubmissions.has(submission.hash)) { continue; } // Mark as active this.activeSubmissionId = submission.id; // Process the submission await this.executeFormSubmission(submission.payload); // Mark as processed this.processedSubmissions.add(submission.hash); // Clear active submission this.activeSubmissionId = null; // Break after first successful submission for form completion if (this.hasSubmittedSuccessfully) { break; } } } finally { this.submissionMutex = false; } } // Clear submission state clearSubmissionState() { this.submissionQueue = []; this.activeSubmissionId = null; this.processedSubmissions.clear(); this.submissionMutex = false; this.isSubmitting = false; } // Add event listener with tracking for cleanup addTrackedEventListener(element, event, handler, options = false) { element.addEventListener(event, handler, options); this.eventListeners.push({ element, event, handler, options }); this.componentElements.add(element); } // Remove all tracked event listeners removeAllEventListeners() { this.eventListeners.forEach(({ element, event, handler, options }) => { try { element.removeEventListener(event, handler, options); } catch (e) { console.warn('Failed to remove event listener:', e); } }); this.eventListeners = []; this.componentElements.clear(); } // Cleanup method for component destruction cleanup() { // Clear timers if (this.loadingTimer) { clearTimeout(this.loadingTimer); this.loadingTimer = null; } if (this.autoAdvanceTimer) { clearTimeout(this.autoAdvanceTimer); this.autoAdvanceTimer = null; } // Remove event listeners this.removeAllEventListeners(); // Clear submission state this.clearSubmissionState(); } // Handle resume prompt for existing session async handleResumePrompt(resumableSession) { return new Promise((resolve) => { // Create a resume prompt UI const resumePrompt = document.createElement('div'); resumePrompt.className = 'form-module resume-prompt'; resumePrompt.innerHTML = `

Gespräch fortsetzen?

Wir haben ein unvollständiges Gespräch von Ihnen gefunden. Möchten Sie fortfahren, wo Sie aufgehört haben?

${resumableSession.progress || 0}% abgeschlossen
`; // Add to container this.container.innerHTML = ''; this.container.appendChild(resumePrompt); this.currentModule = resumePrompt; // Add button handlers const resumeYesButton = resumePrompt.querySelector('.resume-yes'); const resumeNoButton = resumePrompt.querySelector('.resume-no'); resumeYesButton.addEventListener('click', async () => { // Resume existing session this.showLoadingIndicator('Lade Ihre Daten...'); await this.resumeSession(); resolve(); }); resumeNoButton.addEventListener('click', async () => { // Start a new session this.showLoadingIndicator('Starte neues Gespräch...'); // Reset the session await this.resetSession(); // Start new session setTimeout(() => { this.startNewSession(); resolve(); }, 1000); }); }); } // Reset a session async resetSession() { try { const response = await fetch(`${this.apiBaseUrl}/reset_session`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionToken: this.sessionId }) }); if (!response.ok) { throw new Error(`Failed to reset session: ${response.statusText}`); } // Clear local state this.chatHistory = []; this.currentContext = {}; // Persist empty chat history this.persistChatHistoryToStorage(); } catch (error) { console.error("Failed to reset session:", error); this.showErrorMessage("Beim Zurücksetzen ist ein Fehler aufgetreten."); throw error; } } // Start a new session async startNewSession() { try { // this.showLoadingIndicator('Starte Gespräch...'); // For starting a form, directly get the first step/message without starthomesearch action const response = await fetch(`${this.apiBaseUrl}${this.nextEndpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionToken: this.sessionId, chatHistory: this.chatHistory, contextToken: this.contextToken }) }); if (!response.ok) { throw new Error(`Failed to start session: ${response.statusText}`); } const data = await response.json(); if (data.action === "next_step" && data.payload) { // Extract step data from the payload - might be nested inside 'step' const stepData = data.payload.step || data.payload; // Make sure type is defined if (!stepData.type) { console.warn("Step type is undefined:", stepData); // Set a default type to prevent errors stepData.type = 'message-only'; } // Convert step to message format and process this.processNextStep(stepData); } else if (data.action === "error" && data.payload) { this.handleError(data.payload); } else { console.warn("Received unexpected response format:", data); this.showErrorMessage("Unerwartete Antwort vom Server erhalten."); } } catch (error) { console.error("Failed to start new session:", error); this.showErrorMessage("Beim Starten ist ein Fehler aufgetreten."); } } async sendUserInput(messageId, answer) { this.showLoadingIndicator('Verarbeite...'); try { const messageIndex = this.chatHistory.findIndex(msg => msg.message_id === messageId); // Create a variable for transformed answer outside the if block let apiAnswer = answer; let currentMessage = null; if (messageIndex >= 0) { // Update the input in the chat history this.chatHistory[messageIndex].input = answer; // Persist updated chat history this.persistChatHistoryToStorage(); currentMessage = this.chatHistory[messageIndex]; // Transform array answers to comma-separated strings for the API if (currentMessage.type === 'checkbox-list' && Array.isArray(answer)) { console.log("Converting checkbox array to string:", answer); apiAnswer = answer.join(','); } // Check if this is TOS acceptance - trigger account creation if (currentMessage && currentMessage.dataPoint === 'tos_acceptance' && answer === 'accept') { try { this.showLoadingIndicator('Erstelle Konto...'); await this.createAccount(); // Trigger /register endpoint } catch (error) { console.error("Failed to create account during TOS acceptance:", error); this.showErrorMessage("Fehler bei der Kontoerstellung. Bitte versuche es erneut.", true); return; } } // Check if this is an auth step - but keep this inside the if block if (currentMessage && currentMessage.isAuthStep) { await this.handleAuthStep(currentMessage, answer); // Use original answer for auth return; } } // Normal message handling continues outside the if block this.showLoadingIndicator('Verarbeite...'); // Get next message/step using the transformed answer const response = await fetch(`${this.apiBaseUrl}${this.nextEndpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionToken: this.sessionId, messageId: messageId, answer: apiAnswer, // Use the potentially transformed answer chatHistory: this.chatHistory, contextToken: this.contextToken }) }); if (!response.ok) { throw new Error(`Failed to send input: ${response.statusText}`); } const data = await response.json(); if (data.action === "next_step" && data.payload) { this.processNextStep(data.payload); } else if (data.action === "form_complete" && data.payload) { this.handleFormCompletion(data.payload); } else if (data.action === "error" && data.payload) { this.handleError(data.payload); } else if (data.action === "session_expired") { this.handleSessionExpired(); } else { console.warn("Received unexpected response format:", data); this.showErrorMessage("Unerwartete Antwort vom Server erhalten."); } } catch (error) { console.error("Failed to send input:", error); this.showErrorMessage("Beim Senden Ihrer Antwort ist ein Fehler aufgetreten."); } } // Request next message/step with no input (for responder steps) async requestNextStep(currentMessageId, showLoading = true) { // Only show loading indicator if showLoading is true if (showLoading) { this.showLoadingIndicator('Laden...'); } try { const requestBody = { sessionToken: this.sessionId, messageId: currentMessageId, chatHistory: this.chatHistory, contextToken: this.contextToken }; const response = await fetch(`${this.apiBaseUrl}${this.nextEndpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }); if (!response.ok) { throw new Error(`Failed to request next step: ${response.statusText}`); } const data = await response.json(); if (data.action === "next_step" && data.payload) { return data.payload; } else if (data.action === "form_complete" && data.payload) { this.handleFormCompletion(data.payload); return null; } else if (data.action === "error" && data.payload) { this.handleError(data.payload); return null; } else if (data.action === "session_expired") { this.handleSessionExpired(); return null; } else { console.warn("Received unexpected response format:", data); this.showErrorMessage("Unerwartete Antwort vom Server erhalten."); return null; } } catch (error) { console.error("Failed to request next step:", error); if (showLoading) { // Only show error if we were showing loading this.showErrorMessage("Beim Laden des nächsten Schritts ist ein Fehler aufgetreten."); } return null; } } async processNextStep(stepData) { // Cancel any pending loading timers this.cancelLoadingTimer(); // Create message object with additional properties const message = { message_id: stepData.message_id, title: stepData.title || '', message: stepData.message || stepData.title || '', type: stepData.type || 'message-only', // Default type to prevent errors options: stepData.options || [], input: null, // Will be filled when user provides input required: stepData.required || false, autoAdvance: stepData.autoAdvance || false, autoAdvanceOnSelect: stepData.autoAdvanceOnSelect || false, autoAdvanceOnChange: stepData.autoAdvanceOnChange || false, displayTime: stepData.displayTime || 3000, responderText: stepData.responderText || null, explainer: stepData.explainer || null, minDate: stepData.minDate || null, maxDate: stepData.maxDate || null, label: stepData.label || null, validate: stepData.validate || null, // Add placeholder from step data placeholder: stepData.placeholder || null, // Add auth-related properties if they exist isAuthStep: stepData.isAuthStep || false, authStepType: stepData.authStepType || null, authEmail: stepData.authEmail || null, created_at: new Date().toISOString() }; // Add to chat history this.chatHistory.push(message); // Persist updated chat history this.persistChatHistoryToStorage(); // Animate out the current module if it exists if (this.currentModule) { // Check if current module is a loading module const isLoadingModule = this.currentModule.dataset.moduleType === 'loading'; this.isAnimating = true; this.currentModule.classList.add('fade-out-up'); // Use shorter animation time for loading modules const animationTime = isLoadingModule ? 300 : 500; await this.wait(animationTime); this.container.innerHTML = ''; // Clear the container this.isAnimating = false; } // Display the message/step await this.displayMessage(message); // For responder types that auto-advance, pre-fetch the next step if (message.type === 'responder' && message.autoAdvance) { try { // Pre-fetch next step in background WITHOUT showing loading indicator const nextStepData = await this.requestNextStep(message.message_id, false); if (nextStepData) { // Store for auto-advance this._nextStepData = nextStepData; // Set timer for auto-advance this.autoAdvanceTimer = setTimeout(() => { this.handleResponderAutoAdvance(message, nextStepData); }, message.displayTime); } } catch (error) { console.error("Failed to pre-fetch next step:", error); // Continue showing current step even if pre-fetch fails } } } async handleResponderAutoAdvance(message, nextStepData) { // Clear the timer this.autoAdvanceTimer = null; // Create next message object const nextMessage = { message_id: nextStepData.message_id, title: nextStepData.title || '', message: nextStepData.message || nextStepData.title || '', type: nextStepData.type, options: nextStepData.options || [], input: null, required: nextStepData.required || false, autoAdvance: nextStepData.autoAdvance || false, displayTime: nextStepData.displayTime || 3000, responderText: nextStepData.responderText || null, explainer: nextStepData.explainer || null, created_at: new Date().toISOString() }; // Add to chat history this.chatHistory.push(nextMessage); // Persist updated chat history this.persistChatHistoryToStorage(); // First animate out current step await this.animateMessageOut(message.message_id); // Then display the next message await this.displayMessage(nextMessage); // ADDED: If the next message is also a responder with autoAdvance, set up its auto-advance if (nextMessage.type === 'responder' && nextMessage.autoAdvance) { try { // Pre-fetch next step in background WITHOUT showing loading indicator const subsequentStepData = await this.requestNextStep(nextMessage.message_id, false); if (subsequentStepData) { // Store for auto-advance this._nextStepData = subsequentStepData; // Set timer for auto-advance this.autoAdvanceTimer = setTimeout(() => { this.handleResponderAutoAdvance(nextMessage, subsequentStepData); }, nextMessage.displayTime); } } catch (error) { console.error("Failed to pre-fetch next step for subsequent responder:", error); // Continue showing current step even if pre-fetch fails } } } // Go back to previous step async handleBack() { if (this.isAnimating || this.chatHistory.length <= 1) return; this.isAnimating = true; // Set animating flag // Get the previous message from history (excluding the current one) const currentMessage = this.chatHistory[this.chatHistory.length - 1]; const previousMessage = this.chatHistory[this.chatHistory.length - 2]; try { // First, animate out the current message const messageEl = this.container.querySelector(`[data-message-id="${currentMessage.message_id}"]`); if (messageEl) { messageEl.classList.add('fade-out-down'); await this.wait(500); // Wait for animation to complete } // Then remove the current message from history this.chatHistory.pop(); this.persistChatHistoryToStorage(); // Clear the container this.container.innerHTML = ''; // Display the previous message await this.displayMessage(previousMessage); // Update the navigation buttons state this.updateNextButtonState(); } catch (error) { console.error("Failed to go back:", error); this.showErrorMessage("Beim Zurückgehen ist ein Fehler aufgetreten."); } finally { this.isAnimating = false; // Clear animating flag } } async handleAuthStep(message, answer) { this.showLoadingIndicator('Verarbeite...'); console.log ("triggered handleAuthStep"); console.log (answer); try { switch (message.authStepType) { case 'email_choice': await this.handleEmailChoice(message, answer); break; case 'email_collection': await this.handleEmailCollection(message, answer); break; case 'auth_method': await this.handleAuthMethod(message, answer); break; case 'password_creation': await this.handlePasswordCreation(message, answer); break; default: // Unknown auth step type, send answer normally to avoid infinite loop const response = await fetch(`${this.apiBaseUrl}${this.nextEndpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionToken: this.sessionId, messageId: message.message_id, answer: answer, // Important: pass the answer! chatHistory: this.chatHistory, contextToken: this.contextToken }) }); if (!response.ok) { throw new Error(`Failed to send input: ${response.statusText}`); } const data = await response.json(); if (data.action === "next_step" && data.payload) { this.processNextStep(data.payload); } else if (data.action === "form_complete" && data.payload) { this.handleFormCompletion(data.payload); } else if (data.action === "error" && data.payload) { this.handleError(data.payload); } else if (data.action === "session_expired") { this.handleSessionExpired(); } else { console.warn("Received unexpected response format:", data); this.showErrorMessage("Unerwartete Antwort vom Server erhalten."); } } } catch (error) { console.error("Auth step error:", error); this.showErrorMessage("Bei der Anmeldung ist ein Fehler aufgetreten."); } } async handleEmailChoice(message, choice) { try { // Extract the appropriate email based on the choice if (choice === 'use_existing') { // Find the main tenant email from chat history const mainTenantEmail = this.extractEmailFromChatHistory(); if (mainTenantEmail) { this.authState.email = mainTenantEmail; console.log('Set email from application:', mainTenantEmail); } else { console.error('Could not find main tenant email in chat history'); } } // For 'use_different', email will be collected in the next step (EMAIL_COLLECTION) // Send the choice to backend and get next step const response = await fetch(`${this.apiBaseUrl}${this.nextEndpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionToken: this.sessionId, messageId: message.message_id, answer: choice, chatHistory: this.chatHistory, contextToken: this.contextToken }) }); if (!response.ok) { throw new Error(`Failed to send email choice: ${response.statusText}`); } const data = await response.json(); if (data.action === "next_step" && data.payload) { this.processNextStep(data.payload); } else if (data.action === "form_complete" && data.payload) { this.handleFormCompletion(data.payload); } else if (data.action === "error" && data.payload) { this.handleError(data.payload); } else if (data.action === "session_expired") { this.handleSessionExpired(); } else { console.warn("Received unexpected response format:", data); this.showErrorMessage("Unerwartete Antwort vom Server erhalten."); } } catch (error) { console.error("Error handling email choice:", error); this.showErrorMessage("Bei der E-Mail-Auswahl ist ein Fehler aufgetreten."); } } extractEmailFromChatHistory() { // Look through chat history to find the main tenant email for (let i = 0; i < this.chatHistory.length; i++) { const message = this.chatHistory[i]; if (message.message_id && message.message_id.includes('main_tenant_email') && message.input) { console.log('Found main tenant email in chat history:', message.input); return message.input; } } // Also check if we have it stored in any other format console.log('Chat history for email extraction:', this.chatHistory); return null; } extractEmailForAuth(authEmail) { // Use email from backend response if provided if (authEmail) { this.authState.email = authEmail; console.log('Set email from backend response:', authEmail); return; } // Check if email is already set from previous EMAIL_CHOICE step if (this.authState.email) { console.log('Email already set in auth state:', this.authState.email); return; } // Extract email from chat history if not already set (fallback) const extractedEmail = this.extractEmailFromChatHistory(); if (extractedEmail) { this.authState.email = extractedEmail; console.log('Set email from chat history for auth:', extractedEmail); } else { console.error('Could not extract email for authentication'); } } async handleEmailCollection(message, email) { // Store email in auth state this.authState.email = email; // Check if email exists const formData = new FormData(); formData.append('email', email); try { const response = await fetch('https://api.hems-app.com/api:vjIzExhn/auth/check', { method: 'POST', mode: 'cors', credentials: 'include', body: formData }); if (!response.ok) { throw new Error(`Failed to check email: ${response.statusText}`); } const data = await response.json(); // Handle the three possible paths based on the next_step response switch (data.next_step) { case 'login': // User already has an account - redirect to login const loginMessage = document.createElement('div'); loginMessage.className = 'form-module redirect-module fade-in-up'; loginMessage.innerHTML = `
Es gibt schon ein Hems-Konto mit dieser E-Mail. Ich leite dich weiter zur Anmeldung...
`; // Add to container this.container.innerHTML = ''; this.container.appendChild(loginMessage); this.currentModule = loginMessage; // Redirect to login page // setTimeout(() => { // window.location.href = '/anmelden'; // }, 2500); break; case 'request_magic': // Account exists but needs verification const magicMessage = document.createElement('div'); magicMessage.className = 'form-module verification-module fade-in-up'; magicMessage.innerHTML = `
Dein Konto wurde bereits erstellt, aber noch nicht verifiziert. Wir senden dir einen Verifizierungscode.
`; this.container.innerHTML = ''; this.container.appendChild(magicMessage); this.currentModule = magicMessage; // Send verification email try { const verifyFormData = new FormData(); verifyFormData.append('email', email); verifyFormData.append('type', 'verification'); const verifyResponse = await fetch('https://api.hems-app.com/api:H8-XQm8i/auth/verify/resend', { method: 'POST', mode: 'cors', credentials: 'include', body: verifyFormData }); if (!verifyResponse.ok) { throw new Error('Failed to send verification email'); } // Proceed to verification code entry step const nextStepData = await this.requestNextStep(message.message_id); if (nextStepData) { this.processNextStep(nextStepData); } } catch (error) { console.error("Failed to send verification email:", error); this.showErrorMessage("Fehler beim Senden des Verifizierungscodes. Bitte versuche es später erneut.", true); } break; case 'register': default: // New user, proceed with regular signup flow const nextStepData = await this.requestNextStep(message.message_id); if (nextStepData) { this.processNextStep(nextStepData); } break; } } catch (error) { console.error("Email check failed:", error); this.showErrorMessage("Bei der Überprüfung deiner E-Mail ist ein Fehler aufgetreten.", true); } } async handleAuthMethod(message, method) { // Store auth method in state this.authState.authMethod = method; // Extract email from backend response if available const authEmail = message.authEmail; this.extractEmailForAuth(authEmail); // Show loading indicator this.showLoadingIndicator('Verarbeite...'); try { // If passwordless was selected, create the account with passwordless=true if (method === 'passwordless') { try { this.showLoadingIndicator('Erstelle Konto...'); // Create account with passwordless=true (no verification needed) await this.createAccount(); } catch (error) { console.error("Failed to create passwordless account:", error); this.showErrorMessage("Fehler bei der Kontoerstellung. Bitte versuche es erneut.", true); return; } } // For both passwordless and password methods, call next endpoint WITH the answer const nextResponse = await fetch(`${this.apiBaseUrl}${this.nextEndpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionToken: this.sessionId, messageId: message.message_id, answer: method, // Pass the selected auth method as the answer chatHistory: this.chatHistory, contextToken: this.contextToken }) }); if (!nextResponse.ok) { throw new Error(`Failed to get next step: ${nextResponse.statusText}`); } const data = await nextResponse.json(); if (data.action === "next_step" && data.payload) { this.processNextStep(data.payload); } else if (data.action === "form_complete" && data.payload) { this.handleFormCompletion(data.payload); } else if (data.action === "error" && data.payload) { this.handleError(data.payload); } else { console.warn("Received unexpected response format:", data); this.showErrorMessage("Unerwartete Antwort vom Server erhalten."); } } catch (error) { console.error("Auth method handling error:", error); this.showErrorMessage("Bei der Verarbeitung ist ein Fehler aufgetreten.", true); } } async handlePasswordCreation(message, password) { try { this.showLoadingIndicator('Erstelle Konto...'); // Create account with email and password await this.createAccount(password); // Proceed to next step const nextStepData = await this.requestNextStep(message.message_id); if (nextStepData) { this.processNextStep(nextStepData); } } catch (error) { console.error("Password creation failed:", error); this.showErrorMessage("Bei der Kontoerstellung ist ein Fehler aufgetreten.", true); } } async createAccount(password = null) { // First, check email eligibility before proceeding with registration try { console.log("Checking email eligibility before registration:", this.authState.email); const checkResponse = await fetch('https://api.hems-app.com/api:vjIzExhn/auth/check', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ email: this.authState.email }), mode: 'cors' }); if (!checkResponse.ok) { throw new Error(`Email check failed: ${checkResponse.statusText}`); } const checkData = await checkResponse.json(); console.log("Email check response:", checkData); // Only proceed if next_step is 'register' if (checkData.next_step !== 'register') { throw new Error(`Email check failed: expected next_step 'register', got '${checkData.next_step}'`); } console.log("Email check passed, proceeding with registration"); } catch (error) { console.error("Email eligibility check failed:", error); throw new Error("Email verification failed. Please try again later."); } // Create FormData object const formData = new FormData(); // Add email formData.append('email', this.authState.email); // Add password if provided (for non-passwordless flow) if (password) { formData.append('password', password); } // Add passwordless flag formData.append('passwordless', this.authState.authMethod === 'passwordless' ? 'true' : 'false'); // Add product_preference formData.append('product_preference', 'homesearch'); // Add name if available (from a previous step) const nameMessage = this.chatHistory.find(msg => msg.authStepType === 'name_collection'); if (nameMessage && nameMessage.input) { formData.append('name', nameMessage.input); } console.log("Creating account with email:", this.authState.email, "passwordless:", this.authState.authMethod === 'passwordless'); const response = await fetch('https://api.hems-app.com/api:vjIzExhn/auth/register/flash', { method: 'POST', body: formData, credentials: 'include', mode: 'cors' }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); console.error("Account creation failed:", response.status, errorData); throw new Error(`Failed to create account: ${response.statusText}`); } const data = await response.json(); // Update auth state this.authState.isAuthenticated = true; return data; } async resetAndStartNewSession() { // Clear local storage localStorage.removeItem('chatHistory'); localStorage.removeItem('sessionId'); localStorage.removeItem('contextToken'); localStorage.removeItem('sessionTimestamp'); // Create a new session const sessionData = await this.createSession(); this.sessionId = sessionData.session_id; localStorage.setItem('sessionId', this.sessionId); localStorage.setItem('sessionTimestamp', Date.now().toString()); this.chatHistory = []; // Start new session await this.startNewSession(); } async rebuildUIFromHistory() { try { // Show loading while we prepare // this.showLoadingIndicator('Lade Gespräch...'); // Make sure we have chat history if (this.chatHistory.length === 0) { await this.startNewSession(); return; } // Clear container before requesting next step this.container.innerHTML = ''; // Find the last message that has user input let lastMessageWithInput = null; for (let i = this.chatHistory.length - 1; i >= 0; i--) { if (this.chatHistory[i].input !== null) { lastMessageWithInput = this.chatHistory[i]; break; } } // If no message with input found, use the first message if (!lastMessageWithInput && this.chatHistory.length > 0) { lastMessageWithInput = this.chatHistory[0]; } if (lastMessageWithInput) { // Trim the history to remove any steps after the last input const lastInputIndex = this.chatHistory.findIndex(msg => msg.message_id === lastMessageWithInput.message_id); if (lastInputIndex !== -1) { // Keep only up to and including the last message with input this.chatHistory = this.chatHistory.slice(0, lastInputIndex + 1); } // Now request the next step properly const response = await fetch(`${this.apiBaseUrl}${this.nextEndpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionToken: this.sessionId, messageId: lastMessageWithInput.message_id, answer: lastMessageWithInput.input, chatHistory: this.chatHistory, contextToken: this.contextToken }) }); if (!response.ok) { throw new Error(`Failed to continue session: ${response.statusText}`); } const data = await response.json(); if (data.action === "next_step" && data.payload) { this.processNextStep(data.payload); } else if (data.action === "form_complete" && data.payload) { this.handleFormCompletion(data.payload); } else if (data.action === "error" && data.payload) { this.handleError(data.payload); } else { await this.resetAndStartNewSession(); } } else { // No valid message found, start new await this.resetAndStartNewSession(); } // Persist the cleaned-up chat history this.persistChatHistoryToStorage(); } catch (error) { console.error("Error rebuilding UI from history:", error); await this.resetAndStartNewSession(); } } handleSessionExpired() { this.resetAndStartNewSession().catch(error => { console.error("Failed to create new session after expiration:", error); // If all else fails, reload the page window.location.reload(); }); } showSessionExpiredMessage() { // Instead of showing expired message UI, silently create a new session this.resetAndStartNewSession().catch(error => { console.error("Failed to create new session after expiration:", error); // If all else fails, reload the page window.location.reload(); }); } // Handle next button click async handleNext() { // Enhanced protection against multiple simultaneous submissions if (this.isAnimating || this.isSubmitting || this.submissionMutex) { console.log('Blocking handleNext - operation in progress'); return; } // Prevent next button from triggering submission again after success if (this.hasSubmittedSuccessfully) { console.log('Form already submitted successfully, ignoring next button click'); return; } window.scrollTo({ top: 0, behavior: 'smooth' }); if (this.nextButton.classList.contains('inactive')) { return; // Don't proceed if button is inactive } // Get current message if (this.chatHistory.length === 0) return; const currentMessage = this.chatHistory[this.chatHistory.length - 1]; // Skip validation for responder messages if (currentMessage.type !== 'responder' && currentMessage.required && !this.validateMessage(currentMessage)) { return; // Don't proceed if validation fails } // For responder types, just advance (already pre-fetched next step) if (currentMessage.type === 'responder') { // Cancel any auto-advance timer if (this.autoAdvanceTimer) { clearTimeout(this.autoAdvanceTimer); this.autoAdvanceTimer = null; } // If we have pre-fetched next step, use it if (this._nextStepData) { await this.handleResponderAutoAdvance(currentMessage, this._nextStepData); this._nextStepData = null; } else { // Otherwise, request next step (with loading indicator) const nextStepData = await this.requestNextStep(currentMessageId, true); if (nextStepData) { await this.handleResponderAutoAdvance(currentMessage, nextStepData); } } } else if (currentMessage.type === 'photo-upload') { // Handle photo upload specially await this.handlePhotoUpload(currentMessage); } else { // For input types, get user input and send const input = this.getUserInput(currentMessage); await this.sendUserInput(currentMessage.message_id, input); } } // Get success message from backend after successful submission async getSuccessMessageFromBackend() { try { // Find the LOADING message in chat history (the one that triggered form_complete) const loadingMessage = this.chatHistory.find(msg => msg.type === 'loading' || msg.id === 'loading'); if (!loadingMessage) { console.error('Could not find loading message in chat history'); this.displayFallbackSuccessMessage(); return; } // Send answer 'submitted' to the LOADING message to indicate external submit was successful const response = await fetch(`${this.apiBaseUrl}${this.nextEndpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionToken: this.sessionId, messageId: loadingMessage.message_id, answer: 'submitted', // This tells backend that external /submit was successful chatHistory: this.chatHistory, contextToken: this.contextToken }) }); if (!response.ok) { throw new Error('Failed to get success message'); } const data = await response.json(); if (data.action === "next_step" && data.payload) { // Process SUCCESS message through normal flow (proper styling/fonts) this.processNextStep(data.payload); // Clear localStorage after the message is processed setTimeout(() => { localStorage.removeItem('chatHistory'); localStorage.removeItem('sessionId'); localStorage.removeItem('contextToken'); localStorage.removeItem('sessionTimestamp'); }, 100); } else { // Fallback if backend doesn't return SUCCESS message this.displayFallbackSuccessMessage(); } } catch (error) { console.error('Failed to get success message from backend:', error); // Fallback on error this.displayFallbackSuccessMessage(); } } // Fallback success message if backend doesn't respond displayFallbackSuccessMessage() { const successMessage = document.createElement('div'); successMessage.className = 'form-module'; successMessage.innerHTML = `

Thank you!

Your application has been submitted successfully.
`; this.container.innerHTML = ''; this.container.appendChild(successMessage); this.currentModule = successMessage; // Clear localStorage now that everything is complete localStorage.removeItem('chatHistory'); localStorage.removeItem('sessionId'); localStorage.removeItem('contextToken'); localStorage.removeItem('sessionTimestamp'); } // Execute actual form submission (renamed from handleFormCompletion) async executeFormSubmission(payload) { // Cancel any pending loading timers this.cancelLoadingTimer(); // Prevent multiple submissions if (this.isSubmitting || this.hasSubmittedSuccessfully) { console.log('Submission already in progress or completed'); return; } // Set submitting state this.isSubmitting = true; // Create FormData object and add the three required fields const formData = new FormData(); formData.append('sessionToken', this.sessionId); formData.append('contextToken', this.contextToken); formData.append('chatHistory', JSON.stringify(this.chatHistory)); // Submit using FormData fetch('https://api.hems-app.com/api:fUxdXSnV/en/application/submit', { method: 'POST', body: formData, credentials: 'include', mode: 'cors', }) .then(response => { if (!response.ok) { throw new Error('Submission failed'); } return response.json(); }) .then(data => { // Mark submission as successful and clear all pending submissions this.hasSubmittedSuccessfully = true; this.isSubmitting = false; this.submissionQueue = []; // Clear queue to prevent any pending submissions this.submissionMutex = false; // Add the LOADING message to chat history first const loadingMessage = { message_id: payload.message_id, // Use the message_id from the form_complete payload type: 'loading', id: 'loading', title: payload.title, message: payload.message }; this.chatHistory.push(loadingMessage); // Submission successful, now get the SUCCESS message from backend this.getSuccessMessageFromBackend(); // Dispatch event const completionEvent = new CustomEvent('chat:complete', { detail: data }); document.dispatchEvent(completionEvent); // No redirect - form stays on success message }) .catch(error => { console.error('Submission error:', error); // Reset submission state on error this.isSubmitting = false; this.hasSubmittedSuccessfully = false; completionMessage.innerHTML = `

Entschuldigung!

Bei der Übermittlung ist ein Fehler aufgetreten. Bitte versuche es erneut.
`; const retryButton = completionMessage.querySelector('.retry-button'); if (retryButton) { retryButton.addEventListener('click', () => { completionMessage.innerHTML = `

Vielen Dank!

Versuche erneut zu übermitteln...
`; // Reset submission state and retry through queue setTimeout(() => { this.clearSubmissionState(); this.handleFormCompletion(payload); }, 500); }); } }); } // Handle form completion with debouncing and queueing handleFormCompletion(payload) { console.log('Form completion triggered for payload:', payload?.message_id); // Additional protection: check if form already submitted if (this.hasSubmittedSuccessfully) { console.log('Form already submitted successfully, ignoring completion trigger'); return; } // Check if this is a duplicate based on message_id if (payload?.message_id && this.processedSubmissions.has(`msg_${payload.message_id}`)) { console.log('Duplicate form completion detected for message_id:', payload.message_id); return; } // Mark this message_id as being processed if (payload?.message_id) { this.processedSubmissions.add(`msg_${payload.message_id}`); } // Use queueing system to prevent duplicates this.queueSubmission(payload); } // Create debounced version for auto-advance createDebouncedHandleNext() { return this.debounce(() => { if (!this.hasSubmittedSuccessfully && !this.isSubmitting) { this.handleNext(); } }, 300); } // Handle error handleError(payload) { // Cancel any pending loading timers this.cancelLoadingTimer(); console.error('Chat error:', payload.message); this.showErrorMessage(payload.message || 'Ein Fehler ist aufgetreten.'); } showLoadingIndicator(message = 'Lädt...') { // Clear any existing timer if (this.loadingTimer) { clearTimeout(this.loadingTimer); this.loadingTimer = null; } // For known slow operations, show immediately const isKnownSlowOperation = this.knownSlowOperations.includes(message); if (isKnownSlowOperation) { this._displayLoadingAnimation(message); } else { // Otherwise, set a timer to show loading only if operation takes longer than threshold this.loadingTimer = setTimeout(() => { this._displayLoadingAnimation(message); this.loadingTimer = null; }, this.loadingDelay); } } // Private method to actually display the loading animation _displayLoadingAnimation(message) { // First animate out current module if it exists if (this.currentModule) { this.currentModule.classList.add('fade-out-up'); // Wait briefly for animation to start before replacing setTimeout(() => { const loadingModule = document.createElement('div'); loadingModule.className = 'form-module loading-module fade-in-up'; loadingModule.dataset.moduleType = 'loading'; loadingModule.innerHTML = `
${message}
`; this.container.innerHTML = ''; this.container.appendChild(loadingModule); this.currentModule = loadingModule; }, 150); } else { // If no current module, just show the loading immediately const loadingModule = document.createElement('div'); loadingModule.className = 'form-module loading-module fade-in-up'; loadingModule.dataset.moduleType = 'loading'; loadingModule.innerHTML = `
${message}
`; this.container.innerHTML = ''; this.container.appendChild(loadingModule); this.currentModule = loadingModule; } } // Cancel loading timer cancelLoadingTimer() { if (this.loadingTimer) { clearTimeout(this.loadingTimer); this.loadingTimer = null; } } // Enable next button (active state) enableNextButton() { this.nextButton.classList.remove('inactive'); // Update arrow icon const arrowIcon = this.nextButton.querySelector('img'); if (arrowIcon) { arrowIcon.src = "https://cdn.prod.website-files.com/61918430ea8005fe5b5d3b6c/66ea8c6584c7e035cee00be0_right-arrow-wedge-next-white.svg"; } } updateNextButtonState() { // Skip if no chat history if (this.chatHistory.length === 0) return; const currentMessage = this.chatHistory[this.chatHistory.length - 1]; // Always enable for responder types if (currentMessage.type === 'responder') { this.enableNextButton(); return; } // If message is required, check if it's complete if (currentMessage.required) { if (this.isMessageComplete(currentMessage)) { this.enableNextButton(); } else { this.disableNextButton(); } } else { // Not required, always enable this.enableNextButton(); } } // Disable next button (inactive state) disableNextButton() { this.nextButton.classList.add('inactive'); // Update arrow icon const arrowIcon = this.nextButton.querySelector('img'); if (arrowIcon) { arrowIcon.src = "https://cdn.prod.website-files.com/61918430ea8005fe5b5d3b6c/6560739fd4d1fc9cf500057e_right-arrow-icon-mygesuch.svg"; } } showErrorMessage(message, isAuthError = false) { console.error("Error occurred:", message); if (isAuthError) { // For auth errors, show a specific error message const errorModule = document.createElement('div'); errorModule.className = 'form-module error-module fade-in-up'; errorModule.innerHTML = `
${message}
`; // Add to container this.container.innerHTML = ''; this.container.appendChild(errorModule); this.currentModule = errorModule; // Add retry button handler const retryButton = errorModule.querySelector('.error-retry-button'); if (retryButton) { retryButton.addEventListener('click', () => { // Go back to the previous step this.handleBack(); }); } } else { // For non-auth errors, use existing behavior (reset session) this.resetAndStartNewSession().catch(error => { console.error("Failed to create new session after error:", error); window.location.reload(); }); } } async displayMessage(message) { this.isAnimating = true; // Create module container const moduleEl = document.createElement('div'); moduleEl.className = 'form-module fade-in-up'; moduleEl.dataset.messageId = message.message_id; moduleEl.dataset.moduleType = 'message'; // Add title/heading if message has a title if (message.title) { const titleEl = document.createElement('h2'); titleEl.className = 'agent-create-ms-text title-text'; // Add special class if there's no message to add margin below if (!message.message || message.title === message.message) { titleEl.classList.add('title-no-message'); } moduleEl.appendChild(titleEl); // Will be animated later titleEl.textContent = ''; } // Add explanatory message if present if (message.message && message.title !== message.message) { // Avoid duplication if title and message are the same const messageEl = document.createElement('div'); messageEl.className = 'message-text'; moduleEl.appendChild(messageEl); // Always set to empty for consistent animation messageEl.textContent = ''; } // Add content container but initially hidden const contentEl = document.createElement('div'); contentEl.className = 'step-content'; contentEl.style.opacity = '0'; // Always start hidden moduleEl.appendChild(contentEl); // Generate specific content based on type switch (message.type) { case 'message-only': // No additional content break; case 'text-input': // Check if this is a specialized input type if (message.authStepType === 'password_creation') { contentEl.appendChild(this.createPasswordInput(message)); } else { contentEl.appendChild(this.createTextInput(message)); } break; case 'stacked-text-input': contentEl.appendChild(this.createStackedTextInput(message)); break; case 'address-input': contentEl.appendChild(this.createAddressInput(message)); break; case 'date-input': contentEl.appendChild(this.createDateInput(message)); break; case 'money-input': contentEl.appendChild(this.createMoneyInput(message)); break; case 'select': contentEl.appendChild(this.createSelect(message)); // Add class for sequential animation of options const selectEl = contentEl.querySelector('select'); if (selectEl) { selectEl.classList.add('animate-select'); // Hide all options initially Array.from(selectEl.options).forEach((option, index) => { option.style.opacity = '0'; option.style.transition = 'opacity 0.3s ease-out'; option.dataset.animationIndex = index; }); } break; case 'checkbox-list': contentEl.appendChild(this.createCheckboxList(message)); // Add classes for sequential animation const checkboxes = contentEl.querySelectorAll('.selection-option'); checkboxes.forEach((option, index) => { option.classList.add('animate-option'); option.style.opacity = '0'; option.style.transform = 'translateY(10px)'; option.dataset.animationIndex = index; }); break; case 'radio-list': contentEl.appendChild(this.createRadioList(message)); // Add classes for sequential animation const radioOptions = contentEl.querySelectorAll('.selection-option'); radioOptions.forEach((option, index) => { option.classList.add('animate-option'); option.style.opacity = '0'; option.style.transform = 'translateY(10px)'; option.dataset.animationIndex = index; }); break; case 'photo-upload': contentEl.appendChild(this.createPhotoUpload(message)); break; case 'responder': contentEl.appendChild(this.createResponder(message)); break; case 'loading': contentEl.appendChild(this.createLoadingAnimation()); break; default: console.warn(`Unknown message type: ${message.type}`); } if (message.explainer) { const explainerEl = document.createElement('p'); explainerEl.className = 'par-form gray explainer-fade'; explainerEl.style.marginTop = '48px'; // Set content immediately instead of empty explainerEl.textContent = message.explainer; // Start with hidden state for fade-in animation explainerEl.style.opacity = '0'; explainerEl.style.transform = 'translateY(15px)'; moduleEl.appendChild(explainerEl); } // Add to container this.container.appendChild(moduleEl); this.currentModule = moduleEl; // Ensure animation CSS classes take effect await this.wait(10); // Brief microtask delay // CONSISTENT ANIMATION SEQUENCE FOR ALL TYPES: this.updateNextButtonState(); // 1. Animate the title first if (message.title) { const titleEl = moduleEl.querySelector('.title-text'); if (titleEl) { // Ensure the title animation completes fully first await TextAnimator.animate(titleEl, message.title); // Add a more pronounced delay after title animation await this.wait(350); // Increased delay for more noticeable separation } } // 2. Animate the message next (if it exists and is different from title) if (message.message && message.title !== message.message) { const messageEl = moduleEl.querySelector('.message-text'); if (messageEl) { // Now that title is fully complete, animate the message await TextAnimator.animate(messageEl, message.message); await this.wait(300); // Slightly longer pause after message animation } } // 3. For responder types, handle responder text if (message.type === 'responder' && message.responderText) { const responderEl = contentEl.querySelector('.agent-create-ms-text'); if (responderEl) { contentEl.style.opacity = '1'; await TextAnimator.animate(responderEl, message.responderText); } } // For all other types, fade in content else { // First show content container contentEl.style.opacity = '1'; await this.wait(100); // Then animate select options or list items sequentially if applicable if (['select', 'radio-list', 'checkbox-list'].includes(message.type)) { await this.animateOptionsSequentially(contentEl, message.type); } } // 4. Animate the explainer text AFTER content if present if (message.explainer) { const explainerEl = moduleEl.querySelector('.par-form.gray'); if (explainerEl) { // Animate the explainer text after the content is shown await this.wait(300); // Wait a bit before showing explainer // Use CSS transition for fade in and up instead of text animation explainerEl.style.transition = 'opacity 0.8s ease-out, transform 0.8s ease-out'; explainerEl.style.opacity = '1'; explainerEl.style.transform = 'translateY(0)'; } } // Remove animation classes to prevent interference moduleEl.classList.remove('fade-in-up'); this.isAnimating = false; } // New method to animate options sequentially async animateOptionsSequentially(container, type) { if (type === 'select') { const select = container.querySelector('select.animate-select'); if (select) { const options = Array.from(select.options); // Skip the first option if it's a placeholder (usually is) const startIndex = options[0].disabled ? 1 : 0; for (let i = startIndex; i < options.length; i++) { options[i].style.opacity = '1'; await this.wait(70); // Brief delay between each option } // Remove animation class after completion select.classList.remove('animate-select'); } } else if (type === 'radio-list' || type === 'checkbox-list') { const options = container.querySelectorAll('.selection-option.animate-option'); for (let i = 0; i < options.length; i++) { options[i].style.opacity = '1'; options[i].style.transform = 'translateY(0)'; await this.wait(70); // Brief delay between each option } // Remove animation classes after completion options.forEach(option => { option.classList.remove('animate-option'); }); } } // Animate a message out async animateMessageOut(messageId) { const messageEl = this.container.querySelector(`[data-message-id="${messageId}"]`); if (!messageEl) return; this.isAnimating = true; // Animate out messageEl.classList.add('fade-out-up'); await this.wait(500); // Wait for animation to complete // Remove from DOM if (messageEl.parentNode) { messageEl.parentNode.removeChild(messageEl); } this.isAnimating = false; } getUserInput(message) { if (!message) return null; const messageEl = this.container.querySelector(`[data-message-id="${message.message_id}"]`); if (!messageEl) return null; switch (message.type) { case 'text-input': // For password input if (message.authStepType === 'password_creation') { const passwordInput = messageEl.querySelector(`input.cg-input-morph.pw[id="${message.message_id}"]`); return passwordInput ? passwordInput.value : null; } // For regular text input const textInput = messageEl.querySelector(`input[type="text"][id="${message.message_id}"], input[type="password"][id="${message.message_id}"]`); return textInput ? textInput.value : null; case 'date-input': // For custom date input, return the hidden input value const hiddenDateInput = messageEl.querySelector(`input[type="hidden"][id="${message.message_id}"]`); return hiddenDateInput ? hiddenDateInput.value : null; case 'money-input': const moneyInput = messageEl.querySelector(`#${message.message_id}`); const currencyInput = messageEl.querySelector('#currency'); if (!moneyInput) return null; // Get the numeric value normalized to American decimal format const amount = this.parseMoneyValue(moneyInput.value); const currency = currencyInput ? currencyInput.value : '€'; // Return both currency and amount as JSON string return amount !== null ? JSON.stringify({ amount: amount, // Already normalized to American decimal format (dots) currency: currency }) : null; case 'select': const selectEl = messageEl.querySelector(`#${message.message_id}`); return selectEl ? selectEl.value : null; case 'radio-list': const selectedRadio = messageEl.querySelector(`input[name="${message.message_id}"]:checked`); return selectedRadio ? selectedRadio.value : null; case 'checkbox-list': const selectedCheckboxes = messageEl.querySelectorAll(`input[name="${message.message_id}"]:checked`); return selectedCheckboxes.length > 0 ? Array.from(selectedCheckboxes).map(cb => cb.value) : null; case 'stacked-text-input': // For stacked text inputs, return a JSON string of all field values const stackedInputs = messageEl.querySelectorAll(`input[id^="${message.message_id}_"]`); const stackedResult = {}; stackedInputs.forEach(input => { const fieldId = input.id.replace(`${message.message_id}_`, ''); stackedResult[fieldId] = input.value; }); return Object.keys(stackedResult).length > 0 ? JSON.stringify(stackedResult) : null; case 'address-input': // For address inputs, return a JSON string of all address field values const addressInputs = messageEl.querySelectorAll(`input[id^="${message.message_id}_"]`); const addressResult = {}; addressInputs.forEach(input => { const fieldId = input.id.replace(`${message.message_id}_`, ''); addressResult[fieldId] = input.value; }); return Object.keys(addressResult).length > 0 ? JSON.stringify(addressResult) : null; default: return null; } } // Cancel ongoing animations cancelOngoingAnimations() { // Clear auto-advance timer if it exists if (this.autoAdvanceTimer) { clearTimeout(this.autoAdvanceTimer); this.autoAdvanceTimer = null; } // Force completion of text animations if (this.currentModule) { // Find any elements being animated and reveal them instantly const textElements = this.currentModule.querySelectorAll('.agent-create-ms-text, .message-text, .explainer-text'); textElements.forEach(element => { // Get the message ID const messageId = this.currentModule.dataset.messageId; if (!messageId) return; // Find message in history const message = this.chatHistory.find(msg => msg.message_id === messageId); if (!message) return; if (element.closest('.step-content')) { // It's a responder text element.textContent = message.responderText || ''; } else if (element.classList.contains('explainer-text')) { // It's the explainer text element.textContent = message.explainer || ''; } else if (element.classList.contains('message-text')) { // It's the main message element.textContent = message.message || ''; } else if (element.classList.contains('title-text')) { // It's the title element.textContent = message.title || ''; } }); // Show content elements that might be waiting for animation const contentEl = this.currentModule.querySelector('.step-content'); if (contentEl) { contentEl.style.opacity = '1'; // Also reveal any options that might be animating const selectOptions = contentEl.querySelectorAll('select.animate-select option'); selectOptions.forEach(option => { option.style.opacity = '1'; }); const listOptions = contentEl.querySelectorAll('.selection-option.animate-option'); listOptions.forEach(option => { option.style.opacity = '1'; option.style.transform = 'translateY(0)'; }); } // Remove animation classes this.currentModule.classList.remove('fade-in-up', 'fade-in-down'); const selects = this.currentModule.querySelectorAll('select.animate-select'); selects.forEach(select => select.classList.remove('animate-select')); const options = this.currentModule.querySelectorAll('.selection-option.animate-option'); options.forEach(option => option.classList.remove('animate-option')); // Set isAnimating to false so we can proceed this.isAnimating = false; } } // Validate the current message validateMessage(message) { if (!message) return true; // No validation needed for certain types if (['message-only', 'responder'].includes(message.type)) return true; const messageEl = this.container.querySelector(`[data-message-id="${message.message_id}"]`); if (!messageEl) return true; let isValid = true; // Check for required fields based on type if (message.required) { switch (message.type) { case 'text-input': const textInput = messageEl.querySelector(`#${message.message_id}`); const textValidationMessage = messageEl.querySelector('.validation-error'); if (!textInput || !textInput.value.trim()) { if (textValidationMessage) { textValidationMessage.textContent = 'Dieses Feld ist erforderlich'; textValidationMessage.style.display = 'block'; textValidationMessage.style.color = '#ff3b30'; textValidationMessage.style.fontSize = '0.875rem'; textValidationMessage.style.marginTop = '0.5rem'; // Highlight the input textInput.style.borderColor = '#ff3b30'; } isValid = false; } break; case 'date-input': // For custom date input, validate the hidden input value const hiddenDateInput = messageEl.querySelector(`input[type="hidden"][id="${message.message_id}"]`); const dateInputGroup = messageEl.querySelector('.date-input-holder-group'); const validationMessage = messageEl.querySelector('.validation-error'); if (!hiddenDateInput || !hiddenDateInput.value) { if (validationMessage && dateInputGroup) { this.showCustomValidationMessage( validationMessage, dateInputGroup, 'Dieses Feld ist erforderlich' ); } isValid = false; } else if (validationMessage && dateInputGroup) { // Validate the date isValid = this.validateCustomDate( hiddenDateInput.value, message, validationMessage, dateInputGroup ); } break; case 'money-input': const moneyInput = messageEl.querySelector(`#${message.message_id}`); const moneyValidationMessage = messageEl.querySelector('.validation-error'); if (!moneyInput || !moneyInput.value.trim()) { if (moneyValidationMessage) { moneyValidationMessage.textContent = 'Bitte gib einen Betrag ein'; moneyValidationMessage.style.display = 'block'; moneyInput.style.borderColor = '#ff3b30'; } isValid = false; } else { // Check if it's a valid number const numericValue = this.parseMoneyValue(moneyInput.value); if (numericValue === null) { if (moneyValidationMessage) { moneyValidationMessage.textContent = 'Bitte gib einen gültigen Betrag ein'; moneyValidationMessage.style.display = 'block'; moneyInput.style.borderColor = '#ff3b30'; } isValid = false; } else if (message.minValue !== undefined && numericValue < message.minValue) { if (moneyValidationMessage) { moneyValidationMessage.textContent = `Der Mindestbetrag ist ${this.formatMoneyValue(message.minValue)}€`; moneyValidationMessage.style.display = 'block'; moneyInput.style.borderColor = '#ff3b30'; } isValid = false; } else if (message.maxValue !== undefined && numericValue > message.maxValue) { if (moneyValidationMessage) { moneyValidationMessage.textContent = `Der Höchstbetrag ist ${this.formatMoneyValue(message.maxValue)}€`; moneyValidationMessage.style.display = 'block'; moneyInput.style.borderColor = '#ff3b30'; } isValid = false; } } break; case 'select': const selectEl = messageEl.querySelector(`#${message.message_id}`); if (!selectEl || !selectEl.value) { this.showValidationError(selectEl, 'Bitte wähle eine Option'); isValid = false; } break; case 'radio-list': const selectedRadio = messageEl.querySelector(`input[name="${message.message_id}"]:checked`); if (!selectedRadio) { const listEl = messageEl.querySelector('.selection-list'); if (listEl) { this.showValidationError(listEl, 'Bitte wähle eine Option'); } isValid = false; } break; case 'checkbox-list': const selectedCheckboxes = messageEl.querySelectorAll(`input[name="${message.message_id}"]:checked`); if (selectedCheckboxes.length === 0) { const listEl = messageEl.querySelector('.selection-list'); if (listEl) { this.showValidationError(listEl, 'Bitte wähle mindestens eine Option'); } isValid = false; } break; case 'stacked-text-input': const stackedInputs = messageEl.querySelectorAll(`input[id^="${message.message_id}_"]`); const stackedValidationMessage = messageEl.querySelector('.validation-error'); for (const input of stackedInputs) { if (input.dataset.required === 'true' && input.value.trim() === '') { if (stackedValidationMessage) { stackedValidationMessage.textContent = 'Alle erforderlichen Felder müssen ausgefüllt werden'; stackedValidationMessage.style.display = 'block'; stackedValidationMessage.style.color = '#ff3b30'; stackedValidationMessage.style.fontSize = '0.875rem'; stackedValidationMessage.style.marginTop = '0.5rem'; // Highlight the input input.style.borderColor = '#ff3b30'; } isValid = false; } } break; case 'address-input': const addressInputs = messageEl.querySelectorAll(`input[id^="${message.message_id}_"]`); const addressValidationMessage = messageEl.querySelector('.validation-error'); for (const input of addressInputs) { if (input.dataset.required === 'true' && input.value.trim() === '') { if (addressValidationMessage) { addressValidationMessage.textContent = 'All required address fields must be filled out'; addressValidationMessage.style.display = 'block'; addressValidationMessage.style.color = '#ff3b30'; addressValidationMessage.style.fontSize = '0.875rem'; addressValidationMessage.style.marginTop = '0.5rem'; // Highlight the input input.style.borderColor = '#ff3b30'; } isValid = false; } } break; } } return isValid; } isMessageComplete(message) { if (!message) return true; // No validation needed for certain types if (['message-only', 'responder'].includes(message.type)) return true; const messageEl = this.container.querySelector(`[data-message-id="${message.message_id}"]`); if (!messageEl) return true; switch (message.type) { case 'text-input': const textInputEl = messageEl.querySelector(`#${message.message_id}`); return textInputEl && textInputEl.value.trim() !== ''; case 'date-input': // For our custom date input, check the hidden input value const hiddenDateInput = messageEl.querySelector(`input[type="hidden"][id="${message.message_id}"]`); return hiddenDateInput && hiddenDateInput.value !== ''; case 'money-input': const moneyInputEl = messageEl.querySelector(`#${message.message_id}`); const isComplete = moneyInputEl && moneyInputEl.value.trim() !== '' && this.parseMoneyValue(moneyInputEl.value) !== null; return isComplete; case 'select': const selectEl = messageEl.querySelector(`#${message.message_id}`); return selectEl && selectEl.value !== ''; case 'radio-list': const selectedRadio = messageEl.querySelector(`input[name="${message.message_id}"]:checked`); return !!selectedRadio; case 'checkbox-list': const selectedCheckboxes = messageEl.querySelectorAll(`input[name="${message.message_id}"]:checked`); return selectedCheckboxes.length > 0; case 'photo-upload': const photoContainer = messageEl.querySelector('.photo-upload-container'); return photoContainer && photoContainer._hasPhoto && photoContainer._hasPhoto(); case 'stacked-text-input': // Check if all required stacked inputs have values const stackedInputs = messageEl.querySelectorAll(`input[id^="${message.message_id}_"]`); for (const input of stackedInputs) { if (input.dataset.required === 'true' && input.value.trim() === '') { return false; } } return stackedInputs.length > 0; case 'address-input': // Check if all required address inputs have values const addressInputs = messageEl.querySelectorAll(`input[id^="${message.message_id}_"]`); for (const input of addressInputs) { if (input.dataset.required === 'true' && input.value.trim() === '') { return false; } } return addressInputs.length > 0; default: return true; } } // Show validation error message showValidationError(element, message) { if (!element) return; // Remove existing error messages const existingError = element.parentNode.querySelector('.validation-error'); if (existingError) { existingError.parentNode.removeChild(existingError); } // Create and add error message const errorEl = document.createElement('div'); errorEl.className = 'validation-error'; errorEl.style.color = '#ff3b30'; errorEl.style.fontSize = '0.875rem'; errorEl.style.marginTop = '0.5rem'; errorEl.textContent = message; element.parentNode.appendChild(errorEl); // Highlight the input element.style.borderColor = '#ff3b30'; element.addEventListener('input', function onInput() { element.style.borderColor = ''; if (errorEl.parentNode) { errorEl.parentNode.removeChild(errorEl); } element.removeEventListener('input', onInput); }); } // Helper methods to create UI components // Create text input createTextInput(message) { const container = document.createElement('div'); container.className = 'cont-morph-input'; // Create the input with the specified classes const input = document.createElement('input'); input.type = 'text'; input.className = 'cg-input-morph w-input'; // Add the required classes input.id = message.message_id; input.name = message.message_id; input.placeholder = message.placeholder || 'Schreibe hier...'; // input.autofocus = true; // Set autofocus attribute input.maxLength = 256; // Add maxlength as per your example // Set value from stored input if available if (message.input !== null) { input.value = message.input; } // Add event listener for input changes input.addEventListener('input', () => this.updateNextButtonState()); // Create the line element const lineElement = document.createElement('div'); lineElement.className = 'line-input-bottom-new'; // Add validation message container (initially hidden) const validationMessage = document.createElement('div'); validationMessage.className = 'validation-error'; validationMessage.style.display = 'none'; // Append elements to container container.appendChild(input); container.appendChild(lineElement); container.appendChild(validationMessage); return container; } // Create stacked text input (multiple text fields stacked vertically) createStackedTextInput(message) { const container = document.createElement('div'); container.className = 'stacked-input-container'; // Default fields if not specified const defaultFields = [ { id: 'first_name', placeholder: 'Vorname', maxLength: 100, required: true }, { id: 'last_name', placeholder: 'Nachname', maxLength: 100, required: true } ]; const fields = message.fields || defaultFields; const inputs = []; fields.forEach((field, index) => { const inputContainer = document.createElement('div'); inputContainer.className = 'cont-morph-input stacked-input-item'; const input = document.createElement('input'); input.type = 'text'; input.className = 'cg-input-morph w-input'; input.id = `${message.message_id}_${field.id}`; input.name = `${message.message_id}_${field.id}`; input.placeholder = field.placeholder; input.maxLength = field.maxLength || 256; if (field.required) { input.setAttribute('required', 'true'); input.dataset.required = 'true'; } // Set value from stored input if available if (message.inputs && message.inputs[field.id] !== undefined) { input.value = message.inputs[field.id]; } // Add event listener for input changes input.addEventListener('input', () => this.updateNextButtonState()); // Create the line element const lineElement = document.createElement('div'); lineElement.className = 'line-input-bottom-new'; inputs.push(input); // Append elements to input container inputContainer.appendChild(input); inputContainer.appendChild(lineElement); // Add margin between stacked inputs (except for the last one) if (index < fields.length - 1) { inputContainer.style.marginBottom = '20px'; } container.appendChild(inputContainer); }); // Add validation message container (initially hidden) const validationMessage = document.createElement('div'); validationMessage.className = 'validation-error'; validationMessage.style.display = 'none'; container.appendChild(validationMessage); return container; } // Create address input (specialized multi-field input for addresses) createAddressInput(message) { // Main container const container = document.createElement('div'); container.className = 'cont-address-input-holder-group'; // Get address fields from server configuration or use defaults const addressFields = message.addressFields || [ { id: 'street', placeholder: 'Straße & Hausnummer', maxLength: 256, required: true }, { id: 'postal_code', placeholder: 'PLZ', maxLength: 20, required: true }, { id: 'city', placeholder: 'Stadt', maxLength: 100, required: true }, { id: 'country', placeholder: 'Land', maxLength: 100, required: false }, { id: 'state', placeholder: 'Bundesland', maxLength: 100, required: false } ]; // Map server field IDs to frontend field names for compatibility const fieldMapping = { 'street': 'streetname', 'postal_code': 'zip', 'city': 'city', 'country': 'country', 'state': 'state' }; // Create field containers based on layout const streetSpreader = document.createElement('div'); streetSpreader.className = 'address-street-spreader-input-morph'; const citySpreader = document.createElement('div'); citySpreader.className = 'city-street-spreader-input-morph'; const countrySpreader = document.createElement('div'); countrySpreader.className = 'country-spreader-input-morph-copy'; // Store input references for later use const inputElements = {}; // Create address fields dynamically based on server configuration addressFields.forEach(fieldConfig => { const mappedName = fieldMapping[fieldConfig.id] || fieldConfig.id; // Create input container const morphCont = document.createElement('div'); morphCont.className = 'date-morph-cont'; // Create input element const input = document.createElement('input'); input.className = 'cg-input-morph w-input'; input.maxLength = fieldConfig.maxLength || 256; input.name = mappedName; input.setAttribute('data-name', mappedName); input.placeholder = fieldConfig.placeholder; input.type = 'text'; input.id = `${message.message_id}_${mappedName}`; // Set required attribute based on server configuration if (fieldConfig.required) { input.dataset.required = 'true'; input.setAttribute('required', 'true'); } // Create line element const line = document.createElement('div'); line.className = 'line-input-bottom-new'; // Assemble input container morphCont.appendChild(input); morphCont.appendChild(line); // Add to appropriate spreader based on field type if (fieldConfig.id === 'street') { streetSpreader.appendChild(morphCont); } else if (fieldConfig.id === 'city' || fieldConfig.id === 'postal_code') { citySpreader.appendChild(morphCont); } else if (fieldConfig.id === 'country' || fieldConfig.id === 'state') { countrySpreader.appendChild(morphCont); } // Store reference for later use inputElements[mappedName] = input; }); // Assemble the final structure container.appendChild(streetSpreader); container.appendChild(citySpreader); container.appendChild(countrySpreader); // Add validation message container const validationMessage = document.createElement('div'); validationMessage.className = 'validation-error'; validationMessage.style.display = 'none'; validationMessage.style.color = '#ff4444'; validationMessage.style.fontSize = '14px'; validationMessage.style.marginTop = '8px'; container.appendChild(validationMessage); // Set values from stored input if available if (message.addressInputs) { Object.keys(inputElements).forEach(fieldName => { if (message.addressInputs[fieldName]) { inputElements[fieldName].value = message.addressInputs[fieldName]; } }); } // Add event listeners for input changes Object.values(inputElements).forEach(input => { input.addEventListener('input', () => this.updateNextButtonState()); }); return container; } // Create month suggestions dropdown createMonthSuggestions(input, suggestions) { // Remove existing suggestions this.removeMonthSuggestions(); if (suggestions.length === 0) return; const dropdown = document.createElement('div'); dropdown.className = 'month-suggestions'; dropdown.id = 'monthSuggestions'; suggestions.forEach((suggestion, index) => { const item = document.createElement('div'); item.className = 'month-suggestion-item'; item.textContent = suggestion.display; item.dataset.value = suggestion.value; item.dataset.type = suggestion.type; // Highlight first item if (index === 0) { item.classList.add('highlighted'); } item.addEventListener('click', () => { this.selectMonthSuggestion(input, suggestion); }); dropdown.appendChild(item); }); // Position dropdown below the input const rect = input.getBoundingClientRect(); dropdown.style.position = 'absolute'; dropdown.style.top = (rect.bottom + window.scrollY) + 'px'; dropdown.style.left = rect.left + 'px'; dropdown.style.width = rect.width + 'px'; dropdown.style.zIndex = '1000'; dropdown.style.backgroundColor = 'white'; dropdown.style.border = '1px solid #ddd'; dropdown.style.borderRadius = '4px'; dropdown.style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)'; dropdown.style.maxHeight = '200px'; dropdown.style.overflowY = 'auto'; document.body.appendChild(dropdown); } // Remove month suggestions dropdown removeMonthSuggestions() { const existing = document.getElementById('monthSuggestions'); if (existing) { existing.remove(); } } // Select a month suggestion selectMonthSuggestion(input, suggestion) { const monthNum = findClosestMonth(suggestion.value); const formattedMonth = fullMonthNames.en[monthNum - 1]; input.value = formattedMonth; this.removeMonthSuggestions(); // Move to year input const yearInput = input.parentElement.parentElement.querySelector('.year input'); if (yearInput) { setTimeout(() => { yearInput.focus(); this.animatePulse(yearInput); }, 10); } // Update hidden value const hiddenInput = input.closest('.input-field').querySelector('input[type="hidden"]'); if (hiddenInput) { const dayInput = input.parentElement.parentElement.querySelector('.day input'); const message = { message_id: hiddenInput.dataset.messageId }; this.updateHiddenDateValue(message.message_id, dayInput, input, yearInput, hiddenInput); } } // Create date input createDateInput(message) { // Create date input structure with 3 fields const dateInputGroup = document.createElement('div'); dateInputGroup.className = 'date-input-holder-group'; // Create day input const dayInputHolder = document.createElement('div'); dayInputHolder.className = 'date-input-holder'; const dayMorphCont = document.createElement('div'); dayMorphCont.className = 'date-morph-cont day'; const dayInput = document.createElement('input'); dayInput.type = 'text'; dayInput.className = 'cg-input-morph w-input date-input-transition'; dayInput.id = 'day'; dayInput.name = 'day'; dayInput.placeholder = 'Tag'; dayInput.maxLength = 256; dayInput.setAttribute('data-name', 'day'); dayInput.autofocus = true; const dayLine = document.createElement('div'); dayLine.className = 'line-input-bottom-new'; const dayError = document.createElement('div'); dayError.className = 'date-input-error'; dayError.id = 'dayError'; dayError.style.display = 'none'; dayError.textContent = 'Please input a valid day 1-31'; // Create month input const monthInputHolder = document.createElement('div'); monthInputHolder.className = 'date-input-holder'; const monthMorphCont = document.createElement('div'); monthMorphCont.className = 'date-morph-cont month'; const monthInput = document.createElement('input'); monthInput.type = 'text'; monthInput.className = 'cg-input-morph w-input date-input-transition'; monthInput.id = 'month'; monthInput.name = 'month'; monthInput.placeholder = 'Monat'; monthInput.maxLength = 256; monthInput.setAttribute('data-name', 'month'); const monthLine = document.createElement('div'); monthLine.className = 'line-input-bottom-new'; const monthError = document.createElement('div'); monthError.className = 'date-input-error'; monthError.id = 'monthError'; monthError.style.display = 'none'; monthError.textContent = 'Please input a valid month'; // Create year input const yearInputHolder = document.createElement('div'); yearInputHolder.className = 'date-input-holder'; const yearMorphCont = document.createElement('div'); yearMorphCont.className = 'date-morph-cont year'; const yearInput = document.createElement('input'); yearInput.type = 'text'; yearInput.className = 'cg-input-morph w-input date-input-transition'; yearInput.id = 'year'; yearInput.name = 'year'; yearInput.placeholder = 'Jahr'; yearInput.maxLength = 256; yearInput.setAttribute('data-name', 'year'); const yearLine = document.createElement('div'); yearLine.className = 'line-input-bottom-new'; const yearError = document.createElement('div'); yearError.className = 'date-input-error'; yearError.id = 'yearError'; yearError.style.display = 'none'; yearError.textContent = 'Please input a valid year'; // Add inputs to their holders dayMorphCont.appendChild(dayInput); dayMorphCont.appendChild(dayLine); dayMorphCont.appendChild(dayError); dayInputHolder.appendChild(dayMorphCont); monthMorphCont.appendChild(monthInput); monthMorphCont.appendChild(monthLine); monthMorphCont.appendChild(monthError); monthInputHolder.appendChild(monthMorphCont); yearMorphCont.appendChild(yearInput); yearMorphCont.appendChild(yearLine); yearMorphCont.appendChild(yearError); yearInputHolder.appendChild(yearMorphCont); // Add all holders to the group dateInputGroup.appendChild(dayInputHolder); dateInputGroup.appendChild(monthInputHolder); dateInputGroup.appendChild(yearInputHolder); // Add hidden input to store the full date value (YYYY-MM-DD format) const hiddenDateInput = document.createElement('input'); hiddenDateInput.type = 'hidden'; hiddenDateInput.id = message.message_id; hiddenDateInput.name = message.message_id; // Set value from stored input if available if (message.input !== null) { hiddenDateInput.value = message.input; this.populateDateFields(message.input); // Note the "this." prefix } const container = document.createElement('div'); container.className = 'date-input-container'; container.appendChild(dateInputGroup); container.appendChild(hiddenDateInput); // Set up event listeners for all date fields this.setupDateFieldEvents(message, container, dateInputGroup); // Note the "this." prefix return container; } // Create password input with toggle visibility createPasswordInput(message) { // Main container const holderMorphPw = document.createElement('div'); holderMorphPw.className = 'holder-morph-pw-input'; // Password input container const contMorphPassword = document.createElement('div'); contMorphPassword.className = 'cont-morph-password'; holderMorphPw.appendChild(contMorphPassword); // Create password input const input = document.createElement('input'); input.type = 'password'; // Start as password type input.className = 'cg-input-morph pw w-input'; input.id = message.message_id; input.name = 'field-2'; input.dataset.name = 'Field 2'; input.placeholder = '*******'; input.autofocus = true; input.maxLength = 256; input.required = message.required || false; // Set value from stored input if available if (message.input !== null) { input.value = message.input; } // Add event listener for input changes input.addEventListener('input', () => this.updateNextButtonState()); // Create toggle visibility button const toggleButton = document.createElement('div'); toggleButton.id = 'toggleaddPasswordVisibility'; toggleButton.className = 'cta-hide-show-password-lp morph'; // Create eye icon const eyeIcon = document.createElement('img'); eyeIcon.src = 'https://cdn.prod.website-files.com/61918430ea8005fe5b5d3b6c/65d8bdc4be42fe7bc323935c_eye-icon-MyGesuch-big-filled-gray-01.svg'; eyeIcon.loading = 'lazy'; eyeIcon.id = 'eyeicon'; eyeIcon.alt = ''; eyeIcon.className = 'icon-hideshow-password-lp'; // Add click event to toggle password visibility toggleButton.addEventListener('click', () => { if (input.type === 'password') { input.type = 'text'; eyeIcon.src = 'https://cdn.prod.website-files.com/61918430ea8005fe5b5d3b6c/65d8bdc4be42fe7bc323935c_eye-icon-MyGesuch-big-filled-gray-01.svg'; // Use crossed-eye icon for visible password } else { input.type = 'password'; eyeIcon.src = 'https://cdn.prod.website-files.com/61918430ea8005fe5b5d3b6c/65d8bdc4be42fe7bc323935c_eye-icon-MyGesuch-big-filled-gray-01.svg'; // Use regular eye icon for hidden password } }); // Append elements toggleButton.appendChild(eyeIcon); contMorphPassword.appendChild(input); contMorphPassword.appendChild(toggleButton); // Add bottom line const lineElement = document.createElement('div'); lineElement.className = 'line-input-bottom-new'; holderMorphPw.appendChild(lineElement); // Add validation message container (initially hidden) const validationMessage = document.createElement('div'); validationMessage.className = 'validation-error'; validationMessage.style.display = 'none'; holderMorphPw.appendChild(validationMessage); return holderMorphPw; } // Helper method to create individual date fields createDateField(id, placeholder, autofocus = false) { const input = document.createElement('input'); input.className = 'date-field'; input.type = 'text'; input.maxLength = '1'; input.placeholder = placeholder; input.id = id; input.name = id; input.dataset.name = id; if (autofocus) { input.autofocus = true; } // Allow only numbers input.addEventListener('keypress', (e) => { const keyCode = e.which || e.keyCode; if (keyCode < 48 || keyCode > 57) { e.preventDefault(); } }); return input; } // Create money input with currency selection createMoneyInput(message) { const container = document.createElement('div'); container.className = 'holder-morph-figure-input'; // Create the main input wrapper const inputWrapper = document.createElement('div'); inputWrapper.className = 'cont-morph-password'; // Create the income input const incomeInput = document.createElement('input'); incomeInput.className = 'cg-input-morph figure w-input'; incomeInput.autofocus = true; incomeInput.maxLength = 256; incomeInput.name = message.message_id || 'income'; incomeInput.setAttribute('data-name', message.message_id || 'income'); incomeInput.placeholder = message.placeholder || '0.000'; incomeInput.type = 'text'; incomeInput.id = message.message_id || 'income'; incomeInput.required = true; // Set value from stored input if available if (message.input !== null) { incomeInput.value = this.formatMoneyValue(message.input); } // Create the currency selector const currencyToggle = document.createElement('div'); currencyToggle.id = 'toggleaddPasswordVisibility'; currencyToggle.className = 'cta-change-currency-figure'; const currencyInput = document.createElement('input'); currencyInput.className = 'input-currency-select w-input'; currencyInput.maxLength = 256; currencyInput.name = 'currency'; currencyInput.setAttribute('data-name', 'currency'); currencyInput.placeholder = ''; currencyInput.type = 'text'; currencyInput.id = 'currency'; currencyInput.required = true; currencyInput.setAttribute('data-prev', '$'); currencyInput.value = '€'; // Default to Euro // Assemble the input structure currencyToggle.appendChild(currencyInput); inputWrapper.appendChild(incomeInput); inputWrapper.appendChild(currencyToggle); // Create the line element const lineElement = document.createElement('div'); lineElement.className = 'line-input-bottom-new'; // Add validation message container const validationMessage = document.createElement('div'); validationMessage.className = 'validation-error'; validationMessage.style.display = 'none'; // Assemble the container container.appendChild(inputWrapper); container.appendChild(lineElement); container.appendChild(validationMessage); // Add currency validation and formatting logic this.addCurrencyLogic(incomeInput, currencyInput); return container; } // Add currency validation and formatting logic addCurrencyLogic(incomeInput, currencyInput) { // Currency normalization function const normalizeCurrency = (input) => { const val = (input || "").trim().toUpperCase(); if (["EUR", "€", "DE"].includes(val)) return "€"; if (["GBP", "£", "UK"].includes(val)) return "£"; if (["USD", "$", "US"].includes(val)) return "$"; return "$"; // fallback }; // Get locale based on currency symbol const getLocale = (symbol) => { switch (symbol) { case "€": return "de-DE"; case "£": return "en-GB"; case "$": default: return "en-US"; } }; // Parse localized number const parseLocalizedNumber = (inputStr, locale) => { if (!inputStr) return NaN; let normalized = inputStr; // Handle different number formats intelligently based on locale if (locale === "de-DE") { // German/European format: 1.234,56 if (normalized.includes(',')) { const parts = normalized.split(','); if (parts.length === 2 && parts[1].length <= 2) { // Has decimal comma - remove dots (thousands) and convert comma to dot normalized = parts[0].replace(/\./g, '') + '.' + parts[1]; } else { // Multiple commas - treat as thousands separators normalized = normalized.replace(/,/g, ''); } } else if (normalized.includes('.')) { // Only dots - could be thousands separators or decimal const parts = normalized.split('.'); if (parts.length === 2 && parts[1].length <= 2) { // Likely misused decimal format - keep as is } else { // Multiple dots - thousands separators normalized = normalized.replace(/\./g, ''); } } } else { // US/UK format: 1,234.56 if (normalized.includes('.')) { const parts = normalized.split('.'); if (parts.length === 2 && parts[1].length <= 2) { // Has decimal point - remove commas (thousands) normalized = parts[0].replace(/,/g, '') + '.' + parts[1]; } else { // Multiple dots - unusual, remove all normalized = normalized.replace(/\./g, ''); } } else { // Only commas - thousands separators normalized = normalized.replace(/,/g, ''); } } return parseFloat(normalized); }; // Format number with locale const formatNumber = (number, locale) => { if (isNaN(number)) return ""; return new Intl.NumberFormat(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number); }; // Reformat income after currency change const reformatIncomeAfterCurrencyChange = (prevCurrency) => { const prevLocale = getLocale(prevCurrency); const newCurrency = normalizeCurrency(currencyInput.value); const newLocale = getLocale(newCurrency); // Parse based on *old* locale (before switching) const parsedNumber = parseLocalizedNumber(incomeInput.value, prevLocale); const formatted = formatNumber(parsedNumber, newLocale); currencyInput.value = newCurrency; incomeInput.value = formatted; }; // When income loses focus — reformat in-place incomeInput.addEventListener("blur", () => { const currentLocale = getLocale(normalizeCurrency(currencyInput.value)); const parsed = parseLocalizedNumber(incomeInput.value, currentLocale); incomeInput.value = formatNumber(parsed, currentLocale); // Update next button state this.updateNextButtonState(); }); // When currency field gains focus — remember what it was currencyInput.addEventListener("focus", () => { currencyInput.setAttribute("data-prev", normalizeCurrency(currencyInput.value)); }); // When currency field loses focus — reformat with proper conversion currencyInput.addEventListener("blur", () => { const prev = currencyInput.getAttribute("data-prev") || "$"; reformatIncomeAfterCurrencyChange(prev); // Update next button state this.updateNextButtonState(); }); // Initialize currency currencyInput.value = normalizeCurrency(currencyInput.value); } // Helper to format money value for display formatMoneyValue(value) { if (value === null || value === undefined || value === '') { return ''; } // If value is a string with a comma, parse it first if (typeof value === 'string' && value.includes(',')) { value = this.parseMoneyValue(value); if (value === null) return ''; } // Format the number with German/European formatting (1.234,56) return new Intl.NumberFormat('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 2 }).format(value); } // Helper to parse money value from formatted string parseMoneyValue(value) { if (!value) return null; // Remove any currency symbols (€, £, $), spaces value = value.replace(/[€£$\s]/g, ''); // Handle different number formats intelligently if (value.includes(',')) { // If there's a comma, it could be European decimal or thousands separator const parts = value.split(','); if (parts.length === 2 && parts[1].length <= 2) { // Likely European decimal format (1.234,56) // Remove dots (thousands separators) and convert comma to dot value = parts[0].replace(/\./g, '') + '.' + parts[1]; } else { // Likely American thousands separator (1,234,567) value = value.replace(/,/g, ''); } } else if (value.includes('.')) { // If there's a dot, check if it's decimal or thousands separator const parts = value.split('.'); if (parts.length === 2 && parts[1].length <= 2) { // Likely decimal format (1234.56) // Keep as is } else { // Likely thousands separator (1.234.567) value = value.replace(/\./g, ''); } } // If no punctuation, it's just a whole number const numValue = parseFloat(value); return isNaN(numValue) ? null : numValue; } // Add validation for money input validateMoneyInput(message) { const messageEl = document.querySelector(`[data-message-id="${message.message_id}"]`); if (!messageEl) return true; const input = messageEl.querySelector(`#${message.message_id}`); const validationMessage = messageEl.querySelector('.validation-error'); if (!input || !validationMessage) return true; // Clear previous validation validationMessage.style.display = 'none'; input.style.borderColor = ''; // Skip validation if empty and not required if (!input.value.trim() && !message.required) { return true; } // Required check if (message.required && !input.value.trim()) { validationMessage.textContent = 'Bitte gib einen Betrag ein'; validationMessage.style.display = 'block'; input.style.borderColor = '#ff3b30'; return false; } // Parse the value const numericValue = this.parseMoneyValue(input.value); // Check if valid number if (numericValue === null) { validationMessage.textContent = 'Bitte gib einen gültigen Betrag ein'; validationMessage.style.display = 'block'; input.style.borderColor = '#ff3b30'; return false; } // Check minimum value if specified if (message.minValue !== undefined && numericValue < message.minValue) { validationMessage.textContent = `Der Mindestbetrag ist ${this.formatMoneyValue(message.minValue)} €`; validationMessage.style.display = 'block'; input.style.borderColor = '#ff3b30'; return false; } // Check maximum value if specified if (message.maxValue !== undefined && numericValue > message.maxValue) { validationMessage.textContent = `Der Höchstbetrag ist ${this.formatMoneyValue(message.maxValue)} €`; validationMessage.style.display = 'block'; input.style.borderColor = '#ff3b30'; return false; } return true; } animateShake(element) { if (!element) return; element.classList.remove('date-input-shake'); void element.offsetWidth; // Trigger reflow element.classList.add('date-input-shake'); setTimeout(() => { if (element) element.classList.remove('date-input-shake'); }, 600); } animatePulse(element) { if (!element) return; element.classList.remove('date-input-pulse'); void element.offsetWidth; // Trigger reflow element.classList.add('date-input-pulse'); setTimeout(() => { if (element) element.classList.remove('date-input-pulse'); }, 1600); } animateHighlight(element) { if (!element) return; element.classList.remove('date-input-highlight'); void element.offsetWidth; // Trigger reflow element.classList.add('date-input-highlight'); setTimeout(() => { if (element) element.classList.remove('date-input-highlight'); }, 1100); } // Setup event listeners for date fields setupDateFieldEvents(message, container, dateInputGroup) { // Get the 3 input fields const dayInput = container.querySelector('#day'); const monthInput = container.querySelector('#month'); const yearInput = container.querySelector('#year'); const hiddenInput = container.querySelector(`input[id="${message.message_id}"]`); const validationMessage = document.createElement('div'); validationMessage.className = 'validation-error'; validationMessage.style.display = 'none'; container.appendChild(validationMessage); // Current date for reference const currentDate = new Date(); const currentYear = currentDate.getFullYear(); const currentMonth = currentDate.getMonth() + 1; // JavaScript months are 0-based // Track if backward navigation is in progress let backNavigationInProgress = false; // Track month input for text entry let lastMonthInputValue = ''; // Basic validation limits const yearMin = 1900; const yearMax = currentMonth >= 10 ? currentYear + 1 : currentYear; // COMMON KEYBOARD NAVIGATION // Day input keyboard navigation dayInput.addEventListener('keydown', (e) => { // Right arrow to month if (e.key === 'ArrowRight' && dayInput.selectionStart === dayInput.value.length) { e.preventDefault(); monthInput.focus(); monthInput.setSelectionRange(0, 0); } }); // Month input keyboard navigation monthInput.addEventListener('keydown', (e) => { const dropdown = document.getElementById('monthSuggestions'); // Handle dropdown navigation if (dropdown) { const items = dropdown.querySelectorAll('.month-suggestion-item'); const highlighted = dropdown.querySelector('.month-suggestion-item.highlighted'); if (e.key === 'ArrowDown') { e.preventDefault(); if (highlighted) { highlighted.classList.remove('highlighted'); const next = highlighted.nextElementSibling || items[0]; next.classList.add('highlighted'); } else if (items.length > 0) { items[0].classList.add('highlighted'); } } else if (e.key === 'ArrowUp') { e.preventDefault(); if (highlighted) { highlighted.classList.remove('highlighted'); const prev = highlighted.previousElementSibling || items[items.length - 1]; prev.classList.add('highlighted'); } else if (items.length > 0) { items[items.length - 1].classList.add('highlighted'); } } else if (e.key === 'Enter' && highlighted) { e.preventDefault(); const suggestion = { display: highlighted.textContent, value: highlighted.dataset.value, type: highlighted.dataset.type }; this.selectMonthSuggestion(monthInput, suggestion); } else if (e.key === 'Escape') { e.preventDefault(); this.removeMonthSuggestions(); } } // Left arrow to day if (e.key === 'ArrowLeft' && monthInput.selectionStart === 0) { e.preventDefault(); this.removeMonthSuggestions(); backNavigationInProgress = true; dayInput.focus(); dayInput.setSelectionRange(dayInput.value.length, dayInput.value.length); setTimeout(() => { backNavigationInProgress = false; }, 100); } // Right arrow to year else if (e.key === 'ArrowRight' && monthInput.selectionStart === monthInput.value.length) { e.preventDefault(); this.removeMonthSuggestions(); yearInput.focus(); yearInput.setSelectionRange(0, 0); } // Backspace in empty field - go back to day else if (e.key === 'Backspace' && monthInput.value === '') { e.preventDefault(); this.removeMonthSuggestions(); backNavigationInProgress = true; dayInput.focus(); dayInput.setSelectionRange(dayInput.value.length, dayInput.value.length); setTimeout(() => { backNavigationInProgress = false; }, 100); } }); // Year input keyboard navigation yearInput.addEventListener('keydown', (e) => { // Left arrow to month if (e.key === 'ArrowLeft' && yearInput.selectionStart === 0) { e.preventDefault(); backNavigationInProgress = true; monthInput.focus(); monthInput.setSelectionRange(monthInput.value.length, monthInput.value.length); setTimeout(() => { backNavigationInProgress = false; }, 100); } // Backspace in empty field - go back to month else if (e.key === 'Backspace' && yearInput.value === '') { e.preventDefault(); backNavigationInProgress = true; monthInput.focus(); monthInput.setSelectionRange(monthInput.value.length, monthInput.value.length); setTimeout(() => { backNavigationInProgress = false; }, 100); } }); // DAY INPUT HANDLING dayInput.addEventListener('input', (e) => { let value = e.target.value.replace(/\D/g, ''); // Remove non-digits // Limit to 2 digits if (value.length > 2) { value = value.slice(0, 2); } // Cap at 31 if (parseInt(value, 10) > 31) { value = '31'; this.animateShake(dayInput); } e.target.value = value; // Clear validation message validationMessage.style.display = 'none'; dateInputGroup.style.borderColor = ''; // Update hidden field this.updateHiddenDateValue(message.message_id, dayInput, monthInput, yearInput, hiddenInput); // Update next button state this.updateNextButtonState(); // Auto-advance to month if valid 2-digit day if (value.length === 2 && parseInt(value, 10) >= 1 && parseInt(value, 10) <= 31) { setTimeout(() => { monthInput.focus(); this.animatePulse(monthInput); }, 10); } }); // Validate and format day on blur dayInput.addEventListener('blur', (e) => { const value = e.target.value.trim(); if (value === '') { return; // Don't validate empty field } // Validate day const day = parseInt(value, 10); let month = 0; // Try to get month value if available if (monthInput.value) { if (/^\d+$/.test(monthInput.value)) { month = parseInt(monthInput.value, 10); } else { const monthNum = findClosestMonth(monthInput.value); if (monthNum) month = monthNum; } } const year = parseInt(yearInput.value, 10) || 0; const isValid = !isNaN(day) && isValidDay(day, month, year); if (!isValid) { validationMessage.textContent = 'Ungültiger Tag'; validationMessage.style.display = 'block'; this.animateShake(dayInput); } else { // Pad single digit with zero if (value.length === 1) { e.target.value = value.padStart(2, '0'); this.updateHiddenDateValue(message.message_id, dayInput, monthInput, yearInput, hiddenInput); } } }); // MONTH INPUT HANDLING monthInput.addEventListener('input', (e) => { const value = e.target.value.trim(); // Track if user is deleting const isDeleting = value.length < lastMonthInputValue.length; lastMonthInputValue = value; // Clear validation validationMessage.style.display = 'none'; dateInputGroup.style.borderColor = ''; // Numeric input handling if (/^\d+$/.test(value)) { let numValue = value; // Cap at 12 if (parseInt(numValue, 10) > 12) { numValue = '12'; e.target.value = numValue; this.animateShake(monthInput); } // Auto-advance after valid 2-digit month if (numValue.length === 2 && parseInt(numValue, 10) >= 1 && parseInt(numValue, 10) <= 12) { setTimeout(() => { yearInput.focus(); this.animatePulse(yearInput); }, 10); } // Update hidden value this.updateHiddenDateValue(message.message_id, dayInput, monthInput, yearInput, hiddenInput); this.updateNextButtonState(); return; } // Text-based month handling if (!isDeleting && value.length > 0) { // Show autocomplete suggestions const suggestions = getMonthSuggestions(value); this.createMonthSuggestions(monthInput, suggestions); // Try to match to a month name const monthNum = findClosestMonth(value); // If we have a full valid match, move to year if (value.length >= 3 && monthNum !== null) { // Check for exact match const matchesExactly = Object.keys(monthsLookup).some(month => month === value.toLowerCase() && monthsLookup[month] === monthNum ); if (matchesExactly) { this.removeMonthSuggestions(); setTimeout(() => { yearInput.focus(); this.animatePulse(yearInput); }, 10); } } } else if (isDeleting || value.length === 0) { // Remove suggestions when deleting or empty this.removeMonthSuggestions(); } // Update hidden value this.updateHiddenDateValue(message.message_id, dayInput, monthInput, yearInput, hiddenInput); this.updateNextButtonState(); }); // Format and validate month on blur monthInput.addEventListener('blur', (e) => { // Delay removal to allow click on suggestion setTimeout(() => { this.removeMonthSuggestions(); }, 150); lastMonthInputValue = ''; // Reset tracker const value = e.target.value.trim(); if (value === '') { return; // Don't validate empty } // For numeric input if (/^\d+$/.test(value)) { const monthNum = parseInt(value, 10); const isValid = !isNaN(monthNum) && isValidMonth(monthNum); if (!isValid) { validationMessage.textContent = 'Ungültiger Monat'; validationMessage.style.display = 'block'; this.animateShake(monthInput); } else { // Format single-digit months if (value.length === 1) { e.target.value = value.padStart(2, '0'); } this.updateHiddenDateValue(message.message_id, dayInput, monthInput, yearInput, hiddenInput); } return; } // For text input const monthNum = findClosestMonth(value); const isValid = monthNum !== null && isValidMonth(monthNum); if (!isValid) { validationMessage.textContent = 'Ungültiger Monat'; validationMessage.style.display = 'block'; this.animateShake(monthInput); } else { // Format as full month name const formattedMonth = fullMonthNames.en[monthNum - 1]; e.target.value = formattedMonth; this.animateHighlight(monthInput); this.updateHiddenDateValue(message.message_id, dayInput, monthInput, yearInput, hiddenInput); } }); // YEAR INPUT HANDLING yearInput.addEventListener('input', (e) => { let value = e.target.value.replace(/\D/g, ''); // Remove non-digits // Limit to 4 digits if (value.length > 4) { value = value.slice(0, 4); } e.target.value = value; // Clear validation validationMessage.style.display = 'none'; dateInputGroup.style.borderColor = ''; // Update hidden value this.updateHiddenDateValue(message.message_id, dayInput, monthInput, yearInput, hiddenInput); this.updateNextButtonState(); // Auto-validate when 4 digits are entered if (value.length === 4) { const year = parseInt(value, 10); if (year >= yearMin && year <= yearMax) { setTimeout(() => { yearInput.blur(); this.validateDate(dayInput, monthInput, yearInput, validationMessage, dateInputGroup); }, 300); } } }); // Auto-fill year on blur yearInput.addEventListener('blur', (e) => { // Don't auto-fill if navigating back if (backNavigationInProgress) { return; } const value = e.target.value.trim(); if (value === '') { // Auto-fill current year if day & month are filled if (dayInput.value && monthInput.value) { // Get month value to determine if we should use next year let monthValue = monthInput.value; let monthNum; if (/^\d+$/.test(monthValue)) { monthNum = parseInt(monthValue, 10); } else { monthNum = findClosestMonth(monthValue); } // For late-year months (Nov/Dec), consider next year if ((monthNum === 11 || monthNum === 12) && currentMonth >= 10) { e.target.value = (currentYear + 1).toString(); } else { e.target.value = currentYear.toString(); } this.animateHighlight(yearInput); this.updateHiddenDateValue(message.message_id, dayInput, monthInput, yearInput, hiddenInput); } return; } // Validate year const year = parseInt(value, 10); if (!isNaN(year)) { if (year < yearMin) { e.target.value = yearMin.toString(); this.animateShake(yearInput); } else if (year > yearMax) { e.target.value = yearMax.toString(); this.animateShake(yearInput); } else { // Ensure 4 digits e.target.value = year.toString().padStart(4, '0'); } this.updateHiddenDateValue(message.message_id, dayInput, monthInput, yearInput, hiddenInput); // Validate if this completes the date if (dayInput.value && monthInput.value) { this.validateDate(dayInput, monthInput, yearInput, validationMessage, dateInputGroup); } } }); // Debounced validation function const validateDateDelayed = this.debounce(() => { if (dayInput.value && monthInput.value && yearInput.value) { this.validateDate(dayInput, monthInput, yearInput, validationMessage, dateInputGroup); } }, 800); // Attach validation to all fields dayInput.addEventListener('input', validateDateDelayed); monthInput.addEventListener('input', validateDateDelayed); yearInput.addEventListener('input', validateDateDelayed); } // Populate date fields from a date string (YYYY-MM-DD) populateDateFields(dateString) { if (!dateString || dateString.length !== 10) return; try { // Parse YYYY-MM-DD format const year = dateString.substring(0, 4); const month = dateString.substring(5, 7); const day = dateString.substring(8, 10); const dayInput = document.querySelector('#day'); const monthInput = document.querySelector('#month'); const yearInput = document.querySelector('#year'); if (dayInput) { dayInput.value = parseInt(day, 10).toString().padStart(2, '0'); } if (monthInput) { monthInput.value = parseInt(month, 10).toString().padStart(2, '0'); } if (yearInput) { yearInput.value = year; } } catch (e) { console.error('Error populating date fields:', e); } } validateDate(dayInput, monthInput, yearInput, validationMessage, dateInputGroup) { const day = parseInt(dayInput.value, 10); let month = monthInput.value.trim(); const year = parseInt(yearInput.value, 10); // Try to parse month as number or name let monthNum; if (/^\d+$/.test(month)) { monthNum = parseInt(month, 10); } else { monthNum = findClosestMonth(month); } // Check if all fields are valid const dayValid = !isNaN(day) && isValidDay(day, monthNum, year); const monthValid = monthNum !== null && isValidMonth(monthNum); const yearValid = !isNaN(year) && year >= 1900 && year <= (new Date().getFullYear() + 1); // If any part is invalid, show validation message if (!dayValid) { validationMessage.textContent = 'Ungültiger Tag für diesen Monat'; validationMessage.style.display = 'block'; this.animateShake(dayInput); return false; } if (!monthValid) { validationMessage.textContent = 'Ungültiger Monat'; validationMessage.style.display = 'block'; this.animateShake(monthInput); return false; } if (!yearValid) { validationMessage.textContent = 'Ungültiges Jahr'; validationMessage.style.display = 'block'; this.animateShake(yearInput); return false; } // If all valid, clear validation and return true validationMessage.style.display = 'none'; dateInputGroup.style.borderColor = ''; return true; } // Check if all date fields are filled isDateInputComplete(dateFields) { for (let i = 0; i < dateFields.length; i++) { if (!dateFields[i].value) { return false; } } return true; } // Update hidden input with the complete date value updateHiddenDateValue(messageId, dayInput, monthInput, yearInput, hiddenInput) { // Get values const dayValue = dayInput.value.trim(); const monthValue = monthInput.value.trim(); const yearValue = yearInput.value.trim(); // Only update if we have at least partial values in all fields if (dayValue && monthValue && yearValue) { // Parse month value - could be text or number let monthNum; if (/^\d+$/.test(monthValue)) { monthNum = parseInt(monthValue, 10); } else { monthNum = findClosestMonth(monthValue); } // Only set if we have valid numbers const day = parseInt(dayValue, 10); const year = parseInt(yearValue, 10); if (!isNaN(day) && monthNum !== null && !isNaN(year)) { // Format as YYYY-MM-DD const formattedMonth = monthNum.toString().padStart(2, '0'); const formattedDay = day.toString().padStart(2, '0'); hiddenInput.value = `${year}-${formattedMonth}-${formattedDay}`; } } else { // Clear if incomplete hiddenInput.value = ''; } } // Validate the custom date input validateCustomDate(dateValue, message, validationEl, dateInputGroup) { // Check if input is empty when required if (message.required && !dateValue) { this.showCustomValidationMessage(validationEl, dateInputGroup, 'Dieses Feld ist erforderlich'); this.animateShake(dateInputGroup); return false; } // If input has a value, validate date format and constraints if (dateValue) { const selectedDate = new Date(dateValue); // Check if date is valid if (isNaN(selectedDate.getTime())) { this.showCustomValidationMessage(validationEl, dateInputGroup, 'Ungültiges Datum'); this.animateShake(dateInputGroup); return false; } // Extract date parts for advanced validation const [year, month, day] = dateValue.split('-').map(Number); // Validate day for this specific month/year if (!isValidDay(day, month, year)) { this.showCustomValidationMessage(validationEl, dateInputGroup, 'Ungültiger Tag für diesen Monat'); this.animateShake(dateInputGroup); return false; } // Check min date constraint if (message.minDate && new Date(dateValue) < new Date(message.minDate)) { this.showCustomValidationMessage( validationEl, dateInputGroup, `Datum muss nach ${this.formatDate(message.minDate)} sein` ); this.animateShake(dateInputGroup); return false; } // Check max date constraint if (message.maxDate && new Date(dateValue) > new Date(message.maxDate)) { this.showCustomValidationMessage( validationEl, dateInputGroup, `Datum muss vor ${this.formatDate(message.maxDate)} sein` ); this.animateShake(dateInputGroup); return false; } // Custom validation logic from message if provided if (message.validate && typeof message.validate === 'function') { const customValidation = message.validate(dateValue); if (customValidation !== true) { this.showCustomValidationMessage( validationEl, dateInputGroup, customValidation || 'Ungültiges Datum' ); this.animateShake(dateInputGroup); return false; } } } return true; } isDateInputComplete(dateFields) { for (let i = 0; i < dateFields.length; i++) { if (!dateFields[i].value) { return false; } } return true; } showCustomValidationMessage(validationEl, dateInputGroup, message) { // Check if validationEl exists before using it if (!validationEl) { console.warn('Validation element is null. Cannot display validation message.'); return; } // Check if dateInputGroup exists before using it if (!dateInputGroup) { console.warn('Date input group is null. Cannot highlight date fields.'); } else { dateInputGroup.style.borderColor = '#ff3b30'; } validationEl.textContent = message; validationEl.style.display = 'block'; validationEl.style.color = '#ff3b30'; validationEl.style.fontSize = '0.875rem'; validationEl.style.marginTop = '0.5rem'; } // Add a specific validation method for date inputs validateDateInput(input, message, validationEl) { // Check if input is empty when required if (message.required && (!input.value || input.value.trim() === '')) { this.showValidationMessage(validationEl, input, 'Dieses Feld ist erforderlich'); return false; } // If input has a value, validate date format and constraints if (input.value) { const selectedDate = new Date(input.value); // Check if date is valid if (isNaN(selectedDate.getTime())) { this.showValidationMessage(validationEl, input, 'Ungültiges Datum'); return false; } // Check min date constraint if (input.min && new Date(input.value) < new Date(input.min)) { this.showValidationMessage(validationEl, input, `Datum muss nach ${this.formatDate(input.min)} sein`); return false; } // Check max date constraint if (input.max && new Date(input.value) > new Date(input.max)) { this.showValidationMessage(validationEl, input, `Datum muss vor ${this.formatDate(input.max)} sein`); return false; } // Custom validation logic from message if provided if (message.validate && typeof message.validate === 'function') { const customValidation = message.validate(input.value); if (customValidation !== true) { this.showValidationMessage(validationEl, input, customValidation || 'Ungültiges Datum'); return false; } } } return true; } // Helper method to show validation messages showValidationMessage(validationEl, input, message) { validationEl.textContent = message; validationEl.style.display = 'block'; validationEl.style.color = '#ff3b30'; validationEl.style.fontSize = '0.875rem'; validationEl.style.marginTop = '0.5rem'; // Highlight the input input.style.borderColor = '#ff3b30'; } // Helper method to format dates for error messages formatDate(dateString) { const date = new Date(dateString); return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); } // Create select dropdown createSelect(message) { const container = document.createElement('div'); container.className = 'input-field'; const select = document.createElement('select'); select.id = message.message_id; select.name = message.message_id; // Add placeholder option const placeholderOption = document.createElement('option'); placeholderOption.value = ''; placeholderOption.textContent = message.placeholder || 'Bitte wählen'; placeholderOption.disabled = true; placeholderOption.selected = message.input === null; select.appendChild(placeholderOption); // Add options if (message.options && Array.isArray(message.options)) { message.options.forEach(option => { const optionEl = document.createElement('option'); optionEl.value = option.value; optionEl.textContent = option.label; // Set selected if it matches saved input if (message.input === option.value) { optionEl.selected = true; } select.appendChild(optionEl); }); } input.addEventListener('change', () => this.updateNextButtonState()); container.appendChild(select); return container; } // Create checkbox list createCheckboxList(message) { const container = document.createElement('div'); container.className = 'selection-list'; // Add options if (message.options && Array.isArray(message.options)) { message.options.forEach(option => { const optionContainer = document.createElement('div'); optionContainer.className = 'selection-option'; const labelEl = document.createElement('span'); labelEl.className = 'selection-option-label'; labelEl.textContent = option.label; const input = document.createElement('input'); input.type = 'checkbox'; input.id = `${message.message_id}_${option.value}`; input.name = message.message_id; input.value = option.value; // Add custom checkbox visual const checkboxEl = document.createElement('span'); checkboxEl.className = 'selection-checkbox'; // Check if this option is already selected if (message.input && Array.isArray(message.input) && message.input.includes(option.value)) { input.checked = true; } // Check if this option should be disabled/dimmed if (option.disabled) { optionContainer.classList.add('selection-option-dimmed'); input.disabled = true; } input.addEventListener('change', () => this.updateNextButtonState()); optionContainer.appendChild(labelEl); optionContainer.appendChild(input); optionContainer.appendChild(checkboxEl); container.appendChild(optionContainer); // Make entire option clickable optionContainer.addEventListener('click', (e) => { if (!option.disabled && e.target !== input) { input.checked = !input.checked; // Trigger a change event so any listeners can respond input.dispatchEvent(new Event('change')); } }); }); } return container; } // Create radio list createRadioList(message) { const container = document.createElement('div'); container.className = 'selection-list'; // Add options if (message.options && Array.isArray(message.options)) { message.options.forEach(option => { const optionContainer = document.createElement('div'); optionContainer.className = 'selection-option'; const labelEl = document.createElement('span'); labelEl.className = 'selection-option-label'; labelEl.textContent = option.label; const input = document.createElement('input'); input.type = 'radio'; input.id = `${message.message_id}_${option.value}`; input.name = message.message_id; input.value = option.value; // Add custom radio visual const radioEl = document.createElement('span'); radioEl.className = 'selection-radio'; // Check if this option is already selected if (message.input === option.value) { input.checked = true; } // Check if this option should be disabled/dimmed if (option.disabled) { optionContainer.classList.add('selection-option-dimmed'); input.disabled = true; } optionContainer.appendChild(labelEl); optionContainer.appendChild(input); optionContainer.appendChild(radioEl); container.appendChild(optionContainer); // Make entire option clickable optionContainer.addEventListener('click', (e) => { if (!option.disabled && e.target !== input) { input.checked = true; // Trigger a change event so any listeners can respond input.dispatchEvent(new Event('change')); } }); input.addEventListener('change', () => this.updateNextButtonState()); // Add auto-advance functionality for all radio lists input.addEventListener('change', (e) => { if (e.target.checked) { // Use debounced handler to prevent multiple rapid submissions this.debouncedHandleNext(); } }); }); } return container; } // Create photo upload createPhotoUpload(message) { const container = document.createElement('div'); container.className = 'photo-upload-container'; // Hidden file input const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.id = `photo_input_${message.message_id}`; fileInput.accept = 'image/*'; fileInput.style.display = 'none'; // Main circular photo area const photoCircle = document.createElement('div'); photoCircle.className = 'photo-circle'; // Upload trigger (initially visible) const uploadTrigger = document.createElement('div'); uploadTrigger.className = 'photo-upload-trigger circular'; uploadTrigger.innerHTML = `
Hier klicken und Foto machen/ hochladen
`; // Preview image (initially hidden) const previewImage = document.createElement('img'); previewImage.className = 'photo-preview-image circular'; previewImage.style.display = 'none'; previewImage.alt = 'Foto Vorschau'; // Loading ring overlay (initially hidden) const loadingRing = document.createElement('div'); loadingRing.className = 'photo-loading-ring'; loadingRing.style.display = 'none'; loadingRing.innerHTML = ` `; // Action buttons section (initially hidden) const actionsSection = document.createElement('div'); actionsSection.className = 'photo-actions-section'; actionsSection.style.display = 'none'; actionsSection.innerHTML = ` `; // Add elements to photo circle photoCircle.appendChild(uploadTrigger); photoCircle.appendChild(previewImage); photoCircle.appendChild(loadingRing); // Add to main container container.appendChild(fileInput); container.appendChild(photoCircle); container.appendChild(actionsSection); // Store photo state let selectedFile = null; let uploadedPhotoId = null; // Event handlers uploadTrigger.addEventListener('click', () => { fileInput.click(); }); actionsSection.querySelector('.photo-change-btn').addEventListener('click', () => { fileInput.click(); }); actionsSection.querySelector('.photo-remove-btn').addEventListener('click', () => { selectedFile = null; uploadedPhotoId = null; fileInput.value = ''; showUploadTrigger(); this.updateNextButtonState(); }); fileInput.addEventListener('change', (e) => { const file = e.target.files[0]; if (file) { selectedFile = file; showPreview(file); this.updatePhotoSelectedState(message); } }); // Helper functions function showUploadTrigger() { uploadTrigger.style.display = 'flex'; previewImage.style.display = 'none'; loadingRing.style.display = 'none'; actionsSection.style.display = 'none'; } function showPreview(file) { const reader = new FileReader(); reader.onload = (e) => { previewImage.src = e.target.result; uploadTrigger.style.display = 'none'; previewImage.style.display = 'block'; loadingRing.style.display = 'none'; actionsSection.style.display = 'flex'; }; reader.readAsDataURL(file); } function showProgress() { uploadTrigger.style.display = 'none'; previewImage.style.display = 'block'; loadingRing.style.display = 'block'; actionsSection.style.display = 'none'; // Start the ring animation - animate to 90% while uploading const ringProgress = loadingRing.querySelector('.loading-ring-progress'); ringProgress.style.animation = 'none'; // Trigger reflow ringProgress.offsetHeight; ringProgress.style.animation = 'drawRingProgress 1s ease-out forwards'; } function completeProgress() { // Complete the final 10% quickly when upload succeeds const ringProgress = loadingRing.querySelector('.loading-ring-progress'); ringProgress.style.animation = 'drawRingComplete 0.3s ease-out forwards'; } function showError(message) { loadingRing.style.display = 'none'; actionsSection.style.display = 'flex'; // Show error state - you might want to add error UI here alert(message); // Temporary error handling } // Store references for external access container._showProgress = showProgress; container._completeProgress = completeProgress; container._showError = showError; container._getPhotoId = () => uploadedPhotoId; container._setPhotoId = (id) => { uploadedPhotoId = id; }; container._getSelectedFile = () => selectedFile; container._hasPhoto = () => selectedFile !== null; return container; } // Update state when photo is selected (but not uploaded yet) updatePhotoSelectedState(message) { // Update the title display directly without affecting chat history const titleEl = this.currentModule.querySelector('.title-text'); if (titleEl) { TextAnimator.animate(titleEl, "Passt das so? Klicke den Weiter Button um es hochzuladen."); } // Enable the next button this.updateNextButtonState(); } // Handle photo skip async handlePhotoSkip(message) { try { // Add skip data to chat history const messageIndex = this.chatHistory.findIndex(msg => msg.message_id === message.message_id); if (messageIndex >= 0) { this.chatHistory[messageIndex].input = [{ skipped: true, skippedAt: new Date().toISOString() }]; this.persistChatHistoryToStorage(); } const nextStepData = await this.requestNextStep(message.message_id, true); if (nextStepData) { this.processNextStep(nextStepData); } } catch (error) { console.error('Failed to skip photo:', error); alert('Fehler beim Überspringen. Bitte versuchen Sie es erneut.'); } } // Handle photo upload when Next button is clicked async handlePhotoUpload(message) { const container = this.currentModule.querySelector('.photo-upload-container'); const selectedFile = container._getSelectedFile(); if (!selectedFile) { // No photo selected, skip await this.handlePhotoSkip(message); return; } try { // Show progress container._showProgress(); // Upload the photo const photoId = await this.performPhotoUpload(selectedFile); if (photoId) { // Success! Complete the progress animation first container._completeProgress(); // Photo data will be stored in chat history by sendUserInput // Wait a bit for progress completion, then proceed setTimeout(() => { this.isAnimating = true; // Add fade-out animation to current module this.currentModule.classList.add('fade-out-up'); // Wait for animation, then proceed setTimeout(async () => { const nextStepData = await this.sendUserInput(message.message_id, photoId); if (nextStepData) { this.processNextStep(nextStepData); } this.isAnimating = false; }, 500); }, 300); // Wait for progress completion } else { throw new Error('Photo upload returned no ID'); } } catch (error) { console.error('Photo upload failed:', error); container._showError('Foto-Upload fehlgeschlagen. Bitte versuchen Sie es erneut.'); } } // Perform the actual photo upload and return photo ID async performPhotoUpload(file) { const formData = new FormData(); formData.append('photo', file); formData.append('sessionId', this.sessionId); // Upload the file const uploadResult = await fetch('https://api.hems-app.com/api:fUxdXSnV/upload/photo', { method: 'POST', mode: 'cors', credentials: 'include', body: formData }); if (!uploadResult.ok) { throw new Error(`Upload failed: ${uploadResult.statusText}`); } const result = await uploadResult.json(); if (result.success && result.photo_id) { return result.photo_id; } else { throw new Error(result.error || 'Upload failed'); } } // Create responder createResponder(message) { const container = document.createElement('div'); container.className = 'agent-create-ms-text'; container.textContent = ''; return container; } // Create loading animation createLoadingAnimation(message = 'Lade...') { const container = document.createElement('div'); container.className = 'loading-animation'; // Create spinner const spinner = document.createElement('div'); spinner.className = 'spinner'; container.appendChild(spinner); // Create loading text const loadingText = document.createElement('div'); loadingText.textContent = message; container.appendChild(loadingText); return container; } // Enhancement to cancelOngoingAnimations to clean up animation classes cancelOngoingAnimations() { // Clear auto-advance timer if it exists if (this.autoAdvanceTimer) { clearTimeout(this.autoAdvanceTimer); this.autoAdvanceTimer = null; } // Force completion of text animations if (this.currentModule) { // Find any elements being animated and reveal them instantly const textElements = this.currentModule.querySelectorAll('.agent-create-ms-text, .message-text, .par-form.gray'); textElements.forEach(element => { // Get the message ID const messageId = this.currentModule.dataset.messageId; if (!messageId) return; // Find message in history const message = this.chatHistory.find(msg => msg.message_id === messageId); if (!message) return; if (element.closest('.step-content')) { // It's a responder text element.textContent = message.responderText || ''; } else if (element.classList.contains('par-form')) { // It's the explainer text - simply ensure it's fully visible element.style.opacity = '1'; element.style.transform = 'translateY(0)'; // Content is already set when created } else if (element.classList.contains('message-text')) { // It's the main message element.textContent = message.message || ''; } else if (element.classList.contains('title-text')) { // It's the title element.textContent = message.title || ''; } }); // Show content elements that might be waiting for animation const contentEl = this.currentModule.querySelector('.step-content'); if (contentEl) { contentEl.style.opacity = '1'; // Also reveal any options that might be animating const selectOptions = contentEl.querySelectorAll('select.animate-select option'); selectOptions.forEach(option => { option.style.opacity = '1'; }); const listOptions = contentEl.querySelectorAll('.selection-option.animate-option'); listOptions.forEach(option => { option.style.opacity = '1'; option.style.transform = 'translateY(0)'; }); } // Remove animation classes this.currentModule.classList.remove('fade-in-up', 'fade-in-down'); const selects = this.currentModule.querySelectorAll('select.animate-select'); selects.forEach(select => select.classList.remove('animate-select')); const options = this.currentModule.querySelectorAll('.selection-option.animate-option'); options.forEach(option => option.classList.remove('animate-option')); // Set isAnimating to false so we can proceed this.isAnimating = false; } } // Inject CSS styles injectStyles() { const styleEl = document.createElement('style'); styleEl.textContent = ` .form-module { width: 100%; opacity: 1; transform: translateY(0); transition: all 0.5s ease-in-out; max-width: 600px; margin: 0 auto; margin-bottom: 20px; } .fade-out-up { opacity: 0; transform: translateY(-30px); } .fade-out-down { opacity: 0; transform: translateY(30px); } .fade-in-up { animation: fadeInUp 0.5s ease-out forwards; } .fade-in-down { animation: fadeInDown 0.5s ease-out forwards; } @keyframes fadeInUp { from { opacity: 0; transform: translateY(30px); } to { opacity: 1; transform: translateY(0); } } @keyframes fadeInDown { from { opacity: 0; transform: translateY(-30px); } to { opacity: 1; transform: translateY(0); } } .message-text { font-size: 0.9rem; line-height: 1.5; margin-top: 0.7rem; margin-bottom: 1rem; color: #333; } .step-content { opacity: 0; transition: opacity 0.5s ease-out; margin-top: 0.7rem; margin-bottom: 1rem; } .selection-list { width: 100%; } .selection-option { display: flex; justify-content: space-between; align-items: center; padding: 1rem 0; border-bottom: 1px solid #eee; cursor: pointer; transition: all 0.3s ease; } .selection-option.animate-option { transition: opacity 0.3s ease-out, transform 0.3s ease-out; } select.animate-select option { transition: opacity 0.3s ease-out; } .selection-option:last-child { border-bottom: none; } .selection-option-label { font-size: 1rem; color: #000; } .selection-option-dimmed { color: #999; } .selection-radio { width: 24px; height: 24px; border-radius: 50%; border: 2px solid #ddd; background-color: transparent; position: relative; transition: all 0.2s ease; flex-shrink: 0; } .selection-checkbox { width: 24px; height: 24px; border-radius: 5px; border: 2px solid #ddd; background-color: transparent; position: relative; transition: all 0.2s ease; flex-shrink: 0; } .selection-option input { position: absolute; opacity: 0; cursor: pointer; height: 0; width: 0; } .selection-option input:checked ~ .selection-radio { border-color: #C99A6D; background-color: #C99A6D; } .selection-option input:checked ~ .selection-checkbox { border-color: #C99A6D; background-color: #C99A6D; } .selection-option input:checked ~ .selection-option-label { font-weight: 500; } /* Checkmark for checkbox */ .selection-option input:checked ~ .selection-checkbox:after { content: ""; position: absolute; display: block; left: 8px; top: 4px; width: 5px; height: 10px; border: solid white; border-width: 0 2px 2px 0; transform: rotate(45deg); } /* Center dot for radio */ .selection-option input:checked ~ .selection-radio:after { content: ""; position: absolute; display: block; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 10px; height: 10px; border-radius: 50%; background: white; } .input-field { margin-bottom: 1.5rem; transition: opacity 0.3s ease-out, transform 0.3s ease-out; } .input-field label { display: block; margin-bottom: 0.5rem; font-size: 0.9rem; color: #666; } .input-field input, .input-field select { width: 100%; padding: 1rem; border: 1px solid #ddd; border-radius: 8px; font-size: 1rem; transition: border-color 0.2s ease; } .input-field input:focus, .input-field select:focus { outline: none; border-color: #C99A6D; } .date-input { padding: 1rem; border: 1px solid #ddd; border-radius: 8px; width: 100%; font-size: 1rem; } .date-input:focus { outline: none; border-color: #C99A6D; } .loading-animation { display: flex; justify-content: center; align-items: center; flex-direction: column; padding: 2rem 0; } .spinner { width: 50px; height: 50px; border: 5px solid #f3f3f3; border-top: 5px solid #C99A6D; border-radius: 50%; animation: spin 1s linear infinite; margin-bottom: 1rem; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } /* Additional styles for form UI */ .resume-container { padding: 20px; background-color: #f9f9f9; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); text-align: center; } .resume-progress { margin: 20px 0; } .progress-bar { height: 10px; background-color: #e0e0e0; border-radius: 5px; overflow: hidden; } .progress-fill { height: 100%; background-color: #C99A6D; border-radius: 5px; } .progress-text { margin-top: 5px; font-size: 14px; color: #666; } .resume-buttons { display: flex; justify-content: center; gap: 10px; margin-top: 20px; } .resume-button { padding: 10px 20px; border-radius: 4px; font-weight: 500; cursor: pointer; border: none; transition: all 0.2s ease; } .resume-yes { background-color: #C99A6D; color: white; } .resume-no { background-color: #f0f0f0; color: #333; } .error-container, .expired-container { padding: 20px; text-align: center; background-color: #fff0f0; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); } .expired-container { background-color: #f9f9f9; } .error-icon, .expired-icon { font-size: 36px; margin-bottom: 10px; } .error-message { margin-bottom: 20px; color: #d32f2f; } .error-retry-button, .restart-button { padding: 10px 20px; background-color: #C99A6D; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: 500; transition: all 0.2s ease; } .loading-module { min-height: 150px; display: flex; align-items: center; justify-content: center; } .form-module.fade-in-up { animation: fadeInUp 0.5s ease-out forwards; opacity: 0; transform: translateY(30px); } .form-module.fade-in-down { animation: fadeInDown 0.5s ease-out forwards; opacity: 0; transform: translateY(-30px); } .loading-animation { display: flex; justify-content: center; align-items: center; flex-direction: column; padding: 2rem 0; transition: all 0.3s ease-out; } .spinner { width: 50px; height: 50px; border: 5px solid #f3f3f3; border-top: 5px solid #C99A6D; border-radius: 50%; animation: spin 1s linear infinite; margin-bottom: 1rem; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } /* Animation specifically for loading transitions */ .loading-module.fade-out-up { animation: fadeOutUp 0.3s ease-out forwards; } .form-module.fade-in-up { animation: fadeInUp 0.5s ease-out forwards; opacity: 0; transform: translateY(30px); } .form-module.fade-out-up { animation: fadeOutUp 0.5s ease-out forwards; } @keyframes fadeOutUp { from { opacity: 1; transform: translateY(0); } to { opacity: 0; transform: translateY(-30px); } } .explainer-fade { opacity: 0; transform: translateY(15px); transition: opacity 0.8s ease-out, transform 0.8s ease-out; } @keyframes shakeAnimation { 0% { transform: translateX(0); } 20% { transform: translateX(-5px); } 40% { transform: translateX(5px); } 60% { transform: translateX(-5px); } 80% { transform: translateX(5px); } 100% { transform: translateX(0); } } @keyframes pulseAnimation { 0% { box-shadow: 0 0 0 0 rgba(201, 154, 109, 0.4); } 70% { box-shadow: 0 0 0 6px rgba(201, 154, 109, 0); } 100% { box-shadow: 0 0 0 0 rgba(201, 154, 109, 0); } } @keyframes highlightAnimation { 0% { background-color: rgba(255, 213, 79, 0); } 50% { background-color: rgba(255, 213, 79, 0.3); } 100% { background-color: rgba(255, 213, 79, 0); } } .date-input-shake { animation: shakeAnimation 0.5s cubic-bezier(.36,.07,.19,.97) both; border-color: #ff3b30 !important; } .date-input-pulse { animation: pulseAnimation 1.5s cubic-bezier(0.455, 0.03, 0.515, 0.955) both; border-color: #C99A6D !important; } .date-input-highlight { animation: highlightAnimation 1s ease-in-out both; } .date-input-holder-group { transition: all 0.3s ease; } .date-field { transition: all 0.3s ease; } /* Styles for stacked text inputs */ .stacked-input-container { width: 100%; } .stacked-input-item { margin-bottom: 20px; } .stacked-input-item:last-of-type { margin-bottom: 0; } .stacked-input-item input { width: 100%; transition: border-color 0.2s ease; } .stacked-input-item input:focus { outline: none; border-color: #C99A6D; } /* Styles for address inputs */ .address-input-container { width: 100%; } .address-input-item { margin-bottom: 15px; } .address-input-item:last-of-type { margin-bottom: 0; } .address-input-item input { width: 100%; transition: border-color 0.2s ease; } .address-input-item input:focus { outline: none; border-color: #C99A6D; } /* Responsive layout for address inputs */ @media (min-width: 768px) { .address-input-container { display: flex; flex-direction: column; gap: 15px; } /* Optional: You can create a grid layout for larger screens */ .address-input-container.grid-layout { display: grid; grid-template-columns: 2fr 1fr; gap: 15px; } .address-input-container.grid-layout .address-input-item:first-child { grid-column: 1 / -1; /* Street spans full width */ } } /* Photo Upload Styles - Circular Design */ .photo-upload-container { width: 100%; max-width: 400px; margin: 0 auto; display: flex; flex-direction: column; align-items: center; margin-top: 2rem; gap: 2rem; } .photo-circle { position: relative; width: 200px; height: 200px; border-radius: 50%; overflow: hidden; } .photo-upload-trigger.circular { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; height: 100%; border: 3px dashed #C99A6D; border-radius: 50%; cursor: pointer; transition: all 0.3s ease; background-color: #fafafa; position: absolute; top: 0; left: 0; } .photo-upload-trigger.circular:hover { border-color: #B8895A; background-color: #f5f5f5; transform: scale(1.02); } .photo-upload-icon { color: #C99A6D; margin-bottom: 0.5rem; } .photo-upload-text { text-align: center; } .upload-primary { display: block; font-size: 1rem; font-weight: 600; color: #333; } .photo-preview-image.circular { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; position: absolute; top: 0; left: 0; } .photo-loading-ring { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; } .loading-ring-svg { width: 100%; height: 100%; transform: rotate(-90deg); } .loading-ring-background { fill: none; stroke: rgba(201, 154, 109, 0.2); stroke-width: 6; } .loading-ring-progress { fill: none; stroke: #C99A6D; stroke-width: 6; stroke-linecap: round; stroke-dasharray: 339.292; /* 2 * PI * 54 */ stroke-dashoffset: 339.292; transition: stroke-dashoffset 0.3s ease; } @keyframes drawRingProgress { 0% { stroke-dashoffset: 339.292; } 100% { stroke-dashoffset: 33.929; /* 90% complete (10% of 339.292) */ } } @keyframes drawRingComplete { 0% { stroke-dashoffset: 33.929; /* Start from 90% */ } 100% { stroke-dashoffset: 0; /* Complete to 100% */ } } .photo-actions-section { display: flex; gap: 1rem; justify-content: center; } .photo-action-btn { display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem 1.5rem; border: 2px solid #C99A6D; border-radius: 25px; background-color: transparent; color: #C99A6D; font-size: 0.9rem; font-weight: 500; cursor: pointer; transition: all 0.3s ease; } .photo-action-btn:hover { background-color: #C99A6D; color: white; transform: translateY(-2px); box-shadow: 0 4px 12px rgba(201, 154, 109, 0.3); } .photo-action-btn svg { flex-shrink: 0; } .photo-change-btn { border-color: #C99A6D; color: #C99A6D; } .photo-change-btn:hover { background-color: #C99A6D; color: white; } .photo-remove-btn { border-color: #dc3545; color: #dc3545; } .photo-remove-btn:hover { background-color: #dc3545; color: white; box-shadow: 0 4px 12px rgba(220, 53, 69, 0.3); } `; document.head.appendChild(styleEl); } // Helper method to wait for a specified time wait(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } } // Initialize the chat-based form function initializeHemsChat() { const formController = new ChatBasedFormController(); // Initialize the form formController.initialize(); // Make form controller globally accessible for debugging window.formController = formController; return formController; } // Start the form when the DOM is loaded document.addEventListener('DOMContentLoaded', () => { initializeHemsChat(); });