1(function() { 2 const currentScript = document.currentScript || (function() { 3 const scripts = document.getElementsByTagName('script'); 4 return scripts[scripts.length - 1]; 5 })(); 6 7 const scriptSrc = currentScript.src; 8 const urlParams = new URLSearchParams(scriptSrc.split('?')[1] || ''); 9 const agentSlug = urlParams.get('agent') || 'dean-souza'; 10 11 const RSS_BASE_URL = 'https://rss.realsatisfied.com/rss/v1/agent/'; 12 const REALSATISFIED_BASE_URL = 'https://www.realsatisfied.com/'; 13 const MAX_VISIBLE_DOTS = 5; 14 15 const styles = ` 16 .rs-testimonial-container { 17 /* Mobile-first: Start with mobile styles */ 18 width: 100%; 19 min-height: min(80dvh, 400px); 20 max-width: min(95dvw, 400px); 21 position: relative; 22 background: #ffffff; 23 border-radius: clamp(12px, 2.5vw, 20px); 24 box-shadow: 25 0 4px 20px rgba(0, 0, 0, 0.1), 26 0 1px 3px rgba(0, 0, 0, 0.05); 27 overflow: hidden; 28 font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; 29 margin: 0 auto; 30 container-type: inline-size; 31 /* Modern CSS: Intrinsic web design */ 32 display: grid; 33 grid-template-rows: auto 1fr auto; 34 } 35 36 /* Tablet and up */ 37 @media (min-width: 48rem) { 38 .rs-testimonial-container { 39 max-width: min(90dvw, 600px); 40 min-height: min(70dvh, 500px); 41 } 42 } 43 44 /* Desktop and up */ 45 @media (min-width: 64rem) { 46 .rs-testimonial-container { 47 max-width: min(80dvw, 700px); 48 min-height: min(65dvh, 550px); 49 box-shadow: 50 0 20px 60px rgba(0, 0, 0, 0.15), 51 0 4px 12px rgba(0, 0, 0, 0.1); 52 } 53 } 54 55 /* Large screens */ 56 @media (min-width: 80rem) { 57 .rs-testimonial-container { 58 max-width: min(70dvw, 900px); 59 } 60 } 61 62 .rs-testimonial-header { 63 background: linear-gradient(135deg, #beaf87 0%, #d4c49a 100%); 64 padding: clamp(1rem, 4vw, 2rem); 65 text-align: center; 66 color: #252526; 67 /* Modern CSS: Logical properties */ 68 padding-inline: clamp(1rem, 4vw, 2rem); 69 padding-block: clamp(1.25rem, 3vh, 2rem); 70 } 71 72 .rs-agent-info h1 { 73 font-size: clamp(1.25rem, 4vw, 2rem); 74 font-weight: 700; 75 margin: 0 0 clamp(0.5rem, 1vh, 0.75rem) 0; 76 line-height: 1.2; 77 /* Modern CSS: Text balancing */ 78 text-wrap: balance; 79 } 80 81 .rs-agent-info p { 82 font-size: clamp(0.875rem, 2.5vw, 1.1rem); 83 opacity: 0.8; 84 margin: 0 0 clamp(0.75rem, 2vh, 1rem) 0; 85 text-wrap: balance; 86 } 87 88 .rs-rating-stats { 89 display: grid; 90 grid-template-columns: repeat(auto-fit, minmax(4rem, 1fr)); 91 gap: clamp(1rem, 4vw, 2rem); 92 max-width: 300px; 93 margin-inline: auto; 94 } 95 96 /* Tablet and up: Switch to flex for better control */ 97 @media (min-width: 48rem) { 98 .rs-rating-stats { 99 display: flex; 100 justify-content: center; 101 max-width: none; 102 gap: clamp(1.5rem, 5vw, 3rem); 103 } 104 } 105 106 .rs-stat { 107 text-align: center; 108 display: flex; 109 flex-direction: column; 110 align-items: center; 111 } 112 113 .rs-stat-number { 114 font-size: clamp(1.5rem, 5vw, 2.5rem); 115 font-weight: 700; 116 display: block; 117 line-height: 1; 118 } 119 120 /* Tablet and up */ 121 @media (min-width: 48rem) { 122 .rs-stat-number { 123 font-size: clamp(2rem, 4vw, 3.8rem); 124 } 125 } 126 127 .rs-stat-label { 128 font-size: clamp(0.75rem, 2vw, 1.1rem); 129 opacity: 0.8; 130 margin-top: 0.25rem; 131 } 132 133 /* Tablet and up */ 134 @media (min-width: 48rem) { 135 .rs-stat-label { 136 font-size: clamp(0.875rem, 2.5vw, 1.4rem); 137 } 138 } 139 140 .rs-slider-wrapper { 141 padding: clamp(1rem, 4vw, 1.5rem); 142 display: grid; 143 grid-template-rows: 1fr auto; 144 gap: clamp(1rem, 3vh, 1.5rem); 145 min-height: 0; /* Allow grid item to shrink */ 146 } 147 148 .rs-testimonial-slider { 149 overflow: hidden; 150 position: relative; 151 min-height: 0; /* Allow flex/grid item to shrink */ 152 } 153 154 .rs-testimonial-track { 155 display: flex; 156 transition: transform 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94); 157 align-items: stretch; 158 height: 100%; 159 } 160 161 .rs-testimonial-slide { 162 min-width: 100%; 163 padding-inline: clamp(0.75rem, 3vw, 1.25rem); 164 box-sizing: border-box; 165 display: flex; 166 flex-direction: column; 167 justify-content: center; 168 } 169 170 .rs-testimonial-content { 171 text-align: center; 172 display: flex; 173 flex-direction: column; 174 gap: clamp(0.75rem, 2vh, 1rem); 175 } 176 177 .rs-quote-text { 178 font-size: clamp(0.95rem, 2.5vw, 1.7rem); 179 line-height: 1.6; 180 color: #333; 181 font-style: italic; 182 position: relative; 183 padding-inline: clamp(1.5rem, 6vw, 2rem); 184 transition: all 0.3s ease; 185 hyphens: auto; 186 text-wrap: pretty; 187 } 188 189 /* Tablet and up */ 190 @media (min-width: 48rem) { 191 .rs-quote-text { 192 font-size: clamp(1.1rem, 2.2vw, 1.7rem); 193 line-height: 1.7; 194 padding-inline: clamp(2rem, 5vw, 3rem); 195 } 196 } 197 198 .rs-quote-text.truncated { 199 display: -webkit-box; 200 -webkit-box-orient: vertical; 201 -webkit-line-clamp: 4; 202 overflow: hidden; 203 } 204 205 /* Tablet and up: Show more lines */ 206 @media (min-width: 48rem) { 207 .rs-quote-text.truncated { 208 -webkit-line-clamp: 5; 209 } 210 } 211 212 .rs-quote-text.expanded { 213 display: block; 214 overflow: visible; 215 } 216 217 .rs-quote-text::before, 218 .rs-quote-text::after { 219 content: '"'; 220 font-size: clamp(2rem, 6vw, 3rem); 221 color: #beaf87; 222 position: absolute; 223 font-family: Georgia, serif; 224 line-height: 1; 225 } 226 227 /* Tablet and up */ 228 @media (min-width: 48rem) { 229 .rs-quote-text::before, 230 .rs-quote-text::after { 231 font-size: clamp(3rem, 5vw, 4rem); 232 } 233 } 234 235 .rs-quote-text::before { 236 left: clamp(0.25rem, 2vw, 0.5rem); 237 top: clamp(-0.5rem, -2vw, -1rem); 238 } 239 240 .rs-quote-text::after { 241 right: clamp(0.25rem, 2vw, 0.5rem); 242 bottom: clamp(-1.5rem, -4vw, -2rem); 243 } 244 245 .rs-read-more-btn { 246 background: none; 247 border: none; 248 color: #beaf87; 249 font-size: clamp(0.875rem, 2vw, 1rem); 250 font-weight: 600; 251 cursor: pointer; 252 text-decoration: underline; 253 padding: 0.5rem; 254 transition: color 0.3s ease; 255 /* Modern CSS: Touch-friendly sizing */ 256 min-height: 44px;
257 min-width: 44px; 258 border-radius: 0.25rem; 259 } 260 261 .rs-read-more-btn:hover, 262 .rs-read-more-btn:focus-visible { 263 color: #252526; 264 outline: 2px solid #beaf87; 265 outline-offset: 2px; 266 } 267 268 .rs-client-info { 269 display: flex; 270 flex-direction: column; 271 gap: clamp(0.25rem, 1vh, 0.5rem); 272 align-items: center; 273 } 274 275 .rs-client-name { 276 font-size: clamp(1.1rem, 3vw, 1.7rem); 277 font-weight: 600; 278 color: #252526; 279 text-wrap: balance; 280 } 281 282 /* Tablet and up */ 283 @media (min-width: 48rem) { 284 .rs-client-name { 285 font-size: clamp(1.25rem, 2.5vw, 1.6rem); 286 } 287 } 288 289 .rs-client-location { 290 color: #666; 291 font-size: clamp(0.875rem, 2vw, 1.4rem); 292 text-wrap: balance; 293 } 294 295 .rs-client-type { 296 background: #beaf87; 297 color: #252526; 298 padding: clamp(0.25rem, 1vw, 0.375rem) clamp(0.75rem, 2vw, 1rem); 299 border-radius: 1rem; 300 font-size: clamp(0.9rem, 2vw, 1.2rem); 301 font-weight: 500; 302 display: inline-block; 303 } 304 305 /* Tablet and up */ 306 @media (min-width: 48rem) { 307 .rs-client-type { 308 font-size: clamp(1rem, 2vw, 1.1rem); 309 } 310 } 311 312 .rs-testimonial-date { 313 color: #888; 314 font-size: clamp(0.8rem, 1.8vw, 1rem); 315 } 316 317 .rs-slider-controls { 318 display: flex; 319 justify-content: center; 320 align-items: center; 321 gap: clamp(1rem, 4vw, 1.5rem); 322 flex-wrap: wrap; 323 } 324 325 .rs-nav-button { 326 background: #beaf87; 327 color: white; 328 border: none; 329 width: clamp(40px, 8vw, 50px); 330 height: clamp(40px, 8vw, 50px); 331 border-radius: 50%; 332 cursor: pointer; 333 font-size: clamp(1.25rem, 3vw, 2rem); 334 display: flex; 335 align-items: center; 336 justify-content: center; 337 transition: all 0.3s ease; 338 line-height: 0.2; 339 outline-offset: 2px; 340 min-width: 44px; 341 min-height: 44px; 342 } 343 344 .rs-nav-button:hover, 345 .rs-nav-button:focus-visible { 346 background: #252526; 347 color: #ffffff; 348 transform: scale(1.05); 349 outline: 0px solid #beaf87; 350 } 351 352 .rs-nav-button:disabled { 353 opacity: 0.3; 354 cursor: not-allowed; 355 transform: none; 356 } 357 358 .rs-dots-container { 359 display: flex; 360 gap: clamp(0.5rem, 1.5vw, 0.75rem); 361 align-items: center; 362 max-width: min(200px, 40vw); 363 overflow: hidden; 364 position: relative; 365 } 366 367 .rs-dots-wrapper { 368 display: flex; 369 gap: clamp(0.5rem, 1.5vw, 0.75rem); 370 transition: transform 0.3s ease; 371 } 372 373 .rs-dot { 374 width: clamp(8px, 2vw, 10px); 375 height: clamp(8px, 2vw, 10px); 376 border-radius: 50%; 377 background: #ddd; 378 cursor: pointer; 379 transition: all 0.3s ease; 380 flex-shrink: 0; 381 min-width: 8px; 382 min-height: 8px; 383 position: relative; 384 } 385 386 .rs-dot::after { 387 content: ''; 388 position: absolute; 389 top: 50%; 390 left: 50%; 391 transform: translate(-50%, -50%); 392 width: clamp(8px, 2vw, 10px); 393 height: clamp(8px, 2vw, 10px); 394 border-radius: 50%; 395 background: inherit; 396 } 397 398 .rs-dot.active::after { 399 background: #beaf87; 400 transform: translate(-50%, -50%) scale(1.3); 401 } 402 403 .rs-dot:hover::after, 404 .rs-dot:focus-visible::after { 405 transform: translate(-50%, -50%) scale(1.2); 406 } 407 408 .rs-dots-indicator { 409 color: #666; 410 font-size: clamp(0.80rem, 2vw, 1rem); 411 margin-inline: 0.5rem; 412 white-space: nowrap; 413 } 414 415 .rs-branding { 416 text-align: center; 417 padding: clamp(1rem, 3vw, 1.5rem); 418 background: #f8f9fa; 419 color: #666; 420 font-size: clamp(0.75rem, 2vw, 0.9rem); 421 } 422 423 .rs-branding a { 424 color: #beaf87; 425 text-decoration: none; 426 font-weight: 500; 427 transition: color 0.3s ease; 428 outline-offset: 2px; 429 border-radius: 0.25rem; 430 padding: 0.125rem 0.25rem; 431 } 432 433 .rs-branding a:hover, 434 .rs-branding a:focus-visible { 435 color: #252526; 436 text-decoration: underline; 437 outline: 2px solid #beaf87; 438 } 439 440 .rs-branding p { 441 margin: 0 !important; 442 font-size: 1rem; 443 } 444 445 .rs-loading, 446 .rs-error { 447 text-align: center; 448 padding: clamp(2rem, 8vh, 4rem) clamp(1rem, 4vw, 2rem); 449 font-size: clamp(0.9rem, 2.5vw, 1.1rem); 450 display: flex; 451 align-items: center; 452 justify-content: center; 453 min-height: 200px; 454 } 455 456 .rs-loading { 457 color: #666; 458 } 459 460 .rs-error { 461 color: #d32f2f; 462 } 463 464 /* Modern CSS: Container queries for fine-tuned responsive design */ 465 @container (min-width: 400px) { 466 .rs-testimonial-slide { 467 padding-inline: 1rem; 468 } 469 470 .rs-quote-text { 471 padding-inline: 2.5rem; 472 } 473 } 474 475 @container (min-width: 600px) { 476 .rs-rating-stats { 477 grid-template-columns: repeat(3, 1fr); 478 } 479 } 480 481 /* Modern CSS: Preference-based media queries */ 482 @media (prefers-reduced-motion: reduce) { 483 .rs-testimonial-track, 484 .rs-nav-button, 485 .rs-dot, 486 .rs-dots-wrapper, 487 .rs-read-more-btn { 488 transition: none; 489 } 490 } 491 492 @media (prefers-color-scheme: dark) { 493 .rs-testimonial-container { 494 background: #1a1a1a;
495 color: #e0e0e0; 496 } 497 498 .rs-quote-text { 499 color: #e0e0e0; 500 } 501 502 .rs-client-name { 503 color: #f0f0f0; 504 } 505 506 .rs-client-location { 507 color: #b0b0b0; 508 } 509 510 .rs-testimonial-date { 511 color: #999; 512 } 513 514 .rs-branding { 515 background: #2a2a2a; 516 color: #b0b0b0; 517 } 518 } 519 520 /* Modern CSS: High contrast mode support */ 521 @media (prefers-contrast: high) { 522 .rs-testimonial-container { 523 border: 2px solid; 524 } 525 526 .rs-nav-button, 527 .rs-client-type { 528 border: 1px solid; 529 } 530 } 531 `; 532 533 function addStyles() { 534 if (!document.getElementById('rs-testimonial-styles')) { 535 const styleSheet = document.createElement('style'); 536 styleSheet.id = 'rs-testimonial-styles'; 537 styleSheet.textContent = styles; 538 document.head.appendChild(styleSheet); 539 } 540 } 541 542 function parseRSSFeed(xmlText) { 543 const parser = new DOMParser(); 544 const xmlDoc = parser.parseFromString(xmlText, 'text/xml'); 545 546 const parserError = xmlDoc.querySelector('parsererror'); 547 if (parserError) { 548 throw new Error('Failed to parse RSS feed'); 549 } 550 551 const items = xmlDoc.querySelectorAll('item'); 552 const testimonials = []; 553 554 items.forEach(item => { 555 const title = item.querySelector('title')?.textContent || ''; 556 const description = item.querySelector('description')?.textContent || ''; 557 const pubDate = item.querySelector('pubDate')?.textContent || ''; 558 559 // Parse name and location from title 560 // Expected format: "Name, City, State" or "Name - City, State" or just "Name" 561 let name = 'Anonymous'; 562 let location = ''; 563 564 if (title.trim()) { 565 // First try comma separation (most common format) 566 if (title.includes(',')) { 567 const parts = title.split(',').map(part => part.trim()); 568 name = parts[0] || 'Anonymous'; 569 570 // If we have 2 parts: "Name, Location" 571 if (parts.length === 2) { 572 location = parts[1]; 573 } 574 // If we have 3 parts: "Name, City, State" 575 else if (parts.length >= 3) { 576 location = parts.slice(1).join(', '); // Join city and state 577 } 578 } 579 // Fallback to dash separation 580 else if (title.includes(' - ')) { 581 const dashParts = title.split(' - '); 582 name = dashParts[0]?.trim() || 'Anonymous'; 583 location = dashParts[1]?.trim() || ''; 584 } 585 // If no separators, assume it's just the name 586 else { 587 name = title.trim(); 588 } 589 } 590 591 let formattedDate = ''; 592 if (pubDate) { 593 try { 594 const date = new Date(pubDate); 595 formattedDate = date.toLocaleDateString('en-US', { 596 year: 'numeric', 597 month: 'long', 598 day: 'numeric' 599 }); 600 } catch (e) { 601 formattedDate = pubDate; 602 } 603 } 604 605 // Determine client type based on description content 606 let type = 'Buyer'; 607 const descLower = description.toLowerCase(); 608 if (descLower.includes('sell') || descLower.includes('sold') || descLower.includes('listing') || descLower.includes('sale')) { 609 type = 'Seller'; 610 } 611 612 testimonials.push({ 613 name, 614 location, 615 type, 616 date: formattedDate, 617 text: description 618 }); 619 }); 620 621 return testimonials; 622 } 623 624 function parseAgentInfo(xmlText) { 625 const parser = new DOMParser(); 626 const xmlDoc = parser.parseFromString(xmlText, 'text/xml'); 627 628 const channel = xmlDoc.querySelector('channel'); 629 const title = channel?.querySelector('title')?.textContent || ''; 630 const description = channel?.querySelector('description')?.textContent || ''; 631 632 let agentName = agentSlug.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
633 if (title.includes(' - ')) { 634 agentName = title.split(' - ')[0]; 635 } 636 637 return { 638 name: agentName, 639 company: description || 'Real Estate Professional' 640 }; 641 } 642 643 class TestimonialSlider { 644 constructor(container, testimonials, agentInfo) { 645 this.container = container; 646 this.testimonials = testimonials; 647 this.agentInfo = agentInfo; 648 this.currentSlide = 0; 649 this.autoSlideInterval = null; 650 this.expandedStates = {}; 651 652 this.render(); 653 this.bindEvents(); 654 this.startAutoSlide(); 655 } 656 657 needsTruncation(text) { 658 const wordsLimit = 120; 659 return text.split(' ').length > wordsLimit; 660 } 661 662 getTruncatedText(text) { 663 const wordsLimit = 120; 664 const words = text.split(' '); 665 if (words.length <= wordsLimit) return text; 666 return words.slice(0, wordsLimit).join(' ') + '...'; 667 } 668 669 render() { 670 this.container.innerHTML = ` 671 <div class="rs-testimonial-container"> 672 <div class="rs-testimonial-header"> 673 <div class="rs-agent-info"> 674 <h1>${this.agentInfo.name}</h1> 675 <p>${this.agentInfo.company}</p> 676 <div class="rs-rating-stats"> 677 <div class="rs-stat"> 678 <span class="rs-stat-number">94%</span> 679 <span class="rs-stat-label">Satisfaction</span> 680 </div> 681 <div class="rs-stat"> 682 <span class="rs-stat-number">97%</span> 683 <span class="rs-stat-label">Recommendation</span> 684 </div> 685 <div class="rs-stat"> 686 <span class="rs-stat-number">99%</span> 687 <span class="rs-stat-label">Performance</span> 688 </div> 689 </div> 690 </div> 691 </div> 692 <div class="rs-slider-wrapper"> 693 <div class="rs-testimonial-slider"> 694 <div class="rs-testimonial-track" id="rs-testimonial-track"> 695 ${this.createTestimonialSlides()} 696 </div> 697 </div> 698 <div class="rs-slider-controls"> 699 <button class="rs-nav-button" id="rs-prevBtn" aria-label="Previous testimonial">‹</button> 700 <div class="rs-dots-container" id="rs-dots-container"> 701 ${this.createDots()} 702 </div> 703 <button class="rs-nav-button" id="rs-nextBtn" aria-label="Next testimonial">›</button> 704 </div> 705 </div> 706 <div class="rs-branding"> 707 <p>Testimonials powered by <a href="${REALSATISFIED_BASE_URL}${agentSlug}" target="_blank" rel="noopener">RealSatisfied</a></p> 708 </div> 709 </div> 710 `; 711 712 this.updateSlider(); 713 } 714 715 createTestimonialSlides() { 716 return this.testimonials.map((testimonial, index) => { 717 const needsReadMore = this.needsTruncation(testimonial.text); 718 const isExpanded = this.expandedStates[index] || false; 719 const displayText = needsReadMore && !isExpanded ? 720 this.getTruncatedText(testimonial.text) : 721 testimonial.text; 722 723 return ` 724 <div class="rs-testimonial-slide"> 725 <div class="rs-testimonial-content"> 726 <div class="rs-quote-text ${needsReadMore && !isExpanded ? 'truncated' : 'expanded'}" data-index="${index}"> 727 ${displayText} 728 </div> 729 ${needsReadMore ? ` 730 <button class="rs-read-more-btn" data-index="${index}" aria-expanded="${isExpanded}"> 731 ${isExpanded ? 'Read Less' : 'Read More'} 732 </button> 733 ` : ''} 734 <div class="rs-client-info"> 735 <div class="rs-client-name">${testimonial.name}</div> 736 <div class="rs-client-location">${testimonial.location}</div> 737 <div class="rs-client-type">${testimonial.type}</div> 738 ${testimonial.date ? `<div class="rs-testimonial-date">${testimonial.date}</div>` : ''} 739 </div> 740 </div> 741 </div> 742 `; 743 }).join(''); 744 } 745 746 createDots() { 747 const totalDots = this.testimonials.length; 748 749 if (totalDots <= MAX_VISIBLE_DOTS) { 750 // Show all dots normally 751 return `<div class="rs-dots-wrapper" role="tablist" aria-label="Testimonial navigation">` + 752 this.testimonials.map((_, index) => 753 `<div class="rs-dot ${index === 0 ? 'active' : ''}
753" data-slide="${index}" role="tab" tabindex="${index === 0 ? '0' : '-1'}" aria-label="Go to testimonial ${index + 1}"></div>` 754 ).join('') + 755 `</div>`; 756 } else { 757 // Show dynamic dots with indicator 758 const dots = Array.from({ length: MAX_VISIBLE_DOTS }, (_, index) => 759 `<div class="rs-dot ${index === 0 ? 'active' : ''}" data-slide="${index}" role="tab" tabindex="${index === 0 ? '0' : '-1'}" aria-label="Go to testimonial ${index + 1}"></div>` 760 ).join(''); 761 762 return ` 763 <div class="rs-dots-wrapper" id="rs-dots-wrapper" role="tablist" aria-label="Testimonial navigation"> 764 ${dots} 765 </div> 766 <div class="rs-dots-indicator" id="rs-dots-indicator" aria-live="polite">1 / ${totalDots}</div> 767 `; 768 } 769 } 770 771 toggleReadMore(index) { 772 this.expandedStates[index] = !this.expandedStates[index]; 773 const testimonial = this.testimonials[index]; 774 const isExpanded = this.expandedStates[index]; 775 776 // Find the current slide 777 const slide = this.container.querySelector(`[data-index="${index}"]`).closest('.rs-testimonial-slide'); 778 const quoteText = slide.querySelector('.rs-quote-text'); 779 const readMoreBtn = slide.querySelector('.rs-read-more-btn'); 780 781 // Update text content 782 if (isExpanded) { 783 quoteText.textContent = testimonial.text; 784 quoteText.className = 'rs-quote-text expanded'; 785 readMoreBtn.textContent = 'Read Less'; 786 readMoreBtn.setAttribute('aria-expanded', 'true'); 787 } else { 788 quoteText.textContent = this.getTruncatedText(testimonial.text); 789 quoteText.className = 'rs-quote-text truncated'; 790 readMoreBtn.textContent = 'Read More'; 791 readMoreBtn.setAttribute('aria-expanded', 'false'); 792 } 793 794 // Pause auto-slide when expanding 795 if (isExpanded) { 796 this.stopAutoSlide(); 797 } else { 798 this.startAutoSlide(); 799 } 800 } 801 802 bindEvents() { 803 // Navigation buttons 804 this.container.querySelector('#rs-prevBtn').addEventListener('click', () => this.prevSlide()); 805 this.container.querySelector('#rs-nextBtn').addEventListener('click', () => this.nextSlide()); 806 807 // Keyboard navigation for nav buttons 808 [this.container.querySelector('#rs-prevBtn'), this.container.querySelector('#rs-nextBtn')].forEach(btn => { 809 btn.addEventListener('keydown', (e) => { 810 if (e.key === 'Enter' || e.key === ' ') { 811 e.preventDefault(); 812 btn.click(); 813 } 814 }); 815 }); 816 817 // Dots 818 this.container.querySelectorAll('.rs-dot').forEach((dot, index) => { 819 dot.addEventListener('click', () => this.goToSlide(index)); 820 dot.addEventListener('keydown', (e) => { 821 if (e.key === 'Enter' || e.key === ' ') { 822 e.preventDefault(); 823 this.goToSlide(index); 824 } 825 // Arrow key navigation 826 else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { 827 e.preventDefault(); 828 const nextDot = this.container.querySelector(`.rs-dot[data-slide="${index + 1}"]`); 829 if (nextDot) { 830 nextDot.focus(); 831 this.goToSlide(index + 1); 832 } 833 } 834 else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { 835 e.preventDefault(); 836 const prevDot = this.container.querySelector(`.rs-dot[data-sli
836de="${index - 1}"]`); 837 if (prevDot) { 838 prevDot.focus(); 839 this.goToSlide(index - 1); 840 } 841 } 842 }); 843 }); 844 845 // Read more buttons 846 this.container.addEventListener('click', (e) => { 847 if (e.target.classList.contains('rs-read-more-btn')) { 848 e.stopPropagation(); 849 const index = parseInt(e.target.dataset.index); 850 this.toggleReadMore(index); 851 } 852 }); 853 854 // Touch/swipe support with improved touch handling 855 let startX = 0; 856 let endX = 0; 857 let startY = 0; 858 let endY = 0; 859 let isDragging = false; 860 const slider = this.container.querySelector('.rs-testimonial-slider'); 861 862 slider.addEventListener('touchstart', (e) => { 863 startX = e.touches[0].clientX; 864 startY = e.touches[0].clientY; 865 isDragging = true; 866 }, { passive: true }); 867 868 slider.addEventListener('touchmove', (e) => { 869 if (!isDragging) return; 870 871 const currentX = e.touches[0].clientX; 872 const currentY = e.touches[0].clientY; 873 const diffX = Math.abs(currentX - startX); 874 const diffY = Math.abs(currentY - startY); 875 876 // If horizontal movement is greater than vertical, prevent scroll 877 if (diffX > diffY && diffX > 10) { 878 e.preventDefault(); 879 } 880 }); 881 882 slider.addEventListener('touchend', (e) => { 883 if (!isDragging) return; 884 885 endX = e.changedTouches[0].clientX; 886 endY = e.changedTouches[0].clientY; 887 isDragging = false; 888 889 this.handleSwipe(startX, endX, startY, endY); 890 }, { passive: true }); 891 892 // Mouse drag support for desktop 893 let isMouseDown = false; 894 let mouseStartX = 0; 895 896 slider.addEventListener('mousedown', (e) => { 897 isMouseDown = true; 898 mouseStartX = e.clientX; 899 slider.style.cursor = 'grabbing'; 900 e.preventDefault(); 901 }); 902 903 slider.addEventListener('mousemove', (e) => { 904 if (!isMouseDown) return; 905 e.preventDefault(); 906 }); 907 908 slider.addEventListener('mouseup', (e) => { 909 if (!isMouseDown) return; 910 isMouseDown = false; 911 slider.style.cursor = ''; 912 913 const mouseEndX = e.clientX; 914 this.handleSwipe(mouseStartX, mouseEndX, 0, 0); 915 }); 916 917 slider.addEventListener('mouseleave', () => { 918 if (isMouseDown) { 919 isMouseDown = false; 920 slider.style.cursor = ''; 921 } 922 }); 923 924 // Pause auto-slide on hover/focus 925 this.container.addEventListener('mouseenter', () => this.stopAutoSlide()); 926 this.container.addEventListener('mouseleave', () => { 927 // Only restart if no testimonial is currently expanded 928 const hasExpanded = Object.values(this.expandedStates).some(state => state); 929 if (!hasExpanded) { 930 this.startAutoSlide(); 931 } 932 }); 933 934 // Pause on focus 935 this.container.addEventListener('focusin', () => this.stopAutoSlide()); 936 this.container.addEventListener('focusout', (e) => { 937 // Check if focus is moving outside the container 938 if (!this.container.contains(e.relatedTarget)) { 939 const hasExpanded = Object.values(this.expandedStates).some(state => state); 940 if (!hasExpanded) { 941 this.startAutoSlide(); 942 } 943 } 944 }); 945 946 // Keyboard navigation for the entire slider 947 this.container.addEventListener('keydown', (e) => { 948 if (e.target.closest('.rs-dot') || e.target.closest('.rs-nav-button') || e.target.closest('.rs-read-more-btn')) { 949 return; // Let specific elements handle their own keyboard events 950 } 951 952 switch (e.key) { 953 case 'ArrowLeft': 954 case 'ArrowUp': 955 e.preventDefault(); 956 this.prevSlide(); 957 break; 958 case 'ArrowRight': 959 case 'ArrowDown': 960 e.preventDefault(); 961 this.nextSlide(); 962 break; 963 case 'Home': 964 e.preventDefault(); 965 this.goToSlide(0);
966 break; 967 case 'End': 968 e.preventDefault(); 969 this.goToSlide(this.testimonials.length - 1); 970 break; 971 } 972 }); 973 974 // Handle visibility changes to pause/resume auto-slide 975 document.addEventListener('visibilitychange', () => { 976 if (document.hidden) { 977 this.stopAutoSlide(); 978 } else { 979 const hasExpanded = Object.values(this.expandedStates).some(state => state); 980 if (!hasExpanded) { 981 this.startAutoSlide(); 982 } 983 } 984 }); 985 986 // Handle resize events for responsive adjustments 987 let resizeTimeout; 988 window.addEventListener('resize', () => { 989 clearTimeout(resizeTimeout); 990 resizeTimeout = setTimeout(() => { 991 this.updateSlider(); 992 }, 150); 993 }); 994 } 995 996 updateSlider() { 997 const track = this.container.querySelector('#rs-testimonial-track'); 998 const dots = this.container.querySelectorAll('.rs-dot'); 999 const prevBtn = this.container.querySelector('#rs-prevBtn'); 1000 const nextBtn = this.container.querySelector('#rs-nextBtn'); 1001 const dotsIndicator = this.container.querySelector('#rs-dots-indicator'); 1002 1003 // Move slider 1004 track.style.transform = `translateX(-${this.currentSlide * 100}%)`; 1005 1006 // Update ARIA attributes 1007 track.setAttribute('aria-live', 'polite'); 1008 1009 // Update dots based on whether we have dynamic dots or not 1010 if (this.testimonials.length <= MAX_VISIBLE_DOTS) { 1011 // Normal dots behavior 1012 dots.forEach((dot, index) => { 1013 const isActive = index === this.currentSlide; 1014 dot.classList.toggle('active', isActive); 1015 dot.setAttribute('tabindex', isActive ? '0' : '-1'); 1016 dot.setAttribute('aria-selected', isActive.toString()); 1017 }); 1018 } else { 1019 // Dynamic dots behavior 1020 this.updateDynamicDots(); 1021 1022 // Update indicator 1023 if (dotsIndicator) { 1024 dotsIndicator.textContent = `${this.currentSlide + 1} / ${this.testimonials.length}`; 1025 } 1026 } 1027 1028 // Update navigation buttons 1029 prevBtn.disabled = this.currentSlide === 0; 1030 nextBtn.disabled = this.currentSlide === this.testimonials.length - 1; 1031 1032 // Update ARIA labels 1033 prevBtn.setAttribute('aria-label', `Previous testimonial (${this.currentSlide} of ${this.testimonials.length})`); 1034 nextBtn.setAttribute('aria-label', `Next testimonial (${this.currentSlide + 2} of ${this.testimonials.length})`); 1035 1036 // Announce slide change to screen readers 1037 const currentTestimonial = this.testimonials[this.currentSlide]; 1038 if (currentTestimonial) { 1039 const announcement = `Testimonial ${this.currentSlide + 1} of ${this.testimonials.length}: ${currentTestimonial.name}`; 1040 this.announceToScreenReader(announcement); 1041 } 1042 } 1043 1044 updateDynamicDots() { 1045 const dots = this.container.querySelectorAll('.rs-dot'); 1046 const totalSlides = this.testimonials.length; 1047 const maxVisible = MAX_VISIBLE_DOTS; 1048 1049 // Calculate which slides the visible dots should represent 1050 let startSlide, endSlide; 1051 1052 if (this.currentSlide < Math.floor(maxVisible / 2)) { 1053 // Near the beginning 1054 startSlide = 0; 1055 endSlide = maxVisible - 1; 1056 } else if (this.currentSlide >= totalSlides - Math.ceil(maxVisible / 2)) { 1057 // Near the end 1058 startSlide = totalSlides - maxVisible; 1059 endSlide = totalSlides - 1; 1060 } else { 1061 // In the middle 1062 const halfVisible = Math.floor(maxVisible / 2); 1063 startSlide = this.currentSlide - halfVisible; 1064 endSlide = this.currentSlide + halfVisible; 1065 } 1066 1067 // Update each dot
1068 dots.forEach((dot, index) => { 1069 const slideIndex = startSlide + index; 1070 const isActive = slideIndex === this.currentSlide; 1071 1072 dot.dataset.slide = slideIndex; 1073 dot.classList.toggle('active', isActive); 1074 dot.setAttribute('tabindex', isActive ? '0' : '-1'); 1075 dot.setAttribute('aria-selected', isActive.toString()); 1076 dot.setAttribute('aria-label', `Go to testimonial ${slideIndex + 1}`); 1077 1078 // Update click handler 1079 dot.onclick = () => this.goToSlide(slideIndex); 1080 }); 1081 } 1082 1083 goToSlide(index) { 1084 if (index < 0 || index >= this.testimonials.length) return; 1085 1086 this.currentSlide = index; 1087 this.updateSlider(); 1088 1089 // Focus management for accessibility 1090 const activeDot = this.container.querySelector('.rs-dot.active'); 1091 if (activeDot && document.activeElement === activeDot) { 1092 activeDot.focus(); 1093 } 1094 } 1095 1096 nextSlide() { 1097 if (this.currentSlide < this.testimonials.length - 1) { 1098 this.currentSlide++; 1099 this.updateSlider(); 1100 } 1101 } 1102 1103 prevSlide() { 1104 if (this.currentSlide > 0) { 1105 this.currentSlide--; 1106 this.updateSlider(); 1107 } 1108 } 1109 1110 startAutoSlide() { 1111 this.stopAutoSlide(); 1112 this.autoSlideInterval = setInterval(() => { 1113 if (this.currentSlide === this.testimonials.length - 1) { 1114 this.currentSlide = 0; 1115 } else { 1116 this.currentSlide++; 1117 } 1118 this.updateSlider(); 1119 }, 8000); 1120 } 1121 1122 stopAutoSlide() { 1123 if (this.autoSlideInterval) { 1124 clearInterval(this.autoSlideInterval); 1125 this.autoSlideInterval = null; 1126 } 1127 } 1128 1129 handleSwipe(startX, endX, startY = 0, endY = 0) { 1130 const swipeThreshold = 50; 1131 const diffX = startX - endX; 1132 const diffY = Math.abs(startY - endY); 1133 1134 // Only process horizontal swipes 1135 if (Math.abs(diffX) > swipeThreshold && Math.abs(diffX) > diffY) { 1136 if (diffX > 0) { 1137 this.nextSlide(); 1138 } else { 1139 this.prevSlide(); 1140 } 1141 } 1142 } 1143 1144 // Accessibility helper for screen reader announcements 1145 announceToScreenReader(message) { 1146 const announcement = document.createElement('div'); 1147 announcement.setAttribute('aria-live', 'polite'); 1148 announcement.setAttribute('aria-atomic', 'true'); 1149 announcement.className = 'sr-only'; 1150 announcement.style.cssText = 'position:absolute;left:-10000px;width:1px;height:1px;overflow:hidden;'; 1151 announcement.textContent = message; 1152 1153 document.body.appendChild(announcement); 1154 1155 setTimeout(() => { 1156 document.body.removeChild(announcement); 1157 }, 1000); 1158 } 1159 } 1160 1161 // Fetch and initialize testimonial slider 1162 async function initializeTestimonialSlider() { 1163 const containers = document.querySelectorAll('#testimonial-slider'); 1164 1165 if (containers.length === 0) { 1166 console.warn('No testimonial slider containers found'); 1167 return; 1168 } 1169 1170 // Add styles 1171 addStyles(); 1172 1173 try { 1174 // Show loading state 1175 containers.forEach(container => { 1176 container.innerHTML = '<div class="rs-loading" role="status" aria-live="polite">Loading testimonials...</div>'; 1177 }); 1178 1179 // Fetch RSS feed 1180 const rssUrl = `${RSS_BASE_URL}${agentSlug}`; 1181 const response = await fetch(`https://api.allorigins.win/raw?url=${encodeURIComponent(rssUrl)}`); 1182 1183 if (!response.ok) { 1184 throw new Error(`HTTP error! status: ${response.status}`); 1185 } 1186 1187 const xmlText = await response.text(); 1188 const testimonials = parseRSSFeed(xmlText); 1189 const agentInfo = parseAgentInfo(xmlText); 1190 1191 if (testimonials.length === 0) { 1192 throw new Error('No testimonials found'); 1193 } 1194 1195 // Initialize slider for each container
1196 containers.forEach(container => { 1197 new TestimonialSlider(container, testimonials, agentInfo); 1198 }); 1199 1200 } catch (error) { 1201 console.error('Error loading testimonials:', error); 1202 containers.forEach(container => { 1203 container.innerHTML = '<div class="rs-error" role="alert">Unable to load testimonials. Please try again later.</div>'; 1204 }); 1205 } 1206 } 1207 1208 // Initialize when DOM is ready 1209 if (document.readyState === 'loading') { 1210 document.addEventListener('DOMContentLoaded', initializeTestimonialSlider); 1211 } else { 1212 initializeTestimonialSlider(); 1213 } 1214 1215})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.