1<!DOCTYPE html> 2<html lang="en"> 3 4 <head> 5 <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/> 6 <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> 7 <!-- set up Sentry integration --> 8 9
9<script type="text/javascript"> 10 (function(){ 11 const page_version = "v-3c161f3928-20261004t180747"; 12 const reload_key = 'reloaded_for_missing_asset_at'; 13 //a failure this soon after a reload means the reload didn't help, later ones are a new mismatch 14 const repeat_window_ms = 30 * 1000; 15 let has_reported_repeat = false; 16 let is_leaving_page = false; 17 18 //resolves once the event is sent, so a reload right after can't drop it 19 function report_missing_asset(url, type) { 20 //offline the event can't send, so waiting on it would only delay the reload 21 if (typeof Sentry === 'undefined' || navigator.onLine === false) { 22 return Promise.resolve(); 23 } 24 const asset_path = new URL(url, window.location.href).pathname; 25 // so we can track if a certain asset is problematic 26 const missing_asset = asset_path.replace('/public/dist/', '').replace(/\.[0-9a-f]{12}(\.\w+)$/, '$1'); 27 const metric = { 28 attributes: { 29 type: type, 30 missing_asset: missing_asset, 31 page_version: page_version, 32 }, 33 }; 34 Sentry.metrics.count('missing_asset', 1, metric); 35 //we already reloaded and it didn't recover the page 36 if (type === 'repeat') { 37 Sentry.captureMessage('Missing asset after reload', { 38 level: 'error', 39 fingerprint: ['missing-asset-after-reload'], 40 tags: { 41 missing_asset: missing_asset, 42 page_version: page_version, 43 }, 44 extra: { 45 missing_asset_url: asset_path, 46 }, 47 }); 48 } 49 return Sentry.flush(2000); 50 } 51 52 //only failed script and stylesheet loads of hashed files count 53 function return_missing_asset_url(event) { 54 const element = event.target; 55 if (!element || (element.tagName !== 'SCRIPT' && element.tagName !== 'LINK')) { 56 return null; 57 } 58 const url = element.src || element.href || ''; 59 if (!url.includes('/public/dist/')) { 60 return null; 61 } 62 return url; 63 } 64 65 //without storage we can't tell a first failure from a repeat, so we don't reload at all 66 function remember_reload() { 67 try { 68 window.sessionStorage.setItem(reload_key, String(Date.now())); 69 return true; 70 } catch(error) { 71 return false; 72 } 73 } 74 75 function has_reloaded_recently() { 76 try { 77 const reloaded_at = Number(window.sessionStorage.getItem(reload_key)); 78 return Date.now() - reloaded_at < repeat_window_ms; 79 } catch(error) { 80 return false; 81 } 82 } 83 84 //errors from loads the browser abandons on navigation aren't missing files 85 window.addEventListener('pagehide', function(){ 86 is_leaving_page = true; 87 }); 88 89 //html from one deploy can reach an instance of the next, which doesn't have this page's hashed files 90 // .. in which case we reload to force them onto the new instance 91 window.addEventListener('error', function(event){ 92 if (window.is_reloading_for_missing_asset || is_leaving_page) { 93 return; 94 } 95 const url = return_missing_asset_url(event); 96 if (!url) { 97 return; 98 } 99 //at most one reload per window so we don't loop 100 if (!has_reloaded_recently()) { 101 if (remember_reload()) { 102 window.is_reloading_for_missing_asset = true; 103 report_missing_asset(url, 'reload').finally(() => { 104 window.location.reload(); 105 }); 106 } 107 } else if (!has_reported_repeat) { 108 has_reported_repeat = true; 109 report_missing_asset(url, 'repeat'); 110 } 111 }, true); 112 })(); 113</script>
113 114 115
115<script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
115 116 <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> 117
117<script src="/public/assets/js/jquery-ui.min.js"></script>
117 118 <link href="/public/assets/css/jquery-ui.min.css" rel="stylesheet"> 119
119<script type="text/javascript" src="/parse-sdk-js"></script>
119 120
120<script type="text/javascript" src="/public/dist/sc-common.628889f40a86.js"></script>
120 121 <!-- start vue --> 122 123 <!-- prod minified vue js --> 124
124<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/3.4.21/vue.global.prod.min.js"></script>
124 125 126
126<script src="/public/dist/catch-all/vue.99a908762ece.js"></script>
126 127 <!-- end vue --> 128 <!-- library for image orientation --> 129
129<script src="/public/assets/js/load-image-all.min.js"></script>
129 130 <!-- moment.js --> 131
131<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.30.1/moment.min.js"></script>
131 132
132<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.30.1/moment-with-locales.min.js"></script>
132 133
133<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/moment-timezone/0.5.45/moment-timezone-with-data.min.js"></script>
133 134 <!-- library for charts --> 135
135<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.4/Chart.js"></script>
135 136 <link rel="stylesheet" href="/public/assets/css/material-icons.css"> 137 <!-- <link rel="stylesheet" href="https://kit.fontawesome.com/a59cae6e7b.css" crossorigin="anonymous"> --> 138 <link rel="stylesheet" href="/public/assets/css/font-awesome.css"> 139 <!-- more js --> 140
140<script src="/public/dist/js/cookies.184eea104904.js"></script>
140 141 142 143 144 <!-- web builder --> 145 146<link href="https://fonts.googleapis.com/css2?family=Boogaloo&family=Bree+Serif&family=Carlito:ital,wght@0,400;0,700;1,400;1,700&family=Cinzel:wght@400;900&family=Cormorant+Garamond&family=Halant:wght@400;500;600;700&family=Inknut+Antiqua:wght@400;700&family=JetBrains+Mono:wght@400;500&family=Karla&family=Krona+One&family=Lato&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Lora:wght@400;500&family=Montserrat:wght@400;500;600&family=Mrs+Saint+Delafield&family=Mukta:wght@400;700;800&family=Nunito+Sans&family=Open+Sans:ital,wght@0,300;0,800&family=Poller+One&family=PT+Sans:wght@400;700&family=Raleway:wght@400;500;700;800&family=Roboto:wght@400;500&family=Rubik:wght@400;500;700&family=Special+Gothic&family=Spectral+SC:wght@400;500;600;700&family=Spinnaker&family=Vollkorn&family=Zalando+Sans+Expanded:wght@400;500;700&display=swap" rel="stylesheet"> 147 148 <!-- Juice browserified for inline css --> 149
149<script src="/public/dist/browserified/juice-browserified.4ae70b219fc7.js"></script>
149 150 <!-- start calendar stuff (tui-calendar) --> 151 <link rel="stylesheet" type="text/css" href="https://uicdn.toast.com/tui-calendar/v1.15.0/tui-calendar.css" /> 152 <link rel="stylesheet" type="text/css" href="https://uicdn.toast.com/tui.date-picker/latest/tui-date-picker.css" /> 153 <link rel="stylesheet" type="text/css" href="https://uicdn.toast.com/tui.time-picker/latest/tui-time-picker.css" /> 154
154<script src="https://uicdn.toast.com/tui.code-snippet/v1.5.2/tui-code-snippet.min.js"></script>
154 155
155<script src="https://uicdn.toast.com/tui-calendar/v1.15.0/tui-calendar.js"></script>
155 156 <!-- end calendar stuff --> 157
157<script src="https://cdn.jsdelivr.net/npm/dayjs@1/dayjs.min.js"></script>
157 158
158<script src="/public/dist/js/browserify_src/bundle_browserify.078d255a7e2b.js"></script>
158 159
159<script src="/public/dist/catch-all/common-web-builder.6885f73389b0.js"></script>
159 160 <!-- Cropper.js --> 161 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.6/cropper.min.css"> 162
162<script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.6/cropper.min.js"></script>
162 163 <!-- unlayer (newer email builder, to replace tinymce) --> 164
164<script src="https://editor.unlayer.com/embed.js"></script>
164 165 <!-- CodeMirror code editor (for custom-code component) --> 166 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css"> 167
167<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script>
167 168
168<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/xml/xml.min.js"></script>
168 169
169<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/javascript/javascript.min.js"></script>
169 170
170<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/css/css.min.js"></script>
170 171
171<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/htmlmixed/htmlmixed.min.js"></script>
171 172
172<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/addon/edit/closebrackets.min.js"></script>
172 173
173<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/addon/edit/closetag.min.js"></script>
173 174
174<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/addon/edit/matchbrackets.min.js"></script>
174 175 <!-- end CodeMirror --> 176 177 178 179 180 181 <!-- weglot translations --> 182 183 184 185 186 187 188 189 190 <!-- userway.org accessibility snippet --> 191 192 193 194 195 196 197
197<script src="/public/dist/catch-all/hosted-sites.7bbc5790960f.js"></script>
197 198 <link href="/public/dist/css/hosted.4b026a3f2a07.css" rel="stylesheet"> 199
199<script src="https://cdn.jsdelivr.net/npm/dayjs@1/dayjs.min.js"></script>
199 200 201
201<script> 202 window.__cs_web_builder_default_animation = ""; 203 </script>
203 204 <!-- custom styles --> 205 <style> 206 207 208 209 210 211 212 213 214 215 216 217 /* set base fonts */ 218 219 220 221 222 223 224 225 /* set base colors */ 226 227 228 229 230 231 232 /* now write the styles */ 233 234 /* NOTE: the wb-content class needs to prepend all the styles so they are ONLY applied to hosted website content, not other content on the page - eg. for the website builder, it shouldn't impact the builder menu on the left */ 235 236 .wb-content .cs-header p, 237 .wb-content .cs-header div, 238 .cs-footer-sublink-label, 239 .cs-header-sublink-label{ 240 font-family: Lato !important; 241 } 242 243 .wb-content h1{ 244 font-size: 64px; 245 font-weight: normal; 246 font-family: Cormorant Garamond; 247 } 248 249 .custom-font-wrap h1{ 250 font-family: Cormorant Garamond; 251 } 252 253 .wb-content h2 { 254 font-size: 42px; 255 font-weight: normal; 256 font-family: Cormorant Garamond; 257 } 258 259 .custom-font-wrap h2{ 260 font-family: Cormorant Garamond; 261 } 262 263 .wb-content h3{ 264 font-size: 28px; 265 font-weight: normal; 266 font-family: Cormorant Garamond; 267 } 268 269 .wb-content select{ 270 font-size: 28px; 271 font-weight: normal; 272 font-family: Cormorant Garamond; 273 } 274 275 .custom-font-wrap h3{ 276 font-family: Cormorant Garamond; 277 } 278 279 .wb-content p, 280 .wb-content div{ 281 font-size: 16px; 282 font-weight: normal; 283 font-family: Lato; 284 } 285 286 .custom-font-wrap p, 287 .custom-font-wrap div{ 288 font-family: Lato; 289 } 290 291 .wb-content button:not(.exclude-from-custom-button-styles), 292 .custom-font-wrap button:not(.exclude-from-custom-button-styles){ 293 font-family: Cormorant Garamond; 294 font-weight: bold; 295 font-size: 14px; 296 text-transform: uppercase; 297 border-radius: 3px; 298 color: #fff; 299 background-color: #1b2556; 300 border: none !important; 301 transition: filter 0.15s ease-in-out; 302 } 303 304 .wb-content button:not(.exclude-from-custom-button-styles):hover, 305 .custom-font-wrap button:not(.exclude-from-custom-button-styles):hover{ 306 filter: brightness(1.2); 307 background-color: #1b2556; 308 border: none !important; 309 } 310 311 /*-- tab bars --*/ 312 313 .cs-tab-bar-option.active, 314 .cs-tab-bar-option:hover 315 { 316 color: #fff !important; 317 background-color: #236651; 318 } 319 320 /* -- custom code for millbrook --*/ 321 322 /*-- end custom code for millbrook --*/ 323 324 /*-- color schemes --*/ 325 326 .wb-content .color_scheme_white .background, 327 .wb-content .color_scheme_white.background 328 { 329 background-color: #fff; 330 color: #181b3e; 331 } 332 333 .wb-content .color_scheme_light .background, 334 .wb-content .color_scheme_light.background 335 { 336 background-color: #eaefef; 337 color: #181b3e; 338 } 339 340 .wb-content .color_scheme_dark .background, 341 .wb-content .color_scheme_dark.background 342 { 343 background-color: #181b3e; 344 color: #fff; 345 } 346 347 .wb-content .color_scheme_black .background, 348 .wb-content .color_scheme_black.background 349 { 350 background-color: #000; 351 color: #fff; 352 } 353 354 .wb-content .color_scheme_accent_light .background, 355 .wb-content .color_scheme_accent_light.background 356 { 357 background-color: #fff; 358 color: #236651; 359 } 360 361 .wb-content .color_scheme_accent_dark .background, 362 .wb-content .color_scheme_accent_dark.background 363 { 364 background-color: #236651; 365 color: #fff; 366 } 367 368 /*----- color schemes for header ------ */ 369 370 /* Keep gradient channels tied to the same custom palette as the solid header. */ 371 .cs-header.color_scheme_light{ --cs-header-background-rgb: 234, 239, 239 !important; } 372 .cs-header.color_scheme_dark{ --cs-header-background-rgb: 24, 27, 62 !important; } 373 .cs-header.color_scheme_accent_dark{ --cs-header-background-rgb: 35, 102, 81 !important; } 374 .cs-header.cs-header-background-color-white{ --cs-header-background-rgb: 255, 255, 255 !important; } 375 .cs-header.cs-header-background-color-light{ --cs-header-background-rgb: 234, 239, 239 !important; } 376 .cs-header.cs-header-background-color-dark{ --cs-header-background-rgb: 24, 27, 62 !important; } 377 .cs-header.cs-header-background-color-black{ --cs-header-background-rgb: 0, 0, 0 !important; } 378 .cs-header.cs-header-background-color-accent_light{ --cs-header-background-rgb: 255, 255, 255 !important; } 379 .cs-header.cs-header-background-color-accent_dark{ --cs-header-background-rgb: 35, 102, 81 !important; } 380 381 .color_scheme_white .cs-header-link-wrap > p, 382 .color_scheme_white .cs-burgee-wrap > p{ 383 color: #181b3e !important; 384 } 385 386 .color_scheme_white .cs-header-link-wrap::after{ 387 background: #181b3e; 388 } 389 390 .color_scheme_light .cs-header-background{ 391 background-color: #eaefef; 392 } 393 394 .color_scheme_light .cs-header-link-wrap > p, 395 .color_scheme_light .cs-burgee-wrap > p{ 396 color: #181b3e !important; 397 } 398 399 .color_scheme_light .cs-header-link-wrap::after{ 400 background: #181b3e; 401 } 402 403 .color_scheme_dark .cs-header-background{ 404 background-color: #181b3e; 405 } 406 407 .color_scheme_dark .cs-header-link-wrap > p, 408 .color_scheme_dark .cs-burgee-wrap > p{ 409 color: #fff; 410 } 411 412 .color_scheme_dark .cs-header-link-wrap::after{ 413 background: #fff; 414 } 415 416 .color_scheme_black .cs-header-background{ 417 background-color: #000; 418 } 419 420 .color_scheme_black .cs-header-link-wrap > p, 421 .color_scheme_black .cs-burgee-wrap > p{ 422 color: #fff; 423 } 424 425 .color_scheme_black .cs-header-link-wrap::after{ 426 background: #fff; 427 } 428 429 .color_scheme_accent_light .cs-header-background{ 430 background-color: #fff; 431 } 432 433 .color_scheme_accent_light .cs-header-link-wrap > p, 434 .color_scheme_accent_light .cs-burgee-wrap > p{ 435 color: #236651; 436 } 437 438 .color_scheme_accent_light .cs-header-link-wrap::after{ 439 background: #236651; 440 } 441 442 .color_scheme_accent_dark .cs-header-background{ 443 background-color: #236651; 444 } 445 446 .color_scheme_accent_dark .cs-header-link-wrap > p, 447 .color_scheme_accent_dark .cs-burgee-wrap > p{ 448 color: #fff; 449 } 450 451 .color_scheme_accent_dark .cs-header-link-wrap::after{ 452 background: #fff; 453 } 454 455 /* Animated sub-menu text follows the same accent color as the nav links. */ 456 .cs-header-submenu-style_animated_buttons.color_scheme_accent_light .cs-header-sublink-label, 457 .cs-header-submenu-style_animated_flat.color_scheme_accent_light .cs-header-sublink-label{ 458 color: #236651; 459 } 460 461 .cs-header-submenu-style_animated_buttons.color_scheme_accent_light .cs-header-sublink-label:hover, 462 .cs-header-submenu-style_animated_flat.color_scheme_accent_light .cs-header-sublink-label:hover{ 463 color: #236651; 464 opacity: .7; 465 } 466 467 /* Edge-fade / color-token overlays follow club palette. Do not set 468 background-color on bare .cs-image-overlay â classic opacity-only 469 layers must keep the hardcoded #141a21 fill from hosted.css. */ 470 .cs-image-overlay.cs-overlay-color-light, 471 .cs-header-section-gradient.cs-overlay-color-light{ 472 --cs-overlay-rgb: 234, 239, 239; 473 } 474 .cs-image-overlay.cs-overlay-color-dark, 475 .cs-header-section-gradient.cs-overlay-color-dark{ 476 --cs-overlay-rgb: 24, 27, 62; 477 } 478 .cs-image-overlay.cs-overlay-color-top-light{ --cs-overlay-rgb-top: 234, 239, 239; } 479 .cs-image-overlay.cs-overlay-color-top-dark{ --cs-overlay-rgb-top: 24, 27, 62; } 480 .cs-image-overlay.cs-overlay-color-bottom-light{ --cs-overlay-rgb-bottom: 234, 239, 239; } 481 .cs-image-overlay.cs-overlay-color-bottom-dark{ --cs-overlay-rgb-bottom: 24, 27, 62; } 482 .cs-image-overlay.cs-overlay-top, 483 .cs-image-overlay.cs-overlay-bottom, 484 .cs-image-overlay.cs-overlay-both, 485 .cs-image-overlay.cs-overlay-gradient_top, 486 .cs-image-overlay.cs-overlay-gradient_bottom{ 487 background-color: transparent; 488 } 489 .cs-fade-color-top-light{ 490 --cs-fade-color-top: #eaefef; 491 } 492 .cs-fade-color-top-dark{ 493 --cs-fade-color-top: #181b3e; 494 } 495 .cs-fade-color-bottom-light{ 496 --cs-fade-color-bottom: #eaefef; 497 } 498 .cs-fade-color-bottom-dark{ 499 --cs-fade-color-bottom: #181b3e; 500 } 501 502 /* Overlap fade-through color follows club palette when set. */ 503 .cs-overlap-through-light, 504 :not(.event-page-callout):has(+ .cs-overlap-through-light), 505 .componentPreview:has(+ .componentPreview .cs-overlap-through-light), 506 .wb-diff-section:has(+ .wb-diff-section .cs-overlap-through-light){ 507 --cs-overlap-through-rgb: 234, 239, 239; 508 } 509 .cs-overlap-through-dark, 510 :not(.event-page-callout):has(+ .cs-overlap-through-dark), 511 .componentPreview:has(+ .componentPreview .cs-overlap-through-dark), 512 .wb-diff-section:has(+ .wb-diff-section .cs-overlap-through-dark){ 513 --cs-overlap-through-rgb: 24, 27, 62; 514 } 515 516 517</style> <!-- favicon --> 518 519 <!-- try to fall back to the burgee url, but google might not love this if it's not sized properly, so we really want favicon_url set --> 520 <link rel="shortcut icon" href="https://d282wvk2qi4wzk.cloudfront.net/WNFRUHzOSWcB_ZcyB7d5FbiP_1765487044_1765487044306"> 521 <link rel="icon" sizes="16x16 32x32 64x64" href="https://d282wvk2qi4wzk.cloudfront.net/WNFRUHzOSWcB_ZcyB7d5FbiP_1765487044_1765487044306"> 522 523 <!-- touch icon (mobile web) --> 524
525 <!-- web app title (mobile safari) --> 526 527 <meta name="apple-mobile-web-app-title" content="Miami Yacht Club"> 528 529 <!-- app_manifest (android) --> 530 531 532 533 534 535 536 537 <title>About Us - Miami Yacht Club</title> 538 539 540 <link rel="canonical" href="https://miamiyachtclub.com/about-us"> 541 542 <!-- fb open graph tags --> 543 <meta property="fb:app_id" content="314788812688609" /> 544 545 <meta property="og:image" content="https://d282wvk2qi4wzk.cloudfront.net/WNFRUHzOSWcB_ZcyB7d5FbiP_1765487044_1765487044306"> 546 547 548 549 <!-- Google Analytics for clubs --> 550 551 <!-- END google analytics for clubs --> 552 553 <!-- FB pixel for clubs --> 554 555 <!-- FB pixel for clubs --> 556 557 558 559 <!-- vue date picker --> 560 <!-- see: https://vue3datepicker.com/ --> 561
561<script src="/public/assets/js/vue-date-picker-8.5.1.js"></script>
561 562 <link rel="stylesheet" href="/public/assets/css/vue-date-picker.css"> 563 564 <!-- Member portal splash video: warm the CDN connection and start fetching the splash 565 video during initial HTML parse, before Vue mounts the <video> element. This avoids 566 the multi-second gap (especially in the native app webview) where only the poster 567 image shows while the JS bundle loads, the club loads, and Vue renders. --> 568 569 570</head> 571 <body 572 class='wb-content hosted-page' 573 data-club-id='ee2X1rcgnh' 574 data-club-name='Miami Yacht Club' 575 data-timezone='America/New_York' 576 > 577 578 579 <div class="globalMessageInsert flexNoWrap easeFast hideForPrinting"> 580 <div class="global-message-icon-loading"> 581 <div class="global-message-icon-spinner"></div> 582 </div> 583 <p> 584 <i class="fa-solid fa-square-check global-message-icon-good"></i> 585 <i class="fa-solid fa-square-xmark global-message-icon-bad"></i> 586 <span></span> 587 </p> 588 </div> 589 590 591 <!-- start standard content --> 592 <div id="v-content" class="hidden relative leftText relative easeFast paddingTopHeader"> 593 <!-- header --> 594 595 <custom-page-header 596 v-if="! is_react_native_member_app" 597 ></custom-page-header> 598 599 <!-- page components --> 600 601 602 603 604 605 <!-- check the config --> 606 607 608 609<!-- clean them first, to weed out null values --> 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683<!-- now generate markup --> 684 685 <hero-banner-component 686 :client_id="'Qcf3YhEMSq4_1769033465'" 687 :formatting="'{"section_height":"50","section_width":"70","align_items":"center","background_size":"cover","overlay_opacity":"40"}'" 688 :class_string="' color_scheme_dark orientation-left number_of_columns-3 cs-first-page-section caption_style-show_on_hover'" 689 :style_string="'min-height:50vh;align-items:center;'" 690 :element_style_string="'max-width:70%;'" 691 :component_type="'hero-banner'" 692 :elements_string="'%5B%7B%22type%22%3A%22h1-center%22%2C%22content%22%3A%22%3Ch1%20style%3D%5C%22text-align%3Acenter%5C%22%3EMiami%20Yacht%20Club%3C%2Fh1%3E%22%2C%22client_id%22%3A%22UGtbyzzZun74BIiX%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22h3-center%22%2C%22content%22%3A%22%3Ch3%20style%3D%5C%22text-align%3Acenter%5C%22%3ELocated%20in%20the%20Heart%20of%20Miami%3C%2Fh3%3E%3Cdiv%3E%3Cbr%20%2F%3E%3C%2Fdiv%3E%22%2C%22client_id%22%3A%22or1ox6Mn1qHu7Uk5%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22button%22%2C%22content%22%3A%22%3Cbutton%20class%3D%5C%22cs-button%5C%22%20style%3D%5C%22margin-left%3Aauto%3Bmargin-right%3Aauto%3Btext-align%3Acenter%3Bbackground-color%3Argb%2528255%2C%20255%2C%20255%2529%5C%22%3E%3Ca%20href%3D%5C%22%2Fmembership%5C%22%20target%3D%5C%22_blank%5C%22%20class%3D%5C%22cs-link-KTR8Veb4Khz_1769033538%5C%22%3E%3Cspan%20class%3D%5C%22cs-font-size-18%20cs-7dWsHMFuwmp_1769033528_1769033528419%5C%22%20style%3D%5C%22background-color%3Argb%2528255%2C%20255%2C%20255%2529%3Bcolor%3Argb%25280%2C%200%2C%200%2529%5C%22%3E%3Cb%3EMembership%3C%2Fb%3E%3C%2Fspan%3E%3C%2Fa%3E%3C%2Fbutton%3E%22%2C%22client_id%22%3A%22eqt8BdmOsojwCfhY%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%5D'" 693 :images_string="'["https://d282wvk2qi4wzk.cloudfront.net/cLvHvcTIWIRf_Qcf3YhEMSq4_1769033465_0_image_1769033473740"]'" 694 ></hero-banner-component> 695 696 697 698 699 700 <!-- check the config --> 701 702 703 704<!-- clean them first, to weed out null values --> 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768<!-- now generate markup --> 769 770 <hero-banner-component 771 :client_id="'BDwzdDmFICP_1769033762'" 772 :formatting="'{"section_height":"1","section_width":"70","align_items":"center","background_size":"cover","overlay_opacity":"0"}'" 773 :class_string="' color_scheme_light orientation-left number_of_columns-3 caption_style-show_on_hover'" 774 :style_string="'min-height:1vh;align-items:center;'" 775 :element_style_string="'max-width:70%;'" 776 :component_type="'hero-banner'" 777 :elements_string="'%5B%7B%22type%22%3A%22h2-center%22%2C%22content%22%3A%22%3Ch2%20style%3D%5C%22text-align%3Acenter%5C%22%3EMiami%20Yacht%20Club%3C%2Fh2%3E%3Cdiv%3E%3Cbr%20%2F%3E%3C%2Fdiv%3E%22%2C%22client_id%22%3A%22HVCfXQ66LuYa5sea%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22text-left%22%2C%22content%22%3A%22%3Cp%20style%3D%5C%22text-align%3Acenter%5C%22%3EFor%20over%2080%20years%20we%20have%20been%20actively%20working%20with%20our%20community%20to%20foster%20an%20appreciation%20in%20the%20beautiful%20waters%20of%20Miami.%20We%20teach%20children%2C%20teenagers%20and%20adults%20to%20sail%20through%20our%20501%2528c%2529%25283%2529%20not-for-profit%2C%20the%20%3Ca%20href%3D%5C%22mycyouthsailing.com%5C%22%20target%3D%5C%22_blank%5C%22%20class%3D%5C%22cs-link-5nlN5cwGaco_1769034139%5C%22%3EMiami%20Yacht%20Club%20Youth%20Sailing%20Foundation%3C%2Fa%3E.%20These%20classes%20are%20open%20to%20everyone%20%E2%80%93%20not%20just%20members%20of%20the%20Club.%20We%20provide%20meeting%20space%20to%20many%20local%2C%20national%20and%20international%20organizations%20including%20the%20Overtown%20Youth%20Center%2C%20Veterans%20Oceans%20Adventures%2C%20Lotus%20House%2C%20Amigos%20for%20Kids%2C%20Rotary%20Club%20and%20
777others.%3C%2Fp%3E%3Cp%20style%3D%5C%22text-align%3Acenter%5C%22%3E%3Cbr%20%2F%3E%3C%2Fp%3E%3Cp%20style%3D%5C%22text-align%3Acenter%5C%22%3EWe%20also%20host%20the%20City%20of%20Miami%20Marine%20Patrol%20and%20Underwater%20Recovery%20Unit.%3C%2Fp%3E%3Cp%20style%3D%5C%22text-align%3Acenter%5C%22%3E%3Cbr%20%2F%3E%3C%2Fp%3E%3Cp%20style%3D%5C%22text-align%3Acenter%5C%22%3EOur%20sailors%20have%20represented%20Miami%20Yacht%20Club%20all%20over%20the%20world%3A%20from%20youth%20events%20to%20the%20Olympics.%20Through%20the%20efforts%20of%20our%20internationally%20recognized%20coaches%2C%20dedicated%20sailors%20and%20MYC%20families%2C%20we%20are%20truly%20the%20%3Ca%20href%3D%5C%22%2Fracing%5C%22%20target%3D%5C%22_blank%5C%22%20class%3D%5C%22cs-link-8djb42KDn8L_1769034170%5C%22%3E%E2%80%9CHome%20of%20Sailing%20Champions%2521%E2%80%9D%3C%2Fa%3E%3C%2Fp%3E%22%2C%22client_id%22%3A%22XfNxZPXeJ1Pt04Ci%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%5D'" 778 :images_string="'[]'" 779 ></hero-banner-component> 780 781 782 783 784 785 <!-- check the config --> 786 787 788 789<!-- clean them first, to weed out null values --> 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853<!-- now generate markup --> 854 855 <div class="cs-text background flexWrap relative color_scheme_white orientation-left number_of_columns-2 caption_style-show_on_hover" style="min-height:1vh;align-items:center;"> 856 857 <div class="element-wrapper flexNoWrap flexWrapOnMobile" class="'element-wrapper_' + text" style="max-width:100%;"> 858 859 <text-component 860 :number_of_columns="'2'" 861 :column_prop="'%5B%7B%22type%22%3A%22h2-center%22%2C%22content%22%3A%22%3Ch2%20style%3D%5C%22text-align%3Acenter%5C%22%3EMYC%20Quicklinks%3C%2Fh2%3E%22%2C%22client_id%22%3A%22OZMuHCuHEBzkfKYt%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22accordion_header%22%2C%22content%22%3A%22%3Ch3%3EOffice%20Hours%20of%20Operation%3C%2Fh3%3E%22%2C%22client_id%22%3A%221yQ6kYc66ZyneE9I%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22accordion_body%22%2C%22content%22%3A%22%3Cp%3E%3Cb%3EOffice%20Hours%3C%2Fb%3E%3C%2Fp%3E%3Cp%3E%3Cbr%20%2F%3E%3C%2Fp%3E%3Cp%3ETues%20%26amp%3B%20Thurs%3C%2Fp%3E%3Cp%3E11%3A00am%20%E2%80%93%204%3A00pm%3C%2Fp%3E%3Cp%3E%3Cbr%20%2F%3E%3C%2Fp%3E%3Cp%3EWed%20%26amp%3B%20Fri%3C%2Fp%3E%3Cp%3E12%3A00pm%20%E2%80%93%206%3A00pm%3C%2Fp%3E%3Cp%3E%3Cbr%20%2F%3E%3C%2Fp%3E%3Cp%3ESat%20%26amp%3B%20Sun%3C%2Fp%3E%3Cp%3E11%3A00am%20%E2%80%93%204%3A00pm%3C%2Fp%3E%3Cp%3E%3Cbr%20%2F%3E%3C%2Fp%3E%3Cp%3EClosed%20Monday%3C%2Fp%3E%22%2C%22client_id%22%3A%22jRgUbR0zaFeDnAaO%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22accordion_header%22%2C%22content%22%3A%22%3Ch3%3EDining%20%26amp%3B%20Events%3C%2Fh3%3E%22%2C%22client_id%22%3A%22YuEuIeE2iVcVB4Y5%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22accordion_body%22%2C%22content%22%3A%22%3Cp%3E%3Cb%3ERestaurant%20Hours%3C%2Fb%3E%3C%2Fp%3E%3Cp%3E%3Cbr%20%2F%3E%3C%2Fp%3E%3Cp%3EWed%3C%2Fp%3E%3Cp%3E4%3A00pm%20%E2%80%93%2010%3A00pm%3C%2Fp%3E%3Cp%3E%3Cbr%20%2F%3E%3C%2Fp%3E%3Cp%3EThus%20%26amp%3B%20Sun%3C%2Fp%3E%3Cp%3E11%3A00am%20%E2%80%93%2010%3A00pm%3C%2Fp%3E%3Cp%3E%3Cbr%20%2F%3E%3C%2Fp%3E%3Cp%3EFri%20%26amp%3B%20Sat%3C%2Fp%3E%3Cp%3E11%3A00am%20%E2%80%93%2011%3A00pm%3C%2Fp%3E%3Cp%3E%3Cbr%20%2F%3E%3C%2Fp%3E%3Cp%3EClosed%20Monday%20%26amp%3B%20Tuesday%3C%2Fp%3E%22%2C%22client_id%22%3A%22yZNlTeetXsEhH1Jy%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22accordion_header%22%2C%22content%22%3A%22%3Ch3%3EMembership%3C%2Fh3%3E%22%2C%22client_id%22%3A%22sNd5kCyEhgQll9xG%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22accordion_body%22%2C%22content%22%3A%22%3Cdiv%20class%3D%5C%22elementor-toggle-item%5C%22%20style%3D%5C%22box-sizing%3Aborder-box%3Bcolor%3Argb%252851%2C%2051%2C%2051%2529%3Bfont-family%3A-apple-system%2C%20%26quot%3Bsystem-ui%26quot%3B%2C%20%26quot%3BSegoe%20UI%26quot%3B%2C%20Roboto%2C%20%26quot%3BHelvetica%20Neue%26quot%3B%2C%20Arial%2C%20%26quot%3BNoto%20Sans%26quot%3B%2C%20sans-serif%2C%20%26quot%3BApple%20Color%20Emoji%26quot%3B%2C%20%26quot%3BSegoe%20UI%20Emoji%26quot%3B%2C%20%26quot%3BSegoe%20UI%20Symbol%26quot%3B%2C%20%26quot%3BNoto%20Color%20Emoji%26quot%3B%3Bbackground-color%3Argb%2528255%2C%20255%2C%20255%2529%5C%22%3E%3Cdiv%20id%3D%5C%22elementor-tab-content-6903%5C%22%20class%3D%5C%22elementor-tab-content%20elementor-clearfix%20elementor-active%5C%22%20data-tab%3D%5C%223%5C%22%20style%3D%5C%22box-sizing%3Aborder-box%3Bborder-bottom%3A1px%20solid%20rgb%252828%2C%2036%2C%2075%2529%3Bpadding%3A15px%3Bcolor%3Argb%252828%2C%2036%2C%2075%2529%3Bfont-family%3ARoboto%2C%20sans-serif%3Bfont-size%3A18px%5C%22%3E%3Cp%20style%3D%5C%22box-sizing%3Aborder-box%3Bmargin-block%3A0px%200.9rem%5C%22%3EMiami%20Yacht%20Club%2C%20welcomes%20new%20members%20looking%20to%20be%20part%20of%20our%20sailing%20tradition.%20If%20you%20would%20like%20more%20information%2C%20please%C2%A0%3Ca%20href%3D%5C%22mailto%3Aoffice%40miamiyachtclub.com%5C%22%20style%3D%5C%22box-sizing%3Aborder-box%3Bbackground-color%3Atransparent%3Bcolor%3Argb%2528204%2C%2051%2C%20102%2529%3Bbox-sha
861dow%3Anone%5C%22%3Eemail%20us.%3C%2Fa%3E%3C%2Fp%3E%3C%2Fdiv%3E%3C%2Fdiv%3E%22%2C%22client_id%22%3A%22CHqT6jjVQDagzYkT%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22accordion_header%22%2C%22content%22%3A%22%3Ch3%3E%C2%A0Contact%20Us%3C%2Fh3%3E%22%2C%22client_id%22%3A%220kIs0sGLjXljtfp9%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22accordion_body%22%2C%22content%22%3A%22%3Cp%3E%3Cb%3EMiami%20Yacht%20Club%3C%2Fb%3E%3C%2Fp%3E%3Cp%3E%3Cbr%20%2F%3E%3C%2Fp%3E%3Cp%3EServing%20Miami%20since%201927%3C%2Fp%3E%3Cp%3E%3Cbr%20%2F%3E%3C%2Fp%3E%3Cp%3E%3C%2Fp%3E%3Cp%3E%3C%2Fp%3E%3Ca%20href%3D%5C%22tel%3A305-377-9877%5C%22%20target%3D%5C%22_blank%5C%22%20class%3D%5C%22cs-link-OpqGTxcFPPr_1769034366%5C%22%3E%3Cp%3E305-377-9877%3C%2Fp%3E%3Cp%3E%3C%2Fp%3E%3Cp%3E%3C%2Fp%3E%3C%2Fa%3E%3Cp%3E%3Cbr%20%2F%3E%3C%2Fp%3E%3Cp%3E%3Ca%20href%3D%5C%22mailto%3Aoffice%40miamiyachtclub.com%5C%22%20class%3D%5C%22cs-link-zD18UBY29EC_1769034374%5C%22%3Eoffice%40miamiyachtclub.com%3C%2Fa%3E%3C%2Fp%3E%22%2C%22client_id%22%3A%222yzhyaE25oTIMSRc%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%5D'" 862 :column_index="'0'" 863 :section_element_animation="''" 864 ></text-component> 865 866 <text-component 867 :number_of_columns="'2'" 868 :column_prop="'%5B%7B%22type%22%3A%22h2-center%22%2C%22content%22%3A%22%3Ch2%20style%3D%5C%22text-align%3Acenter%5C%22%3EVisitors%20Information%3C%2Fh2%3E%22%2C%22client_id%22%3A%22QUsiz8wuG86rYDbn%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22accordion_header%22%2C%22content%22%3A%22%3Ch3%3EVisitors%20are%20Always%20Welcome%3C%2Fh3%3E%22%2C%22client_id%22%3A%22hSoO70CmznvxUX2m%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22accordion_body%22%2C%22content%22%3A%22%3Cp%3EAs%20a%20visitor%20you%20are%20welcome%20to%20enjoy%20our%20restaurant.%20We%20have%20lunch%20and%20dinner%20specials%20daily%20with%20the%20exception%20of%20Monday%20when%20the%20restaurant%20and%20bar%20are%20closed.%20Visitors%20are%20also%20welcome%20to%20inquire%20about%20hosting%20a%20party%2C%20banquet%20or%20meeting%20at%20the%20club.%20MYC%20burgees%2C%20shirts%2C%20hats%2C%20jackets%20and%20more%20can%20be%20purchased%20in%20our%20ship%E2%80%99s%20store.%3C%2Fp%3E%22%2C%22client_id%22%3A%22sHPwrt8XapdKkXtK%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22accordion_header%22%2C%22content%22%3A%22%3Ch3%3ERacers%20are%20Welcome%20at%20MYC%3C%2Fh3%3E%22%2C%22client_id%22%3A%22MRw5WsIY3vZLKH7o%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22accordion_body%22%2C%22content%22%3A%22%3Cp%3ERegat%3Ca%20href%3D%5C%22racing%5C%22%20class%3D%5C%22cs-link-o63aCm3gBoe_1788365771%5C%22%3E%3C%2Fa%3Eta%20participants%20are%20warmly%20welcomed%20during%20our%20hosted%20events.%20Regatta%20participants%20should%20check%20with%20the%20office%20or%20regatta%20chair%20about%20support%20boat%20storage%2C%20amenities%20or%20if%20extended%20stays%20need%20to%20be%20arranged.%20Daily%20rates%20do%20not%20apply%20to%20regatta%20participants%20during%20hosted%20event.%3C%2Fp%3E%22%2C%22client_id%22%3A%22gYbFTXFMGyctx83r%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22accordion_header%22%2C%22content%22%3A%22%3Ch3%3EVisiting%20Yachts%3C%2Fh3%3E%22%2C%22client_id%22%3A%22E3LxMoaJ8NNJ7DIF%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22accordion_body%22%2C%22content%22%3A%22%3Cp%3EVisiting%20yachts%20should%20contact%20the%20main%20office%20for%20availability%20of%20space%20for%20their%20vessel.%20Charges%20for%20visiting%20yachts%20desiring%20to%20land%20and%20use%20the%20facilities%20of%20the%20Club%20vary%20based%20on%20club%20reciprocity%20with%20the%20visiting%20captain%E2%80%99s%20home%20yacht%20club.%3C%2Fp%3E%22%2C%22client_id%22%3A%2259kTefIyomabwkQA%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22accordion_header%22%2C%22content%22%3A%22%3Ch3%3E%C2%A0Visitor%20Amenities%3C%2Fh3%3E%22%2C%22client_id%22%3A%22Q6TPt5UsMA7u3O1S%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22accordion_body%22%2C%22content%22%3A%22%3Cp%3EVisiting%20yachts%20desiring%20to%20lay%20alongside%20the%20dock%20
868and%20utilize%20the%20electricity%2C%20fresh%20water%2C%20make%20minor%20repairs%2C%20etc.%2C%20may%20do%20so%20for%20a%20charge%20and%20based%20on%20availability.%3C%2Fp%3E%3Cp%3E%3Cbr%20%2F%3E%3C%2Fp%3E%3Cp%3ELike%20our%20members%2C%20all%20visitors%20are%20expected%20to%20follow%20the%20rules%20of%20the%20Club.%3C%2Fp%3E%22%2C%22client_id%22%3A%22gIjjxiwsIxif5yey%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%5D'" 869 :column_index="'1'" 870 :section_element_animation="''" 871 ></text-component> 872 873 <text-component 874 :number_of_columns="'2'" 875 :column_prop="'%5B%7B%22type%22%3A%22h2-center%22%2C%22content%22%3A%22%3Ch2%20style%3D%5C%22text-align%3Acenter%5C%22%3EClick%20to%20edit%3C%2Fh2%3E%22%2C%22client_id%22%3A%22h8JGDLiPxh8BIhoy%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%2C%7B%22type%22%3A%22h3-center%22%2C%22content%22%3A%22%3Ch3%20style%3D%5C%22text-align%3Acenter%5C%22%3EClick%20to%20edit%3C%2Fh3%3E%22%2C%22client_id%22%3A%22bz0fwE3vaBVofo65%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%5D'" 876 :column_index="'2'" 877 :section_element_animation="''" 878 ></text-component> 879 880 </div> 881 882 </div> 883 884 885 886 887 888 <!-- footer --> 889 890 891 892<!-- check the config --> 893 894 895<div class="cs-footer relative color_scheme_white background" style="" > 896 <div class=""> 897 <div class="cs-footer-flex-wrap-row"> 898 <div class="cs-footer-logo-wrap background"> 899 900 901 902 <div class="cs-burgee-wrap relative" style="height:190px;width:300px;"> 903 <div class="cs-burgee" style="background-image:url(https://d282wvk2qi4wzk.cloudfront.net/WNFRUHzOSWcB_ZcyB7d5FbiP_1765487044_1765487044306)"></div> 904 <a class="absoluteA" href="/"></a> 905 </div> 906 907 </div> 908 <div> 909 <div class="flexWrapLeftAlign"> 910 <div class="cs-footer-link-wrap flexGrowOne noPaddingBottom"> 911 912 913 914 915 916 <!-- component --> 917 <div class="footer-link-section flexGrowOne"> 918 919 920 <p class="footer-sublink-section-header smallMarginTop">Navigation</p> 921 922 <!-- handle sub-components --> 923 924 925 <div class="cs-footer-sublink-outer easeFast"> 926 927 928 929 930 931 <div class="cs-footer-sublink-wrap relative"> 932 933 934 935 <p class="cs-footer-sublink-label"> 936 NOAA Nautical Charts 937 <a class="absoluteA" href="https://nauticalcharts.noaa.gov/enconline/enconline.html"></a> 938 </p> 939 940 941 </div> 942 943 944 945 946 947 948 949 <div class="cs-footer-sublink-wrap relative"> 950 951 952 953 <p class="cs-footer-sublink-label"> 954 Adult Sailing Courses 955 <a class="absoluteA" href="https://charismasailing.org/locations/miami-yacht-club"></a> 956 </p> 957 958 959 </div> 960 961 962 963 964 965 966 967 <div class="cs-footer-sublink-wrap relative"> 968 969 970 971 <p class="cs-footer-sublink-label"> 972 Racing 973 <a class="absoluteA" href="/racing"></a> 974 </p> 975 976 977 </div> 978 979 980 981 982 983 984 985 <div class="cs-footer-sublink-wrap relative"> 986 987 988 989 <p class="cs-footer-sublink-label"> 990 MYC Outreach 991 <a class="absoluteA" href="/myc-outreach"></a> 992 </p> 993 994 995 </div> 996 997 998 999 1000 1001 1002 1003 <div class="cs-footer-sublink-wrap relative"> 1004 1005 1006 1007 <p class="cs-footer-sublink-label"> 1008 Event Calendar 1009 <a class="absoluteA" href="/events-calendar"></a> 1010 </p> 1011 1012 1013 </div> 1014 1015 1016 1017 1018 1019 1020 1021 <div class="cs-footer-sublink-wrap relative"> 1022 1023 1024 1025 <p class="cs-footer-sublink-label"> 1026 About Us 1027 <a class="absoluteA" href="/about-us"></a> 1028 </p> 1029 1030 1031 </div> 1032 1033 1034 1035 1036 1037 1038 1039 <div class="cs-footer-sublink-wrap relative"> 1040 1041 1042 1043 <p class="cs-footer-sublink-label"> 1044 Membership 1045 <a class="absoluteA" href="/membership"></a> 1046 </p> 1047 1048 1049 </div> 1050 1051 1052 1053 </div> 1054 1055 </div> 1056 1057 1058 1059 1060 1061 1062 1063 <!-- component --> 1064 <div class="footer-link-section flexGrowOne"> 1065 1066 1067 <p class="footer-sublink-section-header smallMarginTop">Contact Us</p> 1068 1069 <!-- handle sub-components --> 1070 1071 1072 <div class="cs-footer-sublink-outer easeFast"> 1073 1074 1075 1076 1077 1078 <div class="cs-footer-sublink-wrap relative"> 1079 1080 1081 1082 <p class="cs-footer-sublink-label"> 1083 305-377-9877 1084 <a class="absoluteA" href="tel:305-377-9877"></a> 1085 </p> 1086 1087 1088 </div> 1089 1090 1091 1092 1093 1094 1095 1096 <div class="cs-footer-sublink-wrap relative"> 1097 1098 1099 1100 <p class="cs-footer-sublink-label"> 1101 [email protected] 1102 <a class="absoluteA" href="mailto: [email protected]"></a> 1103 </p> 1104 1105 1106 </div> 1107 1108 1109 1110 1111 1112 1113 1114 <div class="cs-footer-sublink-wrap relative"> 1115 1116 1117 1118
1118 <p class="cs-footer-sublink-label"> 1119 1001 MacArthur Causeway, Miami, FL 33132 1120 <a class="absoluteA" href="https://www.google.com/maps/place/Miami+Yacht+Club/@25.7859326,-80.1731247,15z/data=!4m6!3m5!1s0x88d9b416c387f001:0x6423ef1fdc4db60e!8m2!3d25.7859326!4d-80.1731247!16s%2Fg%2F1tfrsqr0"></a> 1121 </p> 1122 1123 1124 </div> 1125 1126 1127 1128 1129 1130 1131 1132 <div class="cs-footer-sublink-wrap relative"> 1133 1134 1135 1136 <p class="cs-footer-sublink-label"> 1137 Membership Application 1138 <a class="absoluteA" href="/apply"></a> 1139 </p> 1140 1141 1142 </div> 1143 1144 1145 1146 </div> 1147 1148 </div> 1149 1150 1151 1152 <!-- social icons --> 1153 1154 <div class="footer-link-section flexGrowOne"> 1155 <p class="footer-sublink-section-header smallMarginTop">Social</p> 1156 <div class="cs-footer-sublink-outer easeFast"> 1157 <div class="flexWrapLeftAlign"> 1158 1159 1160 <a href="https://www.instagram.com/miamiyachtclub1927/" target="_blank" style="display: flex; cursor: pointer; text-decoration: none; border: none !important;"> 1161 <i class="fa-brands fa-instagram" style="font-size:30px; color: #16163f; margin: 10px"></i> 1162 </a> 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 <a href="https://www.facebook.com/MiamiYachtClub/" target="_blank" style="display: flex; cursor: pointer; text-decoration: none; border: none !important;"> 1179 <i class="fa-brands fa-facebook-f" style="font-size:30px; color: #16163f; margin: 10px"></i> 1180 </a> 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 <a href="https://www.linkedin.com/company/miami-yacht-club/about/" target="_blank" style="display: flex; cursor: pointer; text-decoration: none; border: none !important;"> 1191 <i class="fa-brands fa-linkedin-in" style="font-size:30px; color: #16163f; margin: 10px"></i> 1192 </a> 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 </div> 1204 </div> 1205 </div> 1206 1207 </div> 1208 </div> 1209 <div class="cs-footer-link-wrap noPaddingTop"> 1210 <div class="powered_by_wrap relative"> 1211 <div class="flexNoWrap align-items-center"> 1212 <p class="powered_by"><i class="fa-solid fa-shield-check" style="padding-right:3px;"></i> Powered by</p> 1213 <p class="modern pointer powered_by_text relative" style="font-weight:bold;">Clubspot <a class="absoluteA" href="https://theclubspot.com"></a></p> 1214 </div> 1215 1216 <p style="display:flex;font-family:roboto !important;" class="cs-footer-sublink-label roboto copyright_text noPointer">© 2026 Miami Yacht Club, All rights reserved.</p> 1217 </div> 1218 </div> 1219 </div> 1220 </div> 1221 </div> 1222</div> 1223 1224 <!-- react native tab bar --> 1225 <native-tab-bar-component></native-tab-bar-component> 1226 1227 </div> 1228 <div id="v-overlays" class="hidden"> 1229 <!-- common --> 1230 <template v-if="overlays.show_push_notification"> 1231 <show-push-notification></show-push-notification> 1232 </template> 1233 <template v-if="overlays.overflow_overlay"> 1234 <overflow-overlay></overflow-overlay> 1235 </template> 1236 1237 <template v-if="overlays.view_all_events"> 1238 <view-all-events-overlay></view-all-events-overlay> 1239 </template> 1240 1241 <template v-if="overlays.view_photo_carousel"> 1242 <view-photo-carousel></view-photo-carousel> 1243 </template> 1244 <template v-if="overlays.show_event_details_in_member_cal"> 1245 <show-event-details-in-member-cal-overlay></show-event-details-in-member-cal-overlay> 1246 </template> 1247 1248 </div> 1249 <div id="overlay_membership-directory-popup" class="standardOverlay scrollable hugePaddingBottom"> 1250 <div class="standardOverlayCard centeredBlock eightyThenNinety overflowHidden relative"> 1251 <div class="xButton" onclick="fadeOut('#overlay_membership-directory-popup');"></div> 1252 <div class="profile-background overflowHidden"> 1253 <div class="profile-background-inner hundop" style="height:100%;"></div> 1254 </div> 1255 <div class="profile-picture color_scheme_accent_dark background capitalize" onclick="openDirectoryProfileImage(this)"></div> 1256 <!-- enlarged image overlay --> 1257 <div id="directory-image-lightbox" class="directory-image-lightbox" onclick="closeDirectoryProfileImage()"> 1258 <div class="directory-image-lightbox-inner"> 1259 <img class="directory-image-lightbox-img" src="" alt="Profile photo"> 1260 </div> 1261 </div>
1262 <div class="paddingAllAround smallMarginBottom"> 1263 1264 <p class="insert insert_name product-card-label"></p> 1265 1266 <div class="section-wrap tinyMarginTop tinyMarginBottom"> 1267 <p class="flexGrowOne inline-value" data-db-key="street"></p> 1268 <p class="flexGrowOne inline-value" data-db-key="city"></p> 1269 <p class="flexGrowOne inline-value" data-db-key="state"></p> 1270 <p class="flexGrowOne inline-value" data-db-key="zip"></p> 1271 </div> 1272 1273 <div class="section-wrap tinyMarginTop tinyMarginBottom"> 1274 <h4 class="smallBlockLetters leftText smallMarginTop">Contact</h4> 1275 <p class="flexGrowOne inline-value tinyPaddingTop" data-db-key="email"></p> 1276 <p class="flexGrowOne inline-value tinyPaddingTop" data-db-key="mobile"></p> 1277 </div> 1278 1279 <div class="section-wrap tinyMarginTop tinyMarginBottom"> 1280 <h4 class="smallBlockLetters leftText smallMarginTop">Secondary address</h4> 1281 <p class="flexGrowOne inline-value dark tinyPaddingTop" data-db-key="street_secondary"></p> 1282 <p class="flexGrowOne inline-value dark" data-db-key="city_secondary"></p> 1283 <p class="flexGrowOne inline-value dark" data-db-key="state_secondary"></p> 1284 <p class="flexGrowOne inline-value dark" data-db-key="zip_secondary"></p> 1285 </div> 1286 1287 <div class="section-wrap section-wrap_custom_fields tinyMarginTop tinyMarginBottom"> 1288 1289 </div> 1290 1291 <!-- boat fields --> 1292 <div class="section-wrap tinyMarginTop tinyMarginBottom"> 1293 <p class="flexGrowOne inline-value" data-db-key="boatName"></p> 1294 <p class="flexGrowOne inline-value" data-db-key="sailNumber" data-prepend="Sail #:"></p> 1295 <p class="flexGrowOne inline-value" data-db-key="hullNumber" data-prepend="Hull #:"></p> 1296 <p class="flexGrowOne inline-value" data-db-key="class" data-prepend="Class: "></p> 1297 <p class="flexGrowOne inline-value" data-db-key="registration" data-prepend="Registration #:"></p> 1298 </div> 1299 1300 <div class="section-wrap section-wrap_documents hidden tinyMarginTop tinyMarginBottom"> 1301 <h4 class="smallBlockLetters leftText smallMarginTop">Documents</h4> 1302 <!-- documents inserted here --> 1303 1304 </div> 1305 1306 <div class="section-wrap section-wrap_family tinyMarginTop tinyMarginBottom"> 1307 <h4 class="smallBlockLetters leftText smallMarginTop version_member">Family</h4> 1308 <h4 class="smallBlockLetters leftText smallMarginTop version_boat hidden">Related Members</h4> 1309 <!-- family members inserted here --> 1310 1311 </div> 1312 1313 <div class="section-wrap section-wrap_boats hidden tinyMarginTop tinyMarginBottom"> 1314 <h4 class="smallBlockLetters leftText smallMarginTop version_member">Boats</h4> 1315 <h4 class="smallBlockLetters leftText smallMarginTop version_boat hidden">Related boats</h4> 1316 <!-- boats inserted here --> 1317 1318 </div> 1319 1320 </div> 1321 </div> 1322</div> <div id="overlay_event-description-popup" class="standardOverlay scrollable hugePaddingBottom" style="display:none;opacity:0;"> 1323 <div class="standardOverlayCard centeredBlock eightyThenNinety" style="max-width:850px;"> 1324 <h3></h3> 1325 <div class="standardCardBody noPadding fff"> 1326 <div class="centeredBlock eightyThenNinety bigPaddingBottom marginTop"> 1327 <p class="inline-label smallerMarginBottom">Date</p> 1328 <p class="metric marginBottom leftText tinyMarginTop metric_start_date" style="margin-bottom:20px;"></p> 1329 <p class="inline-label smallerMarginBottom">Time</p> 1330 <p class="metric marginBottom leftText tinyMarginTop metric_start_date_time" style="margin-bottom:20px;"></p> 1331 <p class="inline-label smallerMarginBottom">Event Details</p> 1332 <div class="marginBottom leftText tinyMarginTop metric_description" style="line-height:1.5;"></div> 1333 </div> 1334 </div> 1335 <div class="standardCardFooter flexNoWrap"> 1336 <div class="flexGrowOne"></div> 1337 <button class="cardFooterButton easeFast" onclick="fadeOut('#overlay_event-description-popup');">Done</button> 1338 </div> 1339 </div> 1340</div> 1341 </body> 1342
1342<script> 1343 1344 //add the timezone to the global store 1345 store.timezone = $('body').data('timezone'); 1346 1347 //add to global store 1348 store.eventBus = ref({ 1349 listeners: {}, 1350 $on(event, callback) { 1351 if (!this.listeners[event]) { 1352 this.listeners[event] = []; 1353 } 1354 this.listeners[event].push(callback); 1355 }, 1356 $emit(event, ...args) { 1357 if (this.listeners[event]) { 1358 for (const callback of this.listeners[event]) { 1359 callback(...args); 1360 } 1361 } 1362 }, 1363 $off(event, callback) { 1364 if (!this.listeners[event]) { 1365 return; 1366 } 1367 if (!callback) { 1368 delete this.listeners[event]; 1369 return; 1370 } 1371 this.listeners[event] = this.listeners[event].filter(listener => listener !== callback); 1372 }, 1373 }); 1374 1375 //create the app 1376 var app_content = createApp({ 1377 data() { 1378 return store 1379 }, 1380 mounted() { 1381 console.log("mounted vue app: main content"); 1382 $('#v-content').removeClass('hidden'); 1383 var clubId = $('body').data('clubId'); 1384 if(clubId) { 1385 var query = new Parse.Query('clubs'); 1386 query.include('web_settings'); 1387 return query.get(clubId).then((foundClub) => { 1388 store.clubObject = foundClub; 1389 }) 1390 } 1391 }, 1392 methods: { 1393 1394 }, 1395 computed: { 1396 is_react_native_member_app(){ 1397 return store?.is_react_native_member_app || false; 1398 } 1399 } 1400 }); 1401 1402 //create the app 1403 var app_overlays = createApp({ 1404 data() { 1405 return store 1406 }, 1407 mounted() { 1408 console.log("mounted vue app: overlays"); 1409 $('#v-overlays').removeClass('hidden'); 1410 } 1411 }); 1412 1413 </script>
1413 1414 1415 <!-- define/register vue components --> 1416
1416<script type="text/html" id="template-custom-page-header"> 1417 1418 1419 <!-- globals --> 1420 1421 1422 1423 <!-- check the config --> 1424 1425 1426 1427 1428 1429 1430 1431 1432 <!-- color scheme --> 1433 1434 1435 1436 1437 1438 <!-- opacity --> 1439 1440 1441 1442 <!-- font-size --> 1443 1444 1445 1446 1447 <!-- font-weight --> 1448 1449 1450 1451 1452 <!-- header style (eg stacked vs inline vs centered) --> 1453 1454 1455 1456 1457 <!-- header position (eg relative vs absolute) --> 1458 1459 1460 1461 1462 <!-- sub-menu style (eg simple vs animated slide-down) --> 1463 1464 1465 1466 1467 1468 1469 1470 <!-- header background image, eg. for inverness yc --> 1471 1472 1473 <!-- burgee url --> 1474 1475 1476 <!-- ok, start header markup --> 1477 <div 1478 ref="header" 1479 class="cs-header easeFast absolute-header color_scheme_dark cs-font-size-16 header-800 header-style_stacked header-position_absolute" 1480 style="" 1481 1482 1483 > 1484 1485 <div class="cs-header-background background" style="opacity: 40%;"></div> 1486 1487 <div class="innerHeader align-items-middle flexNoWrap"> 1488 <div class="hamburgerWrap marketingMenu"> 1489 <div style="position: fixed; height: 1px; width: 1px; opacity: .01;"></div> 1490 <div class="hamburger hideOnDesktop"> 1491 1492 <div id="headerLineTop" class="ease"></div> 1493 <div id="headerLineMiddle" class="ease"></div> 1494 <div id="headerLineBottom" class="ease"></div> 1495 1496 </div> 1497 1498 <p class="burger-menu-text hideOnDesktop hideOnMobile">Menu</p> 1499 1500 </div> 1501 1502 1503 1504 1505 1506 <div class="cs-burgee-wrap hideOnMobile" style=""> 1507 <div class="cs-burgee" style="background-image:url(https://d282wvk2qi4wzk.cloudfront.net/WNFRUHzOSWcB_ZcyB7d5FbiP_1765487044_1765487044306);background-size:contain;"></div> 1508 <a href="/" class="absoluteA"></a> 1509 </div> 1510 1511 <div class="cs-burgee-wrap-mobile"> 1512 <div class="cs-burgee" style="background-image:url(https://d282wvk2qi4wzk.cloudfront.net/WNFRUHzOSWcB_ZcyB7d5FbiP_1765487044_1765487044306);background-size:contain;"></div> 1513 <a :href="burgee_link" class="absoluteA"></a> 1514 </div> 1515 1516 1517 1518 <div class="cs-desktop-menu-wrap flexGrowOne flexWrapRightAlign hideOnMobile header-style_stacked"> 1519 1520 1521 1522 1523 1524 1525 <div class="cs-header-link-wrap header-style_stacked"> 1526 <p class="button_link_p" style="">Become a Member</p> 1527 <!-- link --> 1528 1529 1530 <a class="absoluteA" href="/membership"></a> 1531 1532 <!-- handle sub-components --> 1533 1534 1535 </div> 1536 1537 1538 1539 1540 1541 <div class="cs-header-link-wrap header-style_stacked"> 1542 <p class="button_link_p" style="">About Us</p> 1543 <!-- link --> 1544 1545 1546 <a class="absoluteA" href="/about-us"></a> 1547 1548 <!-- handle sub-components --> 1549 1550 1551 <div class="cs-header-sublink-outer easeFast"> 1552 1553 1554 1555 1556 1557 1558 <div class="cs-header-sublink-wrap relative" style="--sm-index:0;"> 1559 <p class="cs-header-sublink-label">Outreach</p> 1560 1561 1562 1563 <a class="absoluteA" href="/myc-outreach"></a> 1564 1565 </div> 1566 1567 1568 1569 </div> 1570 1571 </div> 1572 1573 1574 1575 1576 1577 <div class="cs-header-link-wrap header-style_stacked"> 1578 <p class="button_link_p" style="">Dining</p> 1579 <!-- link --> 1580 1581 1582 <a class="absoluteA" href="/myc-dining"></a> 1583 1584 <!-- handle sub-components --> 1585 1586 1587 </div> 1588 1589 1590 1591 1592 1593 <div class="cs-header-link-wrap header-style_stacked"> 1594 <p class="button_link_p" style="">Racing</p> 1595 <!-- link --> 1596 1597 1598 <a class="absoluteA" href="/racing"></a> 1599 1600 <!-- handle sub-components --> 1601 1602 1603 <div class="cs-header-sublink-outer easeFast"> 1604 1605 1606 1607 1608 1609 1610 <div class="cs-header-sublink-wrap relative" style="--sm-index:0;"> 1611 <p class="cs-header-sublink-label">On the Water</p> 1612 1613 1614 1615 <a class="absoluteA" href="/on-the-water"></a> 1616 1617 </div> 1618 1619 1620 1621 </div> 1622 1623 </div> 1624 1625 1626 1627 1628 1629 <div class="cs-header-link-wrap header-style_stacked"> 1630 <p class="button_link_p" style="">Learn to Sail</p> 1631 <!-- link --> 1632 1633 1634 <!-- handle sub-components --> 1635 1636 1637 <div class="cs-header-sublink-outer easeFast"> 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 <div class="cs-header-sublink-wrap relative" style="--sm-index:1;"> 1652 <p class="cs-header-sublink-label">MYC Youth Foundation</p> 1653 1654 1655 1656 <a class="absoluteA" href="https://www.mycyouthsailing.org/"></a> 1657 1658 </div> 1659 1660 1661 1662 </div> 1663 1664 </div> 1665 1666 1667 1668 1669 1670 <div class="cs-header-link-wrap link-style-button header-style_stacked"> 1671 <p class="button_link_p" style="background-color:#000780 !important; color:#fa885c !important;">Member Login</p> 1672 <!-- link --> 1673 1674 1675 <a class="absoluteA" href="/account"></a> 1676 1677 <!-- handle sub-components --> 1678 1679 1680 </div> 1681 1682 1683 1684 </div> 1685 </div> 1686 </div> 1687 1688 <!-- now the mobile menu --> 1689 <div class="mobileMenuOverlay hideOnDesktop ease"></div> 1690 <div class="mobileMenu hideOnDesktop easeFast scrollable"> 1691 <div class="innerMenu"> 1692 <div class="mobile-link-wrap"> 1693 1694 1695 1696 <div class="cs-burgee-wrap" style="width:175px;height:60px;float:none;margin-top:-40px;margin-bottom:15px;"> 1697 <div class="cs-burgee" style="background-position:left;background-image:url(https://d282wvk2qi4wzk.cloudfront.net/WNFRUHzOSWcB_ZcyB7d5FbiP_1765487044_1765487044306);background-size:contain;margin-bottom:15px;"></div> 1698 <a :href="burgee_link" class="absoluteA"></a> 1699 </div> 1700 1701 1702 1703 <div class="mobile-sublink-outer easeFast"> 1704 1705 1706 <div class="header-sublink-wrap relative"> 1707 <p class="header-sublink-label">My Account</p> 1708 <p class="header-sublink-explain">Click below to view your account</p> 1709 <button class="wb-button-primary easeFast boxShadowBottom smallMarginTop"> 1710 View my account 1711 <a class="absoluteA" href="/account"></a> 1712 </button> 1713 </div> 1714 1715 1716 1717 1718 1719 1720 1721 <!-- component --> 1722 1723 1724 <p class="header-sublink-section-header smallMarginTop"> 1725 Become a Member 1726 <a class="absoluteA" href="/membership"></a> 1727 </p> 1728 1729 <!-- handle sub-components --> 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 <!-- component --> 1741 1742 1743 <p class="header-sublink-section-header smallMarginTop"> 1744 About Us 1745 <a class="absoluteA" href="/about-us"></a> 1746 </p> 1747 1748 <!-- handle sub-components --> 1749 1750 1751 1752 1753 1754 1755 1756 1757 <div class="cs-header-sublink-wrap relative"> 1758 1759 1760 <p class="cs-header-sublink-label"> 1761 Outreach 1762 <a class="absoluteA" href="/myc-outreach"></a> 1763 </p> 1764 1765 </div> 1766 1767 1768 1769 1770 1771 1772 1773 1774 1775 1776 1777 1778 <!-- component --> 1779 1780 1781 <p class="header-sublink-section-header smallMarginTop"> 1782 Dining 1783 <a class="absoluteA" href="/myc-dining"></a> 1784 </p> 1785 1786 <!-- handle sub-components --> 1787 1788 1789 1790 1791 1792 1793 1794 1795 1796 1797 <!-- component --> 1798 1799 1800 <p class="header-sublink-section-header smallMarginTop"> 1801 Racing 1802 <a class="absoluteA" href="/racing"></a> 1803 </p> 1804 1805 <!-- handle sub-components --> 1806 1807 1808 1809 1810 1811 1812 1813 1814 <div class="cs-header-sublink-wrap relative"> 1815 1816 1817 <p class="cs-header-sublink-label"> 1818
1818 On the Water 1819 <a class="absoluteA" href="/on-the-water"></a> 1820 </p> 1821 1822 </div> 1823 1824 1825 1826 1827 1828 1829 1830 1831 1832 1833 1834 1835 <!-- component --> 1836 1837 1838 <p class="header-sublink-section-header smallMarginTop">Learn to Sail</p> 1839 1840 <!-- handle sub-components --> 1841 1842 1843 1844 1845 1846 1847 1848 1849 1850 1851 1852 1853 1854 1855 1856 <div class="cs-header-sublink-wrap relative"> 1857 1858 1859 <p class="cs-header-sublink-label"> 1860 MYC Youth Foundation 1861 <a class="absoluteA" href="https://www.mycyouthsailing.org/"></a> 1862 </p> 1863 1864 </div> 1865 1866 1867 1868 1869 1870 1871 1872 1873 1874 1875 1876 1877 1878 1879 1880 1881 1882 1883 1884 <!-- component --> 1885 1886 1887 <p class="header-sublink-section-header smallMarginTop"> 1888 Member Login 1889 <a class="absoluteA" href="/account"></a> 1890 </p> 1891 1892 <!-- handle sub-components --> 1893 1894 1895 1896 1897 1898 </div> 1899 </div> 1900 </div> 1901 </div> 1902 1903</script>
1903 1904 1905 1906
1907<script> 1908 1909 //define the component 1910 app_content.component("custom-page-header", { 1911 template: "#template-custom-page-header", 1912 data() { 1913 return {} 1914 }, 1915 props: {}, 1916 mounted(){ 1917 this.sync_submenu_edge_alignment(); 1918 window.addEventListener("resize", this.schedule_submenu_edge_alignment, { passive: true }); 1919 if( !this.$refs.header ){ 1920 return; 1921 } 1922 // Belt-and-suspenders: re-check right before a panel opens so a 1923 // stale measurement (from before nav content settled) can't leave 1924 // it hanging off-screen. 1925 this.$refs.header.addEventListener("mouseover", this.handle_submenu_hover_check); 1926 this.$refs.header.addEventListener("focusin", this.handle_submenu_hover_check); 1927 let sticky_header = this.$refs.header.classList.contains("cs-header-behavior_always_visible"); 1928 if( !sticky_header ){ 1929 return; 1930 } 1931 window.addEventListener("scroll", this.handle_header_scroll, { passive: true }); 1932 this.$nextTick(() => { 1933 window.requestAnimationFrame(() => { 1934 this.handle_header_scroll(); 1935 }); 1936 }); 1937 }, 1938 beforeUnmount(){ 1939 window.removeEventListener("scroll", this.handle_header_scroll); 1940 window.removeEventListener("resize", this.schedule_submenu_edge_alignment); 1941 window.clearTimeout(this._submenu_edge_alignment_timeout); 1942 if( this.$refs.header ){ 1943 this.$refs.header.removeEventListener("mouseover", this.handle_submenu_hover_check); 1944 this.$refs.header.removeEventListener("focusin", this.handle_submenu_hover_check); 1945 } 1946 }, 1947 methods: { 1948 // Left-aligned animated sub-menus can run past the right edge of the 1949 // viewport for nav items near the end of the menu - flip those to 1950 // hang from the right instead so they stay on screen. 1951 schedule_submenu_edge_alignment(){ 1952 window.clearTimeout(this._submenu_edge_alignment_timeout); 1953 this._submenu_edge_alignment_timeout = window.setTimeout(() => { 1954 this.sync_submenu_edge_alignment(); 1955 }, 150); 1956 }, 1957 handle_submenu_hover_check(event){ 1958 if( event.target.closest && event.target.closest(".cs-header-link-wrap") ){ 1959 this.sync_submenu_edge_alignment(); 1960 } 1961 }, 1962 sync_submenu_edge_alignment(){ 1963 if( !this.$refs.header ){ 1964 return; 1965 } 1966 let is_animated = this.$refs.header.classList.contains("cs-header-submenu-style_animated_buttons") 1967 || this.$refs.header.classList.contains("cs-header-submenu-style_animated_flat"); 1968 if( !is_animated ){ 1969 return; 1970 } 1971 let panels = this.$refs.header.querySelectorAll(".cs-header-sublink-outer"); 1972 let viewport_width = window.innerWidth || document.documentElement.clientWidth || 0; 1973 panels.forEach((panel) => { 1974 panel.classList.remove("cs-header-sublink-outer_align-right"); 1975 if( panel.getBoundingClientRect().right > viewport_width - 8 ){ 1976 panel.classList.add("cs-header-sublink-outer_align-right"); 1977 } 1978 }); 1979 }, 1980 handle_header_scroll(){ 1981 if( !this.$refs.header ){ 1982 return; 1983 } 1984 let scroll_y = window.scrollY || window.pageYOffset || 0; 1985 let header_rect = this.$refs.header.getBoundingClientRect(); 1986 let first_component = document.querySelector("#v-content .cs-first-page-section") 1987 || document.querySelector("#v-content .cs-custom-code-wrap.first-page-section"); 1988 let first_component_rect = first_component 1989 ? first_component.getBoundingClientRect() 1990 : null; 1991 let viewport_height = window.innerHeight || document.documentElement.clientHeight || 0; 1992 let transition_mode = this.$refs.header.getAttribute("data-header-transition-mode") 1993 || 'phased'; 1994 let was_past_handoff = !!this._header_past_handoff;
1995 let past_full_handoff = !!( 1996 first_component_rect 1997 && first_component_rect.height > 0 1998 && first_component_rect.bottom <= ( 1999 header_rect.bottom + ( was_past_handoff ? 8 : -8 ) 2000 ) 2001 ) || ( 2002 viewport_height > 0 2003 && scroll_y >= viewport_height + ( was_past_handoff ? -8 : 8 ) 2004 ); 2005 let immediate_threshold = Math.max(1, Math.round(header_rect.height / 2)); 2006 let header_past_handoff = transition_mode === 'immediate' 2007 ? scroll_y >= immediate_threshold 2008 : scroll_y > 1 && past_full_handoff; 2009 this._header_past_handoff = header_past_handoff; 2010 let early_offset = Math.max(8, Math.round(header_rect.height * 0.2)); 2011 let early_default_gradient = scroll_y >= early_offset 2012 && !header_past_handoff 2013 && transition_mode === 'phased' 2014 && this.$refs.header.classList.contains("cs-header-background-on-scroll"); 2015 this.apply_header_background_for_scroll(header_past_handoff); 2016 if( early_default_gradient ){ 2017 this.sync_header_background_rgb(); 2018 } 2019 this.$refs.header.classList.remove("cs-header-following-section-gradient"); 2020 this.$refs.header.classList.toggle( 2021 "cs-header-early-default-gradient", 2022 early_default_gradient 2023 ); 2024 }, 2025 apply_header_background_for_scroll(is_scrolled){ 2026 if( !this.$refs.header ){ 2027 return; 2028 } 2029 let top_raw = this.$refs.header.getAttribute("data-header-bg-top"); 2030 let scrolled_raw = this.$refs.header.getAttribute("data-header-bg-scrolled"); 2031 if( !top_raw ){ 2032 return; 2033 } 2034 let top = {}; 2035 let scrolled = {}; 2036 try { 2037 top = JSON.parse(top_raw) || {}; 2038 scrolled = JSON.parse(scrolled_raw || top_raw) || top; 2039 } catch (error) { 2040 return; 2041 } 2042 let active = is_scrolled ? scrolled : top; 2043 let previous_text_color = this.$refs.header.className.match(/color_scheme_[^\s]+/); 2044 if( previous_text_color ){ 2045 this.$refs.header.classList.remove(previous_text_color[0]); 2046 } 2047 this.$refs.header.classList.add( 2048 "color_scheme_" + ( active.color || 'white' ) 2049 ); 2050 let previous_mode = this.$refs.header.className.match(/cs-header-background-mode-[^\s]+/); 2051 if( previous_mode ){ 2052 this.$refs.header.classList.remove(previous_mode[0]); 2053 } 2054 let previous_blur = this.$refs.header.className.match(/cs-header-blur-(light|medium|strong)/); 2055 if( previous_blur ){ 2056 this.$refs.header.classList.remove(previous_blur[0]); 2057 } 2058 let previous_color = this.$refs.header.className.match(/cs-header-background-color-[^\s]+/); 2059 if( previous_color ){ 2060 this.$refs.header.classList.remove(previous_color[0]); 2061 } 2062 this.$refs.header.classList.add("cs-header-background-mode-" + ( active.mode || 'none' )); 2063 this.$refs.header.classList.add( 2064 "cs-header-background-color-" + ( active.color || 'white' ) 2065 ); 2066 if( active.mode === 'blur_tint' ){ 2067 this.$refs.header.classList.add("cs-header-blur-" + ( active.blur_amount || 'medium' )); 2068 } 2069 if( active.opacity !== undefined ){ 2070 this.$refs.header.style.setProperty( 2071 '--cs-header-background-opacity', 2072 String(active.opacity / 100) 2073 ); 2074 } 2075 this.$refs.header.classList.toggle( 2076 "cs-header-top-visible", 2077 !is_scrolled && active.mode !== 'none' 2078 ); 2079 this.$refs.header.classList.toggle( 2080 "cs-header-blur-active", 2081 is_scrolled && active.mode !== 'none' 2082 ); 2083 let header_visible = !( is_scrolled && active.mode === 'none' ); 2084 this.$refs.header.style.opacity = header_visible ? '' : '0'; 2085 this.$refs.header.style.visibility = header_visible ? '' : 'hidden'; 2086 this.$refs.header.style.pointerEvents = header_visible ? '' : 'none'; 2087 },
2088 // Sample the real sticky fill (incl. club custom CSS) so the interim 2089 // gradient never falls back to overlay navy / a mismatched default. 2090 sync_header_background_rgb(){ 2091 if( !this.$refs.header ){ 2092 return; 2093 } 2094 let scrolled_raw = this.$refs.header.getAttribute("data-header-bg-scrolled"); 2095 if( scrolled_raw ){ 2096 try { 2097 let scrolled = JSON.parse(scrolled_raw) || {}; 2098 let previous_color = this.$refs.header.className.match(/cs-header-background-color-[^\s]+/); 2099 if( previous_color ){ 2100 this.$refs.header.classList.remove(previous_color[0]); 2101 } 2102 this.$refs.header.classList.add( 2103 "cs-header-background-color-" + ( scrolled.color || 'white' ) 2104 ); 2105 if( scrolled.opacity !== undefined ){ 2106 this.$refs.header.style.setProperty( 2107 '--cs-header-background-opacity', 2108 String(scrolled.opacity / 100) 2109 ); 2110 } 2111 } catch (error) { 2112 return; 2113 } 2114 } 2115 let bg = this.$refs.header.querySelector('.cs-header-background'); 2116 if( !bg ){ 2117 return; 2118 } 2119 let color = window.getComputedStyle(bg).backgroundColor || ''; 2120 let match = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([0-9.]+))?/.exec(color); 2121 if( !match ){ 2122 return; 2123 } 2124 let alpha = match[4] === undefined ? 1 : Number(match[4]); 2125 if( !( alpha > 0 ) ){ 2126 return; 2127 } 2128 this.$refs.header.style.setProperty( 2129 '--cs-header-background-rgb', 2130 match[1] + ', ' + match[2] + ', ' + match[3] 2131 ); 2132 }, 2133 sync_section_gradient_height(section_height_px){ 2134 if( !this.$refs.section_gradient || !this.$refs.header ){ 2135 return; 2136 } 2137 let transfer_raw = this.$refs.header.getAttribute("data-header-top-fade-transfer"); 2138 if( !transfer_raw ){ 2139 return; 2140 } 2141 let transfer = null; 2142 try { 2143 transfer = JSON.parse(transfer_raw); 2144 } catch (error) { 2145 return; 2146 } 2147 if( !transfer || transfer.kind === 'legacy_fade' ){ 2148 return; 2149 } 2150 if( !section_height_px || section_height_px <= 0 ){ 2151 return; 2152 } 2153 this.$refs.section_gradient.style.height = 2154 ( section_height_px * ( transfer.height_percent / 100 ) ) + "px"; 2155 }, 2156 }, 2157 computed: { 2158 burgee_link(){ 2159 //starting june 18 2026 we don't show this custom page header component in the member app at all.. 2160 /* 2161 if( this.is_react_native_member_app ){ 2162 return "/account" 2163 } 2164 */ 2165 return "/" 2166 }, 2167 current_user(){ 2168 return Parse.User.current() 2169 }, 2170 }, 2171 }); 2172 2173 2174</script>
2174
2174<script type="text/html" id="template-image-grid"> 2175 <template v-if="!image_grid_layout || image_grid_layout !== 'carousel'"> 2176 <div 2177 class="image-grid-wrap centeredBlock" 2178 :class="'image-grid-wrap_' + client_id + ' images_per_row_' + images_per_row + ' ' + class_string" 2179 > 2180 <!-- draggable start for image-grid-image --> 2181 <div 2182 v-if="!show_first_row_skeleton && images?.length > 0" 2183 class="image-grid-insert flexWrapLeftAlign" 2184 :class=" is_admin ? 'draggable-container_image_grid_' + component.id : '' " 2185 > 2186 <template v-for="(image, index) in images" :key="image?.id"> 2187 <div 2188 v-if="image && image.url" 2189 class="image-grid-image relative" 2190 :class="'image-grid-image_' + image?.id" 2191 :id="image?.id" 2192 > 2193 <!-- video --> 2194 <div 2195 v-if="image.is_video" 2196 class="image-grid-image-inner" 2197 :class="{ 'image-grid-fade-media': !is_admin, 'is-loaded': image_has_loaded(image) }" 2198 style="align-items:center;display:flex;" 2199 :style="{'border-radius': image_grid_border_radius + 'px' }" 2200 > 2201 <video class="cs-background-video" autoplay playsinline loop muted :src="image.url" style="display:block;"></video> 2202 <div v-if="is_admin" class="image-grid-drag-handle-wrap draggable-handle"> 2203 <i class="fa-regular fa-grip-dots image-grid-drag-handle"></i> 2204 </div> 2205 </div> 2206 <!-- image --> 2207 <div 2208 v-else 2209 class="image-grid-image-inner" 2210 :class="{ 'image-grid-fade-media': !is_admin, 'is-loaded': image_has_loaded(image) }" 2211 :style="{'background-image': 'url(' + image.url + ')', 'border-radius': image_grid_border_radius + 'px' }" 2212 > 2213 <div v-if="is_admin" class="image-grid-drag-handle-wrap draggable-handle"> 2214 <i class="fa-regular fa-grip-dots image-grid-drag-handle"></i> 2215 </div> 2216 </div> 2217 <!-- overlay --> 2218 <div v-if="is_admin" class="image-grid-image-overlay flexWrap easeFast" :style="{ 'background-color': returnRGBAStringForImageGridImageOverlayOpacity( image_grid_opacity ), 'border-radius': image_grid_border_radius + 'px' }" @click.stop="change_image(image)"> 2219 <div class="image-toolbar-wrapper flexNoWrap centeredBlock" @click.stop> 2220 <div 2221 class="image-toolbar-option easeFast" 2222 data-option="change" 2223 style="border-right:1px solid #fff;" 2224 @click.stop="change_image(image)" 2225 >Change</div> 2226 <div 2227 class="image-toolbar-option easeFast" 2228 data-option="update" 2229 style="border-right:1px solid #fff;" 2230 @click.stop="prepThenDisplayImageSettings(image)" 2231 >Update</div> 2232 <div 2233 class="image-toolbar-option easeFast" 2234 data-option="remove" 2235 @click.stop="tapped_remove_image(image)" 2236 ><i class="fa-regular fa-trash" style="font-size:14px;"></i></div> 2237 </div> 2238 <div class="image-text-wrapper" :style="{ alignItems: text_position === 'start' ? 'flex-start' : text_position === 'center' ? 'center' : 'flex-end' }"> 2239 <h2 2240 v-if="image.caption" 2241 class="image-grid-image-caption" 2242 :style="{ 'font-size': caption_size + 'px', 'font-weight': caption_font_weight }" 2243 >{{ image.caption }}</h2> 2244 <p 2245 v-if="image.subtext" 2246 class="image-grid-image-caption marginTopFour" 2247 :style="{ 'font-size': subtext_size + 'px', 'font-weight': caption_font_weight }" 2248 >{{ image.subtext }}</p> 2249 <p 2250 v-if="image.link_label" 2251 class="image-grid-image-caption marginTopFour" 2252 style="text-decoration: underline;" 2253 :style="{ 'font-size': link_label_size + 'px', 'font-weight': caption_font_weight }" 2254 >{{ image.link_label }}</p> 2255 </div> 2256 </div> 2257 <div v-if="!is_admin" class="image-grid-image-overlay flexWrap easeFast" :style="{ 'background-color': returnRGBAStringForImageGridImageOverlayOpacity( image_grid_opacity ), 'border-radius': image_grid_border_radius + 'px' }"> 2258 <div class="image-text-wrapper" :style="{ alignItems: text_position === 'start' ? 'flex-start' : text_position === 'center' ? 'center' : 'flex-end' }"> 2259 <h2 2260 v-if="image.caption" 2261 class="image-grid-image-caption" 2262 :style="{ 'font-size': caption_size + 'px', 'font-weight': caption_font_weight }" 2263 >{{ image.caption }}</h2> 2264 <p 2265 v-if="image.subtext" 2266 class="image-grid-image-caption marginTopFour" 2267 :style="{ 'font-size': subtext_size + 'px', 'font-weight': caption_font_weight }" 2268 >{{ image.subtext }}</p> 2269 <p 2270 v-if="image.link_label" 2271 class="image-grid-image-caption marginTopFour" 2272 style="text-decoration: underline;" 2273 :style="{ 'font-size': link_label_size + 'px', 'font-weight': caption_font_weight }" 2274 >{{ image.link_label }}</p> 2275 <a 2276 v-if="return_link_href(image.link_to)" 2277 class="absoluteA" 2278 :href="return_link_href(image.link_to)" 2279 rel="noopener noreferrer" 2280 ></a> 2281 </div> 2282 </div> 2283 </div> 2284 <div 2285 v-else-if="is_admin && image && image.placeholder" 2286 class="image-grid-image relative cs-image-slot-placeholder-host" 2287 :class="'image-grid-image_' + image?.id" 2288 :id="image?.id" 2289 @click.stop="fill_image_placeholder(image)" 2290 > 2291 <div class="image-grid-image-inner cs-image-slot-placeholder" :style="{ 'border-radius': image_grid_border_radius + 'px' }">
2292 <span class="cs-image-slot-placeholder-icon"><i class="fa-regular fa-image"></i></span> 2293 <span class="cs-image-slot-placeholder-label">Add {{ image.label || 'image' }}</span> 2294 </div> 2295 </div> 2296 </template> 2297 <!-- upload image **for admin --> 2298 <template v-if="is_admin"> 2299 <div class="image-grid-image image-grid-image_upload non-draggable"> 2300 <div class="image-grid-image-inner"> 2301 <div class="uploadImageZone relative pointer"> 2302 <input 2303 @change="imageUploadChange($event)" 2304 class="image-grid-upload-input absoluteInput" 2305 type="file" accept="image/*,video/*" 2306 > 2307 <p class="centeredBlock" style="font-family:roboto !important;">+ Upload image/video</p> 2308 <div class="uploadImageOverlay"> 2309 <div class="uploadImageSpinner centeredBlock"></div> 2310 </div> 2311 </div> 2312 </div> 2313 </div> 2314 </template> 2315 </div> 2316 <!-- draggable end for image-grid-image --> 2317 <div v-if="!show_first_row_skeleton && images?.length === 0 && is_admin" class="image-grid-insert flexWrapLeftAlign"> 2318 <div class="image-grid-image image-grid-image_upload"> 2319 <div class="image-grid-image-inner"> 2320 <div class="uploadImageZone relative pointer"> 2321 <input 2322 @change="imageUploadChange($event)" 2323 class="image-grid-upload-input absoluteInput" 2324 type="file" accept="image/*,video/*" 2325 > 2326 <p class="centeredBlock" style="font-family:roboto !important;">+ Upload image/video</p> 2327 <div class="uploadImageOverlay"> 2328 <div class="uploadImageSpinner centeredBlock"></div> 2329 </div> 2330 </div> 2331 </div> 2332 </div> 2333 </div> 2334 <div v-if="show_first_row_skeleton" class="image-grid-insert flexWrapLeftAlign"> 2335 <div 2336 v-for="n in skeleton_tile_count" 2337 :key="'image-grid-skeleton-' + n" 2338 class="image-grid-image relative" 2339 > 2340 <div 2341 class="image-grid-image-inner image-grid-skeleton-tile" 2342 :style="{ 'border-radius': image_grid_border_radius + 'px' }" 2343 ></div> 2344 </div> 2345 </div> 2346 </div> 2347 </template> 2348 <template v-else> 2349 2350 <div style="box-sizing: border-box; padding: 20px;" class="event-list-wrap centeredBlock centeredText" :class="'event-list-wrap_' + client_id"> 2351 <!-- <div 2352 class="flexNoWrap align-items-center event_list_carousel_default" 2353 style="padding: 0px 0px 15px 5px;" 2354 > 2355 <p>PLACEHOLDER</p> 2356 </div> --> 2357 <div 2358 class="image-grid-wrap centeredBlock" 2359 :class="'image-grid-wrap_' + client_id + class_string" 2360 style="padding: 20px;" 2361 > 2362 <div 2363 class="tableInsert event-list-card-wrapper" 2364 style="overflow: scroll !important; scrollbar-width: 5px !important; flex-wrap: nowrap;" 2365 :style="{ 'gap': formatting_obj.card_spacing ? Number(formatting_obj.card_spacing) + 'px' : '25px' }" 2366 > 2367 <template v-if="show_first_row_skeleton"> 2368 <div 2369 v-for="n in skeleton_tile_count" 2370 :key="'image-grid-carousel-skeleton-' + n" 2371 class="event-list-card noPadding noMargin" 2372 :class="{ 2373 'small-card-image-grid': formatting_obj.card_size == 'small', 2374 'medium-card-image-grid': !formatting_obj.card_size || formatting_obj.card_size == 'medium', 2375 'large-card-image-grid': formatting_obj.card_size == 'large', 2376 }" 2377 :style="{'border-radius': image_grid_border_radius + 'px' ||'0px' }" 2378 > 2379 <div class="image-grid-image relative"> 2380 <div 2381 class="image-grid-event-image image-grid-skeleton-tile" 2382 :style="{'border-radius': image_grid_border_radius + 'px' }" 2383 ></div> 2384 </div> 2385 <div 2386 v-if="formatting_obj.caption_align === 'under'" 2387 class="image-grid-card-content background" 2388 :style="{'border-radius': image_grid_border_radius + 'px' ||'0px' }" 2389 > 2390 <div class="image-grid-skeleton-caption image-grid-skeleton-tile"></div> 2391 </div> 2392 </div> 2393 </template> 2394 <template 2395 v-else-if="images && images.length > 0" 2396 v-for="(image, index) in images" 2397 :key="image?.id" 2398 > 2399 <div 2400 v-if="image && image.url" 2401 class="event-list-card noPadding noMargin" 2402 :class="{ 2403 'small-card-image-grid': formatting_obj.card_size == 'small', 2404 'medium-card-image-grid': !formatting_obj.card_size || formatting_obj.card_size == 'medium', 2405 'large-card-image-grid': formatting_obj.card_size == 'large', 2406 'box-shadow-light': formatting_obj.box_shadow == 'light', 2407 'box-shadow-dark': formatting_obj.box_shadow == 'dar
2407k', 2408 'pointer': !is_admin, 2409 }" 2410 :style="{'border-radius': image_grid_border_radius + 'px' ||'0px' }" 2411 @click.stop="clicked_carousel_image(image, $event)" 2412 > 2413 <div 2414 v-if="image && image.url" 2415 class="image-grid-image relative" 2416 :class="'image-grid-image_' + image?.id" 2417 :id="image?.id" 2418 > 2419 <!-- video --> 2420 <div 2421 v-if="image.is_video" 2422 class="image-grid-event-image" 2423 :class="{ 'image-grid-fade-media': !is_admin, 'is-loaded': image_has_loaded(image) }" 2424 style="align-items:center;display:flex;" 2425 :style="{'border-radius': image_grid_border_radius + 'px' }" 2426 > 2427 <video class="cs-background-video" autoplay playsinline loop muted :src="image.url" style="display:block;"></video> 2428 </div> 2429 <!-- image --> 2430 <div 2431 v-else 2432 class="image-grid-event-image" 2433 :class="{ 'image-grid-fade-media': !is_admin, 'is-loaded': image_has_loaded(image) }" 2434 :style="{'background-image': 'url(' + image.url + ')', 'border-radius': image_grid_border_radius + 'px' }" 2435 ></div> 2436 <!-- overlay --> 2437 <div 2438 @click.stop="change_image(image)" 2439 v-if="is_admin" 2440 class="image-grid-image-overlay flexWrap easeFast" 2441 style="left:0px;right:0px;bottom:0px;top:0px" 2442 :style="{ 'background-color': returnRGBAStringForImageGridImageOverlayOpacity( image_grid_opacity ), 'border-radius': image_grid_border_radius + 'px' }" 2443 > 2444 <div class="image-toolbar-wrapper flexNoWrap centeredBlock" @click.stop> 2445 <div 2446 class="image-toolbar-option easeFast" 2447 data-option="change" 2448 style="border-right:1px solid #fff;" 2449 @click.stop="change_image(image)" 2450 >Change</div> 2451 <div 2452 class="image-toolbar-option easeFast" 2453 data-option="update" 2454 style="border-right:1px solid #fff;" 2455 @click.stop="prepThenDisplayImageSettings(image)" 2456 >Update</div> 2457 <div 2458 class="image-toolbar-option easeFast" 2459 data-option="remove" 2460 @click.stop="tapped_remove_image(image)" 2461 ><i class="fa-regular fa-trash" style="font-size:14px;"></i></div> 2462 </div> 2463 <div v-if="formatting_obj.caption_align !== 'under'" class="image-text-wrapper" :style="{ alignItems: text_position === 'start' ? 'flex-start' : text_position === 'center' ? 'center' : 'flex-end' }"> 2464 <h2 2465 v-if="image.caption" 2466 class="image-grid-image-caption" 2467 :style="{ 'font-size': caption_size + 'px', 'font-weight': caption_font_weight }" 2468 >{{ image.caption }}</h2> 2469 <p 2470 v-if="image.subtext" 2471 class="image-grid-image-caption marginTopFour" 2472 :style="{ 'font-size': subtext_size + 'px', 'font-weight': caption_font_weight }" 2473 >{{ image.subtext }}</p> 2474 <p 2475 v-if="image.link_label" 2476 class="image-grid-image-caption marginTopFour" 2477 style="text-decoration: underline;" 2478 :style="{ 'font-size': link_label_size + 'px', 'font-weight': caption_font_weight }" 2479 >{{ image.link_label }}</p> 2480 </div> 2481 </div> 2482 <div v-if="!is_admin" class="image-grid-image-overlay image-grid-carousel-live-overlay flexWrap easeFast pointer" style="left:0px;right:0px;bottom:0px;top:0px" :style="{ 'background-color': returnRGBAStringForImageGridImageOverlayOpacity( image_grid_opacity ), 'border-radius': image_grid_border_radius + 'px' }" @click.stop="clicked_carousel_image(image, $event)"> 2483 <div v-if="formatting_obj.caption_align !== 'under'" class="image-text-wrapper" :style="{ alignItems: text_position === 'start' ? 'flex-start' : text_position === 'center' ? 'center' : 'flex-end' }"> 2484 <h2 2485 v-if="image.caption" 2486 class="image-grid-image-caption" 2487 :style="{ 'font-size': caption_size + 'px', 'font-weight': caption_font_weight }" 2488 >{{ image.caption }}</h2> 2489 <p 2490 v-if="image.subtext" 2491 class="image-grid-image-caption marginTopFour" 2492 :style="{ 'font-size': subtext_size + 'px', 'font-weight': caption_font_weight }" 2493 >{{ image.subtext }}</p> 2494 <p 2495 v-if="image.link_label" 2496 class="image-grid-image-caption marginTopFour" 2497 style="text-decoration: underline;" 2498 :style="{ 'font-size': link_label_size + 'px', 'font-weight': caption_font_weight }" 2499 >{{ image.link_label }}</p> 2500 </div> 2501 <a 2502 v-if="return_link_href(image.link_to)" 2503 class="absoluteA" 2504 :href="return_link_href(image.link_to)" 2505 rel="noopener noreferrer" 2506 ></a> 2507 </div> 2508 </div> 2509 <div 2510 v-if="formatting_obj.caption_align === 'under'" 2511 class="image-grid-card-content background" 2512 :style="{'border-radius': image_grid_border_radius + 'px' ||'0px' }" 2513 > 2514 <div class="image-text-wrapper" :style="{ alignItems: text_position === 'start' ? 'flex-start' : text_position === 'center' ? 'center' : 'flex-end' }"> 2515 <h2 2516 v-if="image.caption" 2517 class="image-grid-image-caption visible" 2518 :style="{ 'font-size': caption_size + 'px', 'font-weight': caption_font_weight }" 2519 >{{ image.caption }}</h2> 2520 <p 2521 v-if="image.subtext" 2522 class="image-grid-image-caption visible marginTopFour" 2523 :style="{ 'font-size': subtext_size + 'px', 'font-weight': caption_font_weight }" 2524 >{{ image.subtext }}</p> 2525 <p 2526 v-if="image.link_label" 2527 class="image-grid-image-caption visible marginTopFour" 2528 style="text-decoration: underline;" 2529 :style="{ 'font-size': link_label_size + 'px', 'font-weight': caption_font_weight }" 2530 > 2531 <a 2532 v-if="return_link_href(image.link_to)" 2533 :href="return_link_href(image.link_to)" 2534 rel="noopener noreferrer" 2535 >{{ image.link_label }}</a> 2536 <template v-else>{{ image.link_label }}</template> 2537 </p> 2538 </div> 2539 </div> 2540 </div> 2541 <div 2542 v-else-if="is_admin && image && image.placeholder" 2543 class="event-list-card noPadding noMargin cs-image-slot-placeholder-host pointer" 2544 :class="{ 2545 'small-card-image-grid': formatting_obj.card_size == 'small', 2546 'medium-card-image-grid': !formatting_obj.card_size || formatting_obj.card_size == 'medium', 2547 'large-card-image-grid': formatting_obj.card_size == 'large', 2548 }" 2549 :style="{'border-radius': image_grid_border_radius + 'px' ||'0px' }" 2550 @click.stop="fill_image_placeholder(image)" 2551 > 2552 <div class="image-grid-image relative"> 2553 <div class="image-grid-event-image cs-image-slot-placeholder">
2554 <span class="cs-image-slot-placeholder-icon"><i class="fa-regular fa-image"></i></span> 2555 <span class="cs-image-slot-placeholder-label">Add {{ image.label || 'image' }}</span> 2556 </div> 2557 </div> 2558 </div> 2559 </template> 2560 <!-- upload image **for admin --> 2561 <template v-if="is_admin"> 2562 <div 2563 class="event-list-card pointer" 2564 :class="{ 2565 'small-card-image-grid': formatting_obj.card_size == 'small', 2566 'medium-card-image-grid': !formatting_obj.card_size || formatting_obj.card_size == 'medium', 2567 'large-card-image-grid': formatting_obj.card_size == 'large', 2568 }" 2569 > 2570 <div class="image-grid-image relative upload-image-grid-image-zone"> 2571 <div class="image-grid-event-image"></div> 2572 <input 2573 @change="imageUploadChange($event)" 2574 class="image-grid-upload-input absoluteInput" 2575 type="file" accept="image/*,video/*" 2576 > 2577 <p class="centeredBlock" style="font-family:roboto !important;">+ Upload image/video</p> 2578 <!-- <div class="uploadImageOverlay"> 2579 <div class="uploadImageSpinner centeredBlock"></div> 2580 </div> --> 2581 </div> 2582 <div 2583 v-if="formatting_obj.caption_align === 'under'" 2584 class="image-grid-card-content" 2585 :style="{'border-radius': image_grid_border_radius + 'px' ||'0px' }" 2586 > 2587 <div class="image-text-wrapper" :style="{ alignItems: text_position === 'start' ? 'flex-start' : text_position === 'center' ? 'center' : 'flex-end' }"> 2588 <!-- PLACEHOLDER --> 2589 </div> 2590 </div> 2591 </div> 2592 </template> 2593 </div> 2594 </div> 2595 </div> 2596 </template> 2597</script>
2597 2598 2599<style> 2600 .cs-image-slot-placeholder-host { 2601 cursor: pointer; 2602 } 2603 .cs-image-slot-placeholder { 2604 display: flex; 2605 flex-direction: column; 2606 align-items: center; 2607 justify-content: center; 2608 gap: 8px; 2609 width: 100%; 2610 height: 100%; 2611 min-height: 140px; 2612 background: #f1f4f8; 2613 border: 1.5px dashed #b7c1d1; 2614 color: #5f6f8d; 2615 text-align: center; 2616 box-sizing: border-box; 2617 } 2618 .cs-image-slot-placeholder-icon { 2619 font-size: 22px; 2620 } 2621 .cs-image-slot-placeholder-label { 2622 font-family: roboto !important; 2623 font-size: 14px; 2624 font-weight: 600; 2625 } 2626 .image-grid-image.dragging{ 2627 overflow: hidden; 2628 border-radius: 10px; 2629 } 2630 .image-grid-image.dragging .image-grid-image-inner { 2631 transition: all 0.35s ease-in-out; 2632 border: 1.2px solid #fff; 2633 border-radius: 10px; 2634 z-index: 1000; 2635 transform: scale(1.1); 2636 cursor: grab; 2637 cursor: move; 2638 } 2639 .image-grid-image.dragging .image-grid-drag-handle-wrap { 2640 display: none; 2641 position: relative; 2642 } 2643 .event-list-card { 2644 overflow: hidden; 2645 background: white; 2646 font-family: 'Inter', sans-serif; 2647 display: flex; 2648 flex-direction: column; 2649 flex: 0 0 auto; 2650 transition: transform 0.1s ease; 2651 } 2652 .event-list-card.small-card-image-grid { 2653 width: 190px; 2654 } 2655 .event-list-card.medium-card-image-grid { 2656 width: 230px; 2657 } 2658 .event-list-card.large-card-image-grid { 2659 width: 300px; 2660 } 2661 .event-list-card.box-shadow-light { 2662 box-shadow: 2px 4px 8px rgba(0, 0, 0, 0.2); 2663 } 2664 .event-list-card.box-shadow-dark { 2665 box-shadow: 6px 6px 16px rgba(0, 0, 0, 0.2); 2666 } 2667 .event-list-card .image-grid-image { 2668 width: 100%; 2669 flex-basis: auto; 2670 aspect-ratio: 1 / 1; 2671 } 2672 .event-list-card .image-grid-image:before { 2673 display: none; 2674 } 2675 .event-list-card .image-grid-event-image { 2676 display: flex; 2677 text-align: center; 2678 align-items: center; 2679 width: 100%; 2680 height: 100%; 2681 background-size: cover; 2682 background-repeat: no-repeat; 2683 background-position: center; 2684 color: #fff; 2685 position: absolute; 2686 pointer-events: none; 2687 } 2688 .event-list-card .image-grid-event-image * { 2689 pointer-events: none; 2690 } 2691 .event-list-card .image-grid-image-overlay { 2692 z-index: 1; 2693 } 2694 .event-list-card .image-grid-carousel-live-overlay { 2695 pointer-events: auto; 2696 cursor: pointer; 2697 } 2698 .image-grid-card-content { 2699 height: 100px; 2700 padding: 12px 5px; 2701 } 2702 .image-grid-skeleton-tile { 2703 position: relative; 2704 background-color: #e8edf1; 2705 overflow: hidden; 2706 margin-right: 0; 2707 } 2708 .image-grid-skeleton-tile::after { 2709 background: linear-gradient(40deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0)); 2710 display: block; 2711 content: ""; 2712 position: absolute; 2713 width: 100%; 2714 height: 100%; 2715 transform: translateX(-100%); 2716 top: 0; 2717 left: 0; 2718 animation: coming_soon 0.75s infinite; 2719 } 2720 .image-grid-skeleton-caption { 2721 height: 16px; 2722 width: 70%; 2723 border-radius: 4px; 2724 margin: 8px auto 0; 2725 } 2726 .image-grid-fade-media { 2727 opacity: 0; 2728 transition: opacity 0.4s ease; 2729 } 2730 .image-grid-fade-media.is-loaded { 2731 opacity: 1; 2732 } 2733 .upload-image-grid-image-zone{ 2734 color: #323d63; 2735 text-align: center; 2736 border-radius: 2px; 2737 background-color: #f1f5f9; 2738 position: relative; 2739 display: flex; 2740 align-items: center; 2741 } 2742</style> 2743
2744<script> 2745 2746 app_content.component("image-grid-component", { 2747 template: "#template-image-grid", 2748 data() { 2749 return { 2750 global: store, 2751 loading: false, 2752 first_row_ready: false, 2753 loaded_image_ids: {}, 2754 }; 2755 }, 2756 props: { 2757 client_id: String, 2758 class_string: String, 2759 image_grid_layout: String, 2760 images_per_row: [String, Number], 2761 caption_size: String, 2762 subtext_size: String, 2763 link_label_size: String, 2764 text_position: String, 2765 caption_font_weight: String, 2766 image_grid_opacity: String, 2767 images_string: String, 2768 image_grid_border_radius: String, 2769 // carousel 2770 formatting: String, 2771 // admin 2772 is_admin: { 2773 type: Boolean, 2774 default: false, 2775 }, 2776 data_source_type: String, 2777 images_arr: Array, 2778 component: Object, 2779 last_update: Number, 2780 }, 2781 mounted() { 2782 this.preload_first_row(); 2783 this.$nextTick(() => { 2784 if (this.is_admin && this.images_arr){ 2785 this.makeElementsSortable_image_grid(); 2786 } 2787 }) 2788 }, 2789 methods: { 2790 preload_first_row(){ 2791 if( this.is_admin ){ 2792 this.first_row_ready = true; 2793 return; 2794 } 2795 let list = this.images || []; 2796 let count = this.first_row_slot_count; 2797 let slice = []; 2798 for( let i = 0; i < list.length && slice.length < count; i++ ){ 2799 let entry = list[i]; 2800 if( entry && entry.url && !entry.placeholder && !entry.is_video ){ 2801 slice.push(entry); 2802 } 2803 } 2804 if( slice.length === 0 ){ 2805 this.first_row_ready = true; 2806 this.preload_remaining_images(); 2807 return; 2808 } 2809 let remaining = slice.length; 2810 let finish = () => { 2811 this.first_row_ready = true; 2812 this.preload_remaining_images(); 2813 }; 2814 setTimeout(finish, 2500); 2815 slice.forEach((entry) => { 2816 this.preload_image_entry(entry, () => { 2817 remaining -= 1; 2818 if( remaining <= 0 ){ 2819 finish(); 2820 } 2821 }); 2822 }); 2823 }, 2824 preload_remaining_images(){ 2825 let list = this.images || []; 2826 list.forEach((entry) => { 2827 this.preload_image_entry(entry); 2828 }); 2829 }, 2830 preload_image_entry( entry, on_done ){ 2831 if( !entry || !entry.id ){ 2832 if( on_done ){ 2833 on_done(); 2834 } 2835 return; 2836 } 2837 if( entry.is_video || !entry.url || entry.placeholder ){ 2838 this.mark_image_loaded(entry.id); 2839 if( on_done ){ 2840 on_done(); 2841 } 2842 return; 2843 } 2844 if( this.loaded_image_ids[entry.id] ){ 2845 if( on_done ){ 2846 on_done(); 2847 } 2848 return; 2849 } 2850 let img = new Image(); 2851 img.onload = () => { 2852 this.mark_image_loaded(entry.id); 2853 if( on_done ){ 2854 on_done(); 2855 } 2856 }; 2857 img.onerror = () => { 2858 this.mark_image_loaded(entry.id); 2859 if( on_done ){ 2860 on_done(); 2861 } 2862 }; 2863 img.src = entry.url; 2864 },
2865 mark_image_loaded( id ){ 2866 if( !id ){ 2867 return; 2868 } 2869 let next = Object.assign({}, this.loaded_image_ids); 2870 next[id] = true; 2871 this.loaded_image_ids = next; 2872 }, 2873 image_has_loaded( image ){ 2874 if( this.is_admin ){ 2875 return true; 2876 } 2877 if( !image || !image.id ){ 2878 return false; 2879 } 2880 if( image.is_video ){ 2881 return true; 2882 } 2883 return !!this.loaded_image_ids[image.id]; 2884 }, 2885 makeElementsSortable_image_grid(){ 2886 //now create the new instance 2887 const containerSelector = `.draggable-container_image_grid_${this.component.id}`; 2888 const containers = document.querySelectorAll(containerSelector); 2889 if (containers?.length === 0) { 2890 return false; 2891 } 2892 const sortable = new Draggable.Sortable(containers, { 2893 draggable: '.image-grid-image:not(.non-draggable)', 2894 handle: '.draggable-handle', 2895 }); 2896 //now register listeners 2897 sortable.on('sortable:start', event => { 2898 var target = event.data.dragEvent.source; 2899 target.classList.add('dragging'); 2900 }); 2901 sortable.on('sortable:stop', event => { 2902 var target = event.data.dragEvent.source; 2903 target.classList.remove('dragging'); 2904 //now handle ui updates inside a timeout due to a draggable library issue (https://github.com/Shopify/draggable/issues/92) 2905 setTimeout(() => { 2906 this.handle_sortable_stop( event ); 2907 }, 0) 2908 }); 2909 }, 2910 handle_sortable_stop( event ){ 2911 let targetId = event.data.dragEvent.source.id; 2912 let dropElement = document.getElementById(targetId); 2913 var reordered_ids = Array.from(event.newContainer.children).map(event => event.id); 2914 reordered_ids.pop(); //pop off last value which is an empty string placeholder for image upload 2915 var reordered_images = []; 2916 reordered_ids.forEach(image_id => { 2917 //find the image 2918 var this_image = null; 2919 this.images.forEach(image => { 2920 if( image.id == image_id ){ 2921 this_image = image 2922 } 2923 }); 2924 if( this_image ){ 2925 reordered_images.push( this_image ); 2926 } 2927 }); 2928 //save changes 2929 save_changes_for_component( this.component, 'images', reordered_images ); 2930 }, 2931 returnRGBAStringForImageGridImageOverlayOpacity( opacity ){ 2932 if( ! opacity && opacity !== 0 ){ 2933 opacity = 40; 2934 } 2935 return "rgba(8,8,19,"+ opacity / 100 +")"; 2936 }, 2937 clicked_carousel_image( image, event ){ 2938 if( this.is_admin ){ 2939 return; 2940 } 2941 if( event && event.target && event.target.closest && event.target.closest('a') ){ 2942 return; 2943 } 2944 // An image with a link navigates instead of opening the lightbox. 2945 let href = this.return_link_href( image && image.link_to ); 2946 if( href ){ 2947 window.location.href = href; 2948 return; 2949 } 2950 this.open_carousel_lightbox( image ); 2951 }, 2952 // link_to is saved raw from the image settings input, so: 2953 // - a bare domain needs a protocol or it resolves as a relative path 2954 // - anything that isn't a usable link returns '' so callers fall back 2955 // to the lightbox instead of navigating nowhere 2956 return_link_href( link_to ){ 2957 if( ! link_to ){ 2958 return ''; 2959 } 2960 let trimmed = String( link_to ).trim(); 2961 if( /^(https?:\/\/|mailto:|tel:)\S/i.test( trimmed ) ){ 2962 return trimmed; 2963 } 2964 if( /^\//.test( trimmed ) || /^#\S/.test( trimmed ) ){ 2965 return trimmed; 2966 } 2967 if( /^[a-z0-9][a-z0-9-]*(\.[a-z0-9-]+)+([\/?#]\S*)?$/i.test( trimmed ) ){ 2968 return 'https://' + trimmed; 2969 } 2970 return ''; 2971 }, 2972 lightbox_file_name( entry ){ 2973 let url = (entry && entry.url) || ''; 2974 let from_url = url.split('?')[0].split('/').pop(); 2975 if( entry && entry.is_video ){ 2976 if( from_url && from_url.indexOf('.') !== -1 ){ 2977 return from_url; 2978 } 2979 return 'video.mp4'; 2980 } 2981 return from_url || 'photo.jpg'; 2982 }, 2983 open_carousel_lightbox( image ){ 2984 if( !store.components || !store.components.view_photo_carousel ){ 2985 return; 2986 } 2987 let photos = (this.images || []).filter((entry) => { 2988 return entry && entry.url && !entry.placeholder; 2989 }); 2990 if( photos.length === 0 ){ 2991 return; 2992 } 2993 let current_index = photos.findIndex((entry) => { 2994 return image && entry.id === image.id; 2995 }); 2996 if( current_index < 0 ){ 2997 current_index = 0; 2998 } 2999 store.components.view_photo_carousel.json_photos = photos.map((entry) => { 3000 return { 3001 id: entry.id, 3002 url: entry.url, 3003 caption: entry.caption, 3004 file_name: this.lightbox_file_name(entry), 3005 }; 3006 }); 3007 store.components.view_photo_carousel.current_index = current_index; 3008 store.components.view_photo_carousel.json_config = { 3009 context: 'web_builder', 3010 icon_only_actions: true, 3011 allow_download: false, 3012 allow_share: true, 3013 show_caption: true, 3014 caption_size: this.caption_size || 16, 3015 caption_font_weight: this.caption_font_weight || 400, 3016 blur_background: false, 3017 }; 3018 store.overlays.view_photo_carousel = true; 3019 }, 3020 confirmed_remove_image( data ){ 3021 var image = data.image; 3022 var component = data.component; 3023 //find existing images 3024 var images = component.get('config_latest').images || []; 3025 //loop through to remove this one 3026 var new_images = []; 3027 for (var i = 0; i <= images?.length - 1; i++) { 3028 if( images[i].id !== image.id ){ 3029 new_images.push( images[i] ); 3030 } 3031 } 3032 //now save and update the preview 3033 save_changes_for_component( component, 'images', new_images ); 3034 store.overlays.are_you_sure = false;
3035 }, 3036 // Agent-placed empty slot. Open the shared image-slot picker; on a 3037 // pick, write the chosen/imported URL into this slot and clear the 3038 // placeholder flag, so it renders like any uploaded tile. 3039 fill_image_placeholder( image ){ 3040 if( typeof webbuilder_open_image_slot_picker !== 'function' ){ 3041 if( typeof displayMessage === 'function' ){ 3042 displayMessage('Image picker is not loaded. Refresh the page and try again.', true, 5000); 3043 } 3044 return; 3045 } 3046 let label = (image && image.label) ? image.label : 'image'; 3047 let slot_id = image && image.id; 3048 webbuilder_open_image_slot_picker({ 3049 label: label, 3050 mode: 'placeholder', 3051 on_pick: (url) => { 3052 if( !url || !slot_id ){ 3053 return; 3054 } 3055 let current = (this.component.get('config_latest') && this.component.get('config_latest').images) || []; 3056 let next = current.map((entry) => { 3057 if( !entry || entry.id !== slot_id ){ 3058 return entry; 3059 } 3060 let updated = Object.assign({}, entry, { url: url, is_video: false }); 3061 delete updated.placeholder; 3062 return updated; 3063 }); 3064 save_changes_for_component( this.component, 'images', next ); 3065 }, 3066 on_keep_empty: () => { 3067 if( !slot_id ){ 3068 return; 3069 } 3070 let current = (this.component.get('config_latest') && this.component.get('config_latest').images) || []; 3071 let next = current.filter((entry) => entry && entry.id !== slot_id); 3072 save_changes_for_component( this.component, 'images', next ); 3073 }, 3074 }); 3075 }, 3076 // Filled tile "Change" action â swap the image via the shared picker 3077 // (mirrors custom-code's attached-slot menu: pick/paste/copy-external/ 3078 // remove). Caption/link editing stays on the existing Update toolbar. 3079 change_image( image ){ 3080 if( typeof webbuilder_open_image_slot_picker !== 'function' ){ 3081 if( typeof displayMessage === 'function' ){ 3082 displayMessage('Image picker is not loaded. Refresh the page and try again.', true, 5000); 3083 } 3084 return; 3085 } 3086 let slot_id = image && image.id; 3087 if( !slot_id ){ 3088 return; 3089 } 3090 webbuilder_open_image_slot_picker({ 3091 label: 'image', 3092 mode: 'attached', 3093 current_url: (image && image.url) || '', 3094 on_pick: (url) => { 3095 if( !url ){ 3096 return; 3097 } 3098 let current = (this.component.get('config_latest') && this.component.get('config_latest').images) || []; 3099 let next = current.map((entry) => { 3100 if( !entry || entry.id !== slot_id ){ 3101 return entry; 3102 } 3103 return Object.assign({}, entry, { url: url, is_video: false }); 3104 }); 3105 save_changes_for_component( this.component, 'images', next ); 3106 }, 3107 on_remove: () => { 3108 this.confirmed_remove_image({ image: image, component: this.component }); 3109 }, 3110 }); 3111 }, 3112 tapped_remove_image( image ){ 3113 //ask for confirmation 3114 var params = {}; 3115 params["fn"] = this.confirmed_remove_image; 3116 params["title"] = "Remove image"; 3117 params["subtext"] = "Are you sure you want to permanently remove this tile from the image grid?"; 3118 params["button_label"] = "Remove"; 3119 params["data"] = { 3120 "image": image, 3121 "component": this.component 3122 }; 3123 are_you_sure( params ); 3124 return 3125 }, 3126 prepThenDisplayImageSettings(imageObject){ 3127 store.components.image_settings_overlay.componentObject = this.component; 3128 store.components.image_settings_overlay.imageObject = imageObject; 3129 store.components.image_settings_overlay.settings = { 3130 link_input: imageObject.link_to || '', 3131 caption_input: imageObject.caption || '', 3132 subtext_input: imageObject.subtext || '', 3133 link_label_input: imageObject.link_label || '', 3134 } 3135 store.components.image_settings_overlay.image_grid_props = { 3136 client_id: this.client_id, 3137 caption_size: this.caption_size, 3138 caption_font_weight: this.caption_font_weight, 3139 };
3140 this.global.overlays.image_settings_overlay = !this.global.overlays.image_settings_overlay; 3141 this.$nextTick(() => { 3142 this.updatePositionForImageSettings( imageObject ); 3143 }); 3144 }, 3145 updatePositionForImageSettings(imageObject){ 3146 var image_object = imageObject; 3147 if( ! image_object ){ 3148 return 3149 } 3150 var element = $(".image-grid-image_" + image_object.id); 3151 if( ! element ){ 3152 return 3153 } 3154 var offset = element.offset(); 3155 var top = offset.top; 3156 var popupHeight = $('.image-settings').outerHeight(true) || 0; 3157 var above_top = top - ( popupHeight + 10 ); 3158 var viewportTop = $(window).scrollTop(); 3159 var correct_top = above_top >= viewportTop + 10 ? above_top : top + 45; 3160 store.components.image_settings_overlay.position = { 3161 top: `${correct_top}px`, 3162 left: `${offset.left}px` 3163 }; 3164 }, 3165 imageUploadChange($event){ 3166 var file = event.target.files[0]; 3167 var componentObject = this.component; 3168 if(file && componentObject){ 3169 //update the ui 3170 $(this).parents('.uploadImageZone').addClass('loading'); 3171 //set attributes 3172 var type = 'image_grid'; 3173 var timestamp = Date.now(); 3174 //check the file type 3175 var is_video = this.returnIsVideo( file['name'] ) || false; 3176 if( is_video ){ 3177 //ok, it's a video 3178 //.. handle the video 3179 var file_name = componentObject.get('client_id')+"_grid_video_"+timestamp; 3180 return uploadFileToAWSV2( file, file_name ).then(( url )=>{ 3181 this.handleUploadedImageGridImage( url, componentObject, true ); 3182 }); 3183 }else{ 3184 //assume it's an image! 3185 var file_name = componentObject.get('client_id')+"_grid_image_"+timestamp; 3186 //handle the image 3187 loadCleanImage(file, this.handleCleanImage_image_grid, type, file_name, componentObject.get('client_id') ); 3188 } 3189 } 3190 //reset input value to detect change if same file is uploaded 3191 event.target.value = ''; 3192 }, 3193 returnIsVideo(filename) { 3194 var ext = this.getExtension(filename); 3195 switch (ext.toLowerCase()) { 3196 case 'm4v': 3197 case 'avi': 3198 case 'mpg': 3199 case 'mp4': 3200 case 'mp3': 3201 case 'mov': 3202 // etc 3203 return true; 3204 } 3205 return false; 3206 }, 3207 getExtension(filename) { 3208 var parts = filename.split('.'); 3209 return parts[parts?.length - 1]; 3210 }, 3211 completed_crop(params){ 3212 const { fileType, is_video, file_name, blob, componentObject } = params; 3213 //upload it 3214 return uploadFileToAWSV2( blob, file_name ).then(( url )=>{ 3215 this.handleUploadedImageGridImage( url, componentObject, is_video ); 3216 }); 3217 }, 3218 handleCleanImage_image_grid( canvas, type, file_name, componentID, image_index, is_video, fileType ){ 3219 $('body').data('can', canvas); 3220 let componentObject = this.component; 3221 if(!fileType) { 3222 fileType = 'image/jpeg' 3223 } 3224 var url = canvas.toDataURL(fileType); 3225 var params = {}; 3226 params["url"] = url; 3227 params["fn"] = this.completed_crop; 3228 params["componentID"] = componentID; 3229 params["fileType"] = fileType; 3230 params["is_video"] = is_video || false; 3231 params["file_name"] = file_name; 3232 params["componentObject"] = componentObject; 3233 launch_image_cropper( params ); 3234 }, 3235 handleUploadedImageGridImage(url, componentObject, is_video){ 3236 if( componentObject ){ 3237 //it's still in the dom 3238 //.. update it 3239 var images = componentObject.get('config_latest')?.images || []; 3240 images.push({ 3241 "id": generateClientID(), 3242 "url": url, 3243 "is_video": is_video 3244 }); 3245 save_changes_for_component( this.component, 'images', images ); 3246 //store.eventBus.$emit('saveChangesForComponent', this.component, option, value) 3247 //message 3248 if( is_video ){ 3249 displayMessage("Video uploaded. It may take a moment for your video to appear.", false, 5000); 3250 }else{ 3251 displayMessage("Image uploaded.", false, 3000); 3252 } 3253 } 3254 }, 3255 }, 3256 computed: { 3257 images(){ 3258 if (!this.is_admin && this.images_string){ 3259 try { 3260 const parsedObject = JSON.parse(this.images_string); 3261 // Drop unfilled agent placeholders on the public site â 3262 // they are an admin-only "Add image" affordance. 3263 return Array.isArray(parsedObject) 3264 ? parsedObject.filter((entry) => entry && entry.url && !entry.placeholder) 3265 : parsedObject; 3266 } catch (error) { 3267 console.error('Error parsing JSON: ', error) 3268 } 3269 } else if (this.is_admin && this.images_arr){ 3270 return this.images_arr || []; 3271 } 3272 }, 3273 formatting_obj(){ 3274 if (this.formatting){ 3275 return JSON.parse(this.formatting); 3276 } 3277 return {}; 3278 }, 3279 first_row_slot_count(){ 3280 if( this.image_grid_layout === 'carousel' ){ 3281 let size = this.formatting_obj.card_size; 3282 if( size === 'small' ){ 3283 return 5; 3284 } 3285 if( size === 'large' ){ 3286 return 3; 3287 } 3288 return 4; 3289 } 3290 let per_row = Number(this.images_per_row); 3291 if( !per_row || per_row < 1 ){ 3292 return 3; 3293 } 3294 return per_row; 3295 }, 3296 skeleton_tile_count(){ 3297 let images = this.images; 3298 let row = this.first_row_slot_count; 3299 if( images && images.length > 0 && images.length < row ){ 3300 return images.length; 3301 } 3302 return row; 3303 }, 3304 show_first_row_skeleton(){ 3305 if( this.is_admin ){ 3306 return false;
3307 } 3308 return !this.first_row_ready; 3309 }, 3310 }, 3311 }) 3312 3313</script>
3313
3313<script type="text/html" id="template-photo-gallery"> 3314 <div class="photo-gallery-wrap centeredBlock centeredText"> 3315 <div v-if="loading" class="photo-gallery"> 3316 <div 3317 v-for="n in skeleton_tile_count" 3318 :key="'photo-gallery-skeleton-' + n" 3319 class="photo-container" 3320 :style="calculate_container_dimensions()" 3321 > 3322 <div class="photo-cover photo-gallery-skeleton-tile"></div> 3323 </div> 3324 </div> 3325 <!-- hosted site --> 3326 <div v-if="!loading && !is_admin"> 3327 <div 3328 v-if="photo_album_id && json_photos" 3329 class="photo-gallery" 3330 > 3331 <div 3332 v-for="(photo, index) in json_photos" 3333 class="photo-container" 3334 :id="photo.id" 3335 @click="clicked_gallery_photo( index )" 3336 :style="calculate_container_dimensions()" 3337 > 3338 <video v-if="photo && returnIsVideo(photo.file_name)" class="video-cover paddingTop" autoplay muted loop playsinline> 3339 <source :src="photo.url" type="video/mp4"> 3340 </video> 3341 <div 3342 v-else-if="photo && photo.url" 3343 class="photo-cover" 3344 :class="{ 'photo-cover-fade': !is_admin, 'is-loaded': photo_has_loaded(photo) }" 3345 :style="'background-image:url('+encodeURI(photo.url)+')'" 3346 ></div> 3347 <!-- overlay --> 3348 <div class="photo-overlay"></div> 3349 </div> 3350 </div> 3351 </div> 3352 <!-- admin --> 3353 <div v-if="!loading && is_admin"> 3354 <div 3355 v-if="!photo_album_id" 3356 @click="link_photo_album()" 3357 class="noObjectsMSG centeredBlock centeredText" 3358 style="padding: 5em;" 3359 > 3360 <p style="cursor: pointer; font-family: 'Roboto', 'Arial' !important;"><i class="fa-light fa-images smallMarginRight"></i>Click to link a photo album</p> 3361 </div> 3362 <div 3363 v-else-if="photo_album_id && json_photos" 3364 class="photo-gallery" 3365 > 3366 <div 3367 v-for="(photo, index) in json_photos" 3368 class="photo-container" 3369 :id="photo.id" 3370 @click="clicked_gallery_photo( index )" 3371 :style="calculate_container_dimensions()" 3372 > 3373 <video v-if="photo && returnIsVideo(photo.file_name)" class="video-cover paddingTop" autoplay muted loop playsinline> 3374 <source :src="photo.url" type="video/mp4"> 3375 </video> 3376 <div 3377 v-else-if="photo && photo.url" 3378 class="photo-cover" 3379 :class="{ 'photo-cover-fade': !is_admin, 'is-loaded': photo_has_loaded(photo) }" 3380 :style="'background-image:url('+encodeURI(photo.url)+')'" 3381 ></div> 3382 <!-- overlay --> 3383 <div class="photo-overlay"></div> 3384 </div> 3385 </div> 3386 </div> 3387 </div> 3388</script>
3388 3389 3390<style> 3391 .photo-gallery-wrap{ 3392 height: fit-content; 3393 width: 100%; 3394 } 3395 .photo-gallery{ 3396 display: flex; 3397 flex-wrap: wrap; 3398 align-items: center; 3399 justify-content: flex-start; /* center for mobile */ 3400 } 3401 .photo-container { 3402 position: relative; 3403 box-sizing: border-box; 3404 margin: 0.3em; 3405 text-align: center; 3406 } 3407 .photo-container .video-cover { 3408 width: 100%; 3409 object-fit: cover; 3410 } 3411 .photo-container > .photo-cover { 3412 height: 100%; 3413 width: inherit; 3414 display: block; 3415 overflow: hidden; 3416 margin: 0 auto; 3417 background-size: cover; 3418 background-position: center; 3419 background-repeat: no-repeat; 3420 opacity: 1; 3421 } 3422 .photo-container > .photo-cover.photo-cover-fade { 3423 opacity: 0; 3424 transition: opacity 0.4s ease; 3425 } 3426 .photo-container > .photo-cover.photo-cover-fade.is-loaded { 3427 opacity: 1; 3428 } 3429 .photo-overlay{ 3430 position: absolute; 3431 display: flex; 3432 align-items: center; 3433 justify-content: center; 3434 top: 0; 3435 left: 0; 3436 height: 100%; 3437 width: 100%; 3438 opacity: 0; 3439 background-color: rgba(8,8,19,0.3); 3440 transition: opacity 0.2s ease-out; 3441 } 3442 .photo-container:hover .photo-overlay { 3443 cursor: pointer; 3444 opacity: 0.8 !important; 3445 } 3446 .photo-gallery-skeleton-tile { 3447 position: relative; 3448 background-color: #e8edf1; 3449 overflow: hidden; 3450 margin-right: 0; 3451 height: 100%; 3452 width: 100%; 3453 border-radius: 2px; 3454 } 3455 .photo-gallery-skeleton-tile::after { 3456 background: linear-gradient(40deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0)); 3457 display: block; 3458 content: ""; 3459 position: absolute; 3460 width: 100%; 3461 height: 100%; 3462 transform: translateX(-100%); 3463 top: 0; 3464 left: 0; 3465 animation: coming_soon 0.75s infinite; 3466 } 3467</style> 3468
3469<script> 3470 3471 app_content.component("photo-gallery-component", { 3472 template: "#template-photo-gallery", 3473 data() { 3474 return { 3475 global: store, 3476 loading: true, 3477 json_photos: [], 3478 loaded_photo_ids: {}, 3479 }; 3480 }, 3481 props: { 3482 client_id: String, 3483 is_admin: Boolean, 3484 component: Object, 3485 last_update: Number, 3486 photo_album: [String, Object], 3487 show_caption: Boolean, 3488 caption_size: Number, 3489 caption_font_weight: Number, 3490 photos_per_row: Number, 3491 }, 3492 mounted() { 3493 if( !this.photo_album_id ){ 3494 this.loading = false; 3495 return; 3496 } 3497 this.load_photo_albums( this.client_id ); 3498 }, 3499 methods: { 3500 calculate_container_dimensions(){ 3501 const percentage = 100 / this.photos_per_row; 3502 const isMobile = window.innerWidth <= 600; 3503 if ( isMobile ){ 3504 return { 3505 flex: `1 1 calc(${percentage}% - 0.6em)`, 3506 maxWidth: `calc(${percentage}% - 0.6em)`, 3507 height: `calc(${100}px * (${percentage} / 100) * 2.5)`, 3508 }; 3509 } 3510 return { 3511 flex: `1 1 calc(${percentage}% - 0.6em)`, 3512 maxWidth: `calc(${percentage}% - 0.6em)`, 3513 height: `calc(${500}px * (${percentage} / 100) * 2.5)`, 3514 }; 3515 }, 3516 load_photo_albums( client_id, overrides = {} ){ 3517 this.loading = true; 3518 var promise = Parse.Promise.as(); 3519 var thisPromise = promise.then(() => { 3520 var params = {}; 3521 params["client_id"] = client_id; 3522 params["photo_album_id"] = this.photo_album_id; 3523 params["overrides"] = overrides; 3524 params["request_url"] = window.location.href; 3525 return Parse.Cloud.run('retrieve_data_for_component', params); 3526 }).then(retrieved_photos => { 3527 this.json_photos = retrieved_photos; 3528 this.preload_gallery_photos(retrieved_photos); 3529 return Parse.Promise.as(); 3530 }).then(() => { 3531 this.loading = false; 3532 }, error => { 3533 checkParseError(error); 3534 this.loading = false; 3535 }); 3536 return thisPromise; 3537 }, 3538 clicked_gallery_photo( index ){ 3539 store.components.view_photo_carousel.json_photos = this.json_photos; 3540 store.components.view_photo_carousel.current_index = index; 3541 store.components.view_photo_carousel.json_config = { 3542 context: 'web_builder', 3543 icon_only_actions: true, 3544 allow_download: false, 3545 allow_share: true, 3546 show_caption: this.show_caption || false, 3547 caption_size: this.caption_size || 16, 3548 caption_font_weight: this.caption_font_weight || 400, 3549 blur_background: false, 3550 }; 3551 store.overlays.view_photo_carousel = true; 3552 }, 3553 link_photo_album(){ 3554 store.components.link_photo_album.component = this.component; 3555 store.components.link_photo_album.last_update = this.last_update; 3556 store.overlays.link_photo_album = true; 3557 }, 3558 returnIsVideo(filename) { 3559 var ext = this.getExtension(filename); 3560 switch (ext.toLowerCase()) { 3561 case 'm4v': 3562 case 'avi': 3563 case 'mpg': 3564 case 'mp4': 3565 case 'mp3': 3566 case 'mov': 3567 // etc 3568 return true; 3569 } 3570 return false;
3571 }, 3572 getExtension(filename) { 3573 var parts = filename.split('.'); 3574 return parts[parts.length - 1]; 3575 }, 3576 preload_gallery_photos( photos ){ 3577 if( !photos || !photos.length ){ 3578 return; 3579 } 3580 photos.forEach((photo) => { 3581 if( !photo || !photo.id ){ 3582 return; 3583 } 3584 if( this.returnIsVideo(photo.file_name) || !photo.url ){ 3585 this.mark_photo_loaded(photo.id); 3586 return; 3587 } 3588 let img = new Image(); 3589 img.onload = () => { 3590 this.mark_photo_loaded(photo.id); 3591 }; 3592 img.onerror = () => { 3593 this.mark_photo_loaded(photo.id); 3594 }; 3595 img.src = photo.url; 3596 }); 3597 }, 3598 mark_photo_loaded( id ){ 3599 if( !id ){ 3600 return; 3601 } 3602 let next = Object.assign({}, this.loaded_photo_ids); 3603 next[id] = true; 3604 this.loaded_photo_ids = next; 3605 }, 3606 photo_has_loaded( photo ){ 3607 if( this.is_admin ){ 3608 return true; 3609 } 3610 if( !photo || !photo.id ){ 3611 return false; 3612 } 3613 if( this.returnIsVideo(photo.file_name) ){ 3614 return true; 3615 } 3616 return !!this.loaded_photo_ids[photo.id]; 3617 }, 3618 }, 3619 computed: { 3620 photo_album_obj(){ 3621 if (this.photo_album && typeof this.photo_album === 'string'){ 3622 return JSON.parse(unescape(decodeURIComponent(this.photo_album))); 3623 } else if (this.photo_album){ 3624 return this.photo_album; 3625 } 3626 return; 3627 }, 3628 photo_album_id(){ 3629 let album = this.photo_album_obj; 3630 if( !album ){ 3631 return null; 3632 } 3633 return album.id || null; 3634 }, 3635 skeleton_tile_count(){ 3636 let per_row = Number(this.photos_per_row); 3637 if( !per_row || per_row < 1 ){ 3638 return 4; 3639 } 3640 return per_row; 3641 }, 3642 photo_album_name(){ 3643 return this.photo_album_obj.name || null; 3644 }, 3645 }, 3646 watch: { 3647 last_update(newVal, oldVal){ 3648 if (newVal !== oldVal){ 3649 this.load_photo_albums( this.client_id ); 3650 } 3651 } 3652 } 3653 }) 3654 3655</script>
3655
3655<script type="text/html" id="template-membership-calendar"> 3656 <!-- calendar component --> 3657 <div v-if="current_date"class="lightBorderTop hundop"> 3658 <calendar-booking-component 3659 ref="bookingCalendar" 3660 :is_admin="false" 3661 :rerender_key="rerender_key" 3662 :calendar_type="'web_component'" 3663 :default_calendar_tags="formatted_event_types" 3664 :selected_tags="selected_tags" 3665 :events="filtered_events" 3666 :view_preference="view_preference" 3667 :current_date="current_date" 3668 :calendar_tags="calendar_tags" 3669 :timezone="timezone" 3670 :height="'initial'" 3671 :filter_logic="filter_logic" 3672 :loading_parent="loading" 3673 :ui_tag_ids_default="ui_tag_ids_default" 3674 :ui_tag_ids_custom="ui_tag_ids_custom" 3675 :disable_filtering="!allow_user_filtering" 3676 @change_date="change_date" 3677 @change_view_preference="change_view_preference" 3678 @clicked_default_tag="clicked_default_tag" 3679 @clicked_calendar_tag="clicked_calendar_tag" 3680 @clicked_event="clicked_event" 3681 @clicked_show_more_events="clicked_show_more_events" 3682 @zoom_in_to_date="zoom_in_to_date" 3683 @add_event="clicked_day_cell" 3684 @isolate_default_tag="isolate_default_tag" 3685 @select_all_default_tags="select_all_default_tags" 3686 @clear_all_default_tags="clear_all_default_tags" 3687 @select_visible_default_tags="select_visible_default_tags" 3688 @clear_visible_default_tags="clear_visible_default_tags" 3689 @isolate_calendar_tag="isolate_calendar_tag" 3690 @select_all_custom_tags="select_all_custom_tags" 3691 @clear_all_custom_tags="clear_all_custom_tags" 3692 @select_visible_custom_tags="select_visible_custom_tags" 3693 @clear_visible_custom_tags="clear_visible_custom_tags" 3694 @unselect_all_custom_tags="unselect_all_custom_tags" 3695 /> 3696 </div> 3697</script>
3697 3698
3699<script> 3700 /* ----- STORE ----- */ 3701 store.components['membership_cal'] = { 3702 clubId: null, 3703 config_obj: {}, 3704 // new 3705 rerender_key: 0, 3706 current_date: null, 3707 view_preference: "Month", 3708 events: [], 3709 loading: { 3710 calendar: false, 3711 calendar_tags: false, 3712 ui_refresh: false, 3713 }, 3714 ui_tag_ids_default: {}, //used to track which tags are selected in the UI for default tags 3715 ui_tag_ids_custom: {}, 3716 selected_tags: { 3717 default: [], 3718 custom: [], 3719 }, 3720 calendar_tags: [], 3721 }; 3722 /* ----- COMPONENT ----- */ 3723 let membership_calendar_config = { 3724 template: '#template-membership-calendar', 3725 data() { 3726 return store.components['membership_cal']; 3727 }, 3728 props: { 3729 config: String, 3730 client_id: String, 3731 class_string: String, 3732 style_string: String, 3733 //admin 3734 component: Object, 3735 last_update: Number, 3736 tz: String, 3737 }, 3738 mounted() { 3739 if (this.config){ 3740 try { 3741 let raw_config = this.config; 3742 try { 3743 raw_config = decodeURIComponent(raw_config); 3744 } catch (decode_error) { 3745 // Already plain JSON from older markup. 3746 } 3747 this.config_obj = JSON.parse(raw_config); 3748 } catch (parse_error) { 3749 this.config_obj = {}; 3750 } 3751 } 3752 this.clubId = $('body').data('club-id'); 3753 // this.load_membership_cal(this.client_id); 3754 // Use component prop to determine if admin flow 3755 this.is_admin = !!this.component; 3756 // this.checkThenHandleEventTagsForCalendar(this.client_id); 3757 // //eventBus for overflow-overlay 3758 // store.eventBus.$on('clickSchedule_membership_cal', (event) => { 3759 // this.clickSchedule(event) 3760 // }) 3761 3762 //******* NEW CALENDAR BELOW *******// 3763 if(!this.current_date){ 3764 this.current_date = moment.tz( this.timezone ); 3765 } 3766 this.load_calendar(); 3767 this.set_tags(); 3768 // this.handle_filter_options(); 3769 this.load_event_tags(); 3770 this.get_timezone(); 3771 }, 3772 methods: { 3773 get_timezone(){ 3774 var promise = Parse.Promise.as().then(() => { 3775 var query = new Parse.Query("clubs"); 3776 query.select("timezone"); 3777 return query.get(this.clubId); 3778 }).then((clubObj) => { 3779 if(clubObj){ 3780 store.clubObject = clubObj; 3781 } 3782 return Parse.Promise.as(); 3783 }).catch((error) => { 3784 checkParseError(error); 3785 displayMessage("An error occurred while loading the club timezone: " + error.message, true, 5000); 3786 }); 3787 }, 3788 load_calendar(){ 3789 this.loading.calendar = true; 3790 var promise = Parse.Promise.as(); 3791 var events = []; 3792 var camps = []; 3793 var regattas = []; 3794 var group_classes = []; 3795 var recurring_events = []; 3796 var thisPromise = promise.then(() => { 3797 var promises = []; 3798 //if admin configured filter_tag_ids, we need to load all event types 3799 //so we can match events that have those tags regardless of type 3800 var should_load_all_types = this.filter_tag_ids && this.filter_tag_ids.length > 0; 3801 //social events 3802 var primary_promise = promise.then(()=>{ 3803 //see if we want these event types 3804 if( ! this.event_types.includes('socials') && ! should_load_all_types ){ 3805 //not retrieving social events 3806 return Promise.resolve({ 3807 formatted_events: [], 3808 recurring_events: [], 3809 }); 3810 } 3811 var params = {}; 3812 var { startTimestamp, endTimestamp } = this.getCalendarRange(); 3813 params["club_id"] = $('body').data('club-id'); 3814 params["start_timestamp"] = startTimestamp; 3815 params["end_timestamp"] = endTimestamp; 3816 params["flow"] = "member_portal"; 3817 params["is_admin"] = false;
3818 return Parse.Cloud.run('retrieve_social_events_for_calendar_v2', params); 3819 }).then((retrievedEvents) => { 3820 events = retrievedEvents.formatted_events || []; 3821 recurring_events = retrievedEvents.recurring_events || []; 3822 return Parse.Promise.as(); 3823 }); 3824 promises.push(primary_promise); 3825 // load camps 3826 var camps_promise = promise.then(()=>{ 3827 //see if we want these event types 3828 if( ! this.event_types.includes('camps') && ! should_load_all_types ){ 3829 //not retrieving camps 3830 return Promise.resolve([]); 3831 } 3832 var params = {}; 3833 var { startTimestamp, endTimestamp } = this.getCalendarRange(); 3834 params["club_id"] = $('body').data('club-id'); 3835 params["start_timestamp"] = startTimestamp; 3836 params["end_timestamp"] = endTimestamp; 3837 params["is_admin"] = false; 3838 return Parse.Cloud.run('retrieve_camps_for_calendar_v2', params); 3839 }).then((retrievedCamps) => { 3840 camps = retrievedCamps; 3841 return Parse.Promise.as(); 3842 }); 3843 promises.push(camps_promise); 3844 // load regattas 3845 var regattas_promise = promise.then(()=>{ 3846 //see if we want these event types 3847 if( this.hide_regattas ){ 3848 return Promise.resolve([]); 3849 } 3850 if( ! this.event_types.includes('regattas') && ! should_load_all_types ){ 3851 //not retrieving regattas 3852 return Promise.resolve([]); 3853 } 3854 var params = {}; 3855 var { startTimestamp, endTimestamp } = this.getCalendarRange(); 3856 params["club_id"] = $('body').data('club-id'); 3857 params["start_timestamp"] = startTimestamp; 3858 params["end_timestamp"] = endTimestamp; 3859 params["flow"] = "member_portal"; 3860 return Parse.Cloud.run('retrieve_regattas_for_calendar_v2', params); 3861 }).then((retrievedRegattas) => { 3862 regattas = retrievedRegattas; 3863 return Parse.Promise.as(); 3864 }); 3865 promises.push(regattas_promise); 3866 // load group classes 3867 var group_classes_promise = promise.then(()=>{ 3868 if( ! this.event_types.includes('group_classes') && ! should_load_all_types ){ 3869 return Promise.resolve([]); 3870 } 3871 var params = {}; 3872 var { startTimestamp, endTimestamp } = this.getCalendarRange(); 3873 params["club_id"] = $('body').data('club-id'); 3874 params["start_timestamp"] = startTimestamp; 3875 params["end_timestamp"] = endTimestamp; 3876 params["is_admin"] = false; 3877 return Parse.Cloud.run('retrieve_group_classes_for_calendar_v2', params); 3878 }).then((retrievedGroupClasses) => { 3879 group_classes = retrievedGroupClasses || []; 3880 return Parse.Promise.as(); 3881 }); 3882 promises.push(group_classes_promise); 3883 //return them in parallel 3884 return Promise.all(promises); 3885 }).then(() => { 3886 this.events = []; 3887 this.events = [ ...events, ...camps, ...regattas, ...group_classes, ...recurring_events ]; 3888 this.loading.calendar = false;
3889 }).catch((error) => { 3890 checkParseError(error); 3891 displayMessage("An error occurred while loading the calendar: " + error.message, true, 5000); 3892 this.loading.calendar = false; 3893 this.events = []; 3894 }); 3895 return thisPromise; 3896 }, 3897 load_event_tags(){ 3898 this.loading.calendar_tags = true; 3899 var promise = Parse.Promise.as(); 3900 var thisPromise = promise.then(() => { 3901 var query = new Parse.Query("event_tags"); 3902 query.equalTo("clubObject", { 3903 __type: "Pointer", 3904 objectId: $('body').data('club-id'), 3905 className: "clubs" 3906 }); 3907 query.equalTo("archived", false); 3908 query.notEqualTo("internal_only", true); 3909 query.limit( 1000 ); 3910 query.ascending("name"); 3911 return query.find(); 3912 }).then((retrievedTags) => { 3913 //if admin restricted to specific tags, only expose those in the user filter UI 3914 if( this.filter_tag_ids && this.filter_tag_ids.length > 0 ){ 3915 retrievedTags = retrievedTags.filter(tag => this.filter_tag_ids.indexOf(tag.id) !== -1); 3916 } 3917 this.calendar_tags = retrievedTags; 3918 //now set all of them to selected 3919 this.ui_tag_ids_custom = {};
3920 this.calendar_tags.forEach((tag) => { 3921 this.ui_tag_ids_custom[tag.id] = true; 3922 }); 3923 this.selected_tags.custom = this.calendar_tags.map(t => t.id); 3924 this.loading.calendar_tags = false; 3925 }).catch((error) => { 3926 checkParseError(error); 3927 displayMessage("An error occurred while loading calendar tags: " +error.message, true, 5000); 3928 this.loading.calendar_tags = false; 3929 this.calendar_tags = []; 3930 this.loading.calendar = false; 3931 }); 3932 return thisPromise; 3933 }, 3934 set_tags(){ 3935 //select all event types by default 3936 //options: "camp", "regatta", "social", "calendar_event" 3937 this.selected_tags.default = []; 3938 this.selected_tags.custom = []; 3939 let selected = []; 3940 //check what event types are available 3941 let event_types = this.event_types || []; 3942 if( event_types.indexOf('camps') !== -1 ) { 3943 selected.push('camp'); 3944 } 3945 if( event_types.indexOf('regattas') !== -1 ) { 3946 selected.push('regatta'); 3947 } 3948 if( event_types.indexOf('socials') !== -1 ) { 3949 selected.push('social'); 3950 selected.push('calendar_event'); 3951 } 3952 if( event_types.indexOf('group_classes') !== -1 ) { 3953 selected.push('group_class'); 3954 } 3955 this.selected_tags.default = selected; 3956 //and update the ui tags 3957 this.ui_tag_ids_default = {}; 3958 this.selected_tags.default.forEach((id) => { 3959 this.ui_tag_ids_default[id] = true; 3960 }); 3961 }, 3962 getCalendarRange() { 3963 let startTimestamp, endTimestamp; 3964 if (this.view_preference === "Month") { 3965 // Start and end of the previous and next months 3966 startTimestamp = moment(this.current_date).startOf("month").subtract(1, "month").valueOf(); 3967 endTimestamp = moment(this.current_date).endOf("month").add(1, "month").valueOf(); 3968 } else if (this.view_preference === "Week") { 3969 // Start and end of the previous and next 2 weeks 3970 startTimestamp = moment(this.current_date).startOf("isoWeek").subtract(2, "week").valueOf(); 3971 endTimestamp = moment(this.current_date).endOf("isoWeek").add(2, "week").valueOf(); 3972 } else if (this.view_preference === "Day") { 3973 // Start and end of the previous and next 2 weeks 3974 //.. important to do a wide enough range so that multi-day events are included 3975 startTimestamp = moment(this.current_date).startOf("isoWeek").subtract(2, "week").valueOf(); 3976 endTimestamp = moment(this.current_date).endOf("isoWeek").add(2, "week").valueOf(); 3977 } 3978 3979 return { startTimestamp, endTimestamp }; 3980 }, 3981 change_date( new_date ){ 3982 this.current_date = new_date; 3983 this.load_calendar(); 3984 }, 3985 change_view_preference( new_value ){ 3986 this.view_preference = new_value; 3987 this.load_calendar(); 3988 }, 3989 clicked_day_cell(date_components){ 3990 // if its month view, and we are on mobile (width < 720), go to day view 3991 if(this.view_preference === "Month" && window.innerWidth < 720){ 3992 //set the current date to the clicked date 3993 var clicked_date = moment.tz({ 3994 year: date_components.year, 3995 month: date_components.month, 3996 date: date_components.date, 3997 }, this.timezone); 3998 this.zoom_in_to_date( clicked_date ); 3999 } 4000 }, 4001 zoom_in_to_date( date ){ 4002 this.change_view_preference( "Day" ); 4003 this.current_date = date; 4004 }, 4005 clicked_default_tag(tag){ 4006 this.loading.ui_refresh = true; 4007 //update the ui 4008 if(this.ui_tag_ids_default[tag.id] === undefined){ 4009 //if the tag is not in the ui_tag_ids_default, add it 4010 this.ui_tag_ids_default[tag.id] = true; 4011 }else{ 4012 this.ui_tag_ids_default[tag.id] = !this.ui_tag_ids_default[tag.id]; 4013 } 4014 requestAnimationFrame(() => { 4015 // second RAF helps ensure weâre past layout/paint on Chrome/Safari 4016 requestAnimationFrame(() => { 4017 const list = this.selected_tags.default; 4018 const idx = list.indexOf(tag.id); 4019 // create a new array to avoid deep reactive churn 4020 this.selected_tags.default = idx !== -1 4021 ? list.filter(t => t !== tag.id) 4022 : [...list, tag.id]; 4023 this.loading.ui_refresh = false;
4024 }); 4025 }); 4026 }, 4027 clicked_calendar_tag(tag){ 4028 this.loading.ui_refresh = true; 4029 //update the ui 4030 if(this.ui_tag_ids_custom[tag.id] === undefined){ 4031 //if the tag is not in the ui_tag_ids_custom, add it 4032 this.ui_tag_ids_custom[tag.id] = true; 4033 }else{ 4034 this.ui_tag_ids_custom[tag.id] = !this.ui_tag_ids_custom[tag.id]; 4035 } 4036 requestAnimationFrame(() => { 4037 // second RAF helps ensure weâre past layout/paint on Chrome/Safari 4038 requestAnimationFrame(() => { 4039 const list = this.selected_tags.custom; 4040 const idx = list.indexOf(tag.id); 4041 // create a new array to avoid deep reactive churn 4042 this.selected_tags.custom = idx !== -1 4043 ? list.filter(t => t !== tag.id) 4044 : [...list, tag.id]; 4045 this.loading.ui_refresh = false; 4046 }); 4047 }); 4048 }, 4049 isolate_default_tag(tag){ 4050 //set the ui check boxes 4051 this.ui_tag_ids_default = {}; 4052 this.ui_tag_ids_default[tag.id] = true; 4053 this.ui_tags_ 4054 //set the selected tags to just this one 4055 this.selected_tags.default = [tag.id]; 4056 }, 4057 select_all_default_tags(){ 4058 //set the ui check boxes 4059 this.ui_tag_ids_default = {}; 4060 var event_types = ["camp", "regatta", "social", "banquet_event", "calendar_event", "group_class"]; 4061 if(store.clubObject.get('hide_regattas')){ 4062 event_types = event_types.filter(type => type !== 'regatta'); 4063 } 4064 event_types.forEach((type) => { 4065 this.ui_tag_ids_default[type] = true; 4066 }); 4067 //set the selected tags to all default tags 4068 this.selected_tags.default = event_types; 4069 }, 4070 clear_all_default_tags(){ 4071 this.ui_tag_ids_default = {}; 4072 this.selected_tags.default = []; 4073 }, 4074 select_visible_default_tags(visibleIds){ 4075 if (!visibleIds || !visibleIds.length) return; 4076 var current = this.selected_tags.default || []; 4077 var next = current.slice(); 4078 visibleIds.forEach(function(id){ if (next.indexOf(id) === -1) next.push(id); }); 4079 this.selected_tags.default = next; 4080 next.forEach(function(id){ this.ui_tag_ids_default[id] = true; }.bind(this)); 4081 }, 4082 clear_visible_default_tags(visibleIds){ 4083 if (!visibleIds || !visibleIds.length) return; 4084 var set = {}; 4085 visibleIds.forEach(function(id){ set[id] = true; }); 4086 var current = this.selected_tags.default || []; 4087 this.selected_tags.default = current.filter(function(id){ return !set[id]; }); 4088 this.ui_tag_ids_default = {}; 4089 (this.selected_tags.default || []).forEach(function(id){ this.ui_tag_ids_default[id] = true; }.bind(this)); 4090 }, 4091 isolate_calendar_tag(tag){ 4092 //set the ui check boxes 4093 this.ui_tag_ids_custom = {}; 4094 this.ui_tag_ids_custom[tag.id] = true; 4095 //set the selected tags to just this one 4096 this.selected_tags.custom = [tag.id]; 4097 }, 4098 select_all_custom_tags(){ 4099 //set the ui check boxes 4100 this.ui_tag_ids_custom = {};
4101 this.calendar_tags.forEach((tag) => { 4102 this.ui_tag_ids_custom[tag.id] = true; 4103 }); 4104 //set the selected tags to all custom tags 4105 this.selected_tags.custom = this.calendar_tags.map((tag) => tag.id); 4106 }, 4107 unselect_all_custom_tags(){ 4108 //used for the "catch all" option 4109 //set the ui check boxes 4110 this.ui_tag_ids_custom = {}; 4111 //set the selected tags to no custom tags 4112 this.selected_tags.custom = []; 4113 }, 4114 clear_all_custom_tags(){ 4115 this.ui_tag_ids_custom = {}; 4116 this.selected_tags.custom = []; 4117 }, 4118 select_visible_custom_tags(visibleIds){ 4119 if (!visibleIds || !visibleIds.length) return; 4120 var tagIds = visibleIds.filter(function(id){ return id !== 'catch_all_custom'; }); 4121 var current = this.selected_tags.custom || []; 4122 var next = current.slice(); 4123 tagIds.forEach(function(id){ if (next.indexOf(id) === -1) next.push(id); }); 4124 this.selected_tags.custom = next; 4125 next.forEach(function(id){ this.ui_tag_ids_custom[id] = true; }.bind(this)); 4126 }, 4127 clear_visible_custom_tags(visibleIds){ 4128 if (!visibleIds || !visibleIds.length) return; 4129 var set = {}; 4130 visibleIds.forEach(function(id){ set[id] = true; }); 4131 var current = this.selected_tags.custom || []; 4132 this.selected_tags.custom = current.filter(function(id){ return !set[id]; }); 4133 this.ui_tag_ids_custom = {}; 4134 (this.selected_tags.custom || []).forEach(function(id){ this.ui_tag_ids_custom[id] = true; }.bind(this)); 4135 }, 4136 clicked_event(event){ 4137 var is_recurring_event = event.is_recurring; 4138 // open detail view 4139 if(is_recurring_event) { 4140 // if(event.external_event_url) { 4141 // //its external, just pop open the window 4142 // return window.open(event.external_event_url); 4143 // } 4144 // var params = { 4145 // event_json: event, 4146 // }; 4147 show_event_details_in_member_cal({ event_json: event }); 4148 } else if(event) { 4149 if(event.regatta_subevent) { 4150 //go to microsite 4151 var regatta_id = event.parent_regatta_id; 4152 window.open(`/regatta/${ regatta_id }`); 4153 } else if(event.type == 'regatta'){ 4154 if(event.external_regatta_url){ 4155 window.open(event.external_regatta_url); 4156 } else { 4157 window.open(`/regatta/${ event.id }`); 4158 } 4159 } else if(event.type == 'camp'){ 4160 //go to registration 4161 window.open(`/register/camp/${ event.id }/class`); 4162 } else if(event.type === 'group_class'){ 4163 var timestamp = event.ts; 4164 var dateComponents = return_date_components_v2({ 4165 timezone: this.timezone, 4166 date: new Date(timestamp) 4167 }); 4168 var dateString = dateComponents.year + '-' + (dateComponents.month + 1) + '-' + dateComponents.date; 4169 let event_json = { 4170 ...event, 4171 external_event_url: '/group-classes?date_string=' + dateString, 4172 }; 4173 let params = { 4174 event_json: event_json, 4175 }; 4176 show_event_details_in_member_cal(params); 4177 } else { 4178 // if(event.external_event_url){ 4179 // window.open(event.external_event_url); 4180 // } else 4181 if(event.type === 'social') { 4182 //not calendar only ... go to the event page 4183 window.open(`/event/${ event.id }`); 4184 } else { 4185 //calendar event 4186 var params = { 4187 event_json: event, 4188 }; 4189 show_event_details_in_member_cal( params ); 4190 } 4191 } 4192 } 4193 }, 4194 }, 4195 computed: { 4196 event_types(){ 4197 if(this.is_admin) { 4198 return this.component?.get('config_latest').event_types || []; 4199 } else { 4200 return this.config_obj.event_types || []; 4201 } 4202 }, 4203 filter_tag_ids(){ 4204 if(this.is_admin) { 4205 return this.component?.get('config_latest').filter_tag_ids || []; 4206 } else { 4207 return this.config_obj.filter_tag_ids || []; 4208 } 4209 }, 4210 allow_user_filtering(){ 4211 //defaults to true for legacy components where the field is not set 4212 var raw; 4213 if(this.is_admin) { 4214 raw = this.component?.get('config_latest').allow_user_filtering; 4215 } else { 4216 raw = this.config_obj.allow_user_filtering; 4217 } 4218 return raw !== false; 4219 }, 4220 filter_logic(){ 4221 //when admin restricts the calendar to a set of event tags, an event 4222 //should be visible if it matches the type OR has a matching tag (per 4223 //admin filter rules), so we use OR logic on the user-facing filter 4224 //to avoid hiding events that admin intends to show. 4225 if( this.filter_tag_ids && this.filter_tag_ids.length > 0 ){ 4226 return 'or'; 4227 } 4228 return 'and'; 4229 }, 4230 event_type_setting_for_event(){ 4231 //map the actual event.type strings to the toggle keys used in event_types config 4232 return { 4233 'camp': 'camps', 4234 'regatta': 'regattas', 4235 'social': 'socials', 4236 'calendar_event': 'socials', 4237 'group_class': 'group_classes', 4238 }; 4239 }, 4240 filtered_events(){ 4241 var event_types = this.event_types || []; 4242 var filter_tag_ids = this.filter_tag_ids || []; 4243 //if neither types nor tags are configured, fall back to whatever was loaded 4244 if( event_types.length === 0 && filter_tag_ids.length === 0 ){ 4245 return this.events; 4246 } 4247 var type_map = this.event_type_setting_for_event; 4248 return this.events.filter(event => { 4249 var setting_key = type_map[event.type]; 4250 var type_in_selected = setting_key && event_types.indexOf(setting_key) !== -1; 4251 if( filter_tag_ids.length === 0 ){ 4252 //no tag filter configured: only show events whose type is selected 4253 return type_in_selected; 4254 } 4255 //tags are configured: an event is shown if its type is selected 4256 //(regardless of tags applied) OR it has any of the selected tags 4257 if( type_in_selected ){ 4258 return true; 4259 } 4260 var event_tag_ids = event.event_tag_ids || []; 4261 return event_tag_ids.some(id => filter_tag_ids.indexOf(id) !== -1); 4262 }); 4263 }, 4264 formatted_event_types(){ 4265 this.rerender_key; //depend on this to force recompute when it changes 4266 var event_types = this.event_types || []; 4267 // Updated vibrant, distinct colors of similar intensity to #853fc6 4268 var options = []; 4269 var camp_option = { 4270 key: "camp", 4271 label: "Camps", 4272 id: "camp", 4273 color: "#236651" 4274 }; 4275 var social_option = { 4276 key: "social", 4277 label: "Social events", 4278 id: "social", 4279 color: "#ffa69e" 4280 }; 4281 var calendar_event_option = { 4282 key: "calendar_event", 4283 label: "Other", 4284 id: "calendar_event",
4285 color: "#e1e7ea" 4286 }; 4287 if( event_types.includes('camps') ) { 4288 options.push(camp_option); 4289 } 4290 //respect hide regattas on club object 4291 if( event_types.includes('regattas') && ! this.hide_regattas ) { 4292 var regatta_option = { 4293 key: "regatta", 4294 label: "Regattas", 4295 id: "regatta", 4296 color: "#87d4f9" 4297 }; 4298 options.push(regatta_option); 4299 } 4300 if( event_types.includes('socials') ) { 4301 options.push(social_option); 4302 options.push(calendar_event_option); 4303 } 4304 if( event_types.includes('group_classes') ) { 4305 options.push({ 4306 key: "group_class", 4307 label: "Group classes", 4308 id: "group_class", 4309 color: "#5c7c99" 4310 }); 4311 } 4312 return { 4313 title: "Type", 4314 options: options 4315 }; 4316 }, 4317 hide_regattas(){ 4318 return store.clubObject && store.clubObject.get('hide_regattas'); 4319 }, 4320 timezone(){ 4321 if(this.tz){ 4322 return this.tz; 4323 } 4324 return store.clubObject ? store.clubObject.get('timezone') : moment.tz.guess(); 4325 } 4326 4327 }, 4328 watch: { 4329 last_update(newVal, oldVal){ 4330 if (newVal !== oldVal){ 4331 if (this.config){ 4332 try { 4333 let raw_config = this.config; 4334 try { 4335 raw_config = decodeURIComponent(raw_config); 4336 } catch (decode_error) { 4337 // Already plain JSON from older markup. 4338 } 4339 this.config_obj = JSON.parse(raw_config); 4340 } catch (parse_error) { 4341 this.config_obj = {}; 4342 } 4343 } 4344 this.rerender_key = generateClientID(); //force update of event types included 4345 this.clubId = $('body').data('club-id'); 4346 this.load_calendar(); 4347 //re-filter the user-facing tag list in case admin changed filter_tag_ids 4348 this.load_event_tags(); 4349 this.is_admin = !!this.component; 4350 } 4351 } 4352 }, 4353 } 4354 app_content.component('membership-calendar-component', membership_calendar_config); 4355 app_overlays.component('membership-calendar-component', membership_calendar_config); 4356</script>
4356
4356<script type="text/html" id="template-membership-directory"> 4357 <div class="cs-background-image relative" :class="class_string" :style="style_string"> 4358 <template v-if="formatting && formatting.overlay_opacity"> 4359 <div class="cs-image-overlay" :style="{ 'opacity': formatting.overlay_opacity.toString() }"></div> 4360 </template> 4361 <div class="element-wrapper" :class="'element-wrapper_' + component_type" :style="element_style_string"> 4362 <div class="membership-directory-wrap centeredBlock centeredText" :data-client-id="client_id"> 4363 <div class="ease flexNoWrap show_when_loaded"> 4364 <div class="inputWrap searchBarWrap flexGrowOne" style="min-width:100%;"> 4365 <input class="searchBar easeFast" placeholder="Search members..." v-model="search"> 4366 </div> 4367 <div class="flexGrowOne"></div> 4368 </div> 4369 <div v-if="!loading && member_tags?.length >= 1" class="scrollable posScrollbar lightBorderTop noPaddingBottom"> 4370 <div class="flexNoWrap insert_member_tags" style="padding:15px;"> 4371 <div :class="{ 'active' : selectedTag === 'all' }" class="cs-tab-bar-option cs-tab-bar-option_member_tags cs-tab-bar-option_member_tags_all mobile-order" data-option="all" @click="handleTagSelection( 'all' )">All members</div> 4372 <template v-for="tag in member_tags" :key="tag.id"> 4373 <div :class="{ 'active' : selectedTag === tag.id }" class="cs-tab-bar-option cs-tab-bar-option_member_tags mobile-order" :data-option="tag.id" @click="handleTagSelection( tag.id )">{{ tag.get('name') }}</div> 4374 </template> 4375 </div> 4376 </div> 4377 <!-- <div class="show_when_loaded table_markup_insert"> --> 4378 <div class="lightBorderTop scrollable fff noPadding"> 4379 <table v-if="!loading && filtered_members?.length >= 1 && configurable_columns.length >= 2" class="tableInsert hundop"> 4380 <tbody> 4381 <tr class="tableHeaderRow" :class="{ 'ascending' : sortObject.order == 'ascending' }"> 4382 <template v-for="column in configurable_columns"> 4383 <th v-if="!Array.isArray(column.headerName) && !column.headerClasses.includes('sortable')" :class="column.headerClasses" :style="column.headerStyle">{{ column.headerName }}</th> 4384 <th v-if="!Array.isArray(column.headerName) && column.headerClasses.includes('sortable')" 4385 :style="column.headerStyle" 4386 :data-db-key="column['data-db-key']" 4387 @click="clicked_sort(column['data-db-key'])" 4388 :class="[column.headerClasses, { 'sorted': sortObject.key === column['data-db-key'] }]" 4389 > 4390 {{ column.headerName }} 4391 <div class="sort-arrow"></div> 4392 </th> 4393 <template v-for="headerName in column.headerName"> 4394 <th v-if="Array.isArray(column.headerName)" :class="column.headerClasses">{{ headerName }}</th> 4395 </template> 4396 </template> 4397 </tr> 4398 <!-- member rows injected here --> 4399 <template v-for="member in filtered_members" :key="member.id"> 4400 <tr 4401 v-if="check_archive_status( member )" 4402 class="tableRow pointer" 4403 :class="`member-row_${member.id || member.objectId}`" 4404 @click="clicked_member_row( member )" 4405 > 4406 <!-- photo column --> 4407 <td v-if="check_configurable_classes('column_photo')" class="lessPadding column_photo first-cell" style="padding-right:0px;"> 4408 <div v-if="member && member.get('profPicURL')" class="smallIcon noMargin noBorder" :style="{ backgroundImage: 'url(' + member.get('profPicURL') + ')' }"></div> 4409 <div v-else 4410 class="smallIcon noMargin noBorder flexWrap align-items-center" 4411 style="padding-right:0px; 4412 background-color: #f1f5fa; 4413 text-align: center; 4414 font-size: 16px;" 4415 > 4416 <i class="fa-regular fa-user"></i> 4417 </div> 4418 </td> 4419 <!-- firstName column --> 4420 <td v-if="check_configurable_classes('firstName')"> 4421 <p v-if="(member && member.get('firstName')) && !member.get('nickname')" class="capitalize">{{ member.get('firstName') }}</p> 4422 <p v-if="(member && member.get('firstName')) && member.get('nickname')" class="capitalize">{{ member.get('firstName') }} ({{ member.get('nickname') }})</p> 4423 <p v-if="member && !member.get('firstName')" class="incomplete">None</p> 4424 </td> 4425 <!-- lastName column --> 4426 <td v-if="check_configurable_classes('lastName')"> 4427 <p v-if="(member && member.get('lastName'))" class="capitalize">{{ member.get('lastName') }}</p> 4428 <p v-else class="incomplete">None</p> 4429 </td> 4430 <!-- member number column --> 4431 <td v-if="check_configurable_classes('column_member_number')" class="column_member_number"> 4432 <p v-if="(member && member.get('membershipObject')) && member.get('membershipObject').get('memberNumber')" class="uppercase">{{ member.get('membershipObject').get('memberNumber') }}</p> 4433 <p v-else class="incomplete">None</p> 4434 </td> 4435 <!-- email column --> 4436 <td v-if="check_configurable_classes('column_email')" class="column_email"> 4437 <p v-if="member && member.get('email')"><a @click.stop :href="'mailto:' + member.get('email')">{{ member.get('email')}}</a></p> 4438 <p v-else class="incomplete">None</p> 4439 </td> 4440 <!-- mobile column --> 4441 <td v-if="check_configurable_classes('column_mobile')" class="column_mobile"> 4442 <p v-if="member && member.get('mobile')"><a @click.stop :href="'tel:' + member.get('mobile')">{{ member.get('mobile')}}</a></p> 4443 <p v-else class="incomplete">None</p> 4444 </td> 4445 <!-- home phone column --> 4446 <td v-if="check_configurable_classes('column_phone_home')" class="column_phone_home"> 4447 <p v-if="member && member.get('phone_home')"><a @click.stop :href="'tel:' + member.get('phone_home')">{{ member.get('phone_home')}}</a></p> 4448 <p v-else class="incomplete">None</p> 4449 </td> 4450 <!-- member tags column --> 4451 <td v-if="check_configurable_classes('column_member_tags')" class="column_member_tags"> 4452 <div class="flexWrapLeftAlign" v-html="return_member_tags( member ) || ''"></div> 4453 </td> 4454 <!-- ghin number column --> 4455 <td v-if="check_configurable_classes('column_ghin_number')" class="column_ghin_number"> 4456 <p v-if="member && member.get('ghin_number')">{{ member.get('ghin_number') }}</p> 4457 <p v-else class="incomplete">None</p> 4458 </td> 4459 <!-- address ( street, city, state, zip) columns --> 4460 <template v-if="check_configurable_classes('column_address')"> 4461 <td class="column_address"> 4462 <p v-if="member && member.get('street')">{{ member.get('street') }}</p> 4463 <p v-else class="incomplete">None</p> 4464 </td> 4465 <td class="column_address"> 4466 <p v-if="member && member.get('city')">{{ member.get('city') }}</p> 4467 <p v-else class="incomplete">None</p> 4468 </td> 4469 <td class="column_address"> 4470 <p v-if="member && member.get('state')">{{ member.get('state') }}</p> 4471 <p v-else class="incomplete">None</p> 4472 </td> 4473 <td class="column_address"> 4474 <p v-if="member && member.get('zip')">{{ member.get('zip') }}</p> 4475 <p v-else class="incomplete">None</p> 4476 </td> 4477 </template> 4478 <!-- secondary address ( street, city, state, zip ) columns --> 4479 <template v-if="check_configurable_classes('column_address_secondary')"> 4480 <td class="column_address_secondary"> 4481 <p v-if="member && member.get('street_secondary')">{{ member.get('street_secondary') }}</p> 4482 <p v-else class="incomplete">None</p> 4483 </td> 4484 <td class="column_address_secondary"> 4485 <p v-if="member && member.get('city_secondary')">{{ member.get('city_secondary') }}</p> 4486 <p v-else class="incomplete">None</p> 4487 </td> 4488 <td class="column_address_secondary"> 4489 <p v-if="member && member.get('state_secondary')">{{ member.get('state_secondary') }}</p> 4490 <p v-else class="incomplete">None</p> 4491 </td> 4492 <td class="column_address_secondary"> 4493 <p v-if="member && member.get('zip_secondary')">{{ member.get('zip_secondary') }}</p> 4494 <p v-else class="incomplete">None</p> 4495 </td> 4496 </template> 4497 <!-- start date column --> 4498 <td v-if="check_configurable_classes('column_start_date')" class="column_start_date"> 4499 <p v-if="(member && member.get('membershipObject')) && member.get('membershipObject').get('startDate')">{{ format_utc_date( member.get('membershipObject').get('startDate') ) }}</p> 4500 <p v-else class="incomplete">None</p> 4501 </td> 4502 <!-- end date column --> 4503 <td v-if="check_configurable_classes('column_end_date')" class="column_end_date"> 4504 <p v-if="(member && member.get('membershipObject')) && member.get('membershipObject').get('endDate')">{{ format_utc_date( member.get('membershipObject').get('endDate')) }}</p> 4505 <p v-else class="incomplete">None</p> 4506 </td>
4507 <!-- renewal column --> 4508 <td v-if="check_configurable_classes('column_renewed')" class="column_renewed"> 4509 <p v-if="(member && member.get('membershipObject')) && member.get('membershipObject').get('startDate')">{{ format_utc_date( member.get('membershipObject').get('renewed') || member.get('membershipObject').get('startDate') ) }}</p> 4510 <p v-else class="incomplete">None</p> 4511 </td> 4512 <!-- category column --> 4513 <td v-if="check_configurable_classes('column_category')" class="column_category"> 4514 <p v-if="(member && member.get('membershipObject')) && member.get('membershipObject').get('tierObject')">{{ member.get('membershipObject').get('tierObject').get('name') }}</p> 4515 <p v-else class="incomplete">None</p> 4516 </td> 4517 <!-- plan column --> 4518 <td v-if="check_configurable_classes('column_plan')" class="column_plan"> 4519 <p v-if="(member && member.get('membershipObject')) && member.get('membershipObject').get('planObject')">{{ member.get('membershipObject').get('planObject').get('name') }}</p> 4520 <p v-else class="incomplete">None</p> 4521 </td> 4522 </tr> 4523 </template> 4524 </tbody> 4525 </table> 4526 </div> 4527 <!-- </div> --> 4528 <div v-if="loading" class="cardBodySpinnerWrap relative active"> 4529 <div class="cardBodySpinner centeredBlock"></div> 4530 </div> 4531 <p v-if="!loading && filtered_members.length == 0" class="noObjectsMSG withPadding">{{ return_no_results_msg() }}</p> 4532 </div> 4533 </div> 4534 </div> 4535</script>
4535 4536
4537<script> 4538 4539 function prepThenDisplayMembershipDirectoryPopup( memberObject ){ 4540 //clear stuff 4541 $('#overlay_membership-directory-popup .insert').text(''); 4542 //name 4543 var name = ''; 4544 //check type 4545 if( memberObject.className == "boats" ){ 4546 //actually a boat - we re-use the same overlay 4547 $('#overlay_membership-directory-popup .version_boat').removeClass('hidden'); 4548 $('#overlay_membership-directory-popup .version_member').addClass('hidden'); 4549 name = returnNameStringForBoat( memberObject ); 4550 //handle documents 4551 $('.section-wrap_documents').find('.inline-value').remove(); 4552 // var documents = $('.membership-directory-wrap').data('documents'); 4553 var documents = store.components.membership_directory.documents; 4554 if( documents ){ 4555 for (var i = 0; i <= documents.length - 1; i++) { 4556 if( documents[i].get('boatObject') ){ 4557 if( documents[i].get('boatObject').id == memberObject.objectId ){ 4558 var this_name = documents[i].get('name'); 4559 if( this_name && this_name.length >= 1 ){ 4560 $('#overlay_membership-directory-popup .section-wrap_documents').append('<p class="flexGrowOne inline-value tinyPaddingTop"><a class="tappedDocument" data-document-id="'+ documents[i].id +'" target="_blank" href="'+ documents[i].get('URL') +'">'+ this_name +' ></a></p>'); 4561 } 4562 } 4563 } 4564 } 4565 } 4566 }else{ 4567 //ok, it's a member 4568 $('#overlay_membership-directory-popup .version_boat').addClass('hidden'); 4569 $('#overlay_membership-directory-popup .version_member').removeClass('hidden'); 4570 //name 4571 if( memberObject.get('firstName') ){ 4572 name += memberObject.get('firstName'); 4573 if( memberObject.get('nickname') ){ 4574 name += ' ('+ memberObject.get('nickname') + ')'; 4575 } 4576 } 4577 if( memberObject.get('lastName') ){ 4578 if( name.length >= 1 ){ 4579 name += ' '; 4580 } 4581 name += memberObject.get('lastName'); 4582 } 4583 } 4584 $('#overlay_membership-directory-popup .insert_name').text(name); 4585 //prof pic 4586 var profPicURL = memberObject.get('profPicURL') || memberObject.get('imageURL'); //the or is if the "memberObject" is a boat because you clicked on a boat and are seeing boat details 4587 if( profPicURL ){ 4588 $('#overlay_membership-directory-popup .profile-picture').text(""); 4589 $('#overlay_membership-directory-popup .profile-picture').css('background-image', 'url('+profPicURL+')' ); 4590 $('#overlay_membership-directory-popup .profile-background-inner').css('background-image', 'url('+profPicURL+')' ); 4591 }else{ 4592 $('#overlay_membership-directory-popup .profile-picture').css('background-image', '' ); 4593 $('#overlay_membership-directory-popup .profile-background-inner').css('background-image', ''); 4594 if( name.length >= 1 ){ 4595 var initial = name.substr(0,1).toLowerCase(); 4596 $('#overlay_membership-directory-popup .profile-picture').text( initial ); 4597 } 4598 } 4599 //plug in data 4600 $('#overlay_membership-directory-popup .inline-value').each(function(){ 4601 var key = $(this).data('db-key'); 4602 if( key ){ 4603 var value = sanitize_string(memberObject.get(key)); 4604 if( value ){ 4605 if( key.toLowerCase().indexOf('email') >= 0 ){ 4606 //it's an email 4607 $(this).html( '<a href="mailto:'+ value +'">'+ value +'</a>' ); 4608 $(this).removeClass( 'hidden' ); 4609 }else if( key.toLowerCase().includes('mobile') || key.toLowerCase().includes('phone') ){ 4610 //it's a mobile number 4611 $(this).html( '<a href="tel:'+ returnCleanMobileNumber( value ) +'">'+ formatMobileNumber( value ) +'</a>' ); 4612 $(this).removeClass( 'hidden' ); 4613 }else if( $(this).data('prepend') ){ 4614 $(this).text( $(this).data('prepend') + " " + value ); 4615 $(this).removeClass( 'hidden' ); 4616 }else{ 4617 //not an email, mobile number, or special boat field 4618 $(this).text( value ); 4619 $(this).removeClass( 'hidden' ); 4620 } 4621 }else{ 4622 $(this).text( "" ).addClass('hidden'); 4623 $(this).data( 'date', null ); 4624 } 4625 } 4626 }); 4627 //custom fields 4628 $('.section-wrap_custom_fields').html(''); 4629 // var custom_fields = $('.membership-directory-wrap').data('custom_fields'); 4630 var custom_fields = store.components.membership_directory.custom_fields; 4631 if( custom_fields ){ 4632 //plug them in 4633 for (var i = 0; i <= custom_fields.length - 1; i++) { 4634 var response = returnResponseForCustomField( memberObject, custom_fields[i].id ); 4635 if( response ){ 4636 $('.section-wrap_custom_fields').append('<h4 class="smallBlockLetters leftText smallMarginTop">'+ custom_fields[i].get('name') +'</h4>'); 4637 $('.section-wrap_custom_fields').append('<p class="inline-value dark tinyPaddingTop">'+ response +'</p>'); 4638 } 4639 } 4640 } 4641 //family 4642 $('.section-wrap_family').find('.inline-value').remove(); 4643 var membershipObject = memberObject.get('membershipObject'); 4644 var membersArray = membershipObject.get('membersArray'); 4645 var family_members = []; 4646 if( membersArray ){ 4647 for (var i = 0; i <= membersArray.length - 1; i++) { 4648 if( membersArray[i].id != memberObject.id ){ 4649 var this_member = returnObjectByID( membersArray[i].id, store.components.membership_directory.allMembers ); 4650 if( this_member ){ 4651 family_members.push( this_member ); 4652 } 4653 } 4654 } 4655 } 4656 for (var i = 0; i <= family_members.length - 1; i++) { 4657 var this_name = ''; 4658 if( family_members[i].get('firstName') ){ 4659 this_name = family_members[i].get('firstName'); 4660 if( family_members[i].get('lastName') ){ 4661 this_name += ' '; 4662 this_name += family_members[i].get('lastName'); 4663 } 4664 } 4665 if( this_name.length >= 1 ){ 4666 $('#overlay_membership-directory-popup .section-wrap_family').append('<p class="flexGrowOne inline-value tinyPaddingTop"><a class="tappedFamilyMember" data-member-id="'+ family_members[i].id +'">'+ this_name +' ></a></p>'); 4667 } 4668 } 4669 //boats 4670 $('.section-wrap_boats').find('.inline-value').remove(); 4671 var boats_map = store.components.membership_directory.boats_map; 4672 if( boats_map ){ 4673 var these_boats = boats_map[ membershipObject.id ]; 4674 if( these_boats ){ 4675 for (var i = 0; i <= these_boats.length - 1; i++) { 4676 var this_name = returnNameStringForBoat( these_boats[i] ); 4677 if( this_name.length >= 1 ){ 4678 $('#overlay_membership-directory-popup .section-wrap_boats').append('<p class="flexGrowOne inline-value tinyPaddingTop"><a class="tappedBoat" data-boat-id="'+ these_boats[i].id +'" data-membership-id="'+ membershipObject.id +'">'+ this_name +' ></a></p>'); 4679 } 4680 } 4681 } 4682 } 4683 //continue 4684 $('#overlay_membership-directory-popup .section-wrap').each(function(){ 4685 if( $(this).find('.inline-value').not( $('.hidden') ).length >= 1 ){ 4686 $(this).removeClass('hidden'); 4687 }else{ 4688 $(this).addClass('hidden'); 4689 } 4690 }); 4691 //fade it in 4692 fadeIn('#overlay_membership-directory-popup'); 4693 } 4694 4695 function openDirectoryProfileImage( el ){ 4696 var bgImage = $(el).css('background-image'); 4697 if( !bgImage || bgImage === 'none' ) return; 4698 var url = bgImage.replace(/^url\(["']?/, '').replace(/["']?\)$/, ''); 4699 if( !url ) return; 4700 $('#directory-image-lightbox .directory-image-lightbox-img').attr('src', url); 4701 $('#directory-image-lightbox').addClass('active'); 4702 } 4703 4704 function closeDirectoryProfileImage(){ 4705 $('#directory-image-lightbox').removeClass('active'); 4706 $('#directory-image-lightbox .directory-image-lightbox-img').attr('src', ''); 4707 } 4708 4709 function returnNameStringForBoat( boatObject ){ 4710 var this_name = ''; 4711 if( boatObject.get('boatName') ){ 4712 this_name += boatObject.get('boatName'); 4713 } 4714 if( boatObject.get('sailNumber') ){ 4715 if( this_name.length >= 1 ){ 4716 this_name += " - "; 4717 } 4718 this_name += boatObject.get('sailNumber'); 4719 } 4720 if( boatObject.get('class') ){ 4721 if( this_name.length >= 1 ){ 4722 this_name += " - "; 4723 } 4724 this_name += boatObject.get('class'); 4725 } 4726 if( boatObject.get('hullNumber') ){ 4727 if( this_name.length >= 1 ){ 4728 this_name += " - "; 4729 } 4730 this_name += boatObject.get('hullNumber'); 4731 } 4732 return this_name; 4733 } 4734 4735 function returnResponseForCustomField( member, custom_field_id ){ 4736 //find the response in custom_field_responses where .get('member') == ,ember and .get('custom_field').id matches custom_field_id 4737 var all_responses = store.components.membership_directory.custom_field_responses; 4738 if(!member || !custom_field_id){ 4739 return null; 4740 } 4741 var matching_response = all_responses?.find(resp => { 4742 // Match member (Parse.Object, so check .id) 4743 const member_matches = resp.get('member')?.id === member.id; 4744 4745 // Match custom field (resp.get('custom_field') is also a Parse.Object) 4746 const field_matches = resp.get('custom_field')?.id === custom_field_id; 4747 4748 return member_matches && field_matches; 4749 }); 4750 if(matching_response){ 4751 if( matching_response.get('field_type') == 'text' || matching_response.get('field_type') == 'textarea' || matching_response.get('field_type') == 'number' ){ 4752 return matching_response.get('response') 4753 }else if (matching_response.get('field_type') == 'select'){ 4754 var selected_options = matching_response.get('selected_options'); 4755 var displayString = selected_options.map(opt => opt.option_name).join(', ');
4756 return displayString; 4757 }else if( matching_response.get('field_type') == 'radio' ){ 4758 return matching_response.get('selected_options').map(option => option.option_name) 4759 }else if( matching_response.get('field_type') == 'file_upload' ){ 4760 return matching_response.get('field_url') || null 4761 } 4762 } 4763 4764 return null; 4765 } 4766 4767 /* ----- STORE ----- */ 4768 store.components["membership_directory"] = { 4769 clubId: null, 4770 clubIds: ['8pB9LjQKoE', '2oWU1chg3P'], //for mantoloking and cambridge yc 4771 allMembers: [], 4772 member_tags: [], 4773 json: [], 4774 boats_map: [], 4775 custom_fields: [], 4776 custom_field_responses: [], 4777 documents: [], //for scira 4778 selectedTag: 'all', 4779 // configurable_columns: [], 4780 count: 0, 4781 search: '', 4782 loading: true, 4783 sortObject: { 4784 order: 'ascending', 4785 key: 'lastName' 4786 }, 4787 }; 4788 4789 /* ----- COMPONENT ----- */ 4790 app_content.component("membership-directory-component", { 4791 template: "#template-membership-directory", 4792 data() { 4793 return store.components['membership_directory'] 4794 }, 4795 props: { 4796 formatting: Object, 4797 client_id: String, 4798 class_string: String, 4799 style_string: String, 4800 element_style_string: String, 4801 component_type: String, 4802 //admin 4803 last_update: Number, 4804 }, 4805 mounted() { 4806 this.clubId = $('body').data('club-id'); 4807 this.load_membership_directory(); 4808 // this.fillColumnClasses(); 4809 }, 4810 methods: { 4811 sortByFirstName_ascending( a, b ){ 4812 var aFirstName = ''; 4813 if( a.get('firstName') ){ 4814 aFirstName += a.get('firstName').trim().toLowerCase(); 4815 } 4816 var bFirstName = ''; 4817 if( b.get('firstName') ){ 4818 bFirstName += b.get('firstName').trim().toLowerCase(); 4819 } 4820 if(aFirstName < bFirstName) { return -1; } 4821 if(aFirstName > bFirstName) { return 1; } 4822 //they had the same name 4823 return 0; 4824 }, 4825 sortByFirstName_descending( a, b ){ 4826 var aFirstName = ''; 4827 if( a.get('firstName') ){ 4828 aFirstName += a.get('firstName').trim().toLowerCase(); 4829 } 4830 var bFirstName = ''; 4831 if( b.get('firstName') ){ 4832 bFirstName += b.get('firstName').trim().toLowerCase(); 4833 } 4834 if(aFirstName > bFirstName) { return -1; } 4835 if(aFirstName < bFirstName) { return 1; } 4836 return 0; 4837 }, 4838 sortByLastName_ascending( a, b ){ 4839 var aLastName = ''; 4840 if( a.get('lastName') ){ 4841 aLastName += a.get('lastName').trim().toLowerCase(); 4842 } 4843 var bLastName = ''; 4844 if( b.get('lastName') ){ 4845 bLastName += b.get('lastName').trim().toLowerCase(); 4846 } 4847 if(aLastName < bLastName) { return -1; } 4848 if(aLastName > bLastName) { return 1; } 4849 return 0; 4850 }, 4851 sortByLastName_descending( a, b ){ 4852 var aLastName = ''; 4853 if( a.get('lastName') ){ 4854 aLastName += a.get('lastName').trim().toLowerCase(); 4855 } 4856 var bLastName = ''; 4857 if( b.get('lastName') ){ 4858 bLastName += b.get('lastName').trim().toLowerCase(); 4859 } 4860 if(aLastName > bLastName) { return -1; } 4861 if(aLastName < bLastName) { return 1; } 4862 return 0; 4863 }, 4864 sortByMemberNumber_ascending( a, b ){ 4865 var aMemberNumber = ''; 4866 if( a.get('membershipObject') && a.get('membershipObject').get('memberNumber') ){ 4867 aMemberNumber += a.get('membershipObject').get('memberNumber').trim().toLowerCase(); 4868 } 4869 var bMemberNumber = ''; 4870 if( b.get('membershipObject') && b.get('membershipObject').get('memberNumber') ){ 4871 bMemberNumber += b.get('membershipObject').get('memberNumber').trim().toLowerCase(); 4872 } 4873 if(aMemberNumber < bMemberNumber) { return -1; } 4874 if(aMemberNumber > bMemberNumber) { return 1; } 4875 return 0; 4876 }, 4877 sortByMemberNumber_descending( a, b ){ 4878 var aMemberNumber = ''; 4879 if( a.get('membershipObject') && a.get('membershipObject').get('memberNumber') ){ 4880 aMemberNumber += a.get('membershipObject').get('memberNumber').trim().toLowerCase(); 4881 } 4882 var bMemberNumber = ''; 4883 if( b.get('membershipObject') && b.get('membershipObject').get('memberNumber') ){ 4884 bMemberNumber += b.get('membershipObject').get('memberNumber').trim().toLowerCase(); 4885 } 4886 if(aMemberNumber > bMemberNumber) { return -1; } 4887 if(aMemberNumber < bMemberNumber) { return 1; } 4888 return 0; 4889 }, 4890 sortByStartDate_ascending( a, b ){ 4891 var aStartDate; 4892 if( a.get('membershipObject') && a.get('membershipObject').get('startDate') ){ 4893 aStartDate = a.get('membershipObject').get('startDate'); 4894 } 4895 var bStartDate; 4896 if( b.get('membershipObject') && b.get('membershipObject').get('startDate') ){
4897 bStartDate = b.get('membershipObject').get('startDate'); 4898 } 4899 if(aStartDate < bStartDate) { return -1; } 4900 if(aStartDate > bStartDate) { return 1; } 4901 return 0; 4902 }, 4903 sortByStartDate_descending( a, b ){ 4904 var aStartDate; 4905 if( a.get('membershipObject') && a.get('membershipObject').get('startDate') ){ 4906 aStartDate = a.get('membershipObject').get('startDate'); 4907 } 4908 var bStartDate; 4909 if( b.get('membershipObject') && b.get('membershipObject').get('startDate') ){ 4910 bStartDate = b.get('membershipObject').get('startDate'); 4911 } 4912 if(aStartDate > bStartDate) { return -1; } 4913 if(aStartDate < bStartDate) { return 1; } 4914 return 0; 4915 }, 4916 sortByRenewed_ascending( a, b ){ 4917 var aRenewed; 4918 if( a.get('membershipObject') && a.get('membershipObject').get('renewed') ){ 4919 aRenewed = a.get('membershipObject').get('renewed'); 4920 }else if( a.get('membershipObject') && a.get('membershipObject').get('startDate') ){ 4921 aRenewed = a.get('membershipObject').get('startDate'); 4922 } 4923 var bRenewed; 4924 if( b.get('membershipObject') && b.get('membershipObject').get('renewed') ){ 4925 bRenewed = b.get('membershipObject').get('renewed'); 4926 }else if( b.get('membershipObject') && b.get('membershipObject').get('startDate') ){ 4927 bRenewed = b.get('membershipObject').get('startDate'); 4928 } 4929 if(aRenewed < bRenewed) { return -1; } 4930 if(aRenewed > bRenewed) { return 1; } 4931 return 0; 4932 }, 4933 sortByRenewed_descending( a, b ){ 4934 var aRenewed; 4935 if( a.get('membershipObject') && a.get('membershipObject').get('renewed') ){ 4936 aRenewed = a.get('membershipObject').get('renewed'); 4937 }else if( a.get('membershipObject') && a.get('membershipObject').get('startDate') ){ 4938 aRenewed = a.get('membershipObject').get('startDate'); 4939 } 4940 var bRenewed; 4941 if( b.get('membershipObject') && b.get('membershipObject').get('renewed') ){ 4942 bRenewed = b.get('membershipObject').get('renewed'); 4943 }else if( b.get('membershipObject') && b.get('membershipObject').get('startDate') ){ 4944 bRenewed = b.get('membershipObject').get('startDate'); 4945 } 4946 if(aRenewed > bRenewed) { return -1; } 4947 if(aRenewed < bRenewed) { return 1; } 4948 return 0; 4949 }, 4950 sortByEndDate_ascending( a, b ){ 4951 var aEndDate; 4952 if( a.get('membershipObject') && a.get('membershipObject').get('endDate') ){ 4953 aEndDate = a.get('membershipObject').get('endDate'); 4954 } 4955 var bEndDate; 4956 if( b.get('membershipObject') && b.get('membershipObject').get('endDate') ){ 4957 bEndDate = b.get('membershipObject').get('endDate'); 4958 } 4959 if(aEndDate < bEndDate) { return -1; } 4960 if(aEndDate > bEndDate) { return 1; } 4961 return 0; 4962 }, 4963 sortByEndDate_descending( a, b ){ 4964 var aEndDate; 4965 if( a.get('membershipObject') && a.get('membershipObject').get('endDate') ){ 4966 aEndDate = a.get('membershipObject').get('endDate'); 4967 } 4968 var bEndDate; 4969 if( b.get('membershipObject') && b.get('membershipObject').get('endDate') ){ 4970 bEndDate = b.get('membershipObject').get('endDate'); 4971 } 4972 if(aEndDate > bEndDate) { return -1; } 4973 if(aEndDate < bEndDate) { return 1; } 4974 return 0; 4975 }, 4976 load_membership_directory(){ 4977 //update the ui 4978 store.components.membership_directory.loading = true; 4979 var promise = Parse.Promise.as(); 4980 var thisPromise = promise.then(() => { 4981 //get new data 4982 var params = {}; 4983 params["client_id"] = $('.membership-directory-wrap').data('client-id'); 4984 params["request_url"] = window.location.href; 4985 return Parse.Cloud.run('retrieve_data_for_component', params); 4986 }).then(result => { 4987 //vue 4988 store.components.membership_directory.member_tags = result.member_tags; 4989 store.components.membership_directory.loading = false; 4990 var json = []; 4991 var retrieved_members = result.members; 4992 for (var i = 0; i <= retrieved_members.length - 1; i++) { 4993 var boats_string = ""; 4994 var number = ""; 4995 if( retrieved_members[i].get('membershipObject') ){ 4996 number = retrieved_members[i].get('membershipObject').get('memberNumber'); 4997 } 4998 var boats_map = result.boats_map; 4999 if( boats_map && retrieved_members[i].get('membershipObject') ){ 5000 var these_boats = boats_map[ retrieved_members[i].get('membershipObject').id ]; 5001 if( these_boats ){ 5002 for (var b = these_boats.length - 1; b >= 0; b--) { 5003 if( boats_string.length >= 1 ){ 5004 boats_string += " ";
5005 } 5006 boats_string += returnNameStringForBoat( these_boats[b] ); 5007 } 5008 } 5009 } 5010 retrieved_members[i].boats_string = boats_string 5011 json.push({ 5012 "objectId": retrieved_members[i].id, 5013 "name": retrieved_members[i].get('firstName') + " " + retrieved_members[i].get('lastName'), 5014 "boats_string": boats_string, 5015 "number": number 5016 }) 5017 } 5018 //refactor for search 5019 store.components.membership_directory.allMembers = retrieved_members; 5020 //vue 5021 store.components.membership_directory.json = json; 5022 store.components.membership_directory.boats_map = result.boats_map; 5023 store.components.membership_directory.custom_fields = result.custom_fields; 5024 store.components.membership_directory.custom_field_responses = result.custom_field_responses; 5025 store.components.membership_directory.documents = result.documents; //for scira 5026 }, function(error){ 5027 checkParseError( error ); 5028 }); 5029 return thisPromise; 5030 }, 5031 /* ----- common methods ----- */ 5032 clicked_sort( key ){ 5033 if( this.sortObject.key == key ){ 5034 //already sorting by this one - flip it 5035 if( this.sortObject.order == "descending" ){ 5036 this.sortObject.order = "ascending"; 5037 }else{ 5038 this.sortObject.order = "descending"; 5039 } 5040 }else{ 5041 //not sorting by this yet 5042 this.sortObject.key = key; 5043 this.sortObject.order = "descending"; 5044 } 5045 }, 5046 /* ----- table methods ----- */ 5047 handleTagSelection( tag_id ){ 5048 this.selectedTag = tag_id; 5049 }, 5050 fillColumnClasses(){ 5051 // let configureColumns = { 5052 // 'show_photo': { 5053 // order: 1, 5054 // headerClasses: 'column_photo lessPadding', 5055 // headerStyle: 'padding-left:10px;0px', 5056 // headerName: null, 5057 // }, 5058 // 'show_member_number': { 5059 // order: 4, 5060 // headerClasses: 'column_member_number sortable', 5061 // 'data-db-key': 'memberNumber', 5062 // headerName: 'Number' 5063 // }, 5064 // 'show_email': { 5065 // order: 5, 5066 // headerClasses: 'column_email', 5067 // headerName: 'Email' 5068 // }, 5069 // 'show_mobile': { 5070 // order: 6, 5071 // headerClasses: 'column_mobile', 5072 // headerName: 'Mobile' 5073 // }, 5074 // 'show_phone_home': { 5075 // order: 7, 5076 // headerClasses: 'column_phone_home', 5077 // headerName: 'Home Phone' 5078 // }, 5079 // 'show_member_tags': { 5080 // order: 8, 5081 // headerClasses: 'column_member_tags', 5082 // headerName: 'Tags' 5083 // }, 5084 // 'show_ghin_number': { 5085 // order: 9, 5086 // headerClasses: 'column_ghin_number', 5087 // headerName: 'GHIN Number' 5088 // }, 5089 // 'show_address': { 5090 // order: 10, 5091 // headerClasses: 'column_address', 5092 // headerName: ['Street', 'City', 'State', 'Postal Code'] 5093 // }, 5094 // 'show_address_secondary': { 5095 // order: 11, 5096 // headerClasses: 'column_address_secondary', 5097 // headerName: ['Street (Secondary)', 'City (Secondary)', 'State (Secondary)', 'Postal Code (Secondary)'] 5098 // }, 5099 // 'show_start_date': { 5100 // order: 12, 5101 // headerClasses: 'sortable column_start_date', 5102 // 'data-db-key': 'startDate', 5103 // headerName: 'Start Date' 5104 // }, 5105 // 'show_end_date': { 5106 // order: 13, 5107 // headerClasses: 'sortable column_end_date', 5108 // 'data-db-key': 'endDate', 5109 // headerName: 'End Date' 5110 // }, 5111 // 'show_renewed': { 5112 // order: 14, 5113 // headerClasses: 'sortable column_renewed', 5114 // 'data-db-key': 'renewed', 5115 // headerName: 'Renewed' 5116 // }, 5117 // 'show_category': { 5118 // order: 15, 5119 // headerClasses: 'column_category', 5120 // headerName: 'Category' 5121 // }, 5122 // 'show_plan': { 5123 // order: 16, 5124 // headerClasses: 'column_plan', 5125 // headerName: 'Plan' 5126 // }, 5127 // } 5128 // let classStringArr = this.class_string.split(' '); 5129 // this.configurable_columns = classStringArr.reduce((acc, currentClass) => { 5130 // if (configureColumns[currentClass] !== undefined){ 5131 // acc.push(configureColumns[currentClass]) 5132 // } 5133 // return acc; 5134 // }, [ 5135 // { 5136 // order: 2, 5137 // headerClasses: 'sortable firstName', 5138 // 'data-db-key': 'firstName', 5139 // required: true, 5140 // headerName: 'First Name' 5141 // }, 5142 // { 5143 // order: 3, 5144 // headerClasses: 'sortable lastName', 5145 // 'data-db-key': 'lastName', 5146 // required: true, 5147 // headerName: 'Last Name' 5148 // }, 5149 // ]).sort((a, b) => { 5150 // return a.order - b.order; 5151 // }) 5152 }, 5153 check_archive_status( memberObject ){ 5154 var membershipObject = memberObject.get('membershipObject'); 5155 if(membershipObject && ! membershipObject.get('archived') && membershipObject.get('status') != 'canceled' ){ 5156 return true; 5157 } 5158 return false;
5159 }, 5160 clicked_member_row( memberObject ){ 5161 $('#overlay_membership-directory-popup').data('members', this.allMembers); 5162 $('#overlay_membership-directory-popup').data('boats_map', this.boats_map); 5163 prepThenDisplayMembershipDirectoryPopup( memberObject ); 5164 }, 5165 check_configurable_classes( className ){ 5166 for (let i = 0; i < this.configurable_columns.length; i++){ 5167 if (this.configurable_columns[i].headerClasses.includes(className)){ 5168 return true; 5169 break; 5170 } 5171 } 5172 return false; 5173 }, 5174 return_member_tags( memberObject ){ 5175 var member_tags = memberObject.get('member_tags') || []; 5176 var tag_string = ""; 5177 var tagsSituation = ''; 5178 if( member_tags.length >= 1 ){ 5179 for (var i = 0; i <= member_tags.length - 1; i++) { 5180 if( member_tags[i].className == "member_tags" ){ 5181 if( ! member_tags[i].get('archived') && ! member_tags[i].get('hidden') ){ 5182 if( tag_string.length >= 1 ){ 5183 tag_string += ','; 5184 } 5185 tag_string += member_tags[i].id; 5186 if( member_tags[i].get('hex_value') ){ 5187 //custom color 5188 tagsSituation += '<p class="table-row-tag" style="background-color:#'+ member_tags[i].get('hex_value') +';">' + member_tags[i].get('name') + '</p>'; 5189 }else{ 5190 //default color 5191 tagsSituation += '<p class="table-row-tag grey">' + member_tags[i].get('name') + '</p>'; 5192 } 5193 } 5194 } 5195 } 5196 } 5197 if( tagsSituation.length >= 1 ){ 5198 return tagsSituation; 5199 }else{ 5200 return '<p class="incomplete">None</p>' 5201 } 5202 }, 5203 format_utc_date( memberObject ){ 5204 return formatUTCDate( memberObject, true, false, true ) 5205 }, 5206 return_no_results_msg(){ 5207 if (this.clubIds.includes(this.clubId)){ 5208 return 'Search a member to get started.' //for mantoloking, cambridge yc, northport yc 5209 } else { 5210 return 'No members found.'; 5211 } 5212 }, 5213 sortMembershipDirectory( members ){ 5214 var sortObject = store.components.membership_directory.sortObject; 5215 if( ! sortObject ){ 5216 sortObject = {} 5217 } 5218 if( ! sortObject.key ){ 5219 sortObject.key = "startDate"; 5220 } 5221 if( ! sortObject.order ){ 5222 sortObject.order = "descending" 5223 } 5224 if( sortObject.key == "firstName" ){ 5225 if( sortObject.order == "ascending" ){ 5226 members = members.sort( this.sortByFirstName_ascending ); 5227 }else{ 5228 members = members.sort( this.sortByFirstName_descending ); 5229 } 5230 }else if( sortObject.key == "lastName" ){ 5231 if( sortObject.order == "ascending" ){ 5232 members = members.sort( this.sortByLastName_ascending ); 5233 }else{ 5234 members = members.sort( this.sortByLastName_descending ); 5235 } 5236 }else if( sortObject.key == "memberNumber" ){ 5237 if( sortObject.order == "ascending" ){ 5238 members = members.sort( this.sortByMemberNumber_ascending ); 5239 }else{ 5240 members = members.sort( this.sortByMemberNumber_descending ); 5241 } 5242 }else if( sortObject.key == "startDate" ){ 5243 if( sortObject.order == "ascending" ){ 5244 members = members.sort( this.sortByStartDate_ascending ); 5245 }else{ 5246 members = members.sort( this.sortByStartDate_descending ); 5247 } 5248 }else if( sortObject.key == "endDate" ){ 5249 if( sortObject.order == "ascending" ){ 5250 members = members.sort( this.sortByEndDate_ascending ); 5251 }else{ 5252 members = members.sort( this.sortByEndDate_descending ); 5253 } 5254 }else if( sortObject.key == "renewed" ){ 5255 if( sortObject.order == "ascending" ){ 5256 members = members.sort( this.sortByRenewed_ascending ); 5257 }else{ 5258 members = members.sort( this.sortByRenewed_de
5258scending ); 5259 } 5260 } 5261 return members; 5262 } 5263 }, 5264 computed: { 5265 members_json(){ 5266 let json = []; 5267 this.allMembers.forEach(member => { 5268 let this_search = ''; 5269 if( member.get('firstName') ){ 5270 this_search += ' ' + member.get('firstName').toLowerCase(); 5271 } 5272 if( member.get('lastName') ){ 5273 this_search += ' ' + member.get('lastName').toLowerCase(); 5274 } 5275 if( member['boats_string'] ){ 5276 this_search += ' ' + member['boats_string'].toLowerCase(); 5277 } 5278 if( member?.get('membershipObject')?.get('memberNumber') ){ 5279 this_search += ' ' + member?.get('membershipObject')?.get('memberNumber').toLowerCase(); 5280 } 5281 json.push({ 5282 "memberObject": member, 5283 "search": this_search, 5284 }) 5285 }); 5286 return json; 5287 }, 5288 members_matching_search(){ 5289 let val = this.search.toLowerCase(); 5290 var keys = [ 'search' ]; 5291 //sort them 5292 var these_matches = fuzzysort.go(val, this.members_json, { 5293 threshold: -10000, // Don't return matches worse than this (higher is faster) 5294 limit: 20, // Don't return more results than this (lower is faster) 5295 allowTypo: false, // Allows a snigle transpoes (false is faster) 5296 key: null, // For when targets are objects (see its example usage) 5297 keys: keys, // For when targets are objects (see its example usage) 5298 scoreFn: null, // For use with `keys` (see its example usage) 5299 }); 5300 return these_matches.map(match => match.obj.memberObject); 5301 }, 5302 filtered_members(){ 5303 let val = this.search.toLowerCase(); 5304 let filteredMembers; 5305 if (this.clubIds.includes(this.clubId)){ 5306 filteredMembers = []; //for mantoloking, cambridge yc, northport yc 5307 } else { 5308 filteredMembers = this.allMembers; 5309 } 5310 // handle search results using all members 5311 if (this.search.length > 0){ 5312 //use the new method 5313 filteredMembers = this.members_matching_search; 5314 } 5315 // handle member tag selection using filtered search results 5316 if (this.selectedTag !== 'all'){ 5317 filteredMembers = filteredMembers.filter(member => { 5318 let memberTags = member.get('member_tags') || []; 5319 for (let i = 0; i < memberTags.length; i++){ 5320 if (memberTags[i].id === this.selectedTag){ 5321 return member; 5322 break; 5323 } 5324 } 5325 }) 5326 } 5327 if (this.clubIds.includes(this.clubId) && filteredMembers.length > 20){ 5328 return this.sortMembershipDirectory( filteredMembers.slice(0, 20) ); //for mantoloking, cambridge yc, northport yc 5329 } else { 5330 return this.sortMembershipDirectory( filteredMembers ); 5331 } 5332 }, 5333 configurable_columns(){ 5334 let configureColumns = { 5335 'show_photo': { 5336 order: 1, 5337 headerClasses: 'column_photo lessPadding', 5338 headerStyle: 'padding-left:10px;0px', 5339 headerName: null, 5340 }, 5341 'show_member_number': { 5342 order: 4, 5343 headerClasses: 'column_member_number sortable', 5344 'data-db-key': 'memberNumber', 5345 headerName: 'Number' 5346 }, 5347 'show_email': { 5348 order: 5, 5349 headerClasses: 'column_email', 5350 headerName: 'Email' 5351 }, 5352 'show_mobile': { 5353 order: 6, 5354 headerClasses: 'column_mobile', 5355 headerName: 'Mobile' 5356 }, 5357 'show_phone_home': { 5358 order: 7, 5359 headerClasses: 'column_phone_home', 5360 headerName: 'Home Phone' 5361 }, 5362 'show_member_tags': { 5363 order: 8, 5364 headerClasses: 'column_member_tags', 5365 headerName: 'Tags' 5366 }, 5367 'show_ghin_number': { 5368 order: 9, 5369 headerClasses: 'column_ghin_number', 5370 headerName: 'GHIN Number' 5371 }, 5372 'show_address': { 5373 order: 10, 5374 headerClasses: 'column_address', 5375 headerName: ['Street', 'City', 'State', 'Postal Code'] 5376 }, 5377 'show_address_secondary': { 5378 order: 11, 5379 headerClasses: 'column_address_secondary', 5380 headerName: ['Street (Secondary)', 'City (Secondary)', 'State (Secondary)', 'Postal Code (Secondary)'] 5381 }, 5382 'show_start_date': { 5383 order: 12, 5384 headerClasses: 'sortable column_start_date', 5385 'data-db-key': 'startDate', 5386 headerName: 'Start Date' 5387 }, 5388 'show_end_date': { 5389 order: 13, 5390 headerClasses: 'sortable column_end_date', 5391 'data-db-key': 'endDate', 5392 headerName: 'End Date' 5393 }, 5394 'show_renewed': { 5395 order: 14, 5396 headerClasses: 'sortable column_renewed', 5397 'data-db-key': 'renewed', 5398 headerName: 'Renewed' 5399 }, 5400 'show_category': { 5401 order: 15, 5402 headerClasses: 'column_category', 5403 headerName: 'Category' 5404 }, 5405 'show_plan': { 5406 order: 16, 5407 headerClasses: 'column_plan', 5408 headerName: 'Plan' 5409 }, 5410 } 5411 let classStringArr = this.class_string.split(' '); 5412 return classStringArr.reduce((acc, currentClass) => { 5413 if (configureColumns[currentClass] !== undefined){ 5414 acc.push(configureColumns[currentClass]) 5415 } 5416 return acc; 5417 }, [ 5418 { 5419 order: 2, 5420 headerClasses: 'sortable firstName', 5421 'data-db-key': 'firstName', 5422 required: true, 5423 headerName: 'First Name' 5424 }, 5425 { 5426 order: 3, 5427 headerClasses: 'sortable lastName', 5428 'data-db-key': 'lastName', 5429 required: true, 5430 headerName: 'Last Name' 5431 }, 5432 ]).sort((a, b) => { 5433 return a.order - b.order; 5434 }) 5435 }, 5436 } 5437 }) 5438</script>
5438
5438<script type="text/html" id="template-event-list-card"> 5439 <div 5440 @click.stop="tapped_event_list_event()" 5441 class="event-row overlay-event-row flexEventOne noBorderBottom" 5442 :class="'event-row_' + event.id" 5443 :data-event-id="event.id" 5444 > 5445 <div 5446 class="event-list-card" 5447 :class="{ 5448 'small-card': formatting.card_size == 'small', 5449 'large-card': formatting.card_size == 'large', 5450 'box-shadow-light': formatting.box_shadow == 'light', 5451 'box-shadow-dark': formatting.box_shadow == 'dark', 5452 }" 5453 :style="{ 'border-radius': formatting.border_radius + 'px' || '0px' }" 5454 > 5455 <div 5456 v-if="image_data.url" 5457 class="event-image" 5458 :class="{ 5459 'small-card': formatting.card_size == 'small', 5460 'large-card': formatting.card_size == 'large', 5461 'use-contain': image_data.crop === 'contain' 5462 }" 5463 :style="{ 5464 '--event-image-url': `url(${image_data.url})`, 5465 backgroundImage: 'var(--event-image-url)', 5466 backgroundSize: image_data.crop || 'cover' 5467 }" 5468 > 5469 </div> 5470 <div 5471 v-else 5472 class="event-image" 5473 :class="{ 5474 'small-card': formatting.card_size == 'small', 5475 'large-card': formatting.card_size == 'large' 5476 }" 5477 > 5478 <p class="no-background">{{ event.get('name').charAt(0).toUpperCase() }}</p> 5479 </div> 5480 5481 <div 5482 class="card-content" 5483 :style="{ 'border-radius': formatting.border_radius + 'px' || '0px' }" 5484 > 5485 <p class="event-type">{{ event_type }}</p> 5486 <p 5487 class="event-title" 5488 :style="event_title_style" 5489 >{{ event_title }}</p> 5490 <div class="event-date-inline"> 5491 <i class="fa-regular fa-calendar" style="font-size: 0.8em;"></i> 5492 <span>{{ short_month }} {{ days }}</span> 5493 </div> 5494 <div v-if="show_time" class="event-time"> 5495 <i class="fa-regular fa-clock" style="font-size: 0.8em;"></i> 5496 <span>{{ event_time }}</span> 5497 </div> 5498 <button 5499 @click.stop 5500 v-if="button_label" 5501 class="event-button exclude-from-custom-button-styles relative background" 5502 v-html="button_label" 5503 ></button> 5504 </div> 5505 </div> 5506 </div> 5507</script>
5507 5508 5509<style> 5510 .event-list-card { 5511 width: 190px; 5512 overflow: hidden; 5513 background: white; 5514 font-family: 'Inter', sans-serif; 5515 display: flex; 5516 flex-direction: column; 5517 transition: transform 0.1s ease, background-color 0.15s ease; 5518 } 5519 .event-list-card.small-card { 5520 width: 190px; 5521 } 5522 .event-list-card.large-card { 5523 width: 230px; 5524 } 5525 .event-list-card.box-shadow-light { 5526 box-shadow: 2px 4px 8px rgba(0, 0, 0, 0.2); 5527 } 5528 .event-list-card.box-shadow-dark { 5529 box-shadow: 6px 6px 16px rgba(0, 0, 0, 0.2); 5530 } 5531 .event-list-card .event-image { 5532 display: flex; 5533 text-align: center; 5534 align-items: center; 5535 width: 100%; 5536 height: 170px; 5537 /* background-size now controlled via inline style / CSS class */ 5538 background-repeat: no-repeat; 5539 background-position: center; 5540 background-color: #77818f; 5541 color: #fff; 5542 position: relative; 5543 overflow: hidden; /* hide blurred overflow */ 5544 } 5545 /* When the image uses "contain", add a blurred cover background behind it */ 5546 .event-list-card .event-image.use-contain::before { 5547 content: ''; 5548 position: absolute; 5549 inset: 0; 5550 background-image: var(--event-image-url); 5551 background-size: cover; 5552 background-position: center; 5553 filter: blur(12px); 5554 transform: scale(1.1); /* avoid sharp edges from blur */ 5555 z-index: 0; 5556 pointer-events: none; 5557 } 5558 /* Foreground, non-blurred "contain" image layer */ 5559 .event-list-card .event-image.use-contain::after { 5560 content: ''; 5561 position: absolute; 5562 inset: 0; 5563 background-image: var(--event-image-url); 5564 background-size: contain; 5565 background-position: center; 5566 background-repeat: no-repeat; 5567 z-index: 1; 5568 pointer-events: none; 5569 } 5570 /* Ensure foreground content (initials) sits above the image layers */ 5571 .event-list-card .event-image .no-background { 5572 position: relative; 5573 z-index: 2; 5574 } 5575 .event-list-card .event-image.small-card{ 5576 height: 170px; 5577 } 5578 .event-list-card .event-image.large-card{ 5579 height: 220px; 5580 } 5581 .event-list-card .event-image .no-background { 5582 width: 100%; 5583 font-weight: 500; 5584 font-size: 5em; 5585 } 5586 .card-content { 5587 height: 140px; 5588 padding: 14px 12px; 5589 background-color: #f9f9f9; 5590 display: flex; 5591 flex-direction: column; 5592 transition: background-color 0.15s ease; 5593 } 5594 .event-row:hover .card-content { 5595 background-color: #f0f0f0; 5596 } 5597 .event-date-inline { 5598 display: flex; 5599 align-items: center; 5600 font-size: 14px; 5601 color: #000; 5602 margin-bottom: 2px; 5603 } 5604 .event-date-inline i { 5605 margin-right: 6px; 5606 } 5607 .event-list-card .event-type { 5608 font-size: 12px; 5609 text-transform: uppercase; 5610 color: #000; 5611 margin-bottom: 2px; 5612 } 5613 .event-list-card .event-title { 5614 font-size: 18px; 5615 text-transform: uppercase; 5616 margin-bottom: 8px; 5617 color: #000; 5618 font-family: serif; 5619 letter-spacing: 0.2px; 5620 } 5621 .event-list-card .event-time { 5622 display: flex; 5623 align-items: center; 5624 font-size: 14px; 5625 color: #000; 5626 margin-bottom: 12px; 5627 } 5628 .event-time i { 5629 margin-right: 6px; 5630 } 5631 .event-list-card .event-button { 5632 width: 100%; 5633 display: flex; 5634 align-items: center; 5635 justify-content: center; 5636 position: relative; 5637 z-index: 2; 5638 margin-top: auto; 5639 letter-spacing: 1px; 5640 line-height: 16px; 5641 cursor: pointer; 5642 font-size: 14px; 5643 font-weight: normal; 5644 padding: 6px 12px; 5645 text-transform: uppercase; 5646 transition: all 0.2s ease; 5647 border: 1.5px solid #000; 5648 border-radius: 2px; 5649 } 5650 .event-list-card .event-button:hover{ 5651 background-color: #000; 5652 color: white; 5653 } 5654 .res-callout{ 5655 display: flex; 5656 align-items: center; 5657 white-space: nowrap; 5658 height: fit-content; 5659 width: fit-content; 5660 padding: 3.5px; 5661 background-color: #fff; 5662 border: 1px solid #e4ebf1; 5663 border-radius: 5px; 5664 font-size: 10px; 5665 text-transform: uppercase; 5666 color: #5c6d7a; 5667 } 5668</style> 5669
5670<script> 5671 5672 var config = defineComponent({ 5673 template: "#template-event-list-card", 5674 data() { 5675 return {} 5676 }, 5677 props: { 5678 event_data: { 5679 type: Object, 5680 required: true, 5681 }, 5682 event_props: { 5683 type: Object, 5684 required: true, 5685 }, 5686 formatting: Object, 5687 }, 5688 methods: { 5689 tapped_event_list_event(){ 5690 this.$emit('tapped-event-list-event', this.event_data); 5691 }, 5692 }, 5693 computed: { 5694 short_month(){ 5695 if( this.start_date ){ 5696 return format_utc_date_v2({ 5697 "utcDate": this.start_date, 5698 "timezone": store.timezone, 5699 "formatString": "MMM" 5700 }); 5701 } 5702 return null; 5703 }, 5704 days(){ 5705 let string = ''; 5706 if( this.start_date ){ 5707 let start_components = return_date_components_v2({ 5708 "date": this.start_date, 5709 "timezone": store.timezone 5710 }); 5711 string += start_components.date; 5712 if( this.end_date && ! this.event.get('all_day') ){ 5713 let end_components = return_date_components_v2({ 5714 "date": this.end_date, 5715 "timezone": store.timezone 5716 }); 5717 if( start_components.date !== end_components.date ){ 5718 string += ' - ' + end_components.date; 5719 } 5720 } 5721 } 5722 return string; 5723 }, 5724 show_register_button(){ 5725 return this.event_props.show_register_button; 5726 }, 5727 show_time(){ 5728 return this.event_props.show_time; 5729 }, 5730 image_data(){ 5731 return this.event_props.image_data; 5732 }, 5733 start_date(){ 5734 return this.event_props.start_date; 5735 }, 5736 end_date(){ 5737 return this.event_props.end_date; 5738 }, 5739 event_title() { 5740 return this.event.get('name') || ''; 5741 }, 5742 event_title_style() { 5743 let style = {}; 5744 if( this.formatting.event_title_overflow === 'wrap' ){ 5745 style['white-space'] = 'break-spaces'; 5746 } 5747 if( this.formatting.event_title_font_size ){ 5748 style['font-size'] = this.formatting.event_title_font_size + 'px'; 5749 } 5750 return style; 5751 }, 5752 event_type(){ 5753 if( this.event.className == 'camps' ){ 5754 return 'camp'; 5755 }else if( this.event.className == 'regattas' ){ 5756 return 'regatta'; 5757 }else{ 5758 return 'event'; 5759 } 5760 }, 5761 button_label(){ 5762 let eventObject = this.event; 5763 let href_base = ''; 5764 let clubObject = store.clubObject; 5765 let web_settings = null; 5766 if( clubObject ){ 5767 web_settings = clubObject.get('web_settings'); 5768 } 5769 if( web_settings ){ 5770 if( web_settings.get('event_links') == "use_clubspot_regatta_links" && ( eventObject.className == "regattas" || eventObject.className == "subevents" ) ){ 5771 //force clubspot urls for regattas 5772 href_base += 'https://theclubspot.com'; 5773 } 5774 } 5775 var content; 5776 var show_register_button = this.show_register_button; 5777 if( eventObject.className == "regattas" || eventObject.className == "subevents" ){ 5778 if( eventObject.className == "subevents" ){ 5779 eventObject = eventObject.get('regattaObject'); 5780 } 5781 //check for external events 5782 if( eventObject.get('regatta_external') ){ 5783 //it's an external regatta - only render the external button if a URL is set; never fall back to register/results 5784 if( eventObject.get('external_regatta_url') ){ 5785 content = 'View event<a class="absoluteA" target="_blank" href="'+ eventObject.get('external_regatta_url') +'"></a>'; 5786 } 5787 }else{ 5788 //handle button status 5789 if( show_register_button ){ 5790 //register 5791 content = 'Register<a class="absoluteA" href="'+href_base+'/regatta/'+ eventObject.id +'"></a>'; 5792 }else{ 5793 //results 5794 content = 'View Results<a class="absoluteA" href="'+href_base+'/regatta/'+ eventObject.id +'/results"></a>'; 5795 } 5796 } 5797 }else if( eventObject.className == "events" ){ 5798 var registration_link; 5799 var details_link; 5800 if( eventObject.get('calendarOnly') ){ 5801 if( eventObject.get('external_event') ){ 5802 details_link = this.event_props.resolved_external_url || eventObject.get('external_event_url'); 5803 } 5804 }else{ 5805 if( eventObject.get('events_v2') ){ 5806 registration_link = '/event/' + eventObject.id; 5807 }else if( eventObject.get('external_event') && eventObject.get('external_register_link') ){ 5808 registration_link = eventObject.get('external_register_link'); 5809 }else if( eventObject.get('event_product') ){ 5810 if( eventObject.get('event_product').get('availability') && eventObject.get('event_product').get('availability').startDate ){ 5811 registration_link = "/reserve/" + eventObject.get('event_product').id + "?date=" + eventObject.get('event_product').get('availability').startDate; 5812 } 5813 } 5814 } 5815 if( registration_link && show_register_button ){ 5816 content = 'RSVP<a class="absoluteA" href="'+href_base+''+registration_link+'"></a>'; 5817 } else if( details_link && details_link.length > 0 ){ 5818 let button_label = eventObject.get('external_event_url_type') === 'dining' ? 'Reserve' : 'View details'; 5819 content = button_label + '<a class="absoluteA" target="_blank" href="'+ details_link +'"></a>'; 5820 } 5821 }else if( eventObject.className == "camps" ){ 5822 //handle button status 5823 if( show_register_button ){ 5824 //register 5825 content = 'Register<a href="'+href_base+'/register/camp/'+ eventObject.id +'/class" class="absoluteA"></a>'; 5826 } 5827 } 5828 return content; 5829 }, 5830 event(){ 5831 return this.event_data.event; 5832 }, 5833 clubObject(){ 5834 return store.clubObject; 5835 }, 5836 event_time() { 5837 if( this.show_time ){ 5838 return format_utc_date_v2({ 5839 "utcDate": this.start_date, 5840 "timezone": store.timezone, 5841 "formatString": "h:mm A" 5842 }); 5843 } 5844 return ""; 5845 }, 5846 } 5847 }) 5848 //define the component 5849 app_overlays.component("event-list-card-component", config); 5850 app_content.component("event-list-card-component", config); 5851 5852</script>
5852
5852<script type="text/html" id="template-event-list-event"> 5853 <div 5854 @click.stop="tapped_event_list_event()" 5855 class="event-row overlay-event-row flexEventOne" 5856 :class="'event-row_' + event.id" 5857 :data-event-id="event.id" 5858 > 5859 <!-- date and image wrap --> 5860 <div class="cs-signups-row-left flexNoWrapCenterAlign"> 5861 <div class="cs-signups-date-wrap relative" style="width: 55px;"> 5862 <div class="centeredBlock relative"> 5863 <div> 5864 <p class="cs-signups-date-month">{{ short_month }}</p> 5865 <p class="cs-signups-date-day">{{ days }}</p> 5866 </div> 5867 </div> 5868 </div> 5869 <div 5870 v-if="image_data.url" 5871 class="cs-signups-row-image flexWrap" 5872 :class="{ 'background-contain' : image_data.crop === 'contain', 'background-cover' : image_data.crop === 'cover'}" 5873 :style="{ backgroundImage: 'url(' + image_data.url + ')' }" 5874 > 5875 <div v-if="is_different_year" class="header-callout">{{ full_year }}</div> 5876 </div> 5877 <div 5878 v-else 5879 class="cs-signups-row-image flexWrap" 5880 > 5881 <div v-if="is_different_year" class="header-callout">{{ full_year }}</div> 5882 {{ event.get('name').charAt(0).toUpperCase() }} 5883 </div> 5884 </div> 5885 <!-- event info, links, and host wrap --> 5886 <div class="cs-signups-row-inner flexWrapLeftAlign ellipsis align-items-center"> 5887 <!-- event info --> 5888 <div class="cs-signups-name-wrap ellipsis textLeft"> 5889 <span class="flexWrap" style="display: flex; align-items: center; justify-content: flex-start; margin-bottom: 5px;"> 5890 <div class="cs-signups-name-0 textLeft" style="flex-grow: 0; margin-right: 8px;">{{ event.get('name') }}</div> 5891 </span> 5892 <div class="event-date-wrap-v2">{{ niceDate }}</div> 5893 <div class="event-date-wrap-v2 tiniestPaddingTop"> 5894 <p>{{ clubObject.get('name') }}</p> 5895 <p>{{ return_markup( event, 'event-host-2' )}}</p> 5896 </div> 5897 </div> 5898 <!-- event links --> 5899 <div @click.stop class="cs-signups-links-wrap flexGrowOne"> 5900 <div class="cs-signups-links-wrap-inner textLeft"> 5901 <div class="event-nav-1" v-html="return_markup( event, 'links' )"></div> 5902 <div class="event-nav-2" v-html="return_markup( event, 'button' )"></div> 5903 </div> 5904 </div> 5905 </div> 5906 </div> 5907</script>
5907 5908
5909<script> 5910 5911 var config = defineComponent({ 5912 template: "#template-event-list-event", 5913 data() { 5914 return {} 5915 }, 5916 props: { 5917 event_data: { 5918 type: Object, 5919 required: true, 5920 }, 5921 event_props: { 5922 type: Object, 5923 required: true, 5924 }, 5925 }, 5926 methods: { 5927 tapped_event_list_event(){ 5928 this.$emit('tapped-event-list-event', this.event_data); 5929 }, 5930 return_markup( eventObject, type ){ 5931 var href_base = ''; 5932 var clubObject = this.clubObject; 5933 var web_settings = clubObject.get('web_settings'); 5934 if( web_settings ){ 5935 if( web_settings.get('event_links') == "use_clubspot_regatta_links" && ( eventObject.className == "regattas" || eventObject.className == "subevents" ) ){ 5936 //force clubspot urls for regattas 5937 href_base += 'https://theclubspot.com'; 5938 } 5939 } 5940 // returns here 5941 if ( type === 'event-host-2' ){ 5942 var club_string = ''; 5943 var location_params = { 5944 eventObject: eventObject, 5945 hideCountry: true, 5946 hideZip: true, 5947 hideStreet: true, 5948 } 5949 var club_location_params = { 5950 eventObject: clubObject, 5951 hideCountry: true, 5952 hideZip: true, 5953 hideStreet: true, 5954 } 5955 if(formatLocation(location_params) ){ 5956 if( club_string.length >= 1 ){ 5957 club_string += ', '; 5958 } 5959 club_string += formatLocation(location_params); 5960 }else if( clubObject && formatLocation(club_location_params) ){ 5961 if( club_string.length >= 1 ){ 5962 club_string += ', '; 5963 } 5964 club_string += formatLocation(club_location_params); 5965 } 5966 return club_string; 5967 } else if ( type === 'links' ){ 5968 var content; 5969 if( eventObject.className == "regattas" || eventObject.className == "subevents" ){ 5970 //check for external events 5971 if( eventObject.get('regatta_external') ){ 5972 //it's an external regatta - only render the external link if a URL is set; never fall back to internal links 5973 if( eventObject.get('external_regatta_url') ){ 5974 content = '<p class="tiniestPaddingTop"><a target="_blank" href="'+ eventObject.get('external_regatta_url') +'">Event page</a></p>'; 5975 } 5976 }else{ 5977 if( eventObject.className == "subevents" ){ 5978 eventObject = eventObject.get('regattaObject'); 5979 } 5980 //event page 5981 content = '<p class="tiniestPaddingTop"><a href="'+href_base+'/regatta/'+ eventObject.id +'">Event page</a></p>'; 5982 //entries 5983 content += '<p class="tiniestPaddingTop"><a href="'+href_base+'/regatta/'+ eventObject.id +'/#entry-list">Entries</a></p>'; 5984 //race docs 5985 content += '<p class="tiniestPaddingTop"><a href="'+href_base+'/regatta/'+ eventObject.id +'/#notice-board">Notice board</a></p>'; 5986 } 5987 }else if( eventObject.className == "events" ){ 5988 //check for external events 5989 if( ! eventObject.get('calendarOnly') ){ 5990 if( eventObject.get('external_event') && eventObject.get('external_event_url') ){ 5991 //it's an external event 5992 content = '<p class="tiniestPaddingTop"><a target="_blank" href="'+ eventObject.get('external_event_url') +'">Event page</a></p>'; 5993 }else{ 5994 //event page 5995 content = '<p class="tiniestPaddingTop"><a href="'+href_base+'/event/'+ eventObject.id +'">Event page</a></p>'; 5996 } 5997 }else{ 5998 //calendar only event: use resolved URL per instance (recurring dining) or stored URL 5999 let external_url = this.event_props.resolved_external_url || eventObject.get('external_event_url'); 6000 if( 6001 eventObject.get('external_event') 6002 && eventObject.get('external_event_url_type') !== 'dining' 6003 && external_url 6004 ){ 6005 content = '<p class="tiniestPaddingTop"><a target="_blank" href="'+ external_url +'">View details</a></p>'; 6006 } 6007 } 6008 }else if( eventObject.className == "camps" ){ 6009 //it's a camp 6010 content = '<p class="tiniestPaddingTop"><a href="'+href_base+'/camp-registrations/'+ eventObject.id +'">Entry list</a></p>'; 6011 } 6012 return content; 6013 } else if ( type === 'button' ){ 6014 var content; 6015 var show_register_button = this.show_register_button; 6016 if( eventObject.className == "regattas" || eventObject.className == "subevents" ){ 6017 if( eventObject.className == "subevents" ){ 6018 eventObject = eventObject.get('regattaObject'); 6019 } 6020 //check for external events 6021 if( eventObject.get('regatta_external') ){ 6022 //it's an external regatta - only render the external button if a URL is set; never fall back to register/results 6023 if( eventObject.get('external_regatta_url') ){ 6024 content = '<button class="relative background event-list-inline-button exclude-from-custom-button-styles">View event<a class="absoluteA" target="_blank" href="'+ eventObject.get('external_regatta_url') +'"></a></button>'; 6025 } 6026 }else{ 6027 //handle button status 6028 if( show_register_button ){ 6029 //register 6030 content = '<button class="relative background event-list-inline-button exclude-from-custom-button-styles">Register<a class="absoluteA" href="'+href_base+'/register/regatta/'+ eventObject.id +'/class"></a></button>'; 6031 }else{ 6032 //results 6033 content = '<button class="relative background event-list-inline-button exclude-from-custom-button-styles">View Results<a class="absoluteA" href="'+href_base+'/regatta/'+ eventObject.id +'/results"></a></button>'; 6034 } 6035 } 6036 }else if( eventObject.className == "events" ){ 6037 var registration_link; 6038 var details_link; 6039 if( eventObject.get('calendarOnly') ){ 6040 if( eventObject.get('external_event') ){ 6041 details_link = this.event_props.resolved_external_url || eventObject.get('external_event_url'); 6042 } 6043 }else{ 6044 if( eventObject.get('events_v2') ){ 6045 registration_link = '/event/' + eventObject.id; 6046 }else if( eventObject.get('external_event') && eventObject.get('external_register_link') ){ 6047 registration_link = eventObject.get('external_register_link'); 6048 } 6049 } 6050 if( registration_link && show_register_button ){ 6051 content = '<button class="relative background event-list-inline-button exclude-from-custom-button-styles">RSVP<a class="absoluteA" href="'+href_base+''+registration_link+'"></a></button>'; 6052 }else if( details_link ){ 6053 if( eventObject.get('external_event_url_type') === 'dining' ){ 6054 content = '<button class="relative background event-list-inline-button exclude-from-custom-button-styles">Reserve<a class="absoluteA" target="_blank" href="'+ details_link +'"></a></button>'; 6055 }else{ 6056 content = '<button class="relative background event-list-inline-button exclude-from-custom-button-styles">View details<a class="absoluteA" target="_blank" href="'+ details_link +'"></a></button>'; 6057 } 6058 } 6059 }else if( eventObject.className == "camps" ){ 6060 //handle button status 6061 if( show_register_button ){ 6062 //register 6063 content = '<button class="relative background event-list-inline-button exclude-from-custom-button-styles">Register<a href="'+href_base+'/register/camp/'+ eventObject.id +'/class" class="absoluteA"></a></button>'; 6064 } 6065 } 6066 return content; 6067 } 6068 }, 6069 }, 6070 computed: { 6071 show_register_button(){ 6072 return this.event_props.show_register_button; 6073 }, 6074 show_time(){ 6075 return this.event_props.show_time; 6076 }, 6077 image_data(){ 6078 return this.event_props.image_data; 6079 }, 6080 start_date(){ 6081 return this.event_props.start_date; 6082 }, 6083 end_date(){ 6084 return this.event_props.end_date; 6085 }, 6086 niceDate(){ 6087 if( this.start_date ){ 6088 if( this.show_time ){ 6089 //show the time 6090 return format_utc_date_v2({ 6091 "utcDate": this.start_date, 6092 "timezone": store.timezone, 6093 "formatString": "MMM D, YYYY, h:mm A" 6094 }); 6095 }else{ 6096 //show the date only 6097 return format_utc_date_v2({ 6098 "utcDate": this.start_date, 6099 "timezone": store.timezone, 6100 "formatString": "MMM D, YYYY" 6101 }); 6102 } 6103 } 6104 return null; 6105 }, 6106 full_year(){ 6107 if( this.start_date ){ 6108 return format_utc_date_v2({ 6109 "utcDate": this.start_date, 6110 "timezone": store.timezone, 6111 "formatString": "YYYY" 6112 }); 6113 } 6114 return null; 6115 }, 6116 days(){ 6117 let string = ''; 6118 if( this.start_date ){ 6119 let start_components = return_date_components_v2({ 6120 "date": this.start_date, 6121 "timezone": store.timezone 6122 });
6123 string += start_components.date; 6124 if( this.end_date && ! this.event.get('all_day') ){ 6125 let end_components = return_date_components_v2({ 6126 "date": this.end_date, 6127 "timezone": store.timezone 6128 }); 6129 if( start_components.date !== end_components.date ){ 6130 string += ' - ' + end_components.date; 6131 } 6132 } 6133 } 6134 return string; 6135 }, 6136 short_month(){ 6137 if( this.start_date ){ 6138 return format_utc_date_v2({ 6139 "utcDate": this.start_date, 6140 "timezone": store.timezone, 6141 "formatString": "MMM" 6142 }); 6143 } 6144 return null; 6145 }, 6146 is_different_year(){ 6147 if( this.start_date ){ 6148 let start_components = return_date_components_v2({ 6149 "date": this.start_date, 6150 "timezone": store.timezone 6151 }); 6152 let now_components = return_date_components_v2({ 6153 "date": new Date(), 6154 "timezone": store.timezone 6155 }); 6156 return start_components.year !== now_components.year; 6157 } 6158 return false; 6159 }, 6160 event(){ 6161 return this.event_data.event; 6162 }, 6163 clubObject(){ 6164 return store.clubObject; 6165 }, 6166 event_time() { 6167 if( this.show_time ){ 6168 return format_utc_date_v2({ 6169 "utcDate": this.start_date, 6170 "timezone": store.timezone, 6171 "formatString": "h:mm A" 6172 }); 6173 } 6174 return ""; 6175 }, 6176 }, 6177 }) 6178 //define the component 6179 app_overlays.component("event-list-event-component", config); 6180 app_content.component("event-list-event-component", config); 6181 6182</script>
6182 6183 6184<style> 6185 .res-callout{ 6186 display: flex; /* Add this to make the container a flexbox */ 6187 align-items: center; /* Center items vertically */ 6188 white-space: nowrap; /* Prevent wrapping */ 6189 height: fit-content; 6190 width: fit-content; 6191 padding: 3.5px; 6192 background-color: #fff; 6193 border: 1px solid #e4ebf1; 6194 border-radius: 5px; 6195 font-size: 10px; 6196 text-transform: uppercase; 6197 color: #5c6d7a; 6198 } 6199</style>
6199<script type="text/html" id="template-event-list"> 6200 <div class="cs-background-image relative" :class="class_string" :style="style_string"> 6201 <div class="element-wrapper" 6202 :class="'element-wrapper_' + component_type" 6203 :style="element_style_string" 6204 style="max-width:100% !important;min-width:90%;" 6205 > 6206 <!-- list view --> 6207 <template v-if="formatting_obj.layout === 'list' || !formatting_obj.layout"> 6208 <div class="event-list-wrap centeredBlock centeredText" :class="'event-list-wrap_' + client_id" :data-client_id="client_id"> 6209 <div 6210 class="flexNoWrap align-items-center" 6211 style="padding-bottom:5px;" 6212 > 6213 <h3 class="smallMarginRight" :style="{ 'font-size': '20px !important', 'font-weight': '600 !important', 'padding': '10px' }">{{ date_filter.charAt(0).toUpperCase() + date_filter.slice(1) || 'Upcoming' }} events</h3> 6214 <div 6215 class="view-all-events-wrapper tinyMarginLeft" 6216 @click="popViewAllEventsOverlay()" 6217 > 6218 <div :style="{ 'font-size': '14px !important', 'font-weight': '400' }">View all</div> 6219 <div class="smallDrilldownHeaderBackButton flip"></div> 6220 </div> 6221 </div> 6222 <div class="standardCardBody noBorderBottom fff noPadding overflowHidden"> 6223 <div v-if="!loading && sorted_event_data.length >= 1" class="tableInsert hundop flexWrapEvent"> 6224 <template v-for="(event_data, index) in sorted_event_data" :key="event_key(event_data)"> 6225 <p 6226 v-if="is_first_visible_event_of_month(event_data)" 6227 class="cs-section-header" 6228 >{{ month_string( event_data ) }}</p> 6229 <event-list-event-component 6230 :event_data="event_data" 6231 :event_props="event_props( event_data )" 6232 @tapped-event-list-event="tapped_event_list_event" 6233 ></event-list-event-component> 6234 </template> 6235 </div> 6236 <div v-if="loading" class="cardBodySpinnerWrap active relative"> 6237 <div class="cardBodySpinner centeredBlock"></div> 6238 </div> 6239 <no-objects-found 6240 v-if="!loading && sorted_event_data.length == 0" 6241 subtext="No events found" 6242 icon_class="fa-light fa-calendar-days" 6243 ></no-objects-found> 6244 </div> 6245 </div> 6246 </template> 6247 6248 <!-- carousel view --> 6249 <template v-if="formatting_obj.layout === 'carousel'"> 6250 <div class="event-list-wrap centeredBlock centeredText" :class="'event-list-wrap_' + client_id" :data-client_id="client_id"> 6251 <div 6252 class="flexNoWrap align-items-center event_list_carousel_default" 6253 style="padding: 0px 0px 15px 5px;" 6254 > 6255 <h3 6256 class="event_list_carousel_header" 6257 >{{ date_filter || 'upcoming' }} events</h3> 6258 </div> 6259 <span 6260 @mouseenter="start_auto_scroll('left')" 6261 @mouseleave="stop_auto_scroll" 6262 class="event-list-auto-scroll left" 6263 ></span> 6264 <div class="standardCardBody noBorderTop noBorderBottom fff noPadding"> 6265 <div 6266 ref="event_list_card_wrapper" 6267 v-if="!loading && sorted_event_data.length >= 1" 6268 class="tableInsert hundop event-list-card-wrapper" 6269 :style="{ gap: formatting_obj.card_spacing + 'px' || '25px' }" 6270 > 6271 <template v-for="(event_data, index) in sorted_event_data" :key="event_key(event_data)"> 6272 <event-list-card-component 6273 :event_data="event_data" 6274 :formatting="formatting_obj" 6275 :event_props="event_props( event_data )" 6276 @tapped-event-list-event="tapped_event_list_event" 6277 ></event-list-card-component> 6278 </template> 6279 </div> 6280 <div v-if="loading" class="cardBodySpinnerWrap active relative"> 6281 <div class="cardBodySpinner centeredBlock"></div> 6282 </div> 6283 <no-objects-found 6284 v-if="!loading && sorted_event_data.length == 0" 6285 subtext="No events found" 6286 icon_class="fa-light fa-calendar-days" 6287 ></no-objects-found> 6288 </div>
6289 <span 6290 @mouseenter="start_auto_scroll('right')" 6291 @mouseleave="stop_auto_scroll" 6292 class="event-list-auto-scroll right" 6293 ></span> 6294 </div> 6295 </template> 6296 </div> 6297 </div> 6298</script>
6298 6299 6300<style> 6301 .event_list_carousel_default { 6302 --event-list-carousel-font-family: 'Cormorant Garamond'; 6303 --event-list-carousel-font-size: 16px; 6304 --event-list-carousel-font-weight: 600; 6305 --event-list-carousel-text-transform: uppercase; 6306 --event-list-carousel-letter-spacing: 1.2px; 6307 --event-list-carousel-color: #000; 6308 } 6309 .event_list_carousel_header { 6310 /* font-family: var(--event-list-carousel-font-family) !important; */ 6311 font-size: var(--event-list-carousel-font-size) !important; 6312 font-weight: var(--event-list-carousel-font-weight) !important; 6313 text-transform: var(--event-list-carousel-text-transform) !important; 6314 letter-spacing: var(--event-list-carousel-letter-spacing) !important; 6315 color: var(--event-list-carousel-color) !important; 6316 } 6317 .event_list_carousel_header.select_filter { 6318 width: 100px !important; 6319 text-overflow: ellipsis !important; 6320 border: none !important; 6321 border-bottom: 0.5px solid black !important; 6322 appearance: none !important; 6323 -webkit-appearance: none !important; 6324 -moz-appearance: none !important; 6325 background: none !important; 6326 padding: 0px !important; 6327 box-shadow: none !important; 6328 display: inline-block !important; 6329 background-image: none !important; 6330 } 6331 .event-list-card-wrapper { 6332 position: relative; 6333 display: flex; 6334 justify-content: flex-start; 6335 align-items: center; 6336 padding: 10px; 6337 overflow: scroll !important; 6338 scrollbar-width: none !important; 6339 } 6340 .event-list-auto-scroll { 6341 position: absolute; 6342 top: 0; 6343 height: 100%; 6344 width: 20px; 6345 } 6346 .event-list-auto-scroll.left { 6347 left: 0; 6348 } 6349 .event-list-auto-scroll.right { 6350 right: 0; 6351 } 6352</style> 6353
6354<script> 6355 6356 app_content.component('event-list-component', { 6357 template: '#template-event-list', 6358 data() { 6359 return { 6360 event_data: [], 6361 loading: true, 6362 sortObject: { 6363 order: 'ascending', 6364 key: 'startDate' 6365 }, 6366 scroll_animation_frame: null, 6367 } 6368 }, 6369 props: { 6370 class_string: String, 6371 style_string: String, 6372 element_style_string: String, 6373 formatting: String, 6374 filter_types: String, 6375 filter_tag_ids: String, 6376 date_filter: { 6377 type: String, 6378 default: 'upcoming', 6379 }, 6380 component_type: String, 6381 client_id: String, 6382 //admin 6383 component: Object, 6384 last_update: Number, 6385 }, 6386 mounted() { 6387 if( this.date_filter == "past" ){ 6388 this.sortObject = { 6389 order: "descending", 6390 key: "startDate" 6391 }; 6392 } 6393 this.loadEventList(this.client_id); 6394 }, 6395 methods: { 6396 is_first_visible_event_of_month( event_data ){ 6397 return this.first_visible_events.includes( this.event_key(event_data) ); 6398 }, 6399 event_key( event_data ){ 6400 return event_data.event.id + '_' + event_data.ts; 6401 }, 6402 month_string( event_data ){ 6403 let start_date = new Date(event_data.ts); 6404 return format_utc_date_v2({ 6405 "utcDate": start_date, 6406 "timezone": store.timezone, 6407 "formatString": "MMMM YYYY" 6408 }); 6409 }, 6410 event_props( event_data ){ 6411 return { 6412 show_register_button: this.show_register_button( event_data ), 6413 show_time: this.show_time( event_data.event ), 6414 image_data: this.image_data( event_data.event ), 6415 start_date: this.start_date( event_data ), 6416 end_date: this.end_date( event_data ), 6417 resolved_external_url: this.resolve_external_url_for_instance( event_data ), 6418 }; 6419 }, 6420 resolve_external_url_for_instance( event_data ){ 6421 let event = event_data.event; 6422 if ( !event || !event.get('calendarOnly') ) { 6423 return null; 6424 } 6425 if ( event.get('external_event_url_type') === 'dining' ) { 6426 if ( event_data.external_event_url ) { 6427 return event_data.external_event_url; 6428 } 6429 let startDate = event.get('startDate'); 6430 let components = return_date_components_v2({ 6431 "date": startDate, 6432 "timezone": 'UTC' 6433 }); 6434 let date_string = components.year + '-' + ( components.month + 1 ) + '-' + components.date; 6435 let dining_url = '/dining?date_string=' + date_string; 6436 if ( event.get('startTime') ) { 6437 dining_url += '&time=' + event.get('startTime'); 6438 } 6439 return dining_url; 6440 } 6441 if ( event.get('external_event_url') ) { 6442 let url = event.get('external_event_url'); 6443 if ( url && !/^https?:\/\//i.test(url) ) { 6444 url = 'https://' + url; 6445 } 6446 return url; 6447 } 6448 return null; 6449 }, 6450 start_date( event_data ){ 6451 if( event_data.ts ){ 6452 return new Date(event_data.ts); 6453 } 6454 return null; 6455 }, 6456 end_date( event_data ){ 6457 if( event_data.end_ts ){ 6458 return new Date(event_data.end_ts); 6459 } 6460 return null; 6461 }, 6462 tapped_event_list_event( event_data ){ 6463 let event = event_data.event; 6464 if(event && event.className == 'events') { 6465 $('#overlay_event-description-popup h3').text(event.get('name')); 6466 let start_date = this.start_date( event_data ); 6467 let nice_date = format_utc_date_v2({ 6468 "utcDate": start_date, 6469 "timezone": store.timezone, 6470 "formatString": "MMM D, YYYY" 6471 }); 6472 $('#overlay_event-description-popup .metric_start_date').text( nice_date ); 6473 if( this.show_time( event ) ){ 6474 let event_time = format_utc_date_v2({ 6475 "utcDate": start_date, 6476 "timezone": store.timezone, 6477 "formatString": "h:mm A" 6478 }); 6479 $('#overlay_event-description-popup .metric_start_date_time').text( event_time ).removeClass('incomplete'); 6480 }else{ 6481 $('#overlay_event-description-popup .metric_start_date_time').text('Not provided').addClass('incomplete'); 6482 } 6483 if(event.get('description')) { 6484 $('#overlay_event-description-popup .metric_description').html(event.get('description')).removeClass('incomplete'); 6485 } else { 6486 $('#overlay_event-description-popup .metric_description').text('Not provided').addClass('incomplete'); 6487 } 6488 fadeIn('#overlay_event-description-popup'); 6489 } 6490 }, 6491 show_register_button( event_data ){ 6492 var now = new Date(); 6493 var ended = false;
6494 let eventObject = event_data.event; 6495 var registration_closed = eventObject.get('registration_closed'); 6496 var lastChanceDate = eventObject.get('lastChanceDate'); 6497 var startDate = new Date(event_data.ts); 6498 if( event_data.end_ts ){ 6499 var endDate = new Date(event_data.end_ts); 6500 } 6501 if( ! endDate && startDate ){ 6502 endDate = startDate; 6503 } 6504 var endDate_plus_one; 6505 if( endDate ){ 6506 endDate_plus_one = new Date( endDate.getTime() ); 6507 endDate_plus_one.setDate( endDate.getDate() + 1 ); 6508 } 6509 if( now > endDate_plus_one && endDate_plus_one ){ 6510 ended = true; 6511 } 6512 if( now > lastChanceDate && lastChanceDate ){ 6513 registration_closed = true; 6514 } 6515 if( ended || registration_closed || eventObject.get('calendarOnly') ){ 6516 return false; 6517 } else { 6518 return true; 6519 } 6520 }, 6521 show_time( event ){ 6522 if( 6523 event.get('calendarOnly') && event.get('all_day') 6524 ){ 6525 return false; 6526 }else if( 6527 event.className == "camps" || 6528 event.className == "regattas" || 6529 event.className == "subevents" 6530 ){ 6531 return false; 6532 } 6533 return true; 6534 }, 6535 image_data( event ){ 6536 if( event.className == "subevents" && event.get('regattaObject') ){ 6537 event = event.get('regattaObject'); 6538 } 6539 return { 6540 url: event.get('imageURL'), 6541 crop: event.get('imageCrop') 6542 } 6543 }, 6544 popViewAllEventsOverlay(){ 6545 view_all_events({ 6546 "class_string": this.class_string, 6547 "style_string": this.style_string, 6548 "element_style_string": this.element_style_string, 6549 "component_type": this.component_type, 6550 "client_id": this.client_id, 6551 "event_data": this.event_data, 6552 "props_generator": this.event_props, 6553 }); 6554 }, 6555 start_auto_scroll(direction) { 6556 const wrapper = this.$refs.event_list_card_wrapper; 6557 const amount = direction === 'right' ? 10 : -10; //scroll speed 6558 const scrollStep = () => { 6559 wrapper.scrollBy({ left: amount, behavior: 'auto' }); 6560 this.scroll_animation_frame = requestAnimationFrame(scrollStep); 6561 }; 6562 scrollStep(); 6563 }, 6564 stop_auto_scroll() { 6565 cancelAnimationFrame(this.scroll_animation_frame); 6566 this.scroll_animation_frame = null; 6567 }, 6568 loadEventList( client_id, overrides = {} ){ 6569 var promise = Parse.Promise.as(); 6570 var thisPromise = promise.then(() => { 6571 var params = {}; 6572 params["client_id"] = client_id; 6573 params["overrides"] = overrides; 6574 params["request_url"] = window.location.href; 6575 params["date_filter"] = this.date_filter; 6576 return Parse.Cloud.run('retrieve_data_for_component', params); 6577 }).then(event_data => { 6578 this.event_data = event_data; 6579 return Promise.resolve(); 6580 }).then(() => { 6581 this.loading = false; 6582 }, error => { 6583 checkParseError(error); 6584 this.loading = false; 6585 }); 6586 return thisPromise; 6587 }, 6588 sortEventList( event_data, sortObject ){ 6589 event_data = [... event_data]; 6590 if( ! sortObject ){ 6591 sortObject = {} 6592 } 6593 if( ! sortObject.key ){ 6594 sortObject.key = "startDate"; 6595 } 6596 if( ! sortObject.order ){ 6597 sortObject.order = "ascending" 6598 } 6599 if( sortObject.key == "name" ){ 6600 if( sortObject.order == "ascending" ){ 6601 return event_data.sort( this.sortByEventName_ascending ); 6602 }else{ 6603 return event_data.sort( this.sortByEventName_descending ); 6604 } 6605 }else if( sortObject.key == "startDate" ){ 6606 if( sortObject.order == "ascending" ){ 6607 return event_data.sort( this.sortByStartDate_event_ascending ); 6608 }else{ 6609 return event_data.sort( this.sortByStartDate_event_descending ); 6610 } 6611 }else if( sortObject.key == "endDate" ){ 6612 if( sortObject.order == "ascending" ){ 6613 return event_data.sort( this.sortByEndDate_event_ascending ); 6614 }else{ 6615 return event_data.sort( this.sortByEndDate_event_descending ); 6616 } 6617 } 6618 return event_data; 6619 }, 6620 sortByEventName_ascending( a, b ){ 6621 let event_a = a.event; 6622 let event_b = b.event; 6623 var aName = ''; 6624 if( event_a.get('name') ){ 6625 aName += event_a.get('name').trim().toLowerCase(); 6626 } 6627 var bName = ''; 6628 if( event_b.get('name') ){ 6629 bName += event_b.get('name').trim().toLowerCase(); 6630 } 6631 if(aName < bName) { return -1; } 6632 if(aName > bName) { return 1; } 6633 //they had the same name 6634 return 0; 6635 }, 6636 sortByEventName_descending( a, b ){ 6637 let event_a = a.event; 6638 let event_b = b.event; 6639 var aName = ''; 6640 if( event_a.get('name') ){ 6641 aName += event_a.get('name').trim().toLowerCase(); 6642 } 6643 var bName = ''; 6644 if( event_b.get('name') ){ 6645 bName += event_b.get('name').trim().toLowerCase(); 6646 } 6647 if(aName > bName) { return -1; } 6648 if(aName < bName) { return 1; } 6649 //they had the same name 6650 return 0; 6651 }, 6652 sortByStartDate_event_ascending( a, b ){ 6653 let a_ts = a.ts; 6654 let b_ts = b.ts; 6655 if(a_ts < b_ts) { return -1; } 6656 if(a_ts > b_ts) { return 1; } 6657 return 0; 6658 }, 6659 sortByStartDate_event_descending( a, b ){ 6660 let a_ts = a.ts; 6661 let b_ts = b.ts; 6662 if(a_ts > b_ts) { return -1; } 6663 if(a_ts < b_ts) { return 1; } 6664 return 0; 6665 }, 6666 sortByEndDate_event_ascending( a, b ){ 6667 let a_end_ts = a.end_ts; 6668 let b_end_ts = b.end_ts; 6669 if(a_end_ts < b_end_ts) { return -1; } 6670 if(a_end_ts > b_end_ts) { return 1; } 6671 return 0; 6672 }, 6673 sortByEndDate_event_descending( a, b ){ 6674 let a_end_ts = a.end_ts; 6675 let b_end_ts = b.end_ts; 6676 if(a_end_ts < b_end_ts) { return 1; } 6677 if(a_end_ts > b_end_ts) { return -1; } 6678 return 0; 6679 }, 6680 }, 6681 computed: { 6682 pre_filtered_event_data(){ 6683 //filter by date first 6684 let pre_filtered_event_data = [...this.event_data]; 6685 var timestamp = new Date().getTime(); 6686 if ( this.date_filter !== 'all' ){ 6687 pre_filtered_event_data = pre_filtered_event_data.filter(event_data => { 6688 let start = event_data.ts; 6689 let end = event_data.end_ts; 6690 if (this.date_filter === 'past'){ 6691 if( start < timestamp && ( end < timestamp || ! end || end == "undefined" ) ){ 6692 return true; 6693 } 6694 } else if (this.date_filter === 'upcoming'){ 6695 if ( start > timestamp || end > timestamp ){ 6696 return true; 6697 } 6698 // Single-day events: ts and end_ts are both midnight, so they get filtered out 6699 // when the event is today. Include if the event's date is today or later. 6700 if ( start === end || !end ){ 6701 let startOfEventDay = new Date(start); 6702 startOfEventDay.setUTCHours(0, 0, 0, 0); 6703 let startOfToday = new Date(); 6704 startOfToday.setUTCHours(0, 0, 0, 0); 6705 if ( startOfEventDay.getTime() >= startOfToday.getTime() ){ 6706 return true; 6707 } 6708 } 6709 } 6710 return false;
6711 }) 6712 } 6713 return pre_filtered_event_data; 6714 }, 6715 first_visible_events(){ 6716 let first_visible_events = []; 6717 let handled_keys = []; 6718 //loop through the event_data 6719 this.sorted_event_data.forEach(event_data => { 6720 let start_date = new Date(event_data.ts); 6721 let start_components = return_date_components_v2({ 6722 "date": start_date, 6723 "timezone": store.timezone 6724 }); 6725 let key = start_components.year + '_' + start_components.month; 6726 if( ! handled_keys.includes(key) ){ 6727 first_visible_events.push( this.event_key(event_data) ); 6728 handled_keys.push(key); 6729 } 6730 }); 6731 return first_visible_events; 6732 }, 6733 filtered_event_data(){ 6734 let filtered_event_data = this.pre_filtered_event_data; 6735 //filter by selected tag IDs (configured in admin settings panel) 6736 if( this.filter_tag_ids_arr && this.filter_tag_ids_arr.length > 0 ){ 6737 filtered_event_data = filtered_event_data.filter(event_data => { 6738 let event_tag_ids = event_data.event_tag_ids; 6739 return event_tag_ids && this.filter_tag_ids_arr.some(id => event_tag_ids.includes(id)); 6740 }); 6741 } 6742 //return them 6743 return filtered_event_data; 6744 }, 6745 sorted_event_data(){ 6746 let sorted = this.sortEventList( this.filtered_event_data, this.sortObject ); 6747 if( this.formatting_obj.layout === 'carousel' ){ 6748 return sorted.splice(0, 20); 6749 }else{ 6750 return sorted.splice(0, 5); 6751 } 6752 }, 6753 formatting_obj(){ 6754 if (this.formatting){ 6755 return JSON.parse(this.formatting); 6756 } 6757 return {}; 6758 }, 6759 filter_tag_ids_arr(){ 6760 if (this.filter_tag_ids){ 6761 return JSON.parse(this.filter_tag_ids); 6762 } 6763 return []; 6764 }, 6765 filter_types_arr(){ 6766 if (this.filter_types){ 6767 return JSON.parse(this.filter_types); 6768 } 6769 return []; 6770 }, 6771 }, 6772 watch: { 6773 last_update(newVal, oldVal){ 6774 if (newVal !== oldVal){ 6775 let event_types = this.component.get('config_latest')?.event_types || []; 6776 this.loadEventList( this.client_id, { event_types } ); 6777 } 6778 } 6779 }, 6780 }); 6781</script>
6781
6781<script type="text/html" id="template-iframe"> 6782 <div v-if="!loading && !is_admin" class="cs-iframe-wrap" :class="class_string" :style="style_string"> 6783 <iframe class="cs-iframe" :src="iframe_src"></iframe> 6784 </div> 6785 <div v-if="!loading && is_admin" class="cs-iframe-wrap cs-min-section-width" :class="class_string" :style="[style_string, { 'max-width': formatting_obj.section_width + '%' }]"> 6786 <template v-if="iframe_src" > 6787 <iframe class="cs-iframe" sandbox="allow-forms allow-scripts" :src="iframe_src"></iframe> 6788 </template> 6789 <template v-if="!iframe_src"> 6790 <p class="noObjectsMSG centeredBlock centeredText">Click on edit to configure your iframe.</p> 6791 </template> 6792 </div> 6793</script>
6793 6794 6795<style> 6796 /* Draft-editor only floor (the admin wrap carries cs-min-section-width; 6797 the live wrap does not). Without it an iframe with no configured 6798 section_height â or an empty/unconfigured embed â collapses shorter 6799 than the component toolbar + inline "add section" button, so those 6800 controls overlap. A configured section_height (inline min-height) 6801 still wins since it's taller. */ 6802 .cs-iframe-wrap.cs-min-section-width { 6803 min-height: 130px; 6804 } 6805</style> 6806
6807<script> 6808 /* ----- COMPONENT ----- */ 6809 app_content.component('iframe-component', { 6810 template: '#template-iframe', 6811 data() { 6812 return { 6813 global: store, 6814 loading: true, 6815 is_admin: false, 6816 } 6817 }, 6818 props: { 6819 class_string: String, 6820 style_string: String, 6821 iframe_src: String, 6822 //admin 6823 component: Object, 6824 formatting_string: String, 6825 last_update: Number 6826 }, 6827 mounted() { 6828 this.is_admin = window.location.href.indexOf('website-builder') !== -1; 6829 this.loading = false; 6830 }, 6831 computed: { 6832 formatting_obj(){ 6833 if (this.formatting_string){ 6834 return JSON.parse(this.formatting_string); 6835 } 6836 return {}; 6837 }, 6838 }, 6839 }); 6840</script>
6840
6840<script type="text/html" id="template-camera-feed"> 6841 <div v-if="!loading && !is_admin" class="cs-iframe-wrap" :class="class_string" :style="style_string"> 6842 <div v-if="!errored" style="position:relative;padding:8px;width:100%;box-sizing:border-box;"> 6843 <div v-if="!image_loaded" style="width:100%;aspect-ratio:16 / 9;border-radius:12px;background:#e9ecef;display:flex;align-items:center;justify-content:center;color:#adb5bd;font-size:13px;">Loading live cameraâ¦</div> 6844 <img v-show="image_loaded" :src="img_src" style="width:100%;height:100%;object-fit:contain;display:block;border-radius:12px;"> 6845 <span style="position:absolute;top:16px;left:16px;display:inline-flex;align-items:center;gap:5px;background:rgba(0,0,0,0.6);color:#fff;font-size:11px;font-weight:600;letter-spacing:0.5px;padding:3px 9px;border-radius:12px;"> 6846 <span style="width:7px;height:7px;border-radius:50%;background:#ff3b30;display:inline-block;"></span>LIVE 6847 </span> 6848 <span v-if="last_image_time" style="position:absolute;top:16px;right:16px;background:rgba(0,0,0,0.6);color:#fff;font-size:11px;font-weight:600;padding:3px 9px;border-radius:12px;">{{ last_image_time }}</span> 6849 </div> 6850 <p v-if="errored" class="noObjectsMSG centeredBlock centeredText">The live camera is temporarily unavailable.</p> 6851 </div> 6852 <div v-if="!loading && is_admin" class="cs-iframe-wrap cs-min-section-width" :class="class_string" :style="[style_string, { 'max-width': formatting_obj.section_width + '%' }]"> 6853 <template v-if="has_camera"> 6854 <div style="position:relative;padding:8px;width:100%;box-sizing:border-box;"> 6855 <div v-if="!image_loaded" style="width:100%;aspect-ratio:16 / 9;border-radius:12px;background:#e9ecef;display:flex;align-items:center;justify-content:center;color:#adb5bd;font-size:13px;">Loading live cameraâ¦</div> 6856 <img v-show="image_loaded" :src="img_src" style="width:100%;height:100%;object-fit:contain;display:block;border-radius:12px;"> 6857 <span style="position:absolute;top:16px;left:16px;display:inline-flex;align-items:center;gap:5px;background:rgba(0,0,0,0.6);color:#fff;font-size:11px;font-weight:600;letter-spacing:0.5px;padding:3px 9px;border-radius:12px;"> 6858 <span style="width:7px;height:7px;border-radius:50%;background:#ff3b30;display:inline-block;"></span>LIVE 6859 </span> 6860 <span v-if="last_image_time" style="position:absolute;top:16px;right:16px;background:rgba(0,0,0,0.6);color:#fff;font-size:11px;font-weight:600;padding:3px 9px;border-radius:12px;">{{ last_image_time }}</span> 6861 </div> 6862 </template> 6863 <template v-if="!has_camera"> 6864 <p class="noObjectsMSG centeredBlock centeredText">Click on edit to configure your camera.</p> 6865 </template> 6866 </div> 6867</script>
6867 6868
6869<script> 6870 app_content.component('camera-feed-component', { 6871 template: '#template-camera-feed', 6872 data() { 6873 return { 6874 global: store, 6875 loading: true, 6876 is_admin: false, 6877 img_src: '', 6878 errored: false, 6879 refresh_timer: null, 6880 loader: null, 6881 last_image_time: '', 6882 image_loaded: false, 6883 } 6884 }, 6885 props: { 6886 class_string: String, 6887 style_string: String, 6888 client_id: String, 6889 camera_feed_id: String, 6890 refresh_interval_ms: [Number, String], 6891 //admin 6892 component: Object, 6893 formatting_string: String, 6894 last_update: Number, 6895 }, 6896 mounted() { 6897 this.is_admin = window.location.href.indexOf('website-builder') !== -1; 6898 this.loading = false; 6899 if (this.has_camera) { 6900 this.start_refresh(); 6901 } 6902 }, 6903 beforeUnmount() { 6904 this.stop_refresh(); 6905 }, 6906 methods: { 6907 snapshot_base() { 6908 let base = `/camera-feed/${this.client_id}/snapshot.jpg`; 6909 //editor has no subdomain rewrite, so pass the club id explicitly 6910 if (this.is_admin && store.clubObject) { 6911 base += `?club_id=${store.clubObject.id}`; 6912 } 6913 return base; 6914 }, 6915 return_refresh_interval() { 6916 let interval = Number(this.refresh_interval_ms); 6917 if (!interval || isNaN(interval) || interval < 500) { 6918 interval = 2000; 6919 } 6920 return interval; 6921 }, 6922 return_next_snapshot_url() { 6923 let base = this.snapshot_base(); 6924 let separator = base.indexOf('?') === -1 ? '?' : '&'; 6925 return `${base}${separator}t=${Date.now()}`; 6926 }, 6927 start_refresh() { 6928 this.stop_refresh(); 6929 this.load_next_frame(); 6930 }, 6931 //fetch the next frame into an off-DOM Image so the visible <img> 6932 //is only swapped once bytes are fully loaded. we never reassign a 6933 //pending src, so the browser never cancels an in-flight request. 6934 load_next_frame() { 6935 let started = Date.now(); 6936 let url = this.return_next_snapshot_url(); 6937 let loader = new Image(); 6938 loader.onload = () => { 6939 //loaded bytes are in cache, so this swap paints instantly 6940 this.img_src = url; 6941 this.image_loaded = true; 6942 this.errored = false; 6943 this.last_image_time = this.format_time(new Date()); 6944 this.schedule_next_frame(started); 6945 }; 6946 loader.onerror = () => { 6947 this.errored = true; 6948 this.schedule_next_frame(started); 6949 }; 6950 loader.src = url; 6951 this.loader = loader; 6952 }, 6953 //only advance after the current frame finishes, subtracting elapsed 6954 //so cadence tracks the target interval (never firing early) 6955 schedule_next_frame(started) { 6956 let interval = this.return_refresh_interval(); 6957 let elapsed = Date.now() - started; 6958 let delay = Math.max(0, interval - elapsed); 6959 this.refresh_timer = setTimeout(() => { 6960 this.load_next_frame(); 6961 }, delay); 6962 }, 6963 stop_refresh() { 6964 if (this.refresh_timer) { 6965 clearTimeout(this.refresh_timer); 6966 this.refresh_timer = null; 6967 } 6968 if (this.loader) { 6969 this.loader.onload = null; 6970 this.loader.onerror = null; 6971 this.loader = null; 6972 } 6973 }, 6974 format_time(date) { 6975 let hours = date.getHours(); 6976 let minutes = date.getMinutes().toString().padStart(2, '0'); 6977 let seconds = date.getSeconds().toString().padStart(2, '0'); 6978 let period = hours >= 12 ? 'PM' : 'AM'; 6979 hours = hours % 12; 6980 if (hours === 0) { 6981 hours = 12; 6982 } 6983 return `${hours}:${minutes}:${seconds} ${period}`; 6984 }, 6985 }, 6986 computed: { 6987 has_camera() { 6988 if (!this.client_id) { 6989 return false;
6990 } 6991 if (this.camera_feed_id) { 6992 return true; 6993 } 6994 return false; 6995 }, 6996 formatting_obj() { 6997 if (this.formatting_string) { 6998 return JSON.parse(this.formatting_string); 6999 } 7000 return {}; 7001 }, 7002 }, 7003 watch: { 7004 camera_feed_id() { 7005 if (this.has_camera) { 7006 this.start_refresh(); 7007 } else { 7008 this.stop_refresh(); 7009 } 7010 }, 7011 refresh_interval_ms() { 7012 if (this.has_camera) { 7013 this.start_refresh(); 7014 } 7015 }, 7016 }, 7017 }); 7018</script>
7018 7019
7019<script type="text/html" id="template-blog-list"> 7020 <div class="relative" :class="class_string" :style="style_string"> 7021 <div class="element-wrapper cs-min-section-width" :class="'element-wrapper_' + component_type" :style="[element_style_string, { 'min-width': `${formatting_obj.section_width}%`, 'max-width': `${formatting_obj.section_width}%` }]"> 7022 <div class="blog-list-wrap centeredBlock centeredText" :class="'blog-list-wrap_' + client_id" :data-client_id="client_id"> 7023 <div class="ease flexNoWrap show_when_loaded" style="justify-content: center; align-items: center;"> 7024 <!-- template type - list --> 7025 <template v-if="!formatting_obj.template || formatting_obj.template === 'list'"> 7026 <div class="inputWrap searchBarWrap flexGrowOne" style="min-width:100%;"> 7027 <input class="searchBar searchBar_blog-list easeFast" placeholder="Search posts..." v-model="search"> 7028 </div> 7029 <div class="flexGrowOne"></div> 7030 </template> 7031 <!-- template type - bulletin --> 7032 <template v-if="formatting_obj.template === 'bulletin'"> 7033 <div class="searchBarWrap_v2" style="width: 100%; 7034 display: flex; 7035 flex-direction: row; 7036 align-items: center; 7037 justify-content: center; 7038 margin-right: 8px; 7039 margin-left: 8px; 7040 margin-bottom: 10px;"> 7041 <input id="searchBar_blog-list_v2" type="text" placeholder="Search posts..." v-model="search" /> 7042 <button id="searchBar_blog-list_submit" type="submit"> 7043 <svg style="width:24px;height:24px" viewBox="0 0 24 24"><path fill="#666666" d="M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z" /></svg> 7044 </button> 7045 </div> 7046 </template> 7047 </div> 7048 <div class="fff noPadding"> 7049 <!-- template type - list --> 7050 <template v-if="!formatting_obj.template || formatting_obj.template === 'list'"> 7051 <div v-if="!loading && truncated_posts.length >= 1" class="tableInsert hundop"> 7052 <template v-for="(post, index) in truncated_posts" :key="post.id"> 7053 <div 7054 :style="{ 'background-color': formatting_obj.background_color || '#fff', 'border-radius': '4px' }" 7055 class="blog-row flexWrapLeftAlign relative pointer blog-row-border" 7056 :class="'blog-row_' + post.id" 7057 :data-post-id="post.id" 7058 > 7059 <div v-if="post && post.get('thumbnail')" class="blog-post-thumbnail" :style="'background-image:url(' + post.get('thumbnail') + ')'"></div> 7060 <div v-if="post && !post.get('thumbnail')" class="blog-post-thumbnail default-image-icon"></div> 7061 <div class="blog-row-inner flexGrowOne"> 7062 <div class="blog-name-wrap-column"> 7063 <p 7064 class="blog-title" 7065 :style="{ 7066 'color': formatting_obj.title_color || '#000', 7067 'font-weight': formatting_obj.title_styling && formatting_obj.title_styling.bold ? '600 !important' : '400 !important', 7068 'font-style': formatting_obj.title_styling && formatting_obj.title_styling.italic ? 'italic' : 'normal', 7069 'text-decoration': formatting_obj.title_styling && formatting_obj.title_styling.underline ? 'underline' : 'none', 7070 }" 7071 >{{ post.get('title') }}</p> 7072 <p 7073 v-if="post && post.get('subtitle')" 7074 class="blog-subtitle" 7075 :style="{ 7076 'color': formatting_obj.subtitle_color || '#000', 7077 'font-weight': formatting_obj.subtitle_styling && formatting_obj.subtitle_styling.bold ? '600 !important' : '400 !important', 7078 'font-style': formatting_obj.subtitle_styling && formatting_obj.subtitle_styling.italic ? 'italic' : 'normal', 7079 'text-decoration': formatting_obj.subtitle_styling && formatting_obj.subtitle_styling.underline ? 'underline' : 'none', 7080 }" 7081 >{{ post.get('subtitle') }}</p> 7082 <p class="blog-date" :style="{ 'color': formatting_obj.date_color || '#000' }">{{ niceDate(post) }}</p> 7083 <p><a class="absoluteA" :href="'/blog-post/' + post.id" style="text-transform: none !important; border-bottom: none !important;"></a> 7084 <a 7085 :href="'/blog-post/' + post.id" 7086 :style="{ 7087 'font-variant': 'small-caps', 7088 'opacity': '0.9', 7089 'color': formatting_obj.date_color || '#000', 7090 'border-bottom': 'none !important', 7091 'text-decoration': 'underline !important', 7092 'text-decoration-color': (formatting_obj.date_color || '#000') + ' !important' 7093 }" 7094 >Read more</a> 7095 </p> 7096 </div> 7097 </div> 7098 <div v-if="post && post.get('pinned') === true"> 7099 <i class="fa-solid fa-thumbtack thumbtack-icon" style="height: 1em; transform: rotate(45deg);"></i> 7100 </div> 7101 </div> 7102 </template> 7103 <div 7104 v-if="has_more_posts" 7105 class="view_more_button flexNoWrapCenter align-items-center" 7106 @click="view_all = true" 7107 style="margin-bottom:0px;margin-left:0px;margin-right:0px;" 7108 > 7109 <div> 7110 <p>View more</p> 7111 </div> 7112 <i class="fa-regular fa-arrow-right easeFast"></i> 7113 </div> 7114 </div> 7115 </template> 7116 <!-- template type - bulletin --> 7117 <template v-if="formatting_obj.template === 'bulletin'"> 7118 <div 7119 v-if="!loading && truncated_posts.length >= 1" 7120 :class="class_string" 7121 > 7122 <div class="blog-list-container"> 7123 <section 7124 class="blog-list-cards-v2" 7125 > 7126 <div v-for="(post, index) in truncated_posts" :key="post.id"> 7127 <article 7128 :style="{ 'background-color': formatting_obj.background_color || '#fff' }" 7129 style="display: flex; flex-direction: column; align-items: center; justify-content: flex-start; position: relative;" 7130 class="blog_post_card_v2" 7131 @click="go_to_post( post )" 7132 > 7133 <div class="blog-list-image-container"> 7134 <img v-if="post && post.get('thumbnail')" class="blog_post_thumbnail" :src="post.get('thumbnail')"> 7135 <div v-if="post && !post.get('thumbnail')" style="display: flex; align-items: center; justify-content: center;"> 7136 <i class="fa-duotone fa-solid fa-newspaper" style="font-size: 10em;"></i> 7137 </div> 7138 </div> 7139 <i v-if="post && post.get('pinned') === true" class="fa-solid fa-thumbtack" style="margin: 3px 0 0 2px; font-size: 1em; color: #fff; transform: rotate(45deg); position: absolute; top: 0; right: 5px;"></i> 7140 <div class="card__content"> 7141 <div 7142 v-if="post && post.get('title')" 7143 class="card__title_v2" 7144 :style="{ 7145 'color': formatting_obj.title_color || '#000', 7146 'font-weight': formatting_obj.title_styling && formatting_obj.title_styling.bold ? '600 !important' : '400 !important', 7147 'font-style': formatting_obj.title_styling && formatting_obj.title_styling.italic ? 'italic' : 'normal', 7148 'text-decoration': formatting_obj.title_styling && formatting_obj.title_styling.underline ? 'underline' : 'none', 7149 }" 7150 > 7151 {{ post.get('title') }} 7152 </div> 7153 <div 7154 v-if="post && post.get('subtitle')" 7155 class="card__subtitle tinyMarginTop" 7156 :style="{ 7157 'color': formatting_obj.subtitle_color || '#000', 7158 'font-weight': formatting_obj.subtitle_styling && formatting_obj.subtitle_styling.bold ? '600 !important' : '400 !important', 7159 'font-style': formatting_obj.subtitle_styling && formatting_obj.subtitle_styling.italic ? 'italic' : 'normal', 7160 'text-decoration': formatting_obj.subtitle_styling && formatting_obj.subtitle_styling.underline ? 'underline' : 'none', 7161 }" 7162 > 7163 {{ post.get('subtitle') }} 7164 </div> 7165 <div class="card__date" :style="{ 'color': formatting_obj.date_color || '#000' }">{{ niceDate(post) }}</div> 7166 <div class="card__excerpt"> 7167 <a 7168 @click.stop 7169 :href="'/blog-post/' + post.id" 7170 :style="{ 7171 'font-variant': 'small-caps', 7172 'opacity': '0.9', 7173 'color': formatting_obj.date_color || '#000', 7174 'border-bottom': 'none !important', 7175 'text-decoration': 'underline !important', 7176 'text-decoration-color': (formatting_obj.date_color || '#000') + ' !important' 7177 }" 7178 > 7179 read more 7180 </a> 7181 </div> 7182 </div> 7183 </article> 7184 </div> 7185 </section> 7186 </div> 7187 <div 7188 v-if="has_more_posts" 7189 class="view_more_button flexNoWrapCenter align-items-center" 7190 :class="{'larchmont': is_larchmont || is_larchmont === 'true'}" 7191 @click="view_all = true" 7192 style="margin-bottom:0px;" 7193 > 7194 <div> 7195 <p>View more</p> 7196 </div> 7197 <i class="fa-regular fa-arrow-right easeFast"></i> 7198 </div> 7199 </div> 7200 </template> 7201 <div v-if="!loading && filtered_posts.length == 0" class="tableInsert hundop blog-list-contain"> 7202 <p class="noObjectsMSG withPadding textLeft blog-row flexWrapLeftAlign relative pointer">No posts found.</p> 7203 </div> 7204 </div> 7205 <div v-if="loading" class="cardBodySpinnerWrap active relative"> 7206 <div class="cardBodySpinner centeredBlock"></div> 7207 </div> 7208 </div> 7209 </div> 7210 </div> 7211</script>
7211 7212 7213<style> 7214 7215 /*------------------------ view more buttons --------------------- */ 7216 7217 .view_more_button.card-layout{ 7218 margin-top:0px; 7219 } 7220 .view_more_button{ 7221 color: #6a7482; 7222 margin: 8px; 7223 border: 1px solid #e5eaec; 7224 border-top: 1px solid #e5eaec; 7225 border-radius: 5px; 7226 cursor: pointer; 7227 font-size: 16px; 7228 padding: 16px; 7229 margin-top: 8px; 7230 } 7231 .view_more_button:hover{ 7232 color: #081333; 7233 background-color: #f8f9fb; 7234 } 7235 .view_more_button.larchmont{ 7236 color: #fff; 7237 margin: 8px; 7238 border: 1px solid #fff; 7239 border-top: 1px solid #fff; 7240 border-radius: 5px; 7241 cursor: pointer; 7242 font-size: 16px; 7243 padding: 16px; 7244 margin-top: 8px; 7245 } 7246 .view_more_button.larchmont:hover{ 7247 background-color: #002D62; 7248 color: #fff; 7249 } 7250 .view_more_button i{ 7251 padding-left: 8px; 7252 } 7253 .view_more_button:hover i{ 7254 color: #4a56f8; 7255 transform: translateX(4px); 7256 } 7257 7258 /* -- end view more button --*/ 7259 7260 section.blog-list-cards-v2 { 7261 margin: 0 auto; 7262 display: grid; 7263 grid-template-columns: repeat(3, 1fr); 7264 grid-auto-rows: auto; 7265 grid-gap: 16px; 7266 align-content: center; 7267 justify-content: center; 7268 padding: 8px; 7269 width: 100%; 7270 box-sizing: border-box; 7271 } 7272 .blog-list-image-container { 7273 width: 100%; 7274 height: 300px; 7275 display: flex; 7276 align-items: center; 7277 justify-content: center; 7278 overflow: hidden; 7279 border-top-left-radius: 6px; 7280 border-top-right-radius: 6px; 7281 border-bottom-left-radius: 0px; 7282 border-bottom-right-radius: 0px; 7283 background-color: #f2f2f2; 7284 } 7285 .blog_post_thumbnail { 7286 width: 100%; 7287 height: 100%; 7288 object-fit: cover; 7289 display: block; 7290 } 7291 .default_image_icon { 7292 background-image: url('https://s3-us-west-2.amazonaws.com/myclubspot/upload-photo-icon.jpg'); 7293 background-size: contain; 7294 background-position: center; 7295 background-repeat: no-repeat; 7296 width: 100%; 7297 height: 100%; 7298 } 7299 @media (max-width: 768px) { 7300 section.blog-list-cards-v2 { 7301 grid-template-columns: repeat(1, 1fr); 7302 } 7303 } 7304</style> 7305
7306<script> 7307 7308 store.components["blog_list"] = { 7309 allPosts: [], 7310 search: '', 7311 loading: true, 7312 view_all: false, 7313 post_limit: 6 //how many to show on initial load (before clicking "see more") 7314 }; 7315 7316 app_content.component("blog-list-component", { 7317 template: "#template-blog-list", 7318 data() { 7319 return store.components['blog_list'] 7320 }, 7321 props: { 7322 client_id: String, 7323 class_string: String, 7324 style_string: String, 7325 element_style_string: String, 7326 formatting: String, 7327 component_type: String, 7328 is_larchmont: Boolean, String, 7329 //admin 7330 component: Object, 7331 last_update: Number, 7332 }, 7333 mounted() { 7334 this.loadBlogList( this.client_id ) 7335 }, 7336 methods: { 7337 go_to_post( post ){ 7338 window.location.href = "blog-post/" + post.id; 7339 }, 7340 loadBlogList( client_id, overrides = {} ){ 7341 var promise = Parse.Promise.as(); 7342 var thisPromise = promise.then(() => { 7343 //get new data 7344 var params = {}; 7345 params["client_id"] = client_id; 7346 params["overrides"] = overrides; 7347 params["request_url"] = window.location.href; 7348 return Parse.Cloud.run('retrieve_data_for_component', params); 7349 }).then(retrieved_posts => { 7350 // sort with 'pinned' posts on top 7351 this.allPosts = retrieved_posts.sort((a, b) => (a.get('pinned') === b.get('pinned') ? 0 : a.get('pinned') ? -1 : 1)); 7352 this.loading = false; 7353 //and return 7354 return Parse.Promise.as(); 7355 }, error => { 7356 checkParseError( error ); 7357 }); 7358 return thisPromise; 7359 }, 7360 niceDate( postObject ){ 7361 return dayjs(postObject.get('post_date')).format('MMM DD, YYYY'); 7362 }, 7363 return_grid_column_css(){ 7364 if (window.innerWidth <= 990) { 7365 //for mobile 7366 return 'repeat(1, 256px)'; 7367 } else { 7368 return 'repeat(4, 256px)'; 7369 } 7370 }, 7371 }, 7372 computed: { 7373 has_more_posts(){ 7374 if( this.view_all ){ 7375 return false; 7376 } 7377 if( this.filtered_posts.length > this.post_limit ){ 7378 return true; 7379 } 7380 return false; 7381 }, 7382 truncated_posts(){ 7383 if( this.view_all ){ 7384 return this.filtered_posts; 7385 }else{ 7386 return this.filtered_posts.slice(0, this.post_limit); 7387 } 7388 }, 7389 filtered_posts(){ 7390 let val = this.search.toLowerCase(); 7391 let filteredPosts = this.allPosts; 7392 // handle search results using all posts 7393 if (this.search.length > 0){ 7394 filteredPosts = this.allPosts.filter(post => { 7395 let title = post.get('title').toLowerCase(); 7396 if ( title.includes(val) ){ 7397 return post; 7398 } 7399 }) 7400 } 7401 return filteredPosts; 7402 }, 7403 formatting_obj(){ 7404 if (this.formatting){ 7405 return JSON.parse(this.formatting); 7406 } 7407 return {}; 7408 }, 7409 } 7410 }) 7411</script>
7411
7411<script type="text/html" id="template-custom-code"> 7412 <template v-if="preview_element"> 7413 <div 7414 class="cs-custom-code-wrap" 7415 :class="[class_string, { 7416 'preview-mode': active_tab === 'preview', 7417 'first-page-section': is_first, 7418 'active-ai-thread-target': ai_panel_is_open_for_this_component, 7419 'custom-code-preview-blank': !custom_code_is_empty && custom_code_preview_is_blank, 7420 }]" 7421 :style="ai_loading_wrap_style" 7422 @mouseenter="activate_ai_builder_target_from_presence" 7423 > 7424 <!-- Tab bar --> 7425 <div class="custom-code-tab-bar"> 7426 <div 7427 class="custom-code-tab" 7428 :class="{ active: active_tab === 'edit' }" 7429 @click="active_tab = 'edit'" 7430 >Code</div> 7431 <div 7432 class="custom-code-tab" 7433 :class="{ active: active_tab === 'preview' }" 7434 @click="active_tab = 'preview'" 7435 >Preview</div> 7436 </div> 7437 <!-- Code pane --> 7438 <div v-show="active_tab === 'edit'" class="custom-code-pane"> 7439 <div ref="cm_editor" class="custom-code-editor"></div> 7440 </div> 7441 <!-- Preview pane --> 7442 <!-- Hide the welcome page during the pre-first-delta state. 7443 The loading pane below owns that window completely so 7444 the preview area does not show both the composer and 7445 "thinking" UI at the same time. --> 7446 <div 7447 v-show="active_tab === 'preview' && custom_code_is_empty && !has_ai_thread_for_component && !ai_streaming && !ai_streaming_pending && !ai_welcome_generating" 7448 class="custom-code-pane custom-code-preview-pane custom-code-welcome-pane" 7449 :class="{ 'is-welcome-drop-active': welcome_drop_active }" 7450 style="display: flex; align-items: center; justify-content: center; min-height: 420px; padding: 48px 20px; background: #fbfcff;" 7451 @dragenter="handle_welcome_dragenter" 7452 @dragover.prevent="handle_welcome_dragover" 7453 @dragleave="handle_welcome_dragleave" 7454 @drop.prevent="handle_welcome_drop" 7455 > 7456 <!-- Drop overlay (welcome composer) â same pattern as 7457 the chat panel's drop overlay; mounts only while 7458 a file drag is hovering this pane. --> 7459 <div 7460 v-if="welcome_file_upload_enabled && welcome_drop_active" 7461 aria-hidden="true" 7462 style="position: absolute; inset: 0; z-index: 5; display: flex; align-items: center; justify-content: center; background: rgba(244, 246, 255, 0.85); pointer-events: none;" 7463 > 7464 <div style="display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 18px 22px; border-radius: 12px; background: #fff; border: 1px solid #d9dfeb; color: #4a56f8; font-size: 13px; font-weight: 600; box-shadow: 0 6px 22px rgba(74, 86, 248, 0.15);"> 7465 <i class="fa-regular fa-image" style="font-size: 22px;"></i> 7466 <p class="noMargin">Drop images to attach</p> 7467 </div> 7468 </div> 7469 <div style="width: min(680px, 92%); margin: 0 auto; text-align: center;"> 7470 <div style="width: 54px; height: 54px; border-radius: 18px; margin: 0 auto 18px auto; display: flex; align-items: center; justify-content: center; background: rgba(74, 86, 248, 0.08); color: #4a56f8;"> 7471 <i class="fa-regular fa-wand-magic-sparkles" style="font-size: 22px;"></i> 7472 </div> 7473 <h3 class="noMargin" style="font-size: 24px; line-height: 32px; color: #081333; font-weight: 700;">Build a custom section</h3> 7474 <p class="noMargin" style="font-size: 14px; line-height: 22px; color: #748095; margin-top: 6px !important;">Start with AI, then refine the code directly whenever you need to.</p> 7475 <div class="flexWrap" style="gap: 8px; justify-content: center; margin: 22px auto 14px auto;"> 7476 <button 7477 v-for="example in ai_prompt_examples" 7478 :key="example" 7479 type="button" 7480 class="ai-composer-pill pointer exclude-from-custom-button-styles" 7481 @click.stop.prevent="set_ai_prompt_example(example)" 7482 >{{ example }}</button> 7483 </div> 7484 <!-- Attached image thumbnails â read-only mirror of 7485 the chat panel's mcp_attachments. Files queued 7486 here flow into the chat panel's state via the 7487 global ai_mcp_panel_attach_files helper, so 7488 submit ships them alongside the prompt without 7489 this composer needing its own prep pipeline. --> 7490 <div 7491 v-if="welcome_file_upload_enabled && welcome_attachments && welcome_attachments.length" 7492 style="display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin-bottom: 10px;" 7493 > 7494 <div 7495 v-for="(att, ai) in welcome_attachments" 7496 :key="'welcome-att-' + att.id" 7497 :title="att.name" 7498 style="position: relative; width: 56px; height: 56px; border-radius: 8px; overflow: hidden; background: #eef0f4; border: 1px solid #d9dfeb; flex: 0 0 auto;" 7499 :style="att.status === 'error' ? 'border-color: #e
749907b6f;' : ''" 7500 > 7501 <img 7502 v-if="att.preview_url" 7503 :src="att.preview_url" 7504 :alt="att.name" 7505 style="width: 100%; height: 100%; object-fit: cover; display: block;" 7506 :style="att.status === 'pending' ? 'opacity: 0.55;' : ''" 7507 /> 7508 <div 7509 v-if="att.status === 'pending'" 7510 style="position: absolute; top: 50%; left: 50%; width: 18px; height: 18px; margin: -9px 0 0 -9px; border-radius: 50%; border: 2px solid rgba(255,255,255,0.45); border-top-color: #fff; animation: aiMcpLauncherSpin 1s linear infinite; pointer-events: none;" 7511 aria-hidden="true" 7512 ></div> 7513 <button 7514 type="button" 7515 class="pointer exclude-from-custom-button-styles" 7516 style="position: absolute; top: 2px; right: 2px; width: 18px; height: 18px; padding: 0; margin: 0; border: none; border-radius: 999px; background: rgba(8, 19, 51, 0.65); color: #fff; font-size: 9px; cursor: pointer; display: inline-flex; align-items: center; justify-content: center;" 7517 :title="'Remove ' + (att.name || 'image')" 7518 @click.stop.prevent="welcome_remove_attachment(ai)" 7519 > 7520 <i class="fa-regular fa-xmark"></i> 7521 </button> 7522 </div> 7523 </div> 7524 <div style="position: relative; display: flex; align-items: center; gap: 10px; border: 1px solid rgba(8, 19, 51, 0.1); border-radius: 16px; background: #fff; box-shadow: 0 14px 45px rgba(8, 19, 51, 0.08); padding: 11px 12px 11px 15px; text-align: left;"> 7525 <div style="flex: 1 1 auto; min-width: 0;"> 7526 <p class="noMargin" style="font-size: 12px; line-height: 16px; color: #4a56f8; font-weight: 700; margin-bottom: 8px !important;"> 7527 <i class="fa-regular fa-sparkles" style="padding-right: 4px;"></i>Design with AI 7528 </p> 7529 <textarea 7530 ref="ai_prompt_textarea" 7531 v-model="ai_prompt" 7532 rows="1" 7533 class="ai-prompt-textarea" 7534 style="display: block; width: 100%; height: 21px; min-height: 21px; max-height: 168px; overflow-y: auto; border: none; box-shadow: none; resize: none; padding: 0; font-size: 15px; line-height: 21px; color: #081333;" 7535 placeholder="Describe the section you want to build..." 7536 :disabled="ai_prompt_loading" 7537 @input="auto_resize_ai_prompt" 7538 @paste="handle_welcome_paste" 7539 @keydown.enter.exact.prevent="submit_ai_prompt" 7540 ></textarea> 7541 </div> 7542 <input 7543 v-if="welcome_file_upload_enabled" 7544 ref="ai_prompt_file_input" 7545 type="file" 7546 accept="image/*" 7547 multiple 7548 style="display: none;" 7549 @change="handle_welcome_file_input_change" 7550 /> 7551 <button 7552 v-if="welcome_file_upload_enabled" 7553 type="button" 7554 class="pointer exclude-from-custom-button-styles" 7555 :disabled="ai_prompt_loading" 7556 title="Attach image" 7557 style="width: 32px; min-width: 32px; height: 32px; border: 0; border-radius: 999px; background: transparent; color: #5f6f8d; display: flex; justify-content: center; align-items: center; cursor: pointer; font-size: 15px;" 7558 @click.stop.prevent="open_welcome_file_picker" 7559 > 7560 <i class="fa-regular fa-paperclip"></i> 7561 </button> 7562 <button 7563 type="button" 7564 class="ai-submit-button easeFast exclude-from-custom-button-styles" 7565 :class="{ 7566 inactive: !ai_prompt_can_submit, 7567 loading: ai_prompt_loading 7568 }" 7569 :disabled="!ai_prompt_can_submit" 7570 title="Send" 7571 @click.stop.prevent="submit_ai_prompt" 7572 > 7573 <i class="fa-regular fa-paper-plane"></i> 7574 </button> 7575 </div> 7576 </div> 7577 </div> 7578 <!-- Welcome "Generatingâ¦" state â shown after the blank welcome 7579 composer hands the prompt to the page agent, until the first 7580 result lands (content arrives) or the safety timer fires. --> 7581 <div 7582 v-show="active_tab === 'preview' && custom_code_is_empty && ai_welcome_generating" 7583 class="custom-code-pane custom-code-preview-pane custom-code-welcome-generating-pane" 7584 style="display: flex; align-items: center; justify-content: center; min-height: 420px; padding: 48px 20px; background: #fbfcff;"
7585 aria-live="polite" 7586 > 7587 <div style="display: flex; flex-direction: column; align-items: center; gap: 14px; text-align: center;"> 7588 <span class="custom-code-welcome-generating-spinner" aria-hidden="true"></span> 7589 <p class="noMargin" style="font-size: 15px; line-height: 22px; color: #4a56f8; font-weight: 600;">Generatingâ¦</p> 7590 </div> 7591 </div> 7592 <div 7593 v-show="active_tab === 'preview' && custom_code_is_empty && has_ai_thread_for_component && !ai_streaming && !ai_streaming_pending && !ai_welcome_generating" 7594 class="custom-code-pane custom-code-preview-pane custom-code-empty-state-pane" 7595 > 7596 <div class="custom-code-empty-state-copy"> 7597 <i class="fa-regular fa-code"></i> 7598 <span>No content</span> 7599 </div> 7600 </div> 7601 <!-- Preview strips scripts / non-visual tags, so a section that 7602 only has those would otherwise collapse to 0 height. --> 7603 <div 7604 v-show="active_tab === 'preview' && !custom_code_is_empty && custom_code_preview_is_blank && (!ai_streaming || ai_streaming_pending || !ai_generation_active)" 7605 class="custom-code-pane custom-code-preview-pane custom-code-empty-state-pane custom-code-nonvisual-pane" 7606 > 7607 <div class="custom-code-empty-state-copy"> 7608 <i class="fa-regular fa-code"></i> 7609 <span>This section has code that isn't visible in preview</span> 7610 </div> 7611 </div> 7612 <!-- Static preview pane: HTML is mounted into an open shadow 7613 root (see ensure_isolated_custom_code_host) so host theme 7614 CSS cannot restyle the section â same isolation as live. --> 7615 <div 7616 v-show="active_tab === 'preview' && !custom_code_is_empty && !custom_code_preview_is_blank && (!ai_streaming || ai_streaming_pending || !ai_generation_active)" 7617 ref="custom_code_preview" 7618 class="custom-code-pane custom-code-preview-pane" 7619 :style="ai_loading_pane_style" 7620 ></div> 7621 <!-- Pre-first-delta loading. Empty sections get a full pane; 7622 edits to existing content use the same large dots as an 7623 overlay so the current section remains visible while the 7624 updated HTML is being prepared. --> 7625 <div 7626 v-show="active_tab === 'preview' && ai_streaming_pending" 7627 class="custom-code-pane custom-code-preview-pane custom-code-loading-pane" 7628 :class="{ 'is-overlay': !custom_code_is_empty }" 7629 :style="ai_loading_pane_style" 7630 aria-live="polite" 7631 > 7632 <div class="custom-code-pending-dots" aria-label="Generating"> 7633 <span></span><span></span><span></span> 7634 </div> 7635 </div> 7636 <!-- Streaming preview pane: managed by flush_streaming_preview_pane 7637 during a live SSE codegen stream. We bypass v-html so a 7638 tiny morph diff can append-or-replace top-level children 7639 by index â keeps already-rendered sections stable as new 7640 ones stream in below them. Snapped back to the v-html 7641 pane on stream `done`. --> 7642 <div 7643 v-show="active_tab === 'preview' && ai_streaming && !ai_streaming_pending && ai_generation_active" 7644 ref="custom_code_stream_preview" 7645 class="custom-code-pane custom-code-preview-pane" 7646 :style="ai_loading_pane_style" 7647 ></div> 7648 7649 <!-- AI builder launcher â bottom-right of the component. 7650 Clicking sets this component as the active target for 7651 the shared right-side AI panel (defined in 7652 ai-mcp-panel.html) and expands it. The panel's webbuilder 7653 mode then reads `current_code` server-side from
7654 pageComponents.config_latest.custom_code and runs in a 7655 thread pinned to this component instance. --> 7656 <button 7657 type="button" 7658 class="custom-code-ai-launcher pointer exclude-from-custom-button-styles" 7659 :class="{ 'has-active-stream': ai_generation_active }" 7660 @mousedown.stop.prevent 7661 @mouseup.stop 7662 @touchstart.stop 7663 @click.stop.prevent="open_ai_builder" 7664 :title="ai_launcher_title" 7665 v-if="ai_launcher_visible" 7666 > 7667 <i class="fa-regular fa-sparkles"></i> 7668 <span class="custom-code-ai-launcher-label"> 7669 <template v-if="effective_ai_streaming_status === 'queued'">Queuedâ¦</template> 7670 <template v-else-if="ai_generation_active">Generatingâ¦</template> 7671 <template v-else>Edit with AI</template> 7672 </span> 7673 <span 7674 v-if="ai_generation_active" 7675 class="custom-code-ai-launcher-spinner" 7676 aria-hidden="true" 7677 ></span> 7678 </button> 7679 </div> 7680 </template> 7681 <template v-if="!preview_element"> 7682 <!-- Live host: same shadow isolation as builder preview. --> 7683 <span v-if="!loading" ref="custom_code_static" class="cs-custom-code-live-host"></span> 7684 </template> 7685</script>
7685 7686
7687<script> 7688 // Reset + inheritance for custom-code shadow roots. Host theme element 7689 // selectors (h1/a/details/â¦) cannot pierce the shadow; :host inherits 7690 // color/font from the light-DOM wrapper so club theme still flows in. 7691 var CS_CUSTOM_CODE_ISOLATION_CSS = [ 7692 ':host { display: block; width: 100%; color: inherit; font-family: inherit; font-size: inherit; line-height: inherit; }', 7693 '.cs-cc-root { display: block; width: 100%; color: inherit; font: inherit; box-sizing: border-box; }', 7694 '.cs-cc-root *, .cs-cc-root *::before, .cs-cc-root *::after { box-sizing: border-box; }', 7695 '.cs-cc-root summary { list-style: none; cursor: pointer; }', 7696 '.cs-cc-root summary::-webkit-details-marker, .cs-cc-root summary::marker { display: none; }', 7697 '.cs-cc-root img { max-width: 100%; }', 7698 ].join('\n'); 7699 7700 // Builder-only overlays live inside the shadow tree. Document CSS 7701 // cannot pierce it, so these rules must ship with the isolation sheet 7702 // or Add/Change chips render unstyled and get clipped by overflow. 7703 var CS_CUSTOM_CODE_PREVIEW_CHROME_CSS = [ 7704 '.cs-asset-placeholder-host, .cs-asset-attached-host { position: relative; }', 7705 '.cs-asset-background-placeholder-host:hover { outline: 2px dashed rgba(74, 86, 248, 0.55) !important; outline-offset: -4px !important; }', 7706 '.cs-asset-background-placeholder-chip { position: absolute !important; top: 10px !important; right: 10px !important; z-index: 10000 !important; display: inline-flex !important; align-items: center !important; justify-content: center !important; width: 34px !important; height: 34px !important; padding: 0 !important; margin: 0 !important; border: 1px solid rgba(74, 86, 248, 0.45) !important; border-radius: 999px !important; background: rgba(255, 255, 255, 0.88) !important; color: #4a56f8 !important; box-shadow: 0 6px 18px rgba(8, 19, 51, 0.14) !important; cursor: pointer !important; appearance: none !important; }', 7707 '.cs-asset-background-placeholder-chip:hover { background: #ffffff !important; border-color: rgba(74, 86, 248, 0.85) !important; }', 7708 '.cs-asset-placeholder-overlay { position: absolute !important; inset: 0 !important; z-index: 125 !important; display: flex !important; flex-direction: column !important; align-items: center !important; justify-content: center !important; gap: 4px !important; margin: 0 !important; padding: 12px !important; border: 2px dashed rgba(74, 86, 248, 0.55) !important; border-radius: 8px !important; background: rgba(74, 86, 248, 0.08) !important; color: #2a2f5a !important; font-family: Roboto, Arial, Helvetica, sans-serif !important; font-size: 13px !important; font-style: normal !important; font-weight: 500 !important; line-height: 1.25 !important; letter-spacing: 0 !important; text-align: center !important; white-space: normal !important; cursor: pointer !important; backdrop-filter: blur(2px) !important; appearance: none !important; }', 7709 '.cs-asset-placeholder-overlay:hover { background: rgba(74, 86, 248, 0.16) !important; border-color: rgba(74, 86, 248, 0.85) !important; }', 7710 '.cs-asset-placeholder-icon { display: inline-flex !important; align-items: center !important; justify-content: center !important; width: 28px !important; height: 28px !important; flex: 0 0 28px !important; margin-bottom: 4px !important; border-radius: 50% !important; background: rgba(255, 255, 255, 0.65) !important; color: #4a56f8 !important; font-size: 14px !important; font-style: normal !important; line-height: 1 !important; }', 7711 '.cs-asset-placeholder-label { display: inline-block !important; padding: 4px 10px !important; border-radius: 999px !important; background: rgba(255, 255, 255, 0.92) !important; color: #2a2f5a !important; font-family: Roboto, Arial, Helvetica, sans-serif !important; font-size: 13px !important; font-weight: 600 !important; line-height: 1.2 !important; min-width: 118px !important; max-width: 180px !important; white-space: normal !important; }', 7712 '.cs-asset-change-button { position: absolute !important; top: 8px !important; right: 8px !important; z-index: 10000 !important; display: inline-flex !important; align-items: center !important; justify-content: center !important; padding: 6px 10px !important; margin: 0 !important; border: none !important; border-radius: 6px !important; background: rgba(20, 24, 45, 0.82) !important; color: #fff !important; font-family: Roboto, Arial, Helvetica, sans-serif !important; font-size: 12px !important; font-weight: 600 !important; font-style: normal !important; line-height: 1 !important; letter-spacing: 0 !important; text-transform: none !important; cursor: pointer !important; opacity: 0.92 !important; pointer-events: auto !important; appearance: none !important; box-shadow: 0 4px 12px rgba(8, 19, 51, 0.22) !important; }', 7713 '.cs-asset-change-button:hover, .cs-asset-change-button:focus { opacity: 1 !important; background: rgba(20, 24, 45, 0.95) !important; }', 7714 '.cs-ai-asset-hover-highlight { outline: 2px solid rgba(74, 86, 248, 0.32) !important; outline-offset: 4px !important; box-shadow: 0 0 0 6px rgba(74, 86, 248, 0.08) !important; }', 7715 '.cs-cc-root i[class*="fa-"] { font-family: "Font Awesome 6 Pro", "Font Awesome 6 Free", "Font Awesome 5 Pro", "Font Awesome 5 Free", "FontAwesome" !important; font-style: normal !important; }', 7716 ].join('\n'); 7717 7718 function createDebounce() { 7719 let timeout = null; 7720 return function (fnc, delayMs) { 7721 clearTimeout(timeout); 7722 timeout = setTimeout(() => { 7723 fnc(); 7724 }, delayMs || 800); 7725 }; 7726 } 7727
7728 // âââ External image detection (shared) ââââââââââââââââââââââââââââââ 7729 // 7730 // Shared by the custom-code component and section-actions overlay. 7731 var WEBBUILDER_CLUBSPOT_ASSET_HOSTS = [ 7732 'd282wvk2qi4wzk.cloudfront.net', 7733 'myclubspot.s3-us-west-2.amazonaws.com', 7734 'myclubspot.s3.us-west-2.amazonaws.com', 7735 ]; 7736 7737 function webbuilder_is_external_image_url(url) { 7738 if (typeof url !== 'string' || !url.trim()) { 7739 return false; 7740 } 7741 let parsed; 7742 try { 7743 parsed = new URL(url.trim()); 7744 } catch (err) { 7745 return false; 7746 } 7747 if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { 7748 return false; 7749 } 7750 return WEBBUILDER_CLUBSPOT_ASSET_HOSTS.indexOf((parsed.hostname || '').toLowerCase()) === -1; 7751 } 7752 7753 // Parse an HTML string and return the unique external image URLs it 7754 // references â both <img src> and inline `background-image: url(...)`. 7755 function webbuilder_collect_external_image_urls(source_html) { 7756 let source = (typeof source_html === 'string') ? source_html : ''; 7757 if (!source.trim()) { 7758 return []; 7759 } 7760 let found = []; 7761 let seen = {}; 7762 let push_if_external = (raw_url) => { 7763 let candidate = (typeof raw_url === 'string') ? raw_url.trim() : ''; 7764 if (!candidate || seen[candidate]) { 7765 return; 7766 } 7767 if (!webbuilder_is_external_image_url(candidate)) { 7768 return; 7769 } 7770 seen[candidate] = true; 7771 found.push(candidate); 7772 }; 7773 let template = document.createElement('template'); 7774 template.innerHTML = source; 7775 let root = template.content; 7776 let imgs = Array.from(root.querySelectorAll('img[src]')); 7777 for (let img of imgs) { 7778 push_if_external(img.getAttribute('src')); 7779 } 7780 let styled = Array.from(root.querySelectorAll('[style*="background-image"]')); 7781 for (let el of styled) { 7782 let style = el.getAttribute('style') || ''; 7783 let bg_pattern = /background-image\s*:\s*url\(\s*(['"]?)([^'"\)]+)\1\s*\)/gi; 7784 let match; 7785 while ((match = bg_pattern.exec(style)) !== null) { 7786 push_if_external(match[2]); 7787 } 7788 } 7789 return found; 7790 } 7791 7792 // âââ Text editing âââââââââââââââââââââââââââââââââââââââââââââââââââ 7793 // 7794 // In PREVIEW mode we walk the rendered DOM looking for "text leaf" 7795 // elements (headings, paragraphs, buttons, links, list items, etc.) 7796 // and decorate each one with a click-to-edit affordance. Clicking 7797 // opens a small inline popover with a textarea; on save we mutate 7798 // the persisted custom_code source and persist via 7799 // save_changes_for_component, mirroring the placeholder fill flow. 7800 // 7801 // Unlike images, there's no "placeholder" concept for text â the AI 7802 // writes plausible draft copy directly into the section and admins 7803 // tweak it inline. Round-tripping (preview path â source path) 7804 // works because the preview's DOM is parsed from the same source 7805 // string we mutate, with the only structural divergence being 7806 //
7806<script> tags removed by sanitize_custom_code_html (which we 7807 // skip on the source side when walking). 7808 // 7809 // A "text leaf" is any element whose element children are all 7810 // inline tags (or none). That treats `<h1>Welcome <em>back</em></h1>` 7811 // as a single editable string â emphasis collapses on save, by 7812 // design. Admins can use the Code tab for mixed inline formatting. 7813 7814 var TEXT_INLINE_TAGS = new Set([ 7815 'a', 'abbr', 'b', 'bdi', 'bdo', 'br', 'cite', 'code', 'em', 'i', 7816 'kbd', 'mark', 'q', 's', 'samp', 'small', 'span', 'strong', 7817 'sub', 'sup', 'time', 'u', 'var', 'wbr', 7818 ]); 7819 7820 var TEXT_SKIP_TAGS = new Set([ 7821 'script', 'style', 'template', 'noscript', 'iframe', 'svg', 7822 'video', 'audio', 'canvas', 'img', 'picture', 'source', 'track', 7823 'input', 'textarea', 'select', 'option', 'optgroup', 'meta', 7824 'link', 'br', 'hr', 'col', 'param', 'embed', 'object', 7825 ]); 7826 7827 // Tags that never produce a layout box in the sanitized preview. 7828 var CUSTOM_CODE_PREVIEW_SKIP_TAGS = new Set([ 7829 'script', 'style', 'template', 'noscript', 'meta', 'link', 'head', 7830 ]); 7831 // Replaced / form controls that take space even without text. 7832 var CUSTOM_CODE_PREVIEW_VISUAL_TAGS = new Set([ 7833 'img', 'iframe', 'video', 'audio', 'canvas', 'svg', 'object', 'embed', 7834 'picture', 'input', 'textarea', 'select', 'hr', 'br', 7835 ]); 7836 7837 function custom_code_element_has_explicit_preview_box(element) { 7838 let style = element.getAttribute('style'); 7839 if (!style || typeof style !== 'string') { 7840 return false; 7841 } 7842 if (/background-image\s*:\s*url\s*\(/i.test(style)) { 7843 return true; 7844 } 7845 let height_match = style.match(/(?:^|;)\s*(?:min-)?height\s*:\s*([^;]+)/i); 7846 if (!height_match) { 7847 return false; 7848 } 7849 let value = height_match[1].trim().toLowerCase(); 7850 if (!value || value === '0' || value === '0px' || value === '0%' || value === 'auto') { 7851 return false; 7852 } 7853 return true; 7854 } 7855 7856 function custom_code_node_has_visible_preview_content(node) { 7857 if (!node) { 7858 return false; 7859 } 7860 if (node.nodeType === 8) { 7861 return false; 7862 } 7863 if (node.nodeType === 3) { 7864 return !!(node.nodeValue && node.nodeValue.trim()); 7865 } 7866 if (node.nodeType !== 1) { 7867 let children = node.childNodes || []; 7868 for (let i = 0; i < children.length; i++) { 7869 if (custom_code_node_has_visible_preview_content(children[i])) { 7870 return true; 7871 } 7872 } 7873 return false; 7874 } 7875 let tag = (node.tagName || '').toLowerCase(); 7876 if (CUSTOM_CODE_PREVIEW_SKIP_TAGS.has(tag)) { 7877 return false; 7878 } 7879 if (CUSTOM_CODE_PREVIEW_VISUAL_TAGS.has(tag)) { 7880 return true; 7881 } 7882 if (custom_code_element_has_explicit_preview_box(node)) { 7883 return true; 7884 } 7885 for (let i = 0; i < node.childNodes.length; i++) { 7886 if (custom_code_node_has_visible_preview_content(node.childNodes[i])) { 7887 return true; 7888 } 7889 } 7890 return false; 7891 } 7892 7893 function custom_code_html_has_visible_preview_content(html) { 7894 if (!html || typeof html !== 'string' || !html.trim()) { 7895 return false; 7896 } 7897 let template = document.createElement('template'); 7898 template.innerHTML = html; 7899 return custom_code_node_has_visible_preview_content(template.content); 7900 } 7901 7902 app_content.component('custom-code-component', { 7903 template: '#template-custom-code', 7904 data() { 7905 return { 7906 loading: true, 7907 debounce: createDebounce(), 7908 custom_code_value: '', 7909 active_tab: 'preview', 7910 // True while the section-actions "save external images" 7911 // batch is in flight, so duplicate overlay triggers no-op. 7912 importing_external_images: false, 7913 ai_prompt: '', 7914 ai_prompt_examples: [ 7915 'A modern hero section for our club homepage', 7916 'A membership benefits section with three cards', 7917 'An event promo section with image placeholders', 7918 ], 7919 ai_launcher_title: 'Edit with AI', 7920 // âââ Streaming state âââââââââââââââââââââââââââââââ 7921 // Flipped to true by begin_streaming_code() and back 7922 // to false by finish_streaming_code(). Controls the 7923 // template's preview-pane selection (v-html static vs. 7924 // morph-managed div) and the overlay visibility. 7925 ai_streaming: false, 7926 ai_streaming_status: null, // 'streaming' | 'queued' | 'aborted_local' | 'error' | 'done' 7927 // Snapshot of the editor contents at stream begin â 7928 // used by apply_generated_code as the return value 7929 // (Undo target) when the panel's done handler runs. 7930 ai_streaming_pre_code: null, 7931 // Accumulated code chunks. Always the full snapshot 7932 // of what the stream has emitted so far (chunks are 7933 // appended as they arrive). 7934 ai_streaming_buffer: '', 7935 // Local reactive tick for store.ai_streams snapshots. 7936 // Bumped by subscriber events; lets computeds that 7937 // read entry.code_buffer / entry.message_text 7938 // re-evaluate. Launcher status no longer depends on 7939 // this â that lives in store.codegen_request_state. 7940 ai_stream_event_tick: 0, 7941 ai_loading_pane_height: null, 7942 ai_loading_pane_height_release_timer: null, 7943 ai_streaming_child_min_heights: [], 7944 // Welcome composer drag-drop counter â increments on 7945 // dragenter, decrements on dragleave so the overlay 7946 // doesn't flicker on nested-element drag events. The 7947 // attachment LIST itself lives on the chat panel 7948 // (store.components.ai_mcp_panel.mcp_attachments) so 7949 // both composers stay in sync without a second prep 7950 // pipeline. 7951 welcome_drop_counter: 0, 7952 welcome_drop_active: false, 7953 // True between submitting from the blank welcome composer and 7954 // the page agent's first result landing. Swaps the welcome 7955 // composer for a "Generatingâ¦" pane. The codegen stream no 7956 // longer drives this flow (the page agent does), so this is a 7957 // local flag cleared when content arrives or on a safety timer. 7958 ai_welcome_generating: false, 7959 } 7960 }, 7961 props: { 7962 custom_code: String, 7963 //admin 7964 class_string: String, 7965 preview_element: { 7966 type: Boolean, 7967 default: false, 7968 }, 7969 is_first: { 7970 type: Boolean, 7971 default: false, 7972 }, 7973 component: { 7974 type: Object, 7975 default: null, 7976 }, 7977 first_section_top_spacer_px_override: { 7978 type: Number, 7979 default: null, 7980 }, 7981 last_update: Number, 7982 }, 7983 watch: { 7984 active_tab(val) { 7985 if (val !== 'preview') { 7986 this.close_text_edit_popover(); 7987 } 7988 if (val === 'edit' && this.cm_instance) { 7989 this.$nextTick(() => this.cm_instance.refresh()); 7990 } 7991 if (val === 'preview') { 7992 this.$nextTick(() => this.initialize_custom_code_interactions()); 7993 } 7994 this.publish_active_tab_to_store(); 7995 }, 7996 // First result from the page agent has landed â drop the welcome 7997 // "Generatingâ¦" pane so the new section renders. 7998 custom_code_value(val) { 7999 if (this.ai_welcome_generating && (val || '').trim()) { 8000 this.clear_welcome_generating_state(); 8001 } 8002 }, 8003 last_update() { 8004 this.close_text_edit_popover(); 8005 // Don't fight a LIVE stream â the streaming buffer is the 8006 // source of truth until the build settles. But only an actually 8007 // active stream (entry still streaming/queued) should block the 8008 // resync: a stale `ai_streaming` flag (entry terminal or gone, 8009 // e.g. an aborted/errored build) must NOT wedge the section on 8010 // old HTML, or the agent's authoritative __refresh re-pull can 8011 // never land. Otherwise sync the preview straight from the 8012 // component config â the live preview repaints off 8013 // custom_code_value even when the (hidden) editor isn't mounted. 8014 let entry = this.active_codegen_stream_entry; 8015 let stream_active = this.ai_streaming 8016 && entry 8017 && (entry.status === 'streaming' || entry.status === 'queued'); 8018 if (!stream_active) { 8019 if (this.ai_streaming) { 8020 // Stale flag â release the editor lock before syncing. 8021 this.force_clear_ai_loading_state(); 8022 } 8023 let config_latest = this.component.get('config_latest') || {}; 8024 let new_value = config_latest.custom_code || ''; 8025 if (this.cm_instance && new_value !== this.cm_instance.getValue()) { 8026 this.cm_instance.setValue(new_value); 8027 } 8028 this.custom_code_value = new_value; 8029 this.publish_current_code_to_store(); 8030 } 8031 this.$nextTick(() => this.initialize_custom_code_interactions()); 8032 }, 8033 // Re-measure the auto-grow welcome composer whenever the 8034 // prompt changes from outside the textarea â example pill 8035 // click, post-submit clear. Typing still routes through 8036 // @input for synchronous response. 8037 ai_prompt() { 8038 this.$nextTick(() => this.auto_resize_ai_prompt()); 8039 }, 8040 // Editor-lock cleanup. The launcher computed reads the 8041 // store entry directly (and the 1Hz heartbeat keeps it
8042 // fresh), so this watcher is no longer the launcher's 8043 // safety net â it just makes sure `ai_streaming` and the 8044 // editor's readOnly flag release when the entry goes 8045 // terminal, since the morph-preview / editor lock are 8046 // still driven from local flags rather than the store. 8047 ai_stream_event_tick() { 8048 let entry = this.active_codegen_stream_entry; 8049 if (!entry) { 8050 return; 8051 } 8052 if (entry.status !== 'done' && entry.status !== 'error' && entry.status !== 'aborted_local') { 8053 return; 8054 } 8055 // Editor-lock-only cleanup: this watcher does not 8056 // gate launcher visibility (the launcher binds to 8057 // store.codegen_request_state). It exists to release 8058 // ai_streaming / readOnly when the central store 8059 // says the request is over, in case the subscription 8060 // path missed an event. 8061 let needs_clear = this.ai_streaming 8062 || this.ai_streaming_status === 'streaming' 8063 || this.ai_streaming_status === 'queued'; 8064 if (!needs_clear) { 8065 return; 8066 } 8067 this.force_clear_ai_loading_state(); 8068 }, 8069 }, 8070 computed: { 8071 safe_custom_code_value() { 8072 return this.sanitize_custom_code_html(this.custom_code_value); 8073 }, 8074 safe_custom_code() { 8075 return this.sanitize_custom_code_html(this.custom_code || ''); 8076 }, 8077 // Source emptiness, not sanitized HTML â a
8077<script> load is 8078 // still "has code" even though preview strips it. 8079 custom_code_is_empty() { 8080 return !(this.custom_code_value || '').trim(); 8081 }, 8082 custom_code_preview_is_blank() { 8083 return !custom_code_html_has_visible_preview_content(this.safe_custom_code_value); 8084 }, 8085 // Unique external (non-Clubspot-CDN) image URLs referenced 8086 // by this section's persisted HTML â both <img src> and 8087 // inline `background-image: url(...)`. Drives the bulk 8088 // "save external images to my library" affordance. Recomputes 8089 // only when custom_code_value changes, so parsing cost is 8090 // bounded to actual edits. 8091 section_external_image_urls() { 8092 return this.collect_section_external_image_urls(this.custom_code_value); 8093 }, 8094 section_external_image_count() { 8095 return this.section_external_image_urls.length; 8096 }, 8097 ai_loading_pane_style() { 8098 if (!this.ai_loading_pane_height) { 8099 return null; 8100 } 8101 return { 8102 minHeight: this.ai_loading_pane_height + 'px', 8103 }; 8104 }, 8105 ai_loading_wrap_style() { 8106 if (!this.ai_loading_pane_height) { 8107 return null; 8108 } 8109 return { 8110 minHeight: this.ai_loading_pane_height + 'px', 8111 }; 8112 }, 8113 has_ai_thread_for_component() { 8114 if (typeof store === 'undefined') { 8115 return false; 8116 } 8117 let component_id = this.component && this.component.id; 8118 if (!component_id) { 8119 return false; 8120 } 8121 let config_latest = this.component && typeof this.component.get === 'function' 8122 ? (this.component.get('config_latest') || {}) 8123 : {}; 8124 if (config_latest.ai_thread_id) { 8125 return true; 8126 } 8127 let thread_map = store.webbuilder_code_component_has_ai_thread || {}; 8128 return !!thread_map[component_id]; 8129 }, 8130 ai_prompt_loading() { 8131 if (typeof store === 'undefined' || !store.components || !store.components.ai_mcp_panel) { 8132 return false; 8133 } 8134 return !!store.components.ai_mcp_panel.mcp_loading; 8135 }, 8136 ai_prompt_can_submit() { 8137 if (!(this.ai_prompt || '').trim().length) { 8138 return false; 8139 } 8140 return !this.ai_prompt_loading; 8141 }, 8142 // âââ Welcome composer image attachments ââââââââââââââââââ 8143 // 8144 // The welcome-state composer (rendered when a custom-code 8145 // section is empty and there's no prior AI thread) gets a 8146 // paperclip + drag-drop + thumbnail row mirroring the 8147 // chat panel. To avoid duplicating the canvas-resize / 8148 // base64 prep pipeline, we delegate ALL state to the chat 8149 // panel via the global ai_mcp_panel_attach_files helper. 8150 // This computed is just a reactive read of that shared 8151 // state so the welcome composer's thumbnails update in 8152 // lockstep with the chat panel's thumbnails. 8153 welcome_file_upload_enabled() { 8154 if (typeof store === 'undefined' || !store.ai_mcp_panel_component_instance) { 8155 return false; 8156 } 8157 return !!store.ai_mcp_panel_component_instance.mode_enable_file_upload; 8158 }, 8159 welcome_attachments() { 8160 if (typeof store === 'undefined' || !store.components || !store.components.ai_mcp_panel) { 8161 return []; 8162 } 8163 let panel = store.components.ai_mcp_panel; 8164 return Array.isArray(panel.mcp_attachments) ? panel.mcp_attachments : []; 8165 }, 8166 // âââ Single source of truth: store.codegen_request_state ââ 8167 // 8168 // The launcher's "Generatingâ¦" UI reads ONE reactive field: 8169 // store.codegen_request_state[component_id] 8170 // 8171 // That field is owned by store.ai_streams (the central
8172 // streams manager in ai-mcp-panel.html) and written ONLY 8173 // from there as the request transitions through: 8174 // 'starting' â 'queued' â 'streaming' â 'done' / 'error' / 'aborted' 8175 // 8176 // Because `store` is a Vue 3 `reactive()` proxy, both the 8177 // launcher (this file) and the chat panel bind to the same 8178 // key reactively. START and STOP are visible to both 8179 // surfaces from a single write â no subscriptions, no 8180 // ticks, no heartbeats, no watchdog needed. 8181 // 8182 // Local flags (`ai_streaming`, `ai_streaming_buffer`, 8183 // `ai_streaming_status`) still drive the EDITOR LOCK / 8184 // morph preview pane via the subscription path; those 8185 // concerns are deliberately decoupled from the launcher 8186 // so a missed editor-side event can't strand the spinner. 8187 codegen_request_status() { 8188 if (typeof store === 'undefined' || !store.codegen_request_state) { 8189 return null; 8190 } 8191 let component_id = this.component && this.component.id; 8192 if (!component_id) { 8193 return null; 8194 } 8195 return store.codegen_request_state[component_id] || null; 8196 }, 8197 active_codegen_stream_entry() { 8198 /* eslint-disable-next-line no-unused-expressions */ 8199 this.ai_stream_event_tick; 8200 if (typeof store === 'undefined' || !store.ai_streams || !store.ai_streams.get) { 8201 return null; 8202 } 8203 let component_id = this.component && this.component.id; 8204 if (!component_id) { 8205 return null; 8206 } 8207 return store.ai_streams.get('codegen:' + component_id); 8208 }, 8209 effective_ai_streaming_status() { 8210 let s = this.codegen_request_status; 8211 if (s) { 8212 return s; 8213 } 8214 return this.ai_streaming_status; 8215 }, 8216 // Launcher visibility â one rule, one field. 8217 ai_generation_active() { 8218 let s = this.codegen_request_status; 8219 return s === 'starting' || s === 'queued' || s === 'streaming'; 8220 }, 8221 ai_panel_is_open() { 8222 if (typeof store === 'undefined' || !store.components || !store.components.ai_mcp_panel) { 8223 return false; 8224 } 8225 return !!store.components.ai_mcp_panel.expanded; 8226 }, 8227 ai_panel_is_open_for_this_component() { 8228 if (!this.ai_panel_is_open || typeof store === 'undefined') { 8229 return false; 8230 } 8231 store.webbuilder_code_active_component_revision; 8232 let component_id = this.component && this.component.id; 8233 if (!component_id) { 8234 return false; 8235 } 8236 return store.webbuilder_code_active_component_id === component_id; 8237 }, 8238 // Idle Edit with AI lives on the hover toolbar. This in-section 8239 // pill is only the generating/queued indicator. 8240 ai_launcher_visible() { 8241 if (this.ai_panel_is_open) { 8242 return false; 8243 } 8244 return this.ai_generation_active; 8245 }, 8246 // Dots overlay â visible while we have a stream that 8247 // hasn't started emitting code yet. Reads the central 8248 // status for the bookended states; reads the entry's 8249 // code_buffer (still owned by the streams manager) to 8250 // distinguish "streaming but no chunks yet" from 8251 // "streaming with content". 8252 ai_streaming_pending() { 8253 let s = this.codegen_request_status; 8254 if (s === 'starting' || s === 'queued') { 8255 return true; 8256 } 8257 if (s !== 'streaming') { 8258 return false;
8259 } 8260 let entry = this.active_codegen_stream_entry; 8261 if (this.ai_streaming_buffer && this.ai_streaming_buffer.trim()) { 8262 return false; 8263 } 8264 return !!(entry && !(entry.code_buffer || '').trim()); 8265 }, 8266 }, 8267 mounted() { 8268 this.loading = false; 8269 // Listen for the section-actions overlay's "Save external 8270 // images" action. The overlay only has the component Parse 8271 // object, so it broadcasts the id and the matching live 8272 // instance runs the batch (which owns the persist + 8273 // re-render path). 8274 if (typeof store !== 'undefined' && store.eventBus) { 8275 this._save_external_images_handler = (component) => { 8276 let target_id = component && component.id ? component.id : component; 8277 if (this.component && this.component.id === target_id) { 8278 this.import_all_external_images_in_section(); 8279 } 8280 }; 8281 store.eventBus.$on('webbuilderSaveExternalImages', this._save_external_images_handler); 8282 this._webbuilder_refresh_replay_handler = () => { 8283 let component_id = this.component && this.component.id; 8284 if (!component_id || !store.ai_streams || typeof store.ai_streams.get !== 'function') { 8285 return; 8286 } 8287 let entry = store.ai_streams.get('codegen:' + component_id); 8288 if (!entry) { 8289 return; 8290 } 8291 this.subscribe_to_codegen_stream(); 8292 }; 8293 store.eventBus.$on('webbuilderPageRefreshReconciled', this._webbuilder_refresh_replay_handler); 8294 } 8295 if (this.preview_element) { 8296 let config_latest = (this.component && this.component.get('config_latest')) || {}; 8297 this.custom_code_value = config_latest.custom_code || ''; 8298 // Publish the live editor contents to the shared store 8299 // so the chat panel's per-message apply-state badge 8300 // can diff against it reactively without needing a 8301 // back-reference to this component. 8302 this.publish_current_code_to_store(); 8303 this.$nextTick(() => { 8304 this.cm_instance = CodeMirror(this.$refs.cm_editor, { 8305 value: this.custom_code_value, 8306 mode: 'htmlmixed', 8307 lineNumbers: true, 8308 autoCloseBrackets: true, 8309 autoCloseTags: true, 8310 matchBrackets: true, 8311 lineWrapping: true, 8312 tabSize: 2, 8313 indentWithTabs: false, 8314 extraKeys: { 8315 'Tab': function(cm) { 8316 cm.replaceSelection(' ', 'end'); 8317 } 8318 }, 8319 }); 8320 this.cm_instance.on('change', (editor) => { 8321 // Suppress save while we're driving the editor 8322 // from a streaming buffer â the stream's `done` 8323 // handler runs the canonical save once at the 8324 // end, and persisting partial frames would 8325 // spam the API. 8326 if (this._suppress_change_save) { 8327 // Still surface the live value to the 8328 // store so the apply-state badge stays 8329 // accurate during the typewriter â just 8330 // don't fire the debounced save. 8331 this.custom_code_value = editor.getValue(); 8332 this.publish_current_code_to_store(); 8333 return; 8334 } 8335 this.debounce(() => { 8336 let value = editor.getValue(); 8337 this.custom_code_value = value; 8338 this.publish_current_code_to_store(); 8339 save_changes_for_component(this.component, 'custom_code', value); 8340 this.mark_ai_code_revision_changed(); 8341 }); 8342 }); 8343 }); 8344 // Subscribe to this component's codegen stream key so 8345 // the editor + preview update live, independently of 8346 // whether the chat panel is open. Re-subscribing on 8347 // every mount also covers the page-reload recovery 8348 // case â if a passive entry exists for our key, the 8349 // replay event hands us the partial buffer. 8350 this.subscribe_to_codegen_stream(); 8351 } 8352 this.$nextTick(() => { 8353 this.initialize_custom_code_interactions(); 8354 this.auto_resize_ai_prompt(); 8355 this.setup_ai_builder_presence_observer(); 8356 this.publish_active_tab_to_store(); 8357 if (typeof store !== 'undefined' && 8358 store.webbuilder_code_active_component_id === (this.component && this.component.id)) { 8359 store.webbuilder_code_active_component = this; 8360 store.webbuilder_code_active_component_revision = Date.now(); 8361 this.subscribe_to_codegen_stream(); 8362 } 8363 }); 8364 }, 8365 beforeUnmount() { 8366 this.close_text_edit_popover(); 8367 this.cm_instance = null; 8368 if (typeof store !== 'undefined' && store.eventBus && this._save_external_images_handler) { 8369 if (typeof store.eventBus.$off === 'function') { 8370 store.eventBus.$off('webbuilderSaveExternalImages', this._save_external_images_handler); 8371 } else if (store.eventBus.listeners && Array.isArray(store.eventBus.listeners.webbuilderSaveExternalImages)) { 8372 store.eventBus.listeners.webbuilderSaveExternalImages = store.eventBus.listeners.webbuilderSaveExternalImages.filter(callback => { 8373 return callback !== this._save_external_images_handler; 8374 }); 8375 } 8376 this._save_external_images_handler = null; 8377 } 8378 if (typeof store !== 'undefined' && store.eventBus && this._webbuilder_refresh_replay_handler) { 8379 if (typeof store.eventBus.$off === 'function') { 8380 store.eventBus.$off('webbuilderPageRefreshReconciled', this._webbuilder_refresh_replay_handler); 8381 } else if (store.eventBus.listeners && Array.isArray(store.eventBus.listeners.webbuilderPageRefreshReconciled)) { 8382 store.eventBus.listeners.webbuilderPageRefreshReconciled = store.eventBus.listeners.webbuilderPageRefreshReconciled.filter(callback => { 8383 return callback !== this._webbuilder_refresh_replay_handler; 8384 }); 8385 } 8386 this._webbuilder_refresh_replay_handler = null; 8387 } 8388 // Unsubscribe â but do NOT cancel the underlying stream. 8389 // The entry stays alive in store.ai_streams so a remount 8390 // (e.g. component re-rendered after a save) can re-attach 8391 // and continue showing progress. 8392 if (typeof this._codegen_stream_unsubscribe === 'function') { 8393 try { this._codegen_stream_unsubscribe(); } catch (e) {} 8394 this._codegen_stream_unsubscribe = null; 8395 } 8396 if (this._codegen_stream_raf) { 8397 cancelAnimationFrame(this._codegen_stream_raf); 8398 this._codegen_stream_raf = null; 8399 } 8400 if (this._ai_streaming_starting_timer) { 8401 clearTimeout(this._ai_streaming_starting_timer); 8402 this._ai_streaming_starting_timer = null; 8403 } 8404 if (this._ai_welcome_generating_timer) { 8405 clearTimeout(this._ai_welcome_generating_timer); 8406 this._ai_welcome_generating_timer = null; 8407 } 8408 if (this.ai_loading_pane_height_release_timer) { 8409 clearTimeout(this.ai_loading_pane_height_release_timer); 8410 this.ai_loading_pane_height_release_timer = null; 8411 } 8412 if (this._ai_builder_presence_observer) { 8413 this._ai_builder_presence_observer.disconnect(); 8414 this._ai_builder_presence_observer = null; 8415 } 8416 if (this._ai_builder_presence_timeout) { 8417 clearTimeout(this._ai_builder_presence_timeout); 8418 this._ai_builder_presence_timeout = null; 8419 } 8420 // If this component was the active target for the AI 8421 // builder, clear the pointer so the panel doesn't try to 8422 // apply code into a Vue instance that's no longer mounted. 8423 if (typeof store !== 'undefined' && store.webbuilder_code_active_component === this) { 8424 store.webbuilder_code_active_component = null; 8425 store.webbuilder_code_active_component_id = null; 8426 }
8427 // Drop this section's published tab state so a stale 'edit' value 8428 // can't keep the launcher shown after the section is gone. 8429 if (typeof store !== 'undefined' 8430 && store.webbuilder_custom_code_tab_by_client_id 8431 && this.component 8432 && typeof this.component.get === 'function') { 8433 let cid = this.component.get('client_id'); 8434 if (cid) { 8435 delete store.webbuilder_custom_code_tab_by_client_id[cid]; 8436 } 8437 } 8438 }, 8439 methods: { 8440 page_has_multiple_custom_code_components() { 8441 if (typeof store === 'undefined' || !Array.isArray(store.page_components_latest)) { 8442 return false; 8443 } 8444 let custom_code_count = 0; 8445 for (let i = 0; i < store.page_components_latest.length; i++) { 8446 let page_component = store.page_components_latest[i]; 8447 if (page_component && page_component.get && page_component.get('type') === 'custom-code') { 8448 custom_code_count++; 8449 } 8450 if (custom_code_count > 1) { 8451 return true; 8452 } 8453 } 8454 return false; 8455 }, 8456 setup_ai_builder_presence_observer() { 8457 if (!this.preview_element) { 8458 return; 8459 } 8460 if (!this.page_has_multiple_custom_code_components()) { 8461 return; 8462 } 8463 if (typeof IntersectionObserver === 'undefined') { 8464 return; 8465 } 8466 if (!this.$el || this.$el.nodeType !== 1) { 8467 return; 8468 } 8469 this._ai_builder_presence_observer = new IntersectionObserver(entries => { 8470 entries.forEach(entry => { 8471 if (entry && entry.isIntersecting && entry.intersectionRatio >= 0.55) { 8472 this.activate_ai_builder_target_from_presence(); 8473 } 8474 }); 8475 }, { 8476 threshold: [0.55], 8477 }); 8478 this._ai_builder_presence_observer.observe(this.$el); 8479 }, 8480 activate_ai_builder_target_from_presence() { 8481 if (typeof store === 'undefined') { 8482 return; 8483 } 8484 if (!this.page_has_multiple_custom_code_components()) { 8485 return; 8486 } 8487 if (!store.ai_mcp_floating_panel_ui) { 8488 return; 8489 } 8490 let panel_state = store.components && store.components.ai_mcp_panel; 8491 if (!panel_state || !panel_state.expanded) { 8492 return; 8493 } 8494 if (panel_state.mcp_loading || (panel_state.mcp_prompt || '').trim()) { 8495 return; 8496 } 8497 let messages = Array.isArray(panel_state.mcp_messages) ? panel_state.mcp_messages : []; 8498 let last_message = messages.length ? messages[messages.length - 1] : null; 8499 if (last_message && last_message.streaming) { 8500 return; 8501 } 8502 let component_id = this.component && this.component.id ? this.component.id : null; 8503 if (!component_id || store.webbuilder_code_active_component_id === component_id) { 8504 return; 8505 } 8506 if (this._ai_builder_presence_timeout) { 8507 clearTimeout(this._ai_builder_presence_timeout); 8508 } 8509 this._ai_builder_presence_timeout = setTimeout(() => { 8510 this.activate_ai_builder_target(); 8511 this._ai_builder_presence_timeout = null; 8512 }, 120); 8513 }, 8514 activate_ai_builder_target(options) { 8515 if (typeof store === 'undefined') { 8516 return; 8517 } 8518 // Reclaim the per-component codegen mode in case the 8519 // page-level webbuilder-agent launcher switched it. 8520 store.ai_mcp_mode = 'webbuilder-code'; 8521 let opts = options || {}; 8522 let previous_component_id = store.webbuilder_code_active_component_id || null; 8523 store.webbuilder_code_active_component = this; 8524 store.webbuilder_code_active_component_id = this.component && this.component.id ? this.component.id : null; 8525 store.webbuilder_code_active_component_revision = Date.now(); 8526 this.subscribe_to_codegen_stream(); 8527 let panel = store.ai_mcp_panel_component_instance || null; 8528 if (panel && typeof panel.switch_webbuilder_component_thread === 'function') { 8529 panel.switch_webbuilder_component_thread( 8530 store.webbuilder_code_active_component_id, 8531 previous_component_id, 8532 opts 8533 ); 8534 } 8535 }, 8536 set_ai_prompt_example(example) { 8537 this.ai_prompt = example || ''; 8538 }, 8539 // âââ Welcome composer attachment handlers ââââââââââââââââ 8540 // 8541 // All paths funnel through the global helper 8542 // ai_mcp_panel_attach_files which writes into the shared 8543 // chat panel state (canvas-resize + base64 prep happens 8544 // there). The welcome composer is a UI-only mirror â no 8545 // local Blob storage, no local prep. 8546 open_welcome_file_picker() { 8547 if (!this.welcome_file_upload_enabled) { 8548 return; 8549 } 8550 if (this.ai_prompt_loading) { 8551 return; 8552 } 8553 let input = this.$refs && this.$refs.ai_prompt_file_input; 8554 if (!input) { 8555 return; 8556 } 8557 input.click(); 8558 }, 8559 handle_welcome_file_input_change(event) { 8560 let files = event && event.target && event.target.files 8561 ? Array.from(event.target.files) 8562 : []; 8563 if (event && event.target) { 8564 event.target.value = ''; 8565 } 8566 if (!files.length) { 8567 return; 8568 } 8569 if (typeof ai_mcp_panel_attach_files === 'function') { 8570 ai_mcp_panel_attach_files({ 8571 component: this, 8572 files: files, 8573 }); 8574 } 8575 }, 8576 handle_welcome_dragenter(event) { 8577 if (!this.welcome_file_upload_enabled) { 8578 return; 8579 } 8580 if (!this.welcome_event_has_image_files(event)) { 8581 return; 8582 } 8583 event.preventDefault(); 8584 this.welcome_drop_counter++; 8585 this.welcome_drop_active = this.welcome_drop_counter > 0; 8586 }, 8587 handle_welcome_dragover(event) { 8588 if (!this.welcome_file_upload_enabled) { 8589 return; 8590 } 8591 if (!this.welcome_event_has_image_files(event)) { 8592 return; 8593 } 8594 if (event.dataTransfer) { 8595 try { 8596 event.dataTransfer.dropEffect = 'copy'; 8597 } catch (drop_effect_err) {} 8598 } 8599 }, 8600 handle_welcome_dragleave() { 8601 if (!this.welcome_file_upload_enabled) { 8602 return; 8603 } 8604 if (this.welcome_drop_counter > 0) { 8605 this.welcome_drop_counter--; 8606 } 8607 if (this.welcome_drop_counter <= 0) { 8608 this.welcome_drop_counter = 0; 8609 this.welcome_drop_active = false;
8610 } 8611 }, 8612 handle_welcome_drop(event) { 8613 this.welcome_drop_counter = 0; 8614 this.welcome_drop_active = false; 8615 if (!this.welcome_file_upload_enabled) { 8616 return; 8617 } 8618 let files = event && event.dataTransfer && event.dataTransfer.files 8619 ? Array.from(event.dataTransfer.files) 8620 : []; 8621 if (!files.length) { 8622 return; 8623 } 8624 let images = files.filter(function (file) { 8625 return file && typeof file.type === 'string' && file.type.indexOf('image/') === 0; 8626 }); 8627 if (!images.length) { 8628 return; 8629 } 8630 if (typeof ai_mcp_panel_attach_files === 'function') { 8631 ai_mcp_panel_attach_files({ 8632 component: this, 8633 files: images, 8634 }); 8635 } 8636 }, 8637 handle_welcome_paste(event) { 8638 if (!this.welcome_file_upload_enabled) { 8639 return; 8640 } 8641 if (!event || !event.clipboardData || !event.clipboardData.items) { 8642 return; 8643 } 8644 let items = Array.from(event.clipboardData.items); 8645 let pasted = []; 8646 for (let i = 0; i < items.length; i++) { 8647 let item = items[i]; 8648 if (!item || item.kind !== 'file') { 8649 continue; 8650 } 8651 if (typeof item.type !== 'string' || item.type.indexOf('image/') !== 0) { 8652 continue; 8653 } 8654 let file = item.getAsFile && item.getAsFile(); 8655 if (file) { 8656 pasted.push(file); 8657 } 8658 } 8659 if (!pasted.length) { 8660 return; 8661 } 8662 event.preventDefault(); 8663 if (typeof ai_mcp_panel_attach_files === 'function') { 8664 ai_mcp_panel_attach_files({ 8665 component: this, 8666 files: pasted, 8667 }); 8668 } 8669 }, 8670 welcome_event_has_image_files(event) { 8671 if (!event || !event.dataTransfer) { 8672 return false; 8673 } 8674 let types = event.dataTransfer.types; 8675 if (!types) { 8676 return false; 8677 } 8678 for (let i = 0; i < types.length; i++) { 8679 if (types[i] === 'Files' || types[i] === 'application/x-moz-file') { 8680 return true; 8681 } 8682 } 8683 return false; 8684 }, 8685 welcome_remove_attachment(index) { 8686 if (typeof store === 'undefined' || !store.ai_mcp_panel_component_instance) { 8687 return; 8688 } 8689 let panel = store.ai_mcp_panel_component_instance; 8690 if (typeof panel.remove_attachment === 'function') { 8691 panel.remove_attachment(index); 8692 } 8693 }, 8694 // Auto-grow the welcome-page composer textarea. Starts at 8695 // a single line (height matches one line-height) and grows 8696 // up to the CSS max-height (~8 lines), after which the 8697 // textarea scrolls. We reset height to '0' before measuring 8698 // scrollHeight so the height can shrink as well as grow 8699 // (otherwise scrollHeight only ever increases). 8700 // 8701 // Called from @input (typing), the watcher on `ai_prompt` 8702 // (programmatic changes like example pill click / 8703 // post-submit clear), and once on mounted() to settle the 8704 // initial layout. 8705 auto_resize_ai_prompt() { 8706 let el = this.$refs && this.$refs.ai_prompt_textarea; 8707 if (!el) { 8708 return; 8709 } 8710 el.style.height = '0px'; 8711 let next = el.scrollHeight; 8712 if (!next) { 8713 next = 21; 8714 } 8715 el.style.height = next + 'px'; 8716 }, 8717 submit_ai_prompt() { 8718 if (!this.ai_prompt_can_submit) { 8719 return; 8720 } 8721 // The blank welcome composer now seeds the single page agent 8722 // (focused on this section) rather than the per-component 8723 // codegen chat. The page agent owns the conversation + send. 8724 if (typeof ai_mcp_panel_open_agent_with_reference !== 'function') { 8725 this.open_ai_builder(); 8726 return; 8727 } 8728 let opened = ai_mcp_panel_open_agent_with_reference({ 8729 client_id: this.component && this.component.get('client_id'), 8730 component_type: 'custom-code', 8731 seed_prompt: this.ai_prompt,
8732 auto_submit: true, 8733 }); 8734 if (opened) { 8735 this.ai_prompt = ''; 8736 this.start_welcome_generating_state(); 8737 } else { 8738 this.open_ai_builder(); 8739 } 8740 }, 8741 // Swap the welcome composer for the "Generatingâ¦" pane while the 8742 // page agent produces the first result. Cleared when content lands 8743 // (custom_code_value watcher) or by a safety timer. 8744 start_welcome_generating_state() { 8745 this.active_tab = 'preview'; 8746 this.close_text_edit_popover(); 8747 this.ai_welcome_generating = true; 8748 if (this._ai_welcome_generating_timer) { 8749 clearTimeout(this._ai_welcome_generating_timer); 8750 } 8751 this._ai_welcome_generating_timer = setTimeout(() => { 8752 this.clear_welcome_generating_state(); 8753 }, 90000); 8754 }, 8755 clear_welcome_generating_state() { 8756 this.ai_welcome_generating = false; 8757 if (this._ai_welcome_generating_timer) { 8758 clearTimeout(this._ai_welcome_generating_timer); 8759 this._ai_welcome_generating_timer = null; 8760 } 8761 }, 8762 // Writes 'starting' into the central store so the 8763 // launcher reflects intent instantly. store.ai_streams.start 8764 // overwrites this with 'queued' or 'streaming' once the 8765 // entry materializes. The 15s timer is the safety net for 8766 // the rare case where the panel never actually fires a 8767 // submit (e.g. the panel app didn't mount yet). 8768 start_ai_streaming_starting_state() { 8769 this.capture_ai_loading_pane_height(); 8770 this.blur_ai_prompt_before_loading(); 8771 let prior_scroll_y = window.scrollY; 8772 let component_id = this.component && this.component.id; 8773 if (typeof store !== 'undefined' && store.ai_streams && typeof store.ai_streams.mark_starting === 'function' && component_id) { 8774 store.ai_streams.mark_starting(component_id); 8775 } 8776 this.active_tab = 'preview'; 8777 this.close_text_edit_popover(); 8778 this.$nextTick(() => { 8779 if (Math.abs(window.scrollY - prior_scroll_y) > 1) { 8780 window.scrollTo(window.scrollX, prior_scroll_y); 8781 } 8782 }); 8783 if (this._ai_streaming_starting_timer) { 8784 clearTimeout(this._ai_streaming_starting_timer); 8785 } 8786 this._ai_streaming_starting_timer = setTimeout(() => { 8787 this.clear_ai_streaming_starting_state(); 8788 }, 15000); 8789 }, 8790 // Clears the 'starting' bridge from central state if (and 8791 // only if) it's still the current value â never overwrites 8792 // a real downstream status like 'streaming' / 'done'. 8793 clear_ai_streaming_starting_state() { 8794 if (typeof store !== 'undefined' && store.codegen_request_state) { 8795 let component_id = this.component && this.component.id; 8796 if (component_id && store.codegen_request_state[component_id] === 'starting') { 8797 delete store.codegen_request_state[component_id]; 8798 } 8799 } 8800 if (this._ai_streaming_starting_timer) { 8801 clearTimeout(this._ai_streaming_starting_timer); 8802 this._ai_streaming_starting_timer = null; 8803 } 8804 }, 8805 // Idempotent hard-reset of every flag that feeds the 8806 // launcher / loading-overlay computeds. Called from the 8807 // panel's done / fail / snap paths so the launcher always 8808 // converges to idle even if a stream subscriber missed 8809 // an event, threw mid-handler, or fired its `done` while 8810 // the component was unmounted. Safe to call multiple 8811 // times. Does NOT touch the editor contents â the panel's 8812 // apply_generated_code is responsible for the canonical 8813 // code save. 8814 force_clear_ai_loading_state() { 8815 this.clear_ai_streaming_starting_state(); 8816 this.ai_streaming = false;
8817 this.release_ai_loading_pane_height_after_render(); 8818 if (this.ai_streaming_status === 'streaming' || this.ai_streaming_status === 'queued' || this.ai_streaming_status === null) { 8819 this.ai_streaming_status = 'done'; 8820 } 8821 this.ai_streaming_buffer = ''; 8822 this._last_flushed_buffer_len = -1; 8823 this.ai_streaming_child_min_heights = []; 8824 if (this._codegen_stream_raf) { 8825 cancelAnimationFrame(this._codegen_stream_raf); 8826 this._codegen_stream_raf = null; 8827 } 8828 if (this.cm_instance && this.cm_instance.getOption && this.cm_instance.getOption('readOnly')) { 8829 this.cm_instance.setOption('readOnly', false); 8830 } 8831 this.ai_stream_event_tick = (this.ai_stream_event_tick + 1) | 0; 8832 }, 8833 capture_ai_loading_pane_height() { 8834 if (this.ai_loading_pane_height_release_timer) { 8835 clearTimeout(this.ai_loading_pane_height_release_timer); 8836 this.ai_loading_pane_height_release_timer = null; 8837 } 8838 // `$el` can be a Comment / Text node when the root 8839 // `<template v-if>` is currently false (Vue 3 treats 8840 // a falsy single-root v-if as a placeholder comment), 8841 // and it lacks querySelectorAll. Bail in that case 8842 // so the streaming subscriber doesn't throw on every 8843 // pane-height capture. 8844 let root = this.$el; 8845 if (!root || typeof root.querySelectorAll !== 'function') { 8846 return; 8847 } 8848 let panes = Array.prototype.slice.call(root.querySelectorAll('.custom-code-preview-pane')); 8849 let visible = panes.find((pane) => { 8850 if (!pane || pane.classList.contains('custom-code-loading-pane')) { 8851 return false; 8852 } 8853 let style = window.getComputedStyle(pane); 8854 return style.display !== 'none' && pane.getClientRects().length > 0; 8855 }); 8856 if (!visible) { 8857 let hidden_height = this.measure_hidden_custom_code_preview_height(); 8858 if (hidden_height > 0) { 8859 this.ai_loading_pane_height = Math.max(420, hidden_height); 8860 } 8861 return; 8862 } 8863 let rect_height = visible.getBoundingClientRect().height || 0; 8864 let scroll_height = visible.scrollHeight || 0; 8865 let height = Math.ceil(Math.max(rect_height, scroll_height)); 8866 if (height > 0) { 8867 this.ai_loading_pane_height = Math.max(420, height); 8868 } 8869 this.capture_streaming_child_min_heights_from_node(visible); 8870 }, 8871 measure_hidden_custom_code_preview_height() { 8872 let root = this.$el; 8873 if (!root || typeof root.getBoundingClientRect !== 'function') { 8874 return 0; 8875 } 8876 let html = this.safe_custom_code_value || ''; 8877 if (!html.trim()) { 8878 return 0; 8879 } 8880 let root_rect = root.getBoundingClientRect(); 8881 let width = Math.floor(root_rect.width || root.clientWidth || 0); 8882 if (width <= 0 && root.parentElement) { 8883 let parent_rect = root.parentElement.getBoundingClientRect(); 8884 width = Math.floor(parent_rect.width || root.parentElement.clientWidth || 0); 8885 } 8886 if (width <= 0) { 8887 return 0; 8888 } 8889 let probe = document.createElement('div'); 8890 probe.className = 'custom-code-pane custom-code-preview-pane'; 8891 probe.style.position = 'absolute'; 8892 probe.style.visibility = 'hidden'; 8893 probe.style.pointerEvents = 'none'; 8894 probe.style.left = '-10000px'; 8895 probe.style.top = '0'; 8896 probe.style.width = width + 'px'; 8897 probe.style.height = 'auto'; 8898 probe.style.overflow = 'visible'; 8899 probe.style.boxSizing = 'border-box'; 8900 probe.innerHTML = html; 8901 document.body.appendChild(probe); 8902 let rect_height = probe.getBoundingClientRect().height || 0; 8903 let scroll_height = probe.scrollHeight || 0;
8904 let height = Math.ceil(Math.max(rect_height, scroll_height)); 8905 this.capture_streaming_child_min_heights_from_node(probe); 8906 probe.remove(); 8907 return height; 8908 }, 8909 capture_streaming_child_min_heights_from_node(node) { 8910 if (!node || !node.childNodes) { 8911 this.ai_streaming_child_min_heights = []; 8912 return; 8913 } 8914 this.ai_streaming_child_min_heights = Array.prototype.slice.call(node.childNodes).map((child) => { 8915 if (!child || child.nodeType !== 1) { 8916 return 0; 8917 } 8918 let rect_height = child.getBoundingClientRect().height || 0; 8919 let scroll_height = child.scrollHeight || 0; 8920 return Math.ceil(Math.max(rect_height, scroll_height)); 8921 }); 8922 }, 8923 release_ai_loading_pane_height_after_render() { 8924 if (this.ai_loading_pane_height_release_timer) { 8925 clearTimeout(this.ai_loading_pane_height_release_timer); 8926 this.ai_loading_pane_height_release_timer = null; 8927 } 8928 if (!this.ai_loading_pane_height) { 8929 this.ai_streaming_child_min_heights = []; 8930 return; 8931 } 8932 this.ai_loading_pane_height_release_timer = setTimeout(() => { 8933 this.ai_loading_pane_height = null; 8934 this.ai_streaming_child_min_heights = []; 8935 this.ai_loading_pane_height_release_timer = null; 8936 }, 50); 8937 }, 8938 blur_ai_prompt_before_loading() { 8939 let prompt = this.$refs && this.$refs.ai_prompt_textarea; 8940 if (!prompt || document.activeElement !== prompt) { 8941 return; 8942 } 8943 prompt.blur(); 8944 }, 8945 // Launcher click â pin this component as the AI panel's 8946 // active target and expand the panel. The panel runs in 8947 // a separate Vue app (app_ai_mcp, mounted in 8948 // web-builder/primary.ejs) and watches 8949 // store.webbuilder_code_active_component_id; setting it 8950 // here triggers the panel to load this component's thread. 8951 open_ai_builder() { 8952 if (typeof store === 'undefined') { 8953 return; 8954 } 8955 // Open the single page agent focused on this section. (The old 8956 // per-component codegen activation is left dormant below for a 8957 // future live agent->codegen stream hookup.) 8958 if (typeof ai_mcp_panel_open_agent_with_reference === 'function' 8959 && this.component 8960 && typeof this.component.get === 'function') { 8961 ai_mcp_panel_open_agent_with_reference({ 8962 client_id: this.component.get('client_id'), 8963 component_type: 'custom-code', 8964 }); 8965 return; 8966 } 8967 let panel_state = store.components && store.components.ai_mcp_panel; 8968 if (panel_state) { 8969 panel_state.user_toggled = true; 8970 panel_state.expanded = true; 8971 if (panel_state.mcp_panel_width) { 8972 document.body.style.setProperty('--mcp-panel-width', panel_state.mcp_panel_width + 'px'); 8973 } 8974 document.body.classList.add('ai-mcp-expanded'); 8975 } 8976 this.activate_ai_builder_target({ force: true }); 8977 }, 8978 mark_ai_thread_for_component() { 8979 if (typeof store === 'undefined') { 8980 return; 8981 } 8982 let component_id = this.component && this.component.id; 8983 if (!component_id) { 8984 return; 8985 } 8986 if (!store.webbuilder_code_component_has_ai_thread) { 8987 store.webbuilder_code_component_has_ai_thread = {}; 8988 } 8989 store.webbuilder_code_component_has_ai_thread[component_id] = true; 8990 }, 8991 contains_ai_builder_target(target) { 8992 if (!target) { 8993 return false;
8994 } 8995 if (this.$el && this.$el.contains(target)) { 8996 return true; 8997 } 8998 if (this.$refs.cm_editor && this.$refs.cm_editor.contains(target)) { 8999 return true; 9000 } 9001 // The text-edit popover lives at document.body level 9002 // (position: fixed) so it isn't inside $el. Treat clicks 9003 // landing on it as in-component interactions so the AI 9004 // panel's outside-click handler doesn't auto-close 9005 // while the admin is typing in the popover. 9006 if (typeof target.closest === 'function' && target.closest('.cs-text-edit-popover')) { 9007 return true; 9008 } 9009 return false; 9010 }, 9011 get_current_code() { 9012 if (this.cm_instance) { 9013 return this.cm_instance.getValue(); 9014 } 9015 if (typeof this.custom_code_value === 'string') { 9016 return this.custom_code_value; 9017 } 9018 if (this.component) { 9019 let config_latest = this.component.get('config_latest') || {}; 9020 return config_latest.custom_code || ''; 9021 } 9022 return ''; 9023 }, 9024 mark_ai_code_revision_changed() { 9025 if (typeof store === 'undefined') { 9026 return; 9027 } 9028 if (store.webbuilder_code_active_component !== this) { 9029 return; 9030 } 9031 store.webbuilder_code_active_component_revision = Date.now(); 9032 }, 9033 custom_code_render_root() { 9034 let host = null; 9035 if (this.preview_element) { 9036 host = this.$refs.custom_code_preview || null; 9037 } else { 9038 host = this.$refs.custom_code_static || null; 9039 } 9040 if (!host) { 9041 return null; 9042 } 9043 let html = this.preview_element 9044 ? (this.safe_custom_code_value || '') 9045 : (this.safe_custom_code || ''); 9046 let root = this.ensure_isolated_custom_code_host(host, html); 9047 this.apply_first_section_top_spacer(root); 9048 return root; 9049 }, 9050 first_section_top_spacer_px(){ 9051 if( this.first_section_top_spacer_px_override !== null ){ 9052 return normalize_first_section_top_spacer_px(this.first_section_top_spacer_px_override); 9053 } 9054 if( !this.is_first || !this.component ){ 9055 return 0; 9056 } 9057 let config_latest = this.component.get('config_latest') || {}; 9058 let formatting = config_latest.formatting || {}; 9059 return normalize_first_section_top_spacer_px(formatting.first_section_top_spacer_px); 9060 }, 9061 apply_first_section_top_spacer(root){ 9062 if( typeof apply_first_section_top_spacer_to_root === 'function' ){ 9063 apply_first_section_top_spacer_to_root(root, this.first_section_top_spacer_px()); 9064 return; 9065 } 9066 if( !root ){ 9067 return; 9068 } 9069 }, 9070 // Mount sanitized HTML into an open shadow root so hosted theme 9071 // CSS cannot override section styles (preview â live). 9072 ensure_isolated_custom_code_host(host, html) { 9073 if (!host || typeof host.attachShadow !== 'function') { 9074 // Very old browsers: fall back to light-DOM innerHTML. 9075 host.innerHTML = html || ''; 9076 return host; 9077 } 9078 let shadow = host.shadowRoot; 9079 if (!shadow) { 9080 shadow = host.attachShadow({ mode: 'open' }); 9081 // Font Awesome lives on the light DOM page; clone its 9082 // stylesheet so icons inside custom-code still render. 9083 let fa_link = document.querySelector( 9084 'link[href*="font-awesome"], link[href*="fontawesome"], link[href*="FontAwesome"]' 9085 ); 9086 if (fa_link) { 9087 let cloned = fa_link.cloneNode(true); 9088 cloned.setAttribute('data-cs-fa-isolation', '1'); 9089 shadow.appendChild(cloned); 9090 } 9091 let root = document.createElement('div'); 9092 root.className = 'cs-cc-root'; 9093 shadow.appendChild(root); 9094 } 9095 // Only direct shadow children â never a <style> inside the 9096 // section HTML (querySelector would walk into .cs-cc-root). 9097 let isolation_style = null; 9098 let shadow_children = shadow.children; 9099 for (let i = 0; i < shadow_children.length; i++) { 9100 let node = shadow_children[i]; 9101 if (node.tagName === 'STYLE') { 9102 if (node.getAttribute('data-cs-isolation') === '1') { 9103 isolation_style = node; 9104 break; 9105 } 9106 } 9107 } 9108 if (!isolation_style) { 9109 isolation_style = document.createElement('style'); 9110 isolation_style.setAttribute('data-cs-isolation', '1'); 9111 shadow.insertBefore(isolation_style, shadow.firstChild); 9112 } 9113 isolation_style.textContent = CS_CUSTOM_CODE_ISOLATION_CSS; 9114 if (this.preview_element) { 9115 isolation_style.textContent += '\n' + CS_CUSTOM_CODE_PREVIEW_CHROME_CSS; 9116 } 9117 let content_root = shadow.querySelector('.cs-cc-root'); 9118 if (!content_root) { 9119 content_root = document.createElement('div'); 9120 content_root.className = 'cs-cc-root'; 9121 shadow.appendChild(content_root); 9122 } 9123 // Compare against the source we last wrote, not live 9124 // innerHTML. Decoration injects Change/Add chips into 9125 // the tree; a live compare would see that as a mismatch 9126 // and wipe the chips on the next render_root call 9127 // (text-edit decorate runs immediately after). 9128 let next_html = html || ''; 9129 if (content_root.__cs_source_html !== next_html) { 9130 content_root.innerHTML = next_html; 9131 content_root.__cs_source_html = next_html; 9132 } 9133 return content_root; 9134 }, 9135 sanitize_custom_code_html(html) { 9136 if (!html || typeof html !== 'string') { 9137 return ''; 9138 } 9139 let template = document.createElement('template'); 9140 template.innerHTML = html; 9141 template.content.querySelectorAll('script').forEach((node) => { 9142 node.remove(); 9143 }); 9144 // Drop external FA kits â they override Clubspot's Pro icons document-wide. 9145 template.content.querySelectorAll('link[rel="stylesheet"], link[rel="preload"]').forEach((node) => { 9146 let href = String(node.getAttribute('href') || '').toLowerCase(); 9147 if ( 9148 href.indexOf('font-awesome') >= 0 9149 || href.indexOf('fontawesome') >= 0 9150 || href.indexOf('kit.fontawesome') >= 0 9151 ) { 9152 node.remove(); 9153 } 9154 }); 9155 template.content.querySelectorAll('*').forEach((node) => { 9156 Array.prototype.slice.call(node.attributes || []).forEac
9156h((attr) => { 9157 let name = String(attr.name || '').toLowerCase(); 9158 let value = String(attr.value || '').trim().toLowerCase(); 9159 if (name.indexOf('on') === 0) { 9160 node.removeAttribute(attr.name); 9161 } 9162 if ((name === 'href' || name === 'src' || name === 'xlink:href') && value.indexOf('javascript:') === 0) { 9163 node.removeAttribute(attr.name); 9164 } 9165 }); 9166 }); 9167 return template.innerHTML; 9168 }, 9169 initialize_custom_code_interactions() { 9170 let root = this.custom_code_render_root(); 9171 if (!root) { 9172 return; 9173 } 9174 this.apply_first_section_top_spacer(root); 9175 // Show/hide a tab panel. The `hidden` attribute alone is 9176 // not reliable: panels usually carry an inline `display` 9177 // (e.g. `display:flex`), and an inline declaration beats 9178 // the UA `[hidden]{display:none}` rule, so inactive panels 9179 // would stay visible and every tab would render at once. 9180 // Drive `display` directly (remembering each panel's 9181 // original inline value) and keep `hidden` for semantics. 9182 let set_panel_active = (panel, active) => { 9183 if (panel.__csPanelDisplay === undefined) { 9184 panel.__csPanelDisplay = panel.style.display || ''; 9185 } 9186 panel.hidden = !active; 9187 panel.classList.toggle('is-active', active); 9188 panel.style.display = active ? panel.__csPanelDisplay : 'none'; 9189 }; 9190 let find_tab_panels = (group) => { 9191 let panels = Array.prototype.slice.call(group.querySelectorAll('[data-cs-panel]')); 9192 if (panels.length) { 9193 return panels; 9194 } 9195 let panel_scope = group.closest('section') || root; 9196 return Array.prototype.slice.call(panel_scope.querySelectorAll('[data-cs-panel]')); 9197 }; 9198 let sync_tabs = () => { 9199 root.querySelectorAll('[data-cs-tabs]').forEach((group) => { 9200 let tabs = Array.prototype.slice.call(group.querySelectorAll('[data-cs-tab]')); 9201 let panels = find_tab_panels(group); 9202 if (!tabs.length || !panels.length) { 9203 return; 9204 } 9205 let active_tab = tabs.find((tab) => tab.classList.contains('is-active')) || tabs[0]; 9206 let active_name = active_tab.getAttribute('data-cs-tab');
9207 tabs.forEach((tab) => { 9208 let active = tab === active_tab; 9209 tab.classList.toggle('is-active', active); 9210 tab.setAttribute('aria-selected', active ? 'true' : 'false'); 9211 }); 9212 panels.forEach((panel) => { 9213 set_panel_active(panel, panel.getAttribute('data-cs-panel') === active_name); 9214 }); 9215 }); 9216 }; 9217 // The switcher group for a `<select data-cs-select>`: its wrapping 9218 // [data-cs-tabs] if present, else its section, else root â the same 9219 // scope rule tabs use, so a select can drive the same panels. 9220 let find_switcher_group = (el) => el.closest('[data-cs-tabs]') || el.closest('section') || root; 9221 // `<select data-cs-select>` whose `<option value="X">` reveal the 9222 // matching `[data-cs-panel="X"]`. The native control renders/opens 9223 // with no JS; only the panel switch needs the runtime. Ideal for a 9224 // many-option filter (year/category) where tabs would be unwieldy. 9225 let sync_selects = () => { 9226 root.querySelectorAll('[data-cs-select]').forEach((select) => { 9227 let panels = find_tab_panels(find_switcher_group(select)); 9228 if (!panels.length) { 9229 return; 9230 } 9231 let active_name = (select.value !== null && select.value !== '') 9232 ? select.value 9233 : panels[0].getAttribute('data-cs-panel'); 9234 panels.forEach((panel) => { 9235 set_panel_active(panel, panel.getAttribute('data-cs-panel') === active_name); 9236 }); 9237 }); 9238 }; 9239 let sync_all = () => { 9240 sync_tabs(); 9241 sync_selects(); 9242 }; 9243 if (root.__csCustomCodeInteractionsInitialized) { 9244 sync_all(); 9245 if (this.preview_element) { 9246 this.decorate_asset_placeholders(); 9247 this.decorate_editable_text_nodes(); 9248 } 9249 return; 9250 } 9251 root.__csCustomCodeInteractionsInitialized = true; 9252 if (this.preview_element) { 9253 this.decorate_asset_placeholders(); 9254 this.decorate_editable_text_nodes(); 9255 } 9256 root.addEventListener('click', (event) => { 9257 let trigger = event.target && event.target.closest('[data-cs-toggle], [data-cs-tab]'); 9258 if (!trigger || !root.contains(trigger)) { 9259 return; 9260 } 9261 9262 let toggle_selector = trigger.getAttribute('data-cs-toggle'); 9263 if (toggle_selector) { 9264 event.preventDefault(); 9265 let target = root.querySelector(toggle_selector); 9266 if (target) { 9267 let class_name = trigger.getAttribute('data-cs-toggle-class') || 'is-active'; 9268 target.classList.toggle(class_name); 9269 let expanded = target.classList.contains(class_name); 9270 trigger.setAttribute('aria-expanded', expanded ? 'true' : 'false'); 9271 } 9272 } 9273 9274 let tab_name = trigger.getAttribute('data-cs-tab'); 9275 if (tab_name) { 9276 event.preventDefault(); 9277 let group = trigger.closest('[data-cs-tabs]') || root; 9278 group.querySelectorAll('[data-cs-tab]').forEach((tab) => { 9279 tab.classList.toggle('is-active', tab === trigger); 9280 tab.setAttribute('aria-selected', tab === trigger ? 'true' : 'false'); 9281 });
9282 find_tab_panels(group).forEach((panel) => { 9283 set_panel_active(panel, panel.getAttribute('data-cs-panel') === tab_name); 9284 }); 9285 } 9286 }); 9287 root.addEventListener('change', (event) => { 9288 let select = event.target && event.target.closest('[data-cs-select]'); 9289 if (!select || !root.contains(select)) { 9290 return; 9291 } 9292 let active_name = select.value; 9293 find_tab_panels(find_switcher_group(select)).forEach((panel) => { 9294 set_panel_active(panel, panel.getAttribute('data-cs-panel') === active_name); 9295 }); 9296 }); 9297 sync_all(); 9298 }, 9299 // âââ Asset placeholders âââââââââââââââââââââââââââââââââââ 9300 // 9301 // The AI route plants inert `<div data-cs-asset-placeholder>` 9302 // (or `<a>`) elements in the section HTML when it needs an 9303 // asset it couldn't verify. The live (public) site renders 9304 // those placeholders as styled fallback wrappers as-is â 9305 // they're authored to look intentional. In PREVIEW mode 9306 // (admin builder) we overlay a click-to-fill control on top 9307 // of each placeholder so the admin can choose a club photo, 9308 // upload a new one, or paste a URL. On selection we mutate 9309 // the persisted HTML so the next page render shows the 9310 // attached asset. 9311 // 9312 // Two element states: 9313 // - `data-cs-asset-placeholder="<id>"` unfilled slot; 9314 // gets the big full-bleed "Add image" overlay. 9315 // - `data-cs-asset-attached="<id>"` filled slot; 9316 // gets the small top-right "Change" chip. 9317 // 9318 // We keep the wrapper element across the fill transition â 9319 // the wrapper preserves the section's layout box (size, 9320 // border-radius, positioning) and we just swap its inner 9321 // content for an `<img>`/`<a>`/`background-image`. That way 9322 // the section never reflows when an admin fills a slot, and 9323 // styled fallback "frames" the resolved asset. 9324 // 9325 // Sequencing: decorate runs after every preview re-render 9326 // (initialize_custom_code_interactions). The overlay DOM 9327 // lives ONLY in the live preview tree â we never persist 9328 // the overlay markup back to `custom_code`. 9329 9330 decorate_asset_placeholders() { 9331 if (!this.preview_element) { 9332 return; 9333 } 9334 let root = this.custom_code_render_root(); 9335 if (!root) { 9336 return; 9337 } 9338 // Unfilled placeholders â big "Add image" overlay. 9339 let placeholders = root.querySelectorAll('[data-cs-asset-placeholder]'); 9340 placeholders.forEach((placeholder) => { 9341 if (placeholder.__cs_asset_decorated) { 9342 return; 9343 } 9344 placeholder.__cs_asset_decorated = true; 9345 let computed_position = window.getComputedStyle(placeholder).position; 9346 if (computed_position === 'static') { 9347 placeholder.style.position = 'relative'; 9348 } 9349 placeholder.classList.add('cs-asset-placeholder-host'); 9350 placeholder.style.cursor = 'pointer'; 9351 9352 let id = placeholder.getAttribute('data-cs-asset-placeholder') || ''; 9353 let target = (placeholder.getAttribute('data-cs-asset-target') || '').toLowerCase(); 9354 if (target === 'background-image') { 9355 placeholder.classList.add('cs-asset-background-placeholder-host'); 9356 } 9357 let label_attr = (placeholder.getAttribute('data-cs-asset-label') || '').trim(); 9358 let label = label_attr || 'asset'; 9359 9360 if (target === 'background-image') { 9361 let background_chip = document.createElement('button'); 9362 background_chip.type = 'button'; 9363 background_chip.className = 'cs-asset-background-placeholder-chip exclude-from-custom-button-styles'; 9364 background_chip.setAttribute('data-cs-asset-placeholder-id', id); 9365 background_chip.setAttribute('aria-label', 'Add ' + label); 9366 background_chip.setAttribute('title', 'Add ' + label); 9367 background_chip.innerHTML = '<i class="fa-regular fa-image"></i>'; 9368 background_chip.addEventListener('mousedown', (event) => { 9369 event.stopPropagation(); 9370 }); 9371 background_chip.addEventListener('click', (event) => { 9372 event.preventDefault(); 9373 event.stopPropagation(); 9374 this.open_placeholder_menu(id, { mode: 'placeholder' }); 9375 }); 9376 let background_chip_host = placeholder; 9377 if (placeholder.parentElement 9378 && placeholder.parentElement !== root 9379 && root.contains(placeholder.parentElement)) { 9380 background_chip_host = placeholder.parentElement; 9381 if (window.getComputedStyle(background_chip_host).position === 'static') { 9382 background_chip_host.style.position = 'relative'; 9383 } 9384 } 9385 background_chip_host.appendChild(background_chip); 9386 } else {
9387 let overlay = document.createElement('button'); 9388 overlay.type = 'button'; 9389 overlay.className = 'cs-asset-placeholder-overlay exclude-from-custom-button-styles'; 9390 overlay.setAttribute('data-cs-asset-placeholder-id', id); 9391 overlay.innerHTML = 9392 '<span class="cs-asset-placeholder-icon"><i class="fa-regular fa-image"></i></span>' + 9393 '<span class="cs-asset-placeholder-label">Add ' + this.escape_html_text(label) + '</span>'; 9394 overlay.addEventListener('mousedown', (event) => { 9395 event.stopPropagation(); 9396 }); 9397 overlay.addEventListener('click', (event) => { 9398 event.preventDefault(); 9399 event.stopPropagation(); 9400 this.open_placeholder_menu(id, { mode: 'placeholder' }); 9401 }); 9402 placeholder.appendChild(overlay); 9403 } 9404 9405 // Host-level click â make the entire placeholder area 9406 // clickable so admins can tap anywhere inside the 9407 // styled fallback wrapper to open the fill menu, not 9408 // just the centered overlay button. The overlay's own 9409 // listener stops propagation, so we only fire when 9410 // the click originated outside it (e.g. on padding, 9411 // background, or fallback child text/icons). 9412 placeholder.addEventListener('click', (event) => { 9413 if (event.target.closest('.cs-asset-placeholder-overlay')) { 9414 return; 9415 } 9416 if (event.target.closest('.cs-asset-background-placeholder-chip')) { 9417 return; 9418 } 9419 if (target === 'background-image' && event.target !== placeholder) { 9420 return; 9421 } 9422 event.preventDefault(); 9423 event.stopPropagation(); 9424 this.open_placeholder_menu(id, { mode: 'placeholder' }); 9425 }); 9426 }); 9427 9428 // Filled placeholders â small "Change" chip. 9429 let attached = root.querySelectorAll('[data-cs-asset-attached]'); 9430 attached.forEach((attached_el) => { 9431 if (attached_el.__cs_asset_attached_decorated) { 9432 return; 9433 } 9434 attached_el.__cs_asset_attached_decorated = true; 9435 let computed_position = window.getComputedStyle(attached_el).position; 9436 if (computed_position === 'static') { 9437 attached_el.style.position = 'relative'; 9438 } 9439 attached_el.classList.add('cs-asset-attached-host'); 9440 attached_el.style.cursor = 'pointer'; 9441 9442 let id = attached_el.getAttribute('data-cs-asset-attached') || ''; 9443 let change_button = document.createElement('button'); 9444 change_button.type = 'button'; 9445 change_button.className = 'cs-asset-change-button exclude-from-custom-button-styles'; 9446 change_button.setAttribute('data-cs-asset-attached-id', id); 9447 change_button.innerHTML = 9448 '<i class="fa-regular fa-image" style="margin-right:5px;"></i>' + 9449 '<span>Change</span>'; 9450 change_button.addEventListener('mousedown', (event) => { 9451 event.stopPropagation(); 9452 }); 9453 change_button.addEventListener('click', (event) => { 9454 event.preventDefault(); 9455 event.stopPropagation(); 9456 this.open_placeholder_menu(id, { mode: 'attached' }); 9457 }); 9458 attached_el.appendChild(change_button); 9459 9460 // Host-level click â clicking anywhere on the filled
9461 // image / link area opens the same change menu the 9462 // small "Change" hover chip uses. The chip's listener 9463 // stops propagation, so this only fires for clicks 9464 // outside it (e.g. directly on the image surface). 9465 attached_el.addEventListener('click', (event) => { 9466 if (event.target.closest('.cs-asset-change-button')) { 9467 return; 9468 } 9469 event.preventDefault(); 9470 event.stopPropagation(); 9471 this.open_placeholder_menu(id, { mode: 'attached' }); 9472 }); 9473 }); 9474 9475 // Bare `<img>` tags that weren't emitted as a 9476 // `data-cs-asset-*` slot â for example, every image the 9477 // model dropped in during a `capture_web_reference` build 9478 // (`<img src="https://oakmont.../">` directly). Without 9479 // this pass those imgs had no change/import affordance, 9480 // so the only way to swap one out was to hand-edit the 9481 // section HTML. 9482 // 9483 // Implementation: 9484 // - Wrap each bare img in a transient `<span 9485 // class="cs-bare-image-host">` host purely in the 9486 // LIVE preview DOM. The persisted custom_code never 9487 // sees this wrapper â it's recreated every time 9488 // `decorate_asset_placeholders` runs. 9489 // - Append a "Change" chip into the host. The chip 9490 // reuses `.cs-asset-change-button` so it matches 9491 // filled placeholder slots. 9492 // - On click, route to a bare-img menu that uses 9493 // `src`-string replacement against the persisted 9494 // html instead of the `data-cs-asset-attached` 9495 // marker lookup the placeholder pipeline depends 9496 // on. That lets us avoid touching the persisted 9497 // HTML until the admin actually picks a different 9498 // image â there's no surprise `unsaved_changes` 9499 // bump just from opening a reference-built section. 9500 let bare_imgs = Array.from(root.querySelectorAll('img')).filter((img) => { 9501 if (img.__cs_bare_image_decorated) { 9502 return false; 9503 } 9504 if (img.closest('[data-cs-asset-placeholder], [data-cs-asset-attached]')) { 9505 return false; 9506 } 9507 if (img.parentElement && img.parentElement.classList && img.parentElement.classList.contains('cs-bare-image-host')) { 9508 return false; 9509 } 9510 let src = img.getAttribute('src') || ''; 9511 return src.trim().length > 0; 9512 }); 9513 bare_imgs.forEach((img) => { 9514 img.__cs_bare_image_decorated = true; 9515 if (!img.parentNode) { 9516 return; 9517 } 9518 let host = document.createElement('span'); 9519 host.className = 'cs-bare-image-host cs-asset-attached-host'; 9520 host.setAttribute('aria-hidden', 'true'); 9521 // line-height: 0 kills the descender gap an inline 9522 // img would otherwise produce; max-width lets the 9523 // img stay responsive inside flex/grid layouts. 9524 host.style.cssText = 'position: relative; display: inline-block; line-height: 0; max-width: 100%;'; 9525 img.parentNode.insertBefore(host, img); 9526 host.appendChild(img); 9527 9528 let change_button = document.createElement('button'); 9529 change_button.type = 'button'; 9530 change_button.className = 'cs-asset-change-button cs-bare-image-change-button exclude-from-custom-button-styles'; 9531 change_button.innerHTML = 9532 '<i class="fa-regular fa-image" style="margin-right:5px;"></i>' + 9533 '<span>Change</span>'; 9534 change_button.addEventListener('mousedown', (event) => { 9535 event.stopPropagation(); 9536 }); 9537 change_button.addEventListener('click', (event) => { 9538 event.preventDefault(); 9539 event.stopPropagation(); 9540 this.open_bare_image_menu(img); 9541 }); 9542 host.appendChild(change_button); 9543 9544 // Host click â same menu, for clicks on the image 9545 // surface itself (chip stops propagation so this 9546 // only fires when the user clicks outside it). 9547 host.addEventListener('click', (event) => { 9548 if (event.target.closest('.cs-asset-change-button')) { 9549 return; 9550 } 9551 event.preventDefault(); 9552 event.stopPropagation(); 9553 this.open_bare_image_menu(img); 9554 }); 9555 }); 9556 }, 9557 escape_html_text(text) { 9558 if (typeof text !== 'string') { 9559 return ''; 9560 } 9561 let div = document.createElement('div'); 9562 div.appendChild(document.createTextNode(text)); 9563 return div.innerHTML; 9564 }, 9565 open_placeholder_menu(id, options_in) { 9566 let opts = options_in || {}; 9567 let mode = opts.mode || 'placeholder'; 9568 if (typeof handle_common !== 'function') { 9569 // Fallback: jump straight to the image picker. 9570 this.pick_existing_for_placeholder(id); 9571 return; 9572 } 9573 let target_el = this.find_asset_element_by_id(id); 9574 if (!target_el) { 9575 return; 9576 } 9577 let accepts = (target_el.getAttribute('data-cs-asset-accepts') || 'image').toLowerCase(); 9578 let label_attr = (target_el.getAttribute('data-cs-asset-label') || '').trim(); 9579 let label = label_attr || 'asset'; 9580 // Sniff the currently-attached URL so we can offer 9581 // "Copy image to club photos" only when it actually
9582 // points somewhere outside the Clubspot CDN. The same 9583 // helper handles <img src>, <a href>, and inline 9584 // background-image: url(...) wrappers. 9585 let attached_url = mode === 'attached' 9586 ? this.return_attached_url_for_wrapper(target_el) 9587 : null; 9588 let attached_is_external = accepts === 'image' && this.is_external_image_url(attached_url); 9589 let options = []; 9590 // Photos picker â same webbuilder asset picker, filtered to photos. 9591 if (accepts === 'image') { 9592 options.push({ 9593 text: mode === 'attached' ? 'Pick a different photo' : 'Upload or choose from club photos', 9594 subtext: 'Upload a new image or pick an existing one from this club', 9595 fn: () => this.pick_existing_for_placeholder(id), 9596 }); 9597 } 9598 // Documents/files picker â `webbuilder_asset_picker` 9599 // overlay, backed by `browse_webbuilder_assets`. Covers 9600 // PDFs and any public-access club document. Also offered 9601 // for image accepts because admins sometimes store hero 9602 // images in the documents folder. 9603 options.push({ 9604 text: mode === 'attached' 9605 ? 'Pick a different file from club documents' 9606 : (accepts === 'pdf' ? 'Choose from club documents' : 'Choose from club documents'), 9607 subtext: accepts === 'pdf' 9608 ? 'Public-access PDFs in the club documents folder' 9609 : 'Public-access files in the club documents folder', 9610 fn: () => this.pick_document_for_placeholder(id), 9611 }); 9612 options.push({ 9613 text: mode === 'attached' ? 'Paste a different URL' : 'Paste a URL', 9614 subtext: accepts === 'pdf' 9615 ? 'Paste a public PDF link (e.g. a public document on this club)' 9616 : 'Paste a public image URL (Clubspot CDN or external host)', 9617 fn: () => this.paste_url_for_placeholder(id), 9618 }); 9619 if (mode === 'attached' && attached_is_external) { 9620 // Only surface this when the slot is currently 9621 // pointing at a non-Clubspot host - importing a 9622 // CDN URL into itself is a no-op and the server 9623 // rejects it explicitly. Sits ABOVE "Remove" so 9624 // it lines up with the constructive actions 9625 // (pick, paste, copy) rather than the destructive 9626 // one. 9627 options.push({ 9628 text: 'Copy image to club photos', 9629 subtext: 'Save a local copy in your photo library and use that instead of the external URL.', 9630 fn: () => this.run_after_placeholder_menu_close(() => this.import_external_image_for_attached(id)), 9631 }); 9632 } 9633 if (mode === 'attached') { 9634 options.push({ 9635 text: 'Remove this image', 9636 subtext: 'Revert this slot back to its empty placeholder.', 9637 fn: () => this.run_after_placeholder_menu_close(() => this.revert_attached_to_placeholder(id)), 9638 }); 9639 } else { 9640 options.push({ 9641 text: 'Keep empty for now', 9642 subtext: 'Hide this prompt â the slot stays available with a "Change" button if you want to fill it later.', 9643 fn: () => this.run_after_placeholder_menu_close(() => this.remove_placeholder(id)), 9644 }); 9645 } 9646 let params = { 9647 title: mode === 'attached' ? 'Change "' + label + '"' : 'Fill "' + label + '"', 9648 subtitle: mode === 'attached' 9649 ? 'Pick a different photo, paste another URL, or remove the current asset.' 9650 : 'Pick how you want to attach this asset.', 9651 hide_on_selection: false, 9652 options: options, 9653 }; 9654 handle_common(params); 9655 }, 9656 run_after_placeholder_menu_close(fn) { 9657 store.overlays.handle_common = false;
9658 let run_next = () => { 9659 setTimeout(fn, 0); 9660 }; 9661 if (typeof this.$nextTick === 'function') { 9662 this.$nextTick(run_next); 9663 return; 9664 } 9665 run_next(); 9666 }, 9667 pick_existing_for_placeholder(id) { 9668 if (typeof webbuilder_asset_picker !== 'function') { 9669 if (typeof displayMessage === 'function') { 9670 displayMessage('Club photo picker is not loaded. Refresh the page and try again.', true, 5000); 9671 } 9672 return; 9673 } 9674 this.run_after_placeholder_menu_close(() => { 9675 let params = { 9676 title: 'Select club photo', 9677 accepts: 'image', 9678 source: 'photos', 9679 hide_on_selection: true, 9680 fn: (result) => { 9681 let url = result && result.url ? result.url : null; 9682 if (!url) { 9683 return; 9684 } 9685 let attach_params = { 9686 id: id, 9687 url: url, 9688 verified: true, 9689 source: 'club_photos', 9690 visibility: 'public_web', 9691 }; 9692 this.attach_url_to_placeholder(attach_params); 9693 }, 9694 }; 9695 webbuilder_asset_picker(params); 9696 }); 9697 }, 9698 // Opens the documents picker overlay. Same deferred-tick 9699 // dance as `pick_existing_for_placeholder` so the chain of 9700 // handle_common â documents picker doesn't race. 9701 pick_document_for_placeholder(id) { 9702 if (typeof webbuilder_asset_picker !== 'function') { 9703 if (typeof displayMessage === 'function') { 9704 displayMessage('Documents picker is not loaded. Refresh the page and try again.', true, 5000); 9705 } 9706 return; 9707 } 9708 let placeholder = this.find_asset_element_by_id(id); 9709 if (!placeholder) { 9710 return; 9711 } 9712 let accepts = (placeholder.getAttribute('data-cs-asset-accepts') || 'image').toLowerCase(); 9713 this.run_after_placeholder_menu_close(() => { 9714 let params = { 9715 title: accepts === 'pdf' ? 'Select a document' : 'Select a file', 9716 accepts: accepts, 9717 source: 'documents', 9718 hide_on_selection: true, 9719 fn: (result) => { 9720 let url = result && result.url ? result.url : null; 9721 if (!url) { 9722 return; 9723 } 9724 let attach_params = { 9725 id: id, 9726 url: url, 9727 verified: true, 9728 source: result.source || 'public_document', 9729 visibility: result.visibility || 'public_link', 9730 }; 9731 this.attach_url_to_placeholder(attach_params); 9732 }, 9733 }; 9734 webbuilder_asset_picker(params); 9735 }); 9736 }, 9737 paste_url_for_placeholder(id) { 9738 let placeholder = this.find_placeholder_element(id); 9739 if (!placeholder) { 9740 return; 9741 } 9742 let prompt_label = placeholder.getAttribute('data-cs-asset-label') || 'asset'; 9743 let do_attach = (attach) => { 9744 this.attach_url_to_placeholder({ 9745 id: id, 9746 url: attach.url, 9747 verified: attach.verified, 9748 source: attach.source, 9749 visibility: attach.visibility, 9750 }); 9751 }; 9752 // Defer one tick so handle_common can finish dismissing 9753 // before we open the URL-entry modal â otherwise the 9754 // menu overlay stays painted on top. 9755 this.run_after_placeholder_menu_close(() => { 9756 this.prompt_for_pasted_url({ 9757 slot_label: prompt_label, 9758 alt: prompt_label, 9759 do_attach: do_attach, 9760 }); 9761 }); 9762 }, 9763 // âââ Pasted-URL pipeline (shared) ââââââââââââââââââââââââââ 9764 // 9765 // Both the placeholder-marker flow and the bare-<img> flow 9766 // funnel pasted URLs through these three helpers: 9767 // 9768 // prompt_for_pasted_url 9769 // â opens a Clubspot universal_edit_overlay modal for 9770 // URL entry (replaces window.prompt, which blocks 9771 // the event loop, ignores app styling, and on iOS 9772 // leaves overlays painted on top of the OS dialog). 9773 // process_pasted_url 9774 // â verifies the URL server-side and dispatches to 9775 // do_attach() for trusted hosts, or to 9776 // confirm_external_url_action() for external ones. 9777 // confirm_external_url_action 9778 // â opens an are_you_sure modal that rolls what used 9779 // to be two separate browser confirms (the 9780 // "external URL â attach anyway?" confirm and the 9781 // follow-up "save to library?" prompt) into a single 9782 // decision: "Save to club photos" (primary) or 9783 // "Attach as external URL" (secondary). 9784 // 9785 // `do_attach(attach)` is the only caller-specific bit â 9786 // placeholder flow calls attach_url_to_placeholder, bare- 9787 // img flow calls swap_bare_image_src. Both receive the 9788 // same { url, verified, source, visibility } payload. 9789 prompt_for_pasted_url({ slot_label, current_url, alt, do_attach }) { 9790 let label = (typeof slot_label === 'string' && slot_label.trim()) ? slot_label.trim() : 'asset'; 9791 if (typeof universal_edit_overlay !== 'function') { 9792 // Fallback to the browser prompt if the overlay 9793 // isn't loaded for some reason â better than 9794 // silently dropping the action. 9795 let pasted = window.prompt('Paste a URL for "' + label + '":', current_url || ''); 9796 if (!pasted) { 9797 return; 9798 } 9799 this.process_pasted_url({ pasted: pasted, alt: alt, do_attach: do_attach }); 9800 return; 9801 } 9802 universal_edit_overlay({ 9803 title: 'Paste a URL', 9804 subtitle: { label: 'Paste a public URL for "' + label + '". Clubspot
9804CDN links attach directly; external URLs will prompt you to save a local copy.' }, 9805 button_label: 'Attach URL', 9806 hide_on_selection: true, 9807 fields: [{ 9808 key: 'url', 9809 label: 'URL', 9810 value: current_url || '', 9811 type: 'text', 9812 required: true, 9813 }], 9814 fn: (params) => { 9815 let field = (params && params.fields ? params.fields : []).find((f) => f.key === 'url'); 9816 let value = field && typeof field.value === 'string' ? field.value.trim() : ''; 9817 if (!value) { 9818 return; 9819 } 9820 this.process_pasted_url({ pasted: value, alt: alt, do_attach: do_attach }); 9821 }, 9822 }); 9823 }, 9824 process_pasted_url({ pasted, alt, do_attach }) { 9825 let trimmed = (typeof pasted === 'string') ? pasted.trim() : ''; 9826 if (!trimmed) { 9827 return; 9828 } 9829 let club_id = this.return_active_club_id(); 9830 if (!club_id || typeof Parse === 'undefined') {
9831 // Without a club_id we can't run the server-side 9832 // verifier â fall back to attaching the URL with a 9833 // warning so the admin isn't stuck. 9834 if (typeof displayMessage === 'function') { 9835 displayMessage('Could not verify the URL; attaching as-is. Review before publishing.', true, 5000); 9836 } 9837 do_attach({ url: trimmed, verified: false, source: 'pasted_url', visibility: 'unknown' }); 9838 return; 9839 } 9840 Parse.Cloud.run('verify_webbuilder_asset_url', { 9841 club_id: club_id, 9842 url: trimmed, 9843 }, this.parse_session_options()).then((verdict) => { 9844 if (verdict && verdict.allowed) { 9845 do_attach({ 9846 url: verdict.embed_url || trimmed, 9847 verified: true, 9848 source: verdict.source || 'verified_url', 9849 visibility: verdict.visibility || 'public_web', 9850 }); 9851 return; 9852 } 9853 let reason = verdict && verdict.reason ? verdict.reason : 'unverified'; 9854 // External URLs come back as `external_url_not_allowed` 9855 // â surface as a Clubspot modal so the admin can 9856 // choose between importing to the library or 9857 // attaching as-is. The custom-code sanitizer still 9858 // strips dangerous schemes at render time. 9859 if (reason === 'external_url_not_allowed') { 9860 this.confirm_external_url_action({ 9861 url: trimmed, 9862 alt: alt, 9863 on_import: () => { 9864 return this.import_external_image_url(trimmed, alt).then((cdn_url) => { 9865 do_attach({ 9866 url: cdn_url, 9867 verified: true, 9868 source: 'imported_external_url', 9869 visibility: 'public_web', 9870 }); 9871 }); 9872 }, 9873 on_attach_external: () => { 9874 do_attach({ url: trimmed, verified: false, source: 'external', visibility: 'unknown' }); 9875 }, 9876 }); 9877 return; 9878 } 9879 if (typeof displayMessage === 'function') { 9880 displayMessage('URL rejected: ' + reason.replace(/_/g, ' '), true, 5000); 9881 } 9882 }).catch((error) => { 9883 // The verifier cloud function ships in the same 9884 // deploy as this UI, but local devs sometimes don't 9885 // restart their primary server between code pulls. 9886 // Treat "Invalid function" as a soft fallback: attach 9887 // the URL with a warning so the admin isn't stuck, 9888 // and surface the underlying error in the console. 9889 let msg = error && error.message ? error.message : ''; 9890 if (msg.indexOf('Invalid function') !== -1) { 9891 console.warn('[custom-code] verify_webbuilder_asset_url not deployed â attaching URL without verification.'); 9892 if (typeof displayMessage === 'function') { 9893 displayMessage('Attached without verification (server hasn\'t reloaded the new function yet).', true, 5000); 9894 } 9895 do_attach({ url: trimmed, verified: false, source: 'pasted_url', visibility: 'unknown' }); 9896 return; 9897 } 9898 if (typeof displayMessage === 'function') { 9899 displayMessage(msg || 'Could not verify URL.', true, 5000); 9900 } 9901 }); 9902 }, 9903 confirm_external_url_action({ url, alt, on_import, on_attach_external }) { 9904 if (typeof are_you_sure !== 'function') { 9905 if (window.confirm('This URL is hosted outside Clubspot. Save a copy to your club photo library?\n\nClick Cancel to attach the external URL as-is.\n\n' + url)) { 9906 Promise.resolve().then(() => on_import()).catch(() => {}); 9907 } else { 9908 on_attach_external(); 9909 } 9910 return; 9911 } 9912 let host = ''; 9913 try { 9914 host = new URL(url).hostname || ''; 9915 } catch (err) { 9916 host = ''; 9917 } 9918 let truncated = url.length > 90 ? (url.slice(0, 87) + '...') : url; 9919 let escaped = (typeof url === 'string') ? url.replace(/"/g, '"') : ''; 9920 are_you_sure({ 9921 title: 'External image URL', 9922 subtext: 'This image lives on ' + (host || 'another site') + '. Save a copy to your club photo library so it stays available even if the source site changes.', 9923 callout: { html: '<a href="' + escaped + '" target="_blank" rel="noopener" style="word-break:break-all;">' + truncated + '</a>' }, 9924 button_label: 'Save to club photos', 9925 warning_button: false, 9926 no_thanks_label: 'Attach as external URL', 9927 fn: () => { 9928 store.components.are_you_sure.loading = true; 9929 Promise.resolve().then(() => on_import()).then(() => { 9930 store.components.are_you_sure.loading = false;
9931 store.overlays.are_you_sure = false; 9932 if (typeof displayMessage === 'function') { 9933 displayMessage('Image saved to your photo library.', false, 4000); 9934 } 9935 }).catch((error) => { 9936 store.components.are_you_sure.loading = false; 9937 if (typeof checkParseError === 'function') { 9938 checkParseError(error); 9939 } 9940 let msg = error && error.message ? error.message : 'Image import failed.'; 9941 if (typeof displayMessage === 'function') { 9942 displayMessage(msg, true, 6000); 9943 } 9944 }); 9945 }, 9946 secondary_fn: () => { 9947 store.overlays.are_you_sure = false; 9948 on_attach_external(); 9949 }, 9950 }); 9951 }, 9952 // Run the `import_external_image_to_club_photos` Cloud 9953 // function and resolve with the canonical CDN URL. 9954 // Centralizes the embed_url fallback + "no URL returned" 9955 // diagnostics that used to be duplicated across both 9956 // `import_external_image_for_attached` and 9957 // `import_external_image_for_bare`. 9958 import_external_image_url(url, alt) { 9959 let club_id = this.return_active_club_id(); 9960 if (!club_id || typeof Parse === 'undefined') { 9961 return Promise.reject(new Error('Could not determine the active club. Refresh and try again.')); 9962 } 9963 return Parse.Cloud.run('import_external_image_to_club_photos', { 9964 club_id: club_id, 9965 url: url, 9966 alt: alt || '', 9967 }, this.parse_session_options()).then((result) => { 9968 let returned_cdn = result && result.cdn_url ? result.cdn_url : null; 9969 let embed_fallback = result && result.result && result.result.embed_url ? result.result.embed_url : null; 9970 let cdn_url = returned_cdn || embed_fallback; 9971 if (!cdn_url) { 9972 let detail = result ? (' (server returned: ' + JSON.stringify(result).slice(0, 160) + ')') : ''; 9973 throw new Error('Image import finished but no URL was returned.' + detail); 9974 } 9975 return cdn_url; 9976 }); 9977 }, 9978 // âââ Section-level "save external images" batch âââââââââââ 9979 // 9980 // Pure CLIENT operation, mirroring upload_webbuilder_asset_photo: 9981 // discover every external image URL in the section, loop the 9982 // existing import_external_image_to_club_photos cloud function 9983 // per URL (with the admin's real session token / permissions), 9984 // then rewrite the persisted HTML to point at the new CDN 9985 // URLs. No AI service involvement. 9986 // 9987 // Parses the persisted source (not the live preview DOM) so 9988 // it sees the canonical URLs and isn't confused by transient 9989 // decoration wrappers. Returns a deduped array of external 9990 // http(s) image URLs found in <img src> and inline 9991 // `background-image: url(...)`. 9992 collect_section_external_image_urls(source_html) { 9993 return webbuilder_collect_external_image_urls(source_html); 9994 }, 9995 import_all_external_images_in_section() { 9996 if (this.importing_external_images) { 9997 return; 9998 } 9999 let urls = this.section_external_image_urls; 10000 if (!urls.length) { 10001 if (typeof displayMessage === 'function') { 10002 displayMessage('No external images to save in this section.', false, 4000); 10003 } 10004 return; 10005 } 10006 let club_id = this.return_active_club_id(); 10007 if (!club_id || typeof Parse === 'undefined') { 10008 if (typeof displayMessage === 'function') { 10009 displayMessage('Could not determine the active club. Refresh and try again.', true, 5000); 10010 } 10011 return; 10012 } 10013 let count = urls.length; 10014 let run_batch = () => { 10015 this.importing_external_images = true; 10016 if (typeof displayMessage === 'function') { 10017 displayMessage('Saving ' + count + ' image' + (count === 1 ? '' : 's') + ' to your photo library...', false, 4000); 10018 } 10019 this.run_external_image_batch(urls).then((summary) => { 10020 this.importing_external_images = false;
10021 this.report_external_image_batch_result(summary); 10022 }).catch((error) => { 10023 this.importing_external_images = false; 10024 if (typeof checkParseError === 'function') { 10025 checkParseError(error); 10026 } 10027 let msg = error && error.message ? error.message : 'Image import failed.'; 10028 if (typeof displayMessage === 'function') { 10029 displayMessage(msg, true, 6000); 10030 } 10031 }); 10032 }; 10033 if (typeof are_you_sure === 'function') { 10034 are_you_sure({ 10035 title: 'Save external images', 10036 subtext: 'This section uses ' + count + ' image' + (count === 1 ? '' : 's') + ' hosted on other sites. Save ' 10037 + (count === 1 ? 'a copy' : 'copies') + ' to your club photo library and swap the section to use ' 10038 + (count === 1 ? 'it' : 'them') + '? This keeps the images available even if the source site changes.', 10039 button_label: 'Save to club photos', 10040 warning_button: false, 10041 fn: () => { 10042 store.overlays.are_you_sure = false; 10043 run_batch(); 10044 }, 10045 }); 10046 return; 10047 } 10048 run_batch(); 10049 }, 10050 // Import each external URL sequentially (bounded to one 10051 // in-flight request â these are byte-level server fetches, 10052 // and a club reference page rarely has more than a handful 10053 // of images). Builds a url->cdn_url map, then rewrites the 10054 // persisted HTML once at the end so the editor only takes a 10055 // single save + re-render. 10056 run_external_image_batch(urls) { 10057 let url_map = {}; 10058 let errors = []; 10059 let chain = Promise.resolve(); 10060 urls.forEach((url) => { 10061 chain = chain.then(() => { 10062 return this.import_external_image_url(url, '').then((cdn_url) => { 10063 url_map[url] = cdn_url; 10064 }).catch((error) => { 10065 errors.push({ url: url, message: error && error.message ? error.message : 'import failed' }); 10066 }); 10067 }); 10068 }); 10069 return chain.then(() => { 10070 let swapped = this.rewrite_section_external_image_urls(url_map); 10071 return { 10072 requested: urls.length, 10073 imported: Object.keys(url_map).length, 10074 errors: errors, 10075 swapped: swapped, 10076 }; 10077 }); 10078 }, 10079 // Replace every occurrence of each imported external URL 10080 // with its new CDN URL across the persisted HTML â both 10081 // <img src> and inline background-image declarations â and 10082 // persist once. Returns the number of URLs actually swapped. 10083 rewrite_section_external_image_urls(url_map) { 10084 let keys = Object.keys(url_map || {}); 10085 if (!keys.length) { 10086 return 0; 10087 } 10088 let swapped = 0; 10089 let mutated = this.mutate_persisted_html((doc) => { 10090 let changed = false; 10091 let imgs = Array.from(doc.querySelectorAll('img[src]')); 10092 for (let img of imgs) { 10093 let src = img.getAttribute('src'); 10094 if (src && url_map[src]) { 10095 img.setAttribute('src', url_map[src]); 10096 changed = true; 10097 } 10098 } 10099 let styled = Array.from(doc.querySelectorAll('[style*="background-image"]')); 10100 for (let el of styled) { 10101 let style = el.getAttribute('style') || ''; 10102 let next_style = style; 10103 keys.forEach((old_url) => { 10104 if (next_style.indexOf(old_url) !== -1) { 10105 next_style = next_style.split(old_url).join(url_map[old_url]); 10106 } 10107 }); 10108 if (next_style !== style) { 10109 el.setAttribute('style', next_style); 10110 changed = true; 10111 } 10112 } 10113 return changed; 10114 }); 10115 if (mutated) { 10116 this.persist_mutated_code(mutated); 10117 swapped = keys.length; 10118 } 10119 return swapped; 10120 }, 10121 report_external_image_batch_result(summary) { 10122 if (typeof displayMessage !== 'function') { 10123 return; 10124 } 10125 let safe = summary || {}; 10126 let imported = safe.imported || 0; 10127 let errors = Array.isArray(safe.errors) ? safe.errors : []; 10128 if (imported > 0 && errors.length === 0) { 10129 displayMessage('Saved ' + imported + ' image' + (imported === 1 ? '' : 's') + ' to your photo library.', false, 4000); 10130 return; 10131 } 10132 if (imported > 0 && errors.length > 0) { 10133 displayMessage('Saved ' + imported + ', but ' + errors.length + ' failed: ' + (errors[0].message || 'unknown error') + '.', true, 6000); 10134 return; 10135 } 10136 displayMessage('Could not save the external images: ' + (errors.length ? (errors[0].message || 'unknown error') : 'nothing imported') + '.', true, 6000); 10137 }, 10138 return_active_club_id() { 10139 if (typeof store !== 'undefined' && store.clubObject && store.clubObject.id) { 10140 return store.clubObject.id; 10141 } 10142 return null; 10143 }, 10144 parse_session_options() { 10145 if (typeof Parse === 'undefined' || !Parse.User || !Parse.User.current()) { 10146 return {}; 10147 } 10148 let token = Parse.User.current().getSessionToken ? Parse.User.current().getSessionToken() : null; 10149 if (!token) { 10150 return {}; 10151 } 10152 return { sessionToken: token }; 10153 },
10154 find_placeholder_element(id) { 10155 return this.find_asset_element_by_id(id); 10156 }, 10157 // Match either `data-cs-asset-placeholder="<id>"` (unfilled) 10158 // or `data-cs-asset-attached="<id>"` (already filled) so the 10159 // action menu can re-open against the same slot regardless 10160 // of state. 10161 find_asset_element_by_id(id) { 10162 let root = this.custom_code_render_root(); 10163 if (!root || !id) { 10164 return null; 10165 } 10166 let escaped = (typeof CSS !== 'undefined' && CSS.escape) ? CSS.escape(id) : id.replace(/"/g, '\\"'); 10167 let selector = 10168 '[data-cs-asset-placeholder="' + escaped + '"], ' + 10169 '[data-cs-asset-attached="' + escaped + '"]'; 10170 return root.querySelector(selector); 10171 }, 10172 // Hostnames that mean "the file is already in the Clubspot 10173 // library." Mirrors the server-side PUBLIC_ASSET_HOSTS set 10174 // in primary/cloud/webbuilder-assets.js. Drift in this list 10175 // is non-fatal: at worst we offer the user "Copy to club 10176 // photos" on a URL that turns out to be local, and the 10177 // server rejects the import with a clear error. The set 10178 // here matters only for the UX surface (showing/hiding the 10179 // menu option). 10180 return_clubspot_asset_hosts() { 10181 return new Set(WEBBUILDER_CLUBSPOT_ASSET_HOSTS); 10182 }, 10183 is_external_image_url(url) { 10184 return webbuilder_is_external_image_url(url); 10185 }, 10186 // Pull the currently-attached URL out of an asset wrapper. 10187 // Handles the three shapes attach_url_to_placeholder writes: 10188 // - <a data-cs-asset-attached href="..."> for documents. 10189 // - <img data-cs-asset-attached src="..."> (rare; the 10190 // wrapper itself is the <img>). 10191 // - <div data-cs-asset-attached style="background-image: 10192 // url(...);"> for the background-image target. 10193 // - A wrapper <div data-cs-asset-attached> whose first 10194 // <img> child carries the real src. 10195 // 10196 // Returns the raw URL string or null when nothing readable 10197 // is attached. 10198 return_attached_url_for_wrapper(wrapper) { 10199 if (!wrapper || wrapper.nodeType !== 1) { 10200 return null; 10201 } 10202 let tag_name = (wrapper.tagName || '').toLowerCase(); 10203 let target = (wrapper.getAttribute('data-cs-asset-target') || '').toLowerCase(); 10204 if (target === 'background-image') { 10205 let style = wrapper.getAttribute('style') || ''; 10206 let match = style.match(/background-image\s*:\s*url\(\s*(['"]?)([^'"\)]+)\1\s*\)/i); 10207 return match ? match[2] : null; 10208 } 10209 if (tag_name === 'a' || target === 'href') { 10210 return wrapper.getAttribute('href') || null; 10211 } 10212 if (tag_name === 'img') { 10213 return wrapper.getAttribute('src') || null; 10214 } 10215 let child_img = wrapper.querySelector('img[src]'); 10216 return child_img ? child_img.getAttribute('src') : null; 10217 }, 10218 // Single-image "Copy image to club photos" flow for an 10219 // already-attached external image. Reads the wrapper's 10220 // current URL, runs the import via the shared 10221 // `import_external_image_url` helper, and swaps the 10222 // returned CDN URL into the same wrapper. The wrapper 10223 // stays put across the swap, so layout doesn't reflow. 10224 import_external_image_for_attached(id) { 10225 let wrapper = this.find_asset_element_by_id(id); 10226 if (!wrapper) { 10227 return; 10228 } 10229 let original_url = this.return_attached_url_for_wrapper(wrapper); 10230 if (!this.is_external_image_url(original_url)) { 10231 if (typeof displayMessage === 'function') { 10232 displayMessage('This image is already in your library.', false, 4000); 10233 } 10234 return; 10235 } 10236 let alt = wrapper.getAttribute('data-cs-asset-label') 10237 || wrapper.getAttribute('alt') 10238 || ''; 10239 if (typeof displayMessage === 'function') { 10240 displayMessage('Importing image to your photo library...', false, 4000); 10241 } 10242 this.import_external_image_url(original_url, alt).then((cdn_url) => { 10243 this.attach_url_to_placeholder({ 10244 id: id, 10245 url: cdn_url, 10246 verified: true, 10247 source: 'imported_external_url', 10248 visibility: 'public_web', 10249 }); 10250 if (typeof displayMessage === 'function') { 10251 displayMessage('Image saved to your photo library.', false, 4000); 10252 } 10253 }).catch((error) => { 10254 if (typeof checkParseError === 'function') { 10255 checkParseError(error); 10256 } 10257 let msg = error && error.message ? error.message : 'Image import failed.'; 10258 if (typeof displayMessage === 'function') { 10259 displayMessage(msg, true, 6000); 10260 } 10261 }); 10262 }, 10263 10264 // âââ Bare <img> change flow âââââââââââââââââââââââââââââââ 10265 // 10266 // Parallel to the placeholder-marker flow above, but keyed 10267 // on the image's current `src` instead of a synthetic id. 10268 // Used by the third pass of `decorate_asset_placeholders` 10269 // that catches bare `<img>` tags from URL-reference builds. 10270 // 10271 // The same handle_common menu shape powers both â the 10272 // bare-img menu just routes its callbacks through src-based 10273 // mutation so the persisted HTML stays bare (no surprise 10274 // wrappers, no surprise unsaved_changes) until the admin 10275 // actually swaps the image. 10276 10277 open_bare_image_menu(img_el) { 10278 if (typeof handle_common !== 'function') { 10279 return; 10280 } 10281 if (!img_el || typeof img_el.getAttribute !== 'function') { 10282 return; 10283 } 10284 let current_src = img_el.getAttribute('src') || ''; 10285 let current_alt = img_el.getAttribute('alt') || ''; 10286 if (!current_src.trim()) { 10287 return; 10288 } 10289 let is_external = this.is_external_image_url(current_src); 10290 let options = []; 10291 options.push({ 10292 text: 'Pick a different photo', 10293 subtext: 'Upload a new image or pick an existing one from this club', 10294 fn: () => this.run_after_placeholder_menu_close(() => this.pick_existing_for_bare_image(img_el)), 10295 }); 10296 options.push({ 10297 text: 'Choose from club documents', 10298 subtext: 'Public-access files in the club documents folder', 10299 fn: () => this.run_after_placeholder_menu_close(() => this.pick_document_for_bare_image(img_el)), 10300 }); 10301 options.push({ 10302 text: 'Paste a different URL', 10303 subtext: 'Paste a public image URL (Clubspot
10303CDN or external host)', 10304 fn: () => this.run_after_placeholder_menu_close(() => this.paste_url_for_bare_image(img_el)), 10305 }); 10306 if (is_external) { 10307 options.push({ 10308 text: 'Copy image to club photos', 10309 subtext: 'Save a local copy in your photo library and use that instead of the external URL.', 10310 fn: () => this.run_after_placeholder_menu_close(() => this.import_external_image_for_bare(img_el)), 10311 }); 10312 } 10313 let label = (current_alt || '').trim() || 'image'; 10314 handle_common({ 10315 title: 'Change "' + label + '"', 10316 subtitle: is_external 10317 ? 'Pick a different photo, paste another URL, or save this external image into your library.' 10318 : 'Pick a different photo or paste another URL.', 10319 hide_on_selection: false, 10320 options: options, 10321 }); 10322 }, 10323 // Persist a swap from one img src to another. Matches by 10324 // exact `src` attribute value, which covers the model's 10325 // output and any direct admin edits (the codegen pipeline 10326 // doesn't normalize URL strings between turns). Also lifts 10327 // a missing `alt` from the new label so SEO/accessibility 10328 // copy survives the swap. 10329 swap_bare_image_src(old_src, new_url, new_alt) { 10330 if (typeof old_src !== 'string' || !old_src.trim()) { 10331 return false; 10332 } 10333 if (typeof new_url !== 'string' || !new_url.trim()) { 10334 return false; 10335 } 10336 let mutated = this.mutate_persisted_html((doc) => { 10337 let imgs = Array.from(doc.querySelectorAll('img[src]')); 10338 let changed = false; 10339 for (let img of imgs) { 10340 if (img.getAttribute('src') !== old_src) { 10341 continue; 10342 } 10343 img.setAttribute('src', new_url); 10344 if (new_alt && !(img.getAttribute('alt') || '').trim()) { 10345 img.setAttribute('alt', new_alt); 10346 } 10347 changed = true; 10348 } 10349 return changed; 10350 }); 10351 if (mutated) { 10352 this.persist_mutated_code(mutated); 10353 return true; 10354 } 10355 return false; 10356 }, 10357 pick_existing_for_bare_image(img_el) { 10358 if (typeof webbuilder_asset_picker !== 'function') { 10359 if (typeof displayMessage === 'function') { 10360 displayMessage('Club photo picker is not loaded. Refresh the page and try again.', true, 5000); 10361 } 10362 return; 10363 } 10364 let old_src = img_el.getAttribute('src') || ''; 10365 if (!old_src) { 10366 return; 10367 } 10368 webbuilder_asset_picker({ 10369 title: 'Select club photo', 10370 accepts: 'image', 10371 source: 'photos', 10372 hide_on_selection: true, 10373 fn: (result) => { 10374 let url = result && result.url ? result.url : null; 10375 if (!url) { 10376 return; 10377 } 10378 this.swap_bare_image_src(old_src, url, result.label || ''); 10379 }, 10380 }); 10381 }, 10382 pick_document_for_bare_image(img_el) { 10383 if (typeof webbuilder_asset_picker !== 'function') { 10384 if (typeof displayMessage === 'function') { 10385 displayMessage('Documents picker is not loaded. Refresh the page and try again.', true, 5000); 10386 } 10387 return; 10388 } 10389 let old_src = img_el.getAttribute('src') || ''; 10390 if (!old_src) { 10391 return; 10392 } 10393 webbuilder_asset_picker({ 10394 title: 'Select a file', 10395 accepts: 'image', 10396 source: 'documents', 10397 hide_on_selection: true, 10398 fn: (result) => { 10399 let url = result && result.url ? result.url : null; 10400 if (!url) { 10401 return; 10402 } 10403 this.swap_bare_image_src(old_src, url, result.label || ''); 10404 }, 10405 }); 10406 }, 10407 paste_url_for_bare_image(img_el) { 10408 let old_src = img_el.getAttribute('src') || ''; 10409 let label = img_el.getAttribute('alt') || 'image'; 10410 if (!old_src) { 10411 return; 10412 } 10413 let do_attach = (attach) => { 10414 this.swap_bare_image_src(old_src, attach.url, label); 10415 }; 10416 this.prompt_for_pasted_url({ 10417 slot_label: label, 10418 alt: label, 10419 do_attach: do_attach, 10420 }); 10421 }, 10422 // Single-image import path for bare imgs. Reads the img's 10423 // current src, runs the same Cloud function the attached- 10424 // wrapper menu uses, and swaps the src for the CDN URL on 10425 // success. The img stays exactly where it is â no wrapper 10426 // upgrade, no layout change. 10427 import_external_image_for_bare(img_el) { 10428 let original_url = img_el.getAttribute('src') || ''; 10429 let alt = img_el.getAttribute('alt') || ''; 10430 if (!this.is_external_image_url(original_url)) { 10431 if (typeof displayMessage === 'function') { 10432 displayMessage('This image is already in your library.', false, 4000); 10433 } 10434 return; 10435 } 10436 if (typeof displayMessage === 'function') { 10437 displayMessage('Importing image to your photo library...', false, 4000); 10438 } 10439 this.import_external_image_url(original_url, alt).then((cdn_url) => { 10440 this.swap_bare_image_src(original_url, cdn_url, alt); 10441 if (typeof displayMessage === 'function') { 10442 displayMessage('Image saved to your photo library.', false, 4000); 10443 } 10444 }).catch((error) => { 10445 if (typeof checkParseError === 'function') { 10446 checkParseError(error); 10447 } 10448 let msg = error && error.message ? error.message : 'Image import failed.'; 10449 if (typeof displayMessage === 'function') { 10450 displayMessage(msg, true, 6000); 10451 } 10452 }); 10453 }, 10454 10455 // Rewrite the persisted custom-code string so the matching 10456 // placeholder element gains a real `src`/`href`/`background- 10457 // image`. We keep the wrapper element across the transition 10458 // â it carries the section's layout box (dimensions, border- 10459 // radius, positioning, etc) â and just rotate its children 10460 // for `target="src"`. The wrapper's `data-cs-asset- 10461 // placeholder` attribute is renamed to `data-cs-asset- 10462 // attached` so: 10463 // 10464 // 1. Subsequent decorate passes show the small "Change" 10465 // overlay instead of the big "Add" overlay. 10466 // 2. The AI model can tell on follow-up turns that the 10467 // slot is filled (and must not be re-placeholderized). 10468 // 10469 // We also accept being called against an already-attached 10470 // element â that's the "Change" flow swapping one URL for 10471 // another. 10472 attach_url_to_placeholder(params) { 10473 let { id, url } = params; 10474 if (!id || typeof url !== 'string' || !url.trim()) { 10475 return; 10476 } 10477 let mutated = this.mutate_persisted_html((doc) => { 10478 let escaped = (typeof CSS !== 'undefined' && CSS.escape) ? CSS.escape(id) : id.replace(/"/g, '\\"'); 10479 let wrapper = doc.querySelector( 10480 '[data-cs-asset-placeholder="' + escaped + '"], ' + 10481 '[data-cs-asset-attached="' + escaped + '"]' 10482 ); 10483 if (!wrapper) { 10484 return false;
10485 } 10486 let tag_name = (wrapper.tagName || '').toLowerCase(); 10487 let target = (wrapper.getAttribute('data-cs-asset-target') || '').toLowerCase(); 10488 if (!target) { 10489 if (tag_name === 'a') { 10490 target = 'href'; 10491 } else if (tag_name === 'img') { 10492 target = 'src'; 10493 } else { 10494 target = 'src'; 10495 } 10496 } 10497 let alt = wrapper.getAttribute('data-cs-asset-label') || wrapper.getAttribute('alt') || ''; 10498 10499 if (target === 'background-image') { 10500 let existing_style = wrapper.getAttribute('style') || ''; 10501 // Strip any existing background-image declaration 10502 // so the new url() wins without doubling up. Also 10503 // wipe any inner content the placeholder used as 10504 // a fallback label â once we've got a real image, 10505 // the gradient/icon "Add a photo" copy would just 10506 // clutter the live site. 10507 let stripped = existing_style.replace(/background-image\s*:[^;]*;?/gi, '').trim(); 10508 let suffix = stripped && !/;\s*$/.test(stripped) ? '; ' : (stripped ? ' ' : ''); 10509 let bg_value = 'background-image: url("' + this.escape_css_url_segment(url) + '"); background-size: cover; background-position: center;'; 10510 wrapper.setAttribute('style', (stripped + suffix + bg_value).trim()); 10511 } else if (target === 'href' || tag_name === 'a') { 10512 wrapper.setAttribute('href', url); 10513 wrapper.setAttribute('target', '_blank'); 10514 wrapper.setAttribute('rel', 'noopener noreferrer'); 10515 } else if (tag_name === 'img') { 10516 wrapper.setAttribute('src', url); 10517 if (alt && !wrapper.getAttribute('alt')) { 10518 wrapper.setAttribute('alt', alt); 10519 } 10520 } else { 10521 // `target=src` on a wrapper element: drop any 10522 // fallback children (icon, text label) and insert 10523 // an <img> sized to fill the wrapper. The wrapper 10524 // continues to carry the layout box, so the live 10525 // site never reflows between empty/filled states. 10526 this.empty_placeholder_inner(wrapper); 10527 let owner = wrapper.ownerDocument || document; 10528 let img = owner.createElement('img'); 10529 img.setAttribute('src', url); 10530 if (alt) { 10531 img.setAttribute('alt', alt); 10532 } 10533 img.setAttribute('style', 'width:100%;height:100%;object-fit:cover;display:block;border-radius:inherit;'); 10534 wrapper.appendChild(img); 10535 } 10536 // Rename the placeholder marker â attached marker 10537 // (same id). Preserve everything else so the model 10538 // still has the role/label/target context next turn. 10539 let placeholder_id = wrapper.getAttribute('data-cs-asset-placeholder'); 10540 if (placeholder_id !== null) { 10541 wrapper.removeAttribute('data-cs-asset-placeholder'); 10542 wrapper.setAttribute('data-cs-asset-attached', placeholder_id); 10543 } 10544 return true; 10545 }); 10546 if (mutated) { 10547 this.persist_mutated_code(mutated); 10548 } 10549 }, 10550 // Drop any inert fallback content the placeholder shipped 10551 // with (e.g. "Add a hero photo" label, decorative icon spans). 10552 // Leaves the wrapper element itself untouched. 10553 empty_placeholder_inner(wrapper) { 10554 if (!wrapper) { 10555 return; 10556 } 10557 while (wrapper.firstChild) { 10558 wrapper.removeChild(wrapper.firstChild); 10559 } 10560 }, 10561 // "Keep empty for now" â dismiss the prominent "Add image" 10562 // overlay but keep the wrapper marked so the admin can 10563 // still attach an asset later via the small Change button. 10564 // We rename `data-cs-asset-placeholder` â `data-cs-asset- 10565 // attached` (same id, no URL set) which: 10566 // - swaps the decorator from big overlay â Change chip, 10567 // - tells the AI to preserve the slot as-is on follow-up 10568 // rewrites (don't re-placeholderize).
10569 // Permanent deletion is a code-editor operation. 10570 remove_placeholder(id) { 10571 if (!id) { 10572 return; 10573 } 10574 let mutated = this.mutate_persisted_html((doc) => { 10575 let escaped = (typeof CSS !== 'undefined' && CSS.escape) ? CSS.escape(id) : id.replace(/"/g, '\\"'); 10576 let placeholder = doc.querySelector('[data-cs-asset-placeholder="' + escaped + '"]'); 10577 if (!placeholder) { 10578 return false; 10579 } 10580 placeholder.removeAttribute('data-cs-asset-placeholder'); 10581 placeholder.setAttribute('data-cs-asset-attached', id); 10582 return true; 10583 }); 10584 if (mutated) { 10585 this.persist_mutated_code(mutated); 10586 } 10587 }, 10588 // "Remove this image" on an attached slot â wipe the 10589 // filled URL/contents and put the slot back into placeholder 10590 // state so the admin can re-fill from scratch. For `src` 10591 // wrappers we also need to drop the inner <img>. 10592 revert_attached_to_placeholder(id) { 10593 if (!id) { 10594 return; 10595 } 10596 let mutated = this.mutate_persisted_html((doc) => { 10597 let escaped = (typeof CSS !== 'undefined' && CSS.escape) ? CSS.escape(id) : id.replace(/"/g, '\\"'); 10598 let wrapper = doc.querySelector('[data-cs-asset-attached="' + escaped + '"]'); 10599 if (!wrapper) { 10600 return false; 10601 } 10602 let tag_name = (wrapper.tagName || '').toLowerCase(); 10603 let target = (wrapper.getAttribute('data-cs-asset-target') || '').toLowerCase(); 10604 if (!target) { 10605 if (tag_name === 'a') { 10606 target = 'href'; 10607 } else if (tag_name === 'img') { 10608 target = 'src'; 10609 } else { 10610 target = 'src'; 10611 } 10612 } 10613 if (target === 'background-image') { 10614 let style = wrapper.getAttribute('style') || ''; 10615 let stripped = style.replace(/background-image\s*:[^;]*;?/gi, '').trim(); 10616 if (stripped) { 10617 wrapper.setAttribute('style', stripped); 10618 } else { 10619 wrapper.removeAttribute('style'); 10620 } 10621 } else if (target === 'href' || tag_name === 'a') { 10622 wrapper.removeAttribute('href'); 10623 wrapper.removeAttribute('target'); 10624 wrapper.removeAttribute('rel'); 10625 } else { 10626 this.empty_placeholder_inner(wrapper); 10627 } 10628 let attached_id = wrapper.getAttribute('data-cs-asset-attached'); 10629 if (attached_id !== null) { 10630 wrapper.removeAttribute('data-cs-asset-attached'); 10631 wrapper.setAttribute('data-cs-asset-placeholder', attached_id); 10632 } 10633 return true; 10634 }); 10635 if (mutated) { 10636 this.persist_mutated_code(mutated); 10637 } 10638 }, 10639 strip_all_asset_attributes(element) { 10640 if (!element) { 10641 return; 10642 } 10643 let attrs_to_remove = []; 10644 Array.prototype.slice.call(element.attributes || []).forEach((attr) => { 10645 let name = String(attr.name || '').toLowerCase(); 10646 if (name.indexOf('data-cs-asset-') === 0) { 10647 attrs_to_remove.push(attr.name); 10648 } 10649 }); 10650 attrs_to_remove.forEach((name) => element.removeAttribute(name)); 10651 }, 10652 escape_css_url_segment(url) { 10653 if (typeof url !== 'string') { 10654 return ''; 10655 } 10656 return url.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); 10657 }, 10658 // Parse the persisted custom-code string into a scratch 10659 // <template>, hand it to `mutator` for in-place changes, 10660 // and serialize back. Returns the new HTML string or null 10661 // if the mutator reported no change. 10662 mutate_persisted_html(mutator) { 10663 let source = typeof this.custom_code_value === 'string' ? this.custom_code_value : ''; 10664 let template = document.createElement('template'); 10665 template.innerHTML = source; 10666 let changed = mutator(template.content); 10667 if (!changed) { 10668 return null; 10669 } 10670 return template.innerHTML; 10671 }, 10672 persist_mutated_code(new_html) { 10673 if (typeof new_html !== 'string') { 10674 return; 10675 } 10676 this.custom_code_value = new_html; 10677 if (this.cm_instance) { 10678 this.cm_instance.setValue(new_html); 10679 } 10680 if (this.component && typeof save_changes_for_component === 'function') { 10681 save_changes_for_component(this.component, 'custom_code', new_html); 10682 } 10683 this.mark_ai_code_revision_changed(); 10684 this.$nextTick(() => this.initialize_custom_code_interactions()); 10685 }, 10686 10687 // âââ Editable text leaves âââââââââââââââââââââââââââââââââ 10688 // 10689 // Walk the rendered preview, find every "text leaf" element 10690 // (any element whose own children are inline-only, plus 10691 // direct text), and attach a click handler that opens an 10692 // inline edit popover. Skip: 10693 // - asset placeholder/attached wrappers (their inner text 10694 // is fallback content the placeholder fill flow wipes), 10695 // - data-cs-toggle / data-cs-tab interactive triggers 10696 // (admin uses those to test interactions in preview), 10697 // - the AI launcher button. 10698 // 10699 // Each decorated element gets a `__cs_text_decorated` flag 10700 // so we don't double-bind across re-decorate passes within 10701 // one render, and a `cs-text-editable-host` class so CSS can 10702 // give it a hover outline + cursor:pointer affordance. 10703 decorate_editable_text_nodes() { 10704 if (!this.preview_element) { 10705 return; 10706 } 10707 let root = this.custom_code_render_root(); 10708 if (!root) { 10709 return; 10710 } 10711 let candidates = this.collect_editable_text_leaves(root);
10712 candidates.forEach((el) => { 10713 if (el.__cs_text_decorated) { 10714 return; 10715 } 10716 el.__cs_text_decorated = true; 10717 el.classList.add('cs-text-editable-host'); 10718 let handler = (event) => { 10719 if (this.should_allow_text_click_through(event)) { 10720 return; 10721 } 10722 event.preventDefault(); 10723 event.stopPropagation(); 10724 this.open_text_edit_popover(el); 10725 }; 10726 el.addEventListener('click', handler); 10727 }); 10728 }, 10729 10730 // DFS-collect text leaves. When an element qualifies, we do 10731 // NOT recurse into it â we want the OUTERMOST text leaf so 10732 // `<h1>Hello <em>world</em></h1>` is edited as one string, 10733 // not two. We also stop recursing into media/script/svg 10734 // wrappers and into asset placeholder/attached wrappers 10735 // (those manage their own UI), since nothing inside should 10736 // be a text leaf anyway. 10737 collect_editable_text_leaves(root) { 10738 let results = []; 10739 let visit = (el) => { 10740 if (!el || el.nodeType !== 1) { 10741 return; 10742 } 10743 let tag = (el.tagName || '').toLowerCase(); 10744 if (TEXT_SKIP_TAGS.has(tag)) { 10745 return; 10746 } 10747 if (this.is_preview_injected_node(el)) { 10748 return; 10749 } 10750 if (el.hasAttribute && ( 10751 el.hasAttribute('data-cs-asset-placeholder') || 10752 el.hasAttribute('data-cs-asset-attached') 10753 )) { 10754 return; 10755 } 10756 if (this.is_editable_text_leaf(el, root)) { 10757 results.push(el); 10758 return; 10759 } 10760 let child_elements = el.children || []; 10761 for (let child of child_elements) { 10762 visit(child); 10763 } 10764 }; 10765 let top = root.children || []; 10766 for (let child of top) { 10767 visit(child); 10768 } 10769 return results; 10770 }, 10771 10772 is_editable_text_leaf(el, root) { 10773 if (!el || el.nodeType !== 1) { 10774 return false; 10775 } 10776 let tag = (el.tagName || '').toLowerCase(); 10777 if (TEXT_SKIP_TAGS.has(tag)) { 10778 return false; 10779 } 10780 if (el.classList && ( 10781 el.classList.contains('cs-asset-placeholder-overlay') || 10782 el.classList.contains('cs-asset-change-button') || 10783 el.classList.contains('cs-asset-background-placeholder-chip') || 10784 el.classList.contains('cs-bare-image-preview-host') || 10785 el.classList.contains('custom-code-ai-launcher') || 10786 el.classList.contains('cs-text-edit-popover') 10787 )) { 10788 return false; 10789 } 10790 // Skip declarative-interaction triggers â admins want to 10791 // click those to test the toggle/tab behavior in preview. 10792 if (el.hasAttribute && ( 10793 el.hasAttribute('data-cs-toggle') || 10794 el.hasAttribute('data-cs-tab') 10795 )) { 10796 return false; 10797 } 10798 // Skip if self or ancestor is an asset placeholder/attached 10799 // wrapper â those wrappers own their fallback content. 10800 let ancestor = el; 10801 while (ancestor && ancestor !== root) { 10802 if (ancestor.nodeType === 1 && ancestor.hasAttribute && ( 10803 ancestor.hasAttribute('data-cs-asset-placeholder') || 10804 ancestor.hasAttribute('data-cs-asset-attached') 10805 )) { 10806 return false;
10807 } 10808 if (this.is_preview_injected_node(ancestor)) { 10809 return false; 10810 } 10811 ancestor = ancestor.parentNode; 10812 } 10813 let has_text = false; 10814 for (let child of el.childNodes) { 10815 if (child.nodeType === 3 && child.nodeValue && child.nodeValue.trim()) { 10816 has_text = true; 10817 break; 10818 } 10819 } 10820 if (!has_text) { 10821 return false; 10822 } 10823 let element_children = el.children || []; 10824 for (let child of element_children) { 10825 let child_tag = (child.tagName || '').toLowerCase(); 10826 if (!TEXT_INLINE_TAGS.has(child_tag)) { 10827 return false; 10828 } 10829 } 10830 return true; 10831 }, 10832 10833 // Walk up from `element` to `root`, recording the index of 10834 // each step among its parent's child NODES (text + element). 10835 // Skip preview-only injected nodes (asset overlays) since 10836 // they don't exist in the persisted source. The same path 10837 // can then be replayed on the parsed source (skipping 10838 //
10838<script>, which sanitize_custom_code_html removes from 10839 // the rendered preview). 10840 compute_text_element_path(element, root) { 10841 if (!element || !root || element === root) { 10842 return null; 10843 } 10844 let path = []; 10845 let current = element; 10846 while (current && current !== root) { 10847 let parent = current.parentNode; 10848 if (!parent) { 10849 return null; 10850 } 10851 let visible_index = -1; 10852 let count = 0; 10853 let found = false; 10854 for (let child of parent.childNodes) { 10855 if (this.is_preview_injected_node(child)) { 10856 continue; 10857 } 10858 if (child === current) { 10859 visible_index = count; 10860 found = true; 10861 break; 10862 } 10863 count++; 10864 } 10865 if (!found || visible_index === -1) { 10866 return null; 10867 } 10868 path.unshift(visible_index); 10869 current = parent; 10870 } 10871 if (current !== root) { 10872 return null; 10873 } 10874 return path; 10875 }, 10876 10877 is_preview_injected_node(node) { 10878 if (!node || node.nodeType !== 1) { 10879 return false; 10880 } 10881 if (!node.classList) { 10882 return false; 10883 } 10884 return ( 10885 node.classList.contains('cs-asset-placeholder-overlay') || 10886 node.classList.contains('cs-asset-change-button') || 10887 node.classList.contains('cs-asset-background-placeholder-chip') || 10888 node.classList.contains('cs-bare-image-host') || 10889 node.classList.contains('cs-bare-image-preview-host') 10890 ); 10891 }, 10892 should_allow_text_click_through(event) { 10893 if (!this._text_click_through_until || Date.now() > this._text_click_through_until) { 10894 return false; 10895 } 10896 let target = event && event.target ? event.target : null; 10897 if (!target || typeof target.closest !== 'function') { 10898 return false; 10899 } 10900 return !!target.closest('a, button, [role="button"], [data-cs-toggle], [data-cs-tab]'); 10901 }, 10902 10903 // Walk a parsed-source tree using a path captured from the 10904 // preview. Skip
10904<script> nodes on the source side because 10905 // sanitize_custom_code_html strips them at render time â 10906 // without skipping, source/preview indices diverge any 10907 // time a section embeds a script tag. 10908 find_text_element_by_path(root, path) { 10909 if (!root || !path) { 10910 return null; 10911 } 10912 let current = root; 10913 for (let target_index of path) { 10914 if (!current || !current.childNodes) { 10915 return null; 10916 } 10917 let visible_index = 0; 10918 let next = null; 10919 for (let child of current.childNodes) { 10920 if ( 10921 child.nodeType === 1 && 10922 (child.tagName || '').toLowerCase() === 'script' 10923 ) { 10924 continue; 10925 } 10926 if (visible_index === target_index) { 10927 next = child; 10928 break; 10929 } 10930 visible_index++; 10931 } 10932 if (!next) { 10933 return null; 10934 } 10935 current = next; 10936 } 10937 return current; 10938 }, 10939 10940 // Inline popover: textarea + Save/Cancel, positioned near 10941 // the clicked element via fixed coords. We close on outside 10942 // click, ESC, or scroll of the preview pane (re-positioning 10943 // on scroll added complexity for little gain â admins will 10944 // re-click after scrolling). 10945 open_text_edit_popover(target_el) { 10946 this.close_text_edit_popover(); 10947 if (!target_el) { 10948 return; 10949 } 10950 let root = this.custom_code_render_root(); 10951 if (!root) { 10952 return; 10953 } 10954 let path = this.compute_text_element_path(target_el, root); 10955 if (!path) { 10956 return; 10957 } 10958 let initial_value = this.read_element_text(target_el); 10959 let link_target = this.find_text_edit_link_target(target_el, root); 10960 let link_path = link_target ? this.compute_text_element_path(link_target, root) : null; 10961 let initial_url = link_target ? this.read_text_edit_url(link_target) : ''; 10962 10963 let popover = document.createElement('div'); 10964 popover.className = 'cs-text-edit-popover'; 10965 popover.setAttribute('data-cs-text-popover', '1'); 10966 10967 let header = document.createElement('div'); 10968 header.className = 'cs-text-edit-popover-header'; 10969 header.textContent = 'Edit text'; 10970 popover.appendChild(header); 10971 10972 let textarea = document.createElement('textarea'); 10973 textarea.className = 'cs-text-edit-textarea'; 10974 textarea.value = initial_value; 10975 textarea.rows = Math.min(8, Math.max(2, initial_value.split('\n').length + 1)); 10976 popover.appendChild(textarea); 10977 10978 let url_input = null; 10979 if (link_target && link_path) { 10980 let url_wrap = document.createElement('label'); 10981 url_wrap.className = 'cs-text-edit-url-field'; 10982 10983 let url_label = document.createElement('span'); 10984 url_label.className = 'cs-text-edit-url-label'; 10985 url_label.textContent = 'URL or path'; 10986 url_wrap.appendChild(url_label); 10987 10988 url_input = document.createElement('input'); 10989 url_input.type = 'text'; 10990 url_input.className = 'cs-text-edit-url-input'; 10991 url_input.value = initial_url; 10992 url_input.placeholder = ''; 10993 url_wrap.appendChild(url_input); 10994 10995 popover.appendChild(url_wrap); 10996 } 10997 10998 let footer = document.createElement('div'); 10999 footer.className = 'cs-text-edit-popover-footer'; 11000 let ask_btn = document.createElement('button'); 11001 ask_btn.type = 'button'; 11002 ask_btn.className = 'cs-text-edit-ask'; 11003 ask_btn.title = 'Ask AI about this text â adds it as a reference in the chat'; 11004 ask_btn.innerHTML = 11005 '<i class="fa-regular fa-sparkles"></i>' + 11006 '<span>Ask</span>'; 11007 let footer_spacer = document.createElement('div'); 11008 footer_spacer.className = 'cs-text-edit-popover-footer-spacer';
11009 let cancel_btn = document.createElement('button'); 11010 cancel_btn.type = 'button'; 11011 cancel_btn.className = 'cs-text-edit-cancel'; 11012 cancel_btn.textContent = 'Cancel'; 11013 let save_btn = document.createElement('button'); 11014 save_btn.type = 'button'; 11015 save_btn.className = 'cs-text-edit-save'; 11016 save_btn.textContent = 'Save'; 11017 footer.appendChild(ask_btn); 11018 footer.appendChild(footer_spacer); 11019 footer.appendChild(cancel_btn); 11020 footer.appendChild(save_btn); 11021 popover.appendChild(footer); 11022 11023 document.body.appendChild(popover); 11024 this.position_text_edit_popover(popover, target_el); 11025 11026 let do_save = () => { 11027 let new_value = textarea.value; 11028 let new_url = url_input ? url_input.value : null; 11029 this.commit_text_edit(path, new_value, link_path, new_url); 11030 this.close_text_edit_popover(); 11031 }; 11032 let do_cancel = () => { 11033 this.close_text_edit_popover(); 11034 }; 11035 let do_ask = () => { 11036 // Snapshot the textarea value (admin may have 11037 // tweaked the text before clicking Ask) and add 11038 // it as a chat reference. We don't persist the 11039 // edit â the chip carries the current draft so 11040 // the AI sees what the admin is looking at. 11041 let snippet = textarea.value; 11042 this.send_text_edit_to_ai_chat(target_el, snippet); 11043 this.close_text_edit_popover(); 11044 }; 11045 save_btn.addEventListener('click', do_save); 11046 cancel_btn.addEventListener('click', do_cancel); 11047 ask_btn.addEventListener('click', do_ask); 11048 textarea.addEventListener('keydown', (event) => { 11049 if (event.key === 'Escape') { 11050 event.preventDefault(); 11051 do_cancel(); 11052 return; 11053 } 11054 if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { 11055 event.preventDefault(); 11056 do_save(); 11057 } 11058 }); 11059 if (url_input) { 11060 url_input.addEventListener('keydown', (event) => { 11061 if (event.key === 'Escape') { 11062 event.preventDefault(); 11063 do_cancel(); 11064 return; 11065 } 11066 if (event.key === 'Enter') { 11067 event.preventDefault(); 11068 do_save(); 11069 } 11070 }); 11071 } 11072 // Stop propagation so our outside-click handler doesn't 11073 // see clicks landing inside the popover and dismiss it. 11074 popover.addEventListener('mousedown', (event) => { 11075 event.stopPropagation(); 11076 }); 11077 popover.addEventListener('click', (event) => { 11078 event.stopPropagation(); 11079 }); 11080 11081 let outside_handler = (event) => { 11082 if (popover.contains(event.target)) { 11083 return; 11084 } 11085 let target = event && event.target ? event.target : null; 11086 if (target && root.contains(target)) { 11087 this._text_click_through_until = Date.now() + 250; 11088 } 11089 do_cancel(); 11090 }; 11091 // Pin the popover to the target element on scroll + 11092 // resize. Capture-phase scroll listener on window 11093 // catches scroll events from any nested scroll 11094 // container (scroll doesn't bubble, but capture fires 11095 // for every ancestor). If the target gets detached 11096 // (e.g. AI re-renders the section, admin switches 11097 // tabs) we close instead of leaving an orphan popover 11098 // pointing at a stale rect. 11099 let reposition = () => { 11100 if (!this._text_popover || this._text_popover.el !== popover) { 11101 return; 11102 } 11103 if (!target_el || !target_el.isConnected) { 11104 do_cancel(); 11105 return; 11106 } 11107 this.position_text_edit_popover(popover, target_el); 11108 }; 11109 // Defer attach so the click that opened the popover 11110 // doesn't immediately close it. Also listen for 11111 // touchstart so taps on a touch device dismiss the 11112 // popover the same way mouse clicks do. 11113 setTimeout(() => { 11114 document.addEventListener('mousedown', outside_handler, true); 11115 document.addEventListener('touchstart', outside_handler, true); 11116 window.addEventListener('scroll', reposition, true); 11117 window.addEventListener('resize', reposition); 11118 }, 0); 11119 11120 this._text_popover = { 11121 el: popover, 11122 target_el: target_el, 11123 outside_handler: outside_handler, 11124 reposition: reposition, 11125 }; 11126 11127 // Highlight the element being edited so it's obvious 11128 // which text the popover targets. 11129 target_el.classList.add('cs-text-editable-active'); 11130 11131 this.$nextTick(() => { 11132 textarea.focus(); 11133 textarea.select(); 11134 }); 11135 }, 11136 11137 close_text_edit_popover() { 11138 if (!this._text_popover) { 11139 return; 11140 } 11141 let popover_state = this._text_popover; 11142 this._text_popover = null; 11143 if (popover_state.outside_handler) { 11144 document.removeEventListener('mousedown', popover_state.outside_handler, true); 11145 document.removeEventListener('touchstart', popover_state.outside_handler, true); 11146 } 11147 if (popover_state.reposition) { 11148 window.removeEventListener('scroll', popover_state.reposition, true); 11149 window.removeEventListener('resize', popover_state.reposition); 11150 } 11151 if (popover_state.target_el && popover_state.target_el.classList) { 11152 popover_state.target_el.classList.remove('cs-text-editable-active'); 11153 } 11154 if (popover_state.el && popover_state.el.parentNode) { 11155 popover_state.el.parentNode.removeChild(popover_state.el); 11156 } 11157 }, 11158 11159 // Position the popover below the element when there's room, 11160 // otherwise above. Use viewport (fixed) coordinates so the 11161 // popover doesn't get clipped by overflow:hidden ancestors 11162 // (the preview pane itself scrolls). 11163 position_text_edit_popover(popover, target_el) { 11164 if (!popover || !target_el) { 11165 return; 11166 } 11167 let rect = target_el.getBoundingClientRect();
11168 // Width: clamp between 240 and 420 px, max 92vw. 11169 let max_width = Math.min(window.innerWidth - 24, 420); 11170 let width = Math.max(240, Math.min(max_width, Math.round(rect.width))); 11171 popover.style.width = width + 'px'; 11172 11173 // Render once so we can measure height for vertical fit. 11174 popover.style.left = '0px'; 11175 popover.style.top = '0px'; 11176 popover.style.visibility = 'hidden'; 11177 popover.style.display = 'block'; 11178 11179 let popover_rect = popover.getBoundingClientRect(); 11180 let popover_height = popover_rect.height; 11181 let gap = 8; 11182 let viewport_h = window.innerHeight; 11183 let viewport_w = window.innerWidth; 11184 11185 let left = Math.round(rect.left); 11186 if (left + width + 8 > viewport_w) { 11187 left = Math.max(8, viewport_w - width - 8); 11188 } 11189 if (left < 8) { 11190 left = 8; 11191 } 11192 11193 let top; 11194 let space_below = viewport_h - rect.bottom; 11195 let space_above = rect.top; 11196 if (space_below >= popover_height + gap || space_below >= space_above) { 11197 top = Math.round(rect.bottom + gap); 11198 if (top + popover_height + 8 > viewport_h) { 11199 top = Math.max(8, viewport_h - popover_height - 8); 11200 } 11201 } else { 11202 top = Math.round(rect.top - popover_height - gap); 11203 if (top < 8) { 11204 top = 8; 11205 } 11206 } 11207 11208 popover.style.left = left + 'px'; 11209 popover.style.top = top + 'px'; 11210 popover.style.visibility = ''; 11211 }, 11212 11213 // Concatenate all text nodes inside the element, preserving 11214 // line breaks for `<br>` so multi-line copy round-trips 11215 // sensibly. Inline emphasis tags collapse to plain text on 11216 // save by design â admins use the Edit tab for fancy 11217 // inline markup. 11218 read_element_text(el) { 11219 if (!el) { 11220 return ''; 11221 } 11222 let parts = []; 11223 let walk = (node) => { 11224 if (!node) { 11225 return; 11226 } 11227 if (node.nodeType === 3) { 11228 parts.push(node.nodeValue || ''); 11229 return; 11230 } 11231 if (node.nodeType !== 1) { 11232 return; 11233 } 11234 let tag = (node.tagName || '').toLowerCase(); 11235 if (tag === 'br') { 11236 parts.push('\n'); 11237 return; 11238 } 11239 if (TEXT_SKIP_TAGS.has(tag)) { 11240 return; 11241 } 11242 for (let child of node.childNodes) { 11243 walk(child); 11244 } 11245 }; 11246 for (let child of el.childNodes) { 11247 walk(child); 11248 } 11249 return parts.join('').replace(/[ \t]+/g, ' ').replace(/\s+\n/g, '\n').replace(/\n\s+/g, '\n').trim(); 11250 }, 11251 11252 // "Ask" button on the text-edit popover â push a chat 11253 // composer chip referencing this element and pin the panel 11254 // open. The shared AI panel handles attachment + 11255 // formatting; we just describe the element. 11256 send_text_edit_to_ai_chat(target_el, snippet) { 11257 if (typeof ai_mcp_panel_attach_context !== 'function') { 11258 if (typeof displayMessage === 'function') { 11259 displayMessage('AI panel is not available right now.', true, 4000); 11260 } 11261 return; 11262 } 11263 if (!target_el) { 11264 return; 11265 } 11266 let tag = (target_el.tagName || 'element').toLowerCase(); 11267 let raw_text = typeof snippet === 'string' && snippet.length 11268 ? snippet 11269 : this.read_element_text(target_el); 11270 let display = (raw_text || '').replace(/\s+/g, ' ').trim(); 11271 let label = display.length > 60 ? display.slice(0, 60).trim() + 'â¦' : display; 11272 if (!label) { 11273 label = '(empty)'; 11274 } 11275 let id = 'ctx_' + Math.random().toString(36).slice(2, 10) + '_' + Date.now().toString(36); 11276 ai_mcp_panel_attach_context({ 11277 component: this, 11278 context: { 11279 id: id, 11280 kind: 'preview-text', 11281 tag: tag, 11282 text: raw_text || '', 11283 label: label, 11284 preview: display, 11285 }, 11286 }); 11287 }, 11288 11289 find_text_edit_link_target(target_el, root) { 11290 if (!target_el || !root || typeof target_el.closest !== 'function') { 11291 return null; 11292 } 11293 let link_target = target_el.closest('a, button'); 11294 if (!link_target || !root.contains(link_target)) { 11295 return null; 11296 } 11297 if (link_target.hasAttribute && ( 11298 link_target.hasAttribute('data-cs-toggle') || 11299 link_target.hasAttribute('data-cs-tab') 11300 )) { 11301 return null; 11302 } 11303 return link_target; 11304 }, 11305 read_text_edit_url(link_target) { 11306 if (!link_target || !link_target.getAttribute) { 11307 return ''; 11308 } 11309 let tag = (link_target.tagName || '').toLowerCase(); 11310 if (tag === 'a') { 11311 return link_target.getAttribute('href') || ''; 11312 } 11313 return link_target.getAttribute('data-cs-href') || ''; 11314 }, 11315 normalize_text_edit_url(value) { 11316 let url = typeof value === 'string' ? value.trim() : ''; 11317 if (!url) { 11318 return ''; 11319 } 11320 let lowered = url.toLowerCase(); 11321 if (lowered.indexOf('javascript:') === 0) { 11322 return ''; 11323 } 11324 return url; 11325 }, 11326 apply_text_edit_url(doc, link_path, url_value) { 11327 if (!doc || !link_path) { 11328 return false;
11329 } 11330 let target = this.find_text_element_by_path(doc, link_path); 11331 if (!target || target.nodeType !== 1) { 11332 return false; 11333 } 11334 let url = this.normalize_text_edit_url(url_value); 11335 let tag = (target.tagName || '').toLowerCase(); 11336 if (tag === 'a') { 11337 if (url) { 11338 target.setAttribute('href', url); 11339 } else { 11340 target.removeAttribute('href'); 11341 } 11342 return true; 11343 } 11344 if (tag !== 'button') { 11345 return false; 11346 } 11347 if (!url) { 11348 target.removeAttribute('data-cs-href'); 11349 return true; 11350 } 11351 // A plain <button> cannot navigate without JavaScript, 11352 // and inline JS is stripped by sanitize_custom_code_html. 11353 // For CTA-like buttons, convert to an anchor while 11354 // preserving classes, children, and safe generic attrs. 11355 let owner = doc.ownerDocument || document; 11356 let anchor = owner.createElement('a'); 11357 Array.prototype.slice.call(target.attributes || []).forEach((attr) => { 11358 let name = String(attr.name || '').toLowerCase(); 11359 if (name === 'type' || name === 'disabled' || name.indexOf('form') === 0) { 11360 return; 11361 } 11362 if (name.indexOf('on') === 0 || name === 'data-cs-href') { 11363 return; 11364 } 11365 anchor.setAttribute(attr.name, attr.value); 11366 }); 11367 anchor.setAttribute('href', url); 11368 while (target.firstChild) { 11369 anchor.appendChild(target.firstChild); 11370 } 11371 if (target.parentNode) { 11372 target.parentNode.replaceChild(anchor, target); 11373 } 11374 return true; 11375 }, 11376 11377 // Apply the edit against the parsed source and persist. 11378 // Multi-line input becomes textNode + <br> + textNode + ... 11379 // so it round-trips to the same lines on next read. 11380 commit_text_edit(path, new_value, link_path, url_value) { 11381 if (!path) { 11382 return; 11383 } 11384 let safe_value = typeof new_value === 'string' ? new_value : ''; 11385 let mutated = this.mutate_persisted_html((doc) => { 11386 let target = this.find_text_element_by_path(doc, path); 11387 if (!target || target.nodeType !== 1) { 11388 return false; 11389 } 11390 while (target.firstChild) { 11391 target.removeChild(target.firstChild); 11392 } 11393 let lines = safe_value.split('\n'); 11394 let owner = doc.ownerDocument || document; 11395 lines.forEach((line, index) => { 11396 if (index > 0) { 11397 target.appendChild(owner.createElement('br')); 11398 } 11399 if (line.length) { 11400 target.appendChild(owner.createTextNode(line)); 11401 } 11402 }); 11403 if (link_path) { 11404 this.apply_text_edit_url(doc, link_path, url_value); 11405 } 11406 return true; 11407 }); 11408 if (mutated !== null) { 11409 this.persist_mutated_code(mutated); 11410 } 11411 }, 11412 11413 // Called from ai-mcp-panel.html. Pushes AI-generated HTML 11414 // into CodeMirror, persists via save_changes_for_component, 11415 // and returns the prior editor value so the panel can offer 11416 // Undo for auto-applied responses. 11417 // 11418 // Streaming-aware: when we've been driving the editor from 11419 // a live stream, the canonical "previous_code" the panel 11420 // wants is the snapshot we took at begin_streaming_code, 11421 // NOT the streamed-in code currently in the buffer. 11422 apply_generated_code(code, options) { 11423 if (typeof code !== 'string') { 11424 return null; 11425 } 11426 this.close_text_edit_popover(); 11427 let opts = options || {}; 11428 if (opts.manual_apply || opts.auto) { 11429 this.clear_ai_streaming_starting_state(); 11430 this.ai_streaming = false;
11431 this.release_ai_loading_pane_height_after_render(); 11432 if (opts.manual_apply || this.ai_streaming_status === 'streaming' || this.ai_streaming_status === 'queued') { 11433 this.ai_streaming_status = 'done'; 11434 } 11435 this.ai_streaming_buffer = ''; 11436 this._last_flushed_buffer_len = -1; 11437 this.ai_streaming_child_min_heights = []; 11438 if (this._codegen_stream_raf) { 11439 cancelAnimationFrame(this._codegen_stream_raf); 11440 this._codegen_stream_raf = null; 11441 } 11442 this.ai_stream_event_tick = (this.ai_stream_event_tick + 1) | 0; 11443 } 11444 let previous_code; 11445 if (this.ai_streaming_pre_code !== null && this.ai_streaming_pre_code !== undefined) { 11446 previous_code = this.ai_streaming_pre_code; 11447 this.ai_streaming_pre_code = null; 11448 } else { 11449 previous_code = this.get_current_code(); 11450 } 11451 let safe_code = this.sanitize_custom_code_html(code); 11452 if (this.cm_instance) { 11453 this._suppress_change_save = true; 11454 this.cm_instance.setValue(safe_code); 11455 this._suppress_change_save = false; 11456 } 11457 this.custom_code_value = safe_code; 11458 this.publish_current_code_to_store(); 11459 this.save_ai_applied_custom_code(safe_code); 11460 this.mark_ai_code_revision_changed(); 11461 if (opts.switch_to_preview !== false) { 11462 this.active_tab = 'preview'; 11463 } 11464 this.$nextTick(() => this.initialize_custom_code_interactions()); 11465 return previous_code; 11466 }, 11467 save_ai_applied_custom_code(code) { 11468 if (!this.component) { 11469 return; 11470 } 11471 let config_latest = this.component.get('config_latest') || this.component.get('config') || {}; 11472 let next_config_latest = Object.assign({}, config_latest, { custom_code: code }); 11473 let ai_description = this.extract_ai_description_from_code(code); 11474 if (ai_description) { 11475 next_config_latest.ai_description = ai_description; 11476 } 11477 this.component.set('config_latest', next_config_latest); 11478 if (typeof save_changes_for_component === 'function') { 11479 save_changes_for_component(this.component, 'custom_code', code); 11480 return; 11481 } 11482 this.component.set('unsaved_changes', true); 11483 this.component.save().catch((error) => { 11484 console.warn('[custom-code] AI custom code save failed:', error && error.message); 11485 }); 11486 }, 11487 11488 // âââ Codegen stream subscription ââââââââââââââââââââââ 11489 // 11490 // Subscribes to store.ai_streams for this component's key. 11491 // The component owns the editor + preview, so it's the 11492 // right place to handle code-side events directly â the 11493 // panel only cares about message text + tool calls. 11494 subscribe_to_codegen_stream() { 11495 if (typeof store === 'undefined' || !store.ai_streams) { 11496 return; 11497 } 11498 let component_id = this.component && this.component.id; 11499 if (!component_id) { 11500 return; 11501 } 11502 if (typeof this._codegen_stream_unsubscribe === 'function') { 11503 try { 11504 this._codegen_stream_unsubscribe(); 11505 } catch (e) {} 11506 this._codegen_stream_unsubscribe = null; 11507 } 11508 let key = 'codegen:' + component_id; 11509 let me = this; 11510 this._codegen_stream_unsubscribe = store.ai_streams.subscribe(key, function (event_type, payload) { 11511 me.handle_codegen_stream_event(event_type, payload); 11512 }); 11513 // If we're (re-)subscribing into an already-terminal 11514 // entry, the synchronous `replay` event above won't 11515 // unwind any stuck loading flags on its own (replay 11516 // for status='done' is intentionally lightweight to 11517 // avoid re-touching the editor). Hard-converge here 11518 // so a stale "Generatingâ¦" label clears on the next 11519 // mount / panel-open. 11520 let entry = (store.ai_streams.get && store.ai_streams.get(key)) || null; 11521 if (entry && (entry.status === 'done' || entry.status === 'error' || entry.status === 'aborted_local')) { 11522 let needs_clear = this.ai_streaming 11523 || this.ai_streaming_status === 'streaming' 11524 || this.ai_streaming_status === 'queued'; 11525 if (needs_clear) { 11526 this.force_clear_ai_loading_state(); 11527 } 11528 } 11529 }, 11530 handle_codegen_stream_event(event_type, payload) { 11531 let safe = payload || {}; 11532 this.ai_stream_event_tick = (this.ai_stream_event_tick + 1) | 0; 11533 switch (event_type) { 11534 case 'replay': 11535 // Late subscriber catch-up. If a stream is 11536 // already in flight we lock the editor + flush 11537 // the accumulated buffer in one shot so the 11538 // preview catches up before the next delta. 11539 if (safe.status === 'streaming' || safe.status === 'queued') { 11540 this.capture_ai_loading_pane_height(); 11541 this.clear_ai_streaming_starting_state(); 11542 if (safe.response_json_buffer) { 11543 this._response_json_buffer = ''; 11544 this._response_json_html = ''; 11545 this._append_split_ready = false;
11546 this._append_head = ''; 11547 this._append_tail = ''; 11548 this.handle_codegen_response_json_delta({ text: safe.response_json_buffer }); 11549 } else if (!this.ai_streaming && safe.code_buffer) { 11550 this.begin_streaming_code(safe.code_buffer || ''); 11551 } else if (safe.code_buffer) { 11552 this.ai_streaming_buffer = safe.code_buffer; 11553 this.schedule_streaming_flush(); 11554 } 11555 this.ai_streaming_status = safe.status; 11556 } else if (safe.status === 'done' && safe.code_buffer) { 11557 // Parent-agent loopback streams are passive: a new 11558 // section may mount only after the child stream has 11559 // already settled. Replay the final buffered HTML so 11560 // the preview catches up even before the page refresh 11561 // reconciles the persisted component. 11562 this.clear_ai_streaming_starting_state(); 11563 this.force_clear_ai_loading_state(); 11564 let replay_code = safe.code_buffer || ''; 11565 if (this.cm_instance && replay_code !== this.cm_instance.getValue()) { 11566 this._suppress_change_save = true; 11567 this.cm_instance.setValue(replay_code); 11568 this._suppress_change_save = false; 11569 } 11570 this.custom_code_value = replay_code; 11571 this.publish_current_code_to_store(); 11572 this.$nextTick(() => this.initialize_custom_code_interactions()); 11573 } else if (safe.final && safe.status === 'done') { 11574 // We mounted right after the standalone stream 11575 // finished â make sure chrome is clear without 11576 // touching the canonical editor value. 11577 this.clear_ai_streaming_starting_state(); 11578 } 11579 break; 11580 case 'status': 11581 this.clear_ai_streaming_starting_state(); 11582 if (safe.status === 'streaming' || safe.status === 'queued') { 11583 this.capture_ai_loading_pane_height(); 11584 } 11585 if (safe.status === 'queued') { 11586 // Show queued chrome without engaging the 11587 // editor lock â nothing is actually being 11588 // generated yet. 11589 this.ai_streaming_status = 'queued'; 11590 } 11591 if (safe.status === 'aborted_local' && this.ai_streaming) { 11592 this.cancel_streaming({ initiated_remotely: true }); 11593 } 11594 break; 11595 case 'code_delta': 11596 if (safe.component_id && this.component && safe.component_id !== this.component.id) { 11597 break; 11598 } 11599 this.clear_ai_streaming_starting_state(); 11600 if (typeof safe.text !== 'string' || !safe.text.length) { 11601 break; 11602 } 11603 if (safe.atomic) { 11604 if (!this.ai_streaming) { 11605 this.begin_streaming_code(safe.text); 11606 } else { 11607 this.ai_streaming_buffer = safe.text; 11608 this.schedule_streaming_flush(); 11609 } 11610 break; 11611 } 11612 if (!this.ai_streaming) { 11613 this.begin_streaming_code(''); 11614 } 11615 this.append_streaming_code(safe.text); 11616 break; 11617 case 'response_json_delta': 11618 this.handle_codegen_response_json_delta(safe); 11619 break; 11620 case 'new_section_created': 11621 if (safe.new_component && this.component && safe.new_component.id !== this.component.id) { 11622 this.force_clear_ai_loading_state(); 11623 } 11624 break; 11625 case 'agent_settle': 11626 // Parent webbuilder-agent build finished/failed. The 11627 // live preview already morphed in the full document via 11628 // the forwarded deltas, so just clear the loading 11629 // overlay WITHOUT a client save â the agent's 11630 // authoritative refresh re-pull reconciles the canonical 11631 // HTML (incl. post-build image-import URL swaps). 11632 this.clear_ai_streaming_starting_state(); 11633 this.force_clear_ai_loading_state(); 11634 if (safe.error) { 11635 this.ai_streaming_status = 'error'; 11636 } 11637 break; 11638 case 'stream_reset': 11639 this.clear_ai_streaming_starting_state(); 11640 this.ai_streaming_buffer = ''; 11641 this._response_json_buffer = ''; 11642 this._response_json_html = ''; 11643 this._append_split_ready = false;
11644 this._append_head = ''; 11645 this._append_tail = ''; 11646 if (this.ai_streaming) { 11647 let restore_code = (this.ai_streaming_pre_code !== null && this.ai_streaming_pre_code !== undefined) 11648 ? this.ai_streaming_pre_code 11649 : this.get_current_code(); 11650 if (this.cm_instance) { 11651 if (this.cm_instance.getValue() !== restore_code) { 11652 this._suppress_change_save = true; 11653 this.cm_instance.setValue(restore_code); 11654 this._suppress_change_save = false; 11655 } 11656 this.cm_instance.setOption('readOnly', false); 11657 } 11658 this.custom_code_value = restore_code; 11659 this.publish_current_code_to_store(); 11660 this.ai_streaming = false; 11661 this.ai_streaming_status = 'streaming'; 11662 }
11663 // Wipe the morph preview too â next deltas 11664 // re-build it from a clean slate. 11665 let stream_root = this.$refs.custom_code_stream_preview; 11666 if (stream_root) { 11667 while (stream_root.firstChild) { 11668 stream_root.removeChild(stream_root.firstChild); 11669 } 11670 } 11671 break; 11672 case 'done': 11673 this.clear_ai_streaming_starting_state(); 11674 if (safe.edit_mode === 'new_section' || safe.edit_mode === 'new_sections') { 11675 this.finish_streaming_code(null); 11676 break; 11677 } 11678 let final_code = null; 11679 if (typeof safe.code === 'string') { 11680 final_code = safe.code; 11681 } else if (safe.result && typeof safe.result.code === 'string') { 11682 final_code = safe.result.code; 11683 } 11684 this.finish_streaming_code(final_code); 11685 break; 11686 case 'error': 11687 this.clear_ai_streaming_starting_state(); 11688 // Leave the partial buffer in place but flip 11689 // the overlay so the admin can dismiss. 11690 this.ai_streaming_status = 'error'; 11691 if (this.ai_streaming) { 11692 this.snap_streaming_done({ error: true }); 11693 } 11694 break; 11695 default: 11696 break; 11697 } 11698 }, 11699 handle_codegen_response_json_delta(payload) { 11700 if (!payload || typeof payload.text !== 'string') { 11701 return; 11702 } 11703 this._response_json_buffer = (this._response_json_buffer || '') + payload.text; 11704 let tool_name = this.detect_streamed_response_action_name(this._response_json_buffer); 11705 if (tool_name === 'set_html') { 11706 this.stream_set_html_from_buffer(); 11707 return; 11708 } 11709 if (tool_name === 'append_html') { 11710 this.stream_append_html_from_buffer(); 11711 return; 11712 } 11713 }, 11714 // Live set_html: the streamed `html` field IS the whole section, so 11715 // forward each new tail slice as a code delta. 11716 stream_set_html_from_buffer() { 11717 let extracted = this.extract_streamed_json_string_field(this._response_json_buffer, 'html'); 11718 if (!extracted || !extracted.value) { 11719 return; 11720 } 11721 let previous = this._response_json_html || ''; 11722 if (extracted.value.length <= previous.length) { 11723 return; 11724 } 11725 let delta = extracted.value.slice(previous.length); 11726 this._response_json_html = extracted.value; 11727 this.clear_ai_streaming_starting_state(); 11728 if (!this.ai_streaming) { 11729 this.begin_streaming_code(''); 11730 } 11731 this.append_streaming_code(delta); 11732 }, 11733 // Live append_html: the streamed `html` field is only the NEW items 11734 // (a fragment). Reconstruct the whole document each delta as 11735 // head + fragment + tail, split at the <!--cs-append-anchor--> so 11736 // the new items render in place while existing items stay stable. 11737 stream_append_html_from_buffer() { 11738 let extracted = this.extract_streamed_json_string_field(this._response_json_buffer, 'html'); 11739 if (!extracted) { 11740 return; 11741 } 11742 let fragment = extracted.value || ''; 11743 if (!this._append_split_ready) { 11744 let code = this.get_current_code() || ''; 11745 let anchor_index = code.indexOf('<!--cs-append-anchor-->'); 11746 if (anchor_index >= 0) { 11747 this._append_head = code.slice(0, anchor_index); 11748 this._append_tail = code.slice(anchor_index); 11749 } else { 11750 this._append_head = code; 11751 this._append_tail = ''; 11752 } 11753 this._append_split_ready = true; 11754 } 11755 this.clear_ai_streaming_starting_state(); 11756 if (!this.ai_streaming) { 11757 this.begin_streaming_code(''); 11758 } 11759 // Fragment grows in the MIDDLE, so set the whole buffer (not an 11760 // append) and let the preview morph reconcile against it. 11761 this.ai_streaming_buffer = this._append_head + fragment + this._append_tail; 11762 this.schedule_streaming_flush(); 11763 }, 11764 detect_streamed_response_action_name(buffer) { 11765 let match = /"name"\s*:\s*"(set_html|patch_html|append_html|create_section|propose_sections)"/.exec(buffer || ''); 11766 if (!match) { 11767 return null; 11768 } 11769 return match[1]; 11770 }, 11771 extract_streamed_json_string_field(buffer, field_name) { 11772 if (!buffer || !field_name) { 11773 return null; 11774 } 11775 let target = '"' + field_name + '"'; 11776 let key_index = buffer.indexOf(target); 11777 if (key_index === -1) { 11778 return null; 11779 } 11780 let colon_index = buffer.indexOf(':', key_index + target.length); 11781 if (colon_index === -1) { 11782 return null; 11783 } 11784 let quote_index = buffer.indexOf('"', colon_index + 1); 11785 if (quote_index === -1) { 11786 return null; 11787 } 11788 let value = ''; 11789 let escaping = false;
11790 let unicode_remaining = 0; 11791 let unicode_buffer = ''; 11792 for (let i = quote_index + 1; i < buffer.length; i++) { 11793 let character = buffer.charAt(i); 11794 if (unicode_remaining > 0) { 11795 unicode_buffer += character; 11796 unicode_remaining--; 11797 if (unicode_remaining === 0) { 11798 let code = parseInt(unicode_buffer, 16); 11799 if (Number.isFinite(code)) { 11800 value += String.fromCharCode(code); 11801 } 11802 unicode_buffer = ''; 11803 } 11804 continue; 11805 } 11806 if (escaping) { 11807 escaping = false; 11808 if (character === 'u') { 11809 unicode_remaining = 4; 11810 unicode_buffer = ''; 11811 } else { 11812 value += this.decode_streamed_json_escape(character); 11813 } 11814 continue; 11815 } 11816 if (character === '\\') { 11817 escaping = true; 11818 continue; 11819 } 11820 if (character === '"') { 11821 return { 11822 value: value, 11823 closed: true, 11824 }; 11825 } 11826 value += character; 11827 } 11828 return { 11829 value: value, 11830 closed: false, 11831 }; 11832 }, 11833 decode_streamed_json_escape(character) { 11834 if (character === 'n') { 11835 return '\n'; 11836 } 11837 if (character === 'r') { 11838 return '\r'; 11839 } 11840 if (character === 't') { 11841 return '\t'; 11842 } 11843 if (character === 'b') { 11844 return '\b'; 11845 } 11846 if (character === 'f') { 11847 return '\f'; 11848 } 11849 return character; 11850 }, 11851 // Lock the editor, snapshot prior code for Undo, switch to 11852 // preview tab. `initial_buffer` is the accumulated code we 11853 // missed if subscribing mid-stream â it seeds the buffer 11854 // and triggers an immediate flush. 11855 begin_streaming_code(initial_buffer) { 11856 if (this.ai_streaming) { 11857 return; 11858 } 11859 this.capture_ai_loading_pane_height(); 11860 this.clear_ai_streaming_starting_state(); 11861 this.ai_streaming = true; 11862 this.ai_streaming_status = 'streaming'; 11863 this.ai_streaming_pre_code = this.get_current_code(); 11864 this.ai_streaming_buffer = (typeof initial_buffer === 'string') ? initial_buffer : ''; 11865 if (this.cm_instance) { 11866 this.cm_instance.setOption('readOnly', 'nocursor'); 11867 } 11868 // Switch to preview so the admin sees the morph render 11869 // by default â most builds are done from preview anyway. 11870 this.active_tab = 'preview'; 11871 this.close_text_edit_popover(); 11872 if (this.ai_streaming_buffer) { 11873 this.schedule_streaming_flush(); 11874 } 11875 }, 11876 append_streaming_code(chunk) { 11877 this.ai_streaming_buffer += chunk; 11878 this.schedule_streaming_flush(); 11879 }, 11880 // Coalesce many code_delta events into one DOM update per 11881 // animation frame. cheap and keeps streaming responsive 11882 // even on chatty 100-chunk-per-second responses. 11883 schedule_streaming_flush() { 11884 if (this._codegen_stream_raf) { 11885 return; 11886 } 11887 let me = this; 11888 this._codegen_stream_raf = requestAnimationFrame(function () { 11889 me._codegen_stream_raf = null; 11890 me.flush_streaming_panes(); 11891 }); 11892 }, 11893 flush_streaming_panes() { 11894 if (!this.ai_streaming) { 11895 return; 11896 } 11897 let buffer = this.ai_streaming_buffer || ''; 11898 // Cheap stable check â avoids reflushing when the buffer length hasn't changed. 11899 if (this._last_flushed_buffer_len === buffer.length) { 11900 return; 11901 } 11902 this._last_flushed_buffer_len = buffer.length; 11903 this.flush_streaming_edit_pane(buffer); 11904 this.flush_streaming_preview_pane(buffer); 11905 }, 11906 flush_streaming_edit_pane(buffer) { 11907 if (!this.cm_instance) { 11908 return; 11909 } 11910 // setValue replaces the doc atomically. That's fine 11911 // for streaming because the doc is not user-editable 11912 // (readOnly: 'nocursor') and the buffer is always 11913 // strictly-grown â selection / scroll resets are 11914 // unavoidable but visually acceptable. 11915 let current = this.cm_instance.getValue(); 11916 if (current === buffer) { 11917 return; 11918 } 11919 this._suppress_change_save = true; 11920 this.cm_instance.setValue(buffer); 11921 this._suppress_change_save = false;
11922 this.custom_code_value = buffer; 11923 this.publish_current_code_to_store(); 11924 }, 11925 // Sequential morph: parse only the stable-prefix (chars up 11926 // through the last '>') of the buffer into a detached 11927 // tree, then walk top-level children in parallel against 11928 // the live preview root. Append-or-replace by index so 11929 // already-rendered sections stay stable while new ones 11930 // stream in below them. 11931 flush_streaming_preview_pane(buffer) { 11932 let root = this.$refs.custom_code_stream_preview; 11933 if (!root) { 11934 return; 11935 } 11936 let last_gt = buffer.lastIndexOf('>'); 11937 if (last_gt < 0) { 11938 // Nothing parseable yet (we haven't even seen one 11939 // closing bracket) â defer the flush. 11940 return; 11941 } 11942 let stable_prefix = buffer.slice(0, last_gt + 1); 11943 // Sanitize first so the morph never plants scripts / 11944 // inline handlers into the live DOM. Same function 11945 // safe_custom_code_value uses for the static pane. 11946 let safe_html = this.sanitize_custom_code_html(stable_prefix); 11947 let template_el = document.createElement('template'); 11948 template_el.innerHTML = safe_html; 11949 let new_nodes = template_el.content.childNodes; 11950 let live_nodes = root.childNodes; 11951 let i = 0; 11952 let max = new_nodes.length; 11953 // Track whether this flush appended brand-new top-level 11954 // nodes. If so the section is growing (new HTML in a 11955 // new_section / full build) and the page should follow 11956 // the bottom. If only existing nodes were morphed in 11957 // place (edit / edits flows replacing content) the 11958 // scroll position should stay put. 11959 let appended_new_node = false; 11960 let last_appended_node = null; 11961 while (i < max) { 11962 let new_node = new_nodes[i]; 11963 let live_node = live_nodes[i]; 11964 let next_node = new_node.cloneNode(true); 11965 this.apply_streaming_child_min_height(next_node, i); 11966 if (!live_node) { 11967 // Append-only path â most common during forward 11968 // streaming. next_node is cloned because new_node lives 11969 // in a <template> document fragment we don't 11970 // want to consume. 11971 root.appendChild(next_node); 11972 appended_new_node = true; 11973 last_appended_node = next_node; 11974 } else { 11975 let new_html = (next_node.outerHTML !== undefined) 11976 ? next_node.outerHTML 11977 : (next_node.textContent || ''); 11978 let live_html = (live_node.outerHTML !== undefined) 11979 ? live_node.outerHTML 11980 : (live_node.textContent || ''); 11981 if (new_html !== live_html) { 11982 root.replaceChild(next_node, live_node); 11983 } 11984 } 11985 i++; 11986 } 11987 // Trim any leftover live nodes â happens on stream_reset 11988 // (we wipe in the handler so this is rare) and on the 11989 // edge case where the model deletes trailing content. 11990 while (live_nodes.length > max) { 11991 root.removeChild(live_nodes[live_nodes.length - 1]); 11992 } 11993 this.$nextTick(() => this.initialize_custom_code_interactions()); 11994 // Forward-streaming auto-follow: new top-level nodes 11995 // were appended this frame, so the user is watching new 11996 // content land. Ask the panel to smooth-scroll the 11997 // window so the bottom of the newly-appended chunk
11998 // stays in view. Edits/replacements skip this branch 11999 // because their scroll position should be preserved. 12000 if (appended_new_node && last_appended_node) { 12001 let panel = (typeof store !== 'undefined') ? store.ai_mcp_panel_component_instance : null; 12002 if (panel && typeof panel.schedule_smooth_scroll_to_streaming_bottom === 'function') { 12003 let scroll_target = (last_appended_node.nodeType === 1) 12004 ? last_appended_node 12005 : root; 12006 panel.schedule_smooth_scroll_to_streaming_bottom(scroll_target); 12007 } 12008 } 12009 }, 12010 apply_streaming_child_min_height(node, index) { 12011 if (!node || node.nodeType !== 1) { 12012 return; 12013 } 12014 let min_height = this.ai_streaming_child_min_heights[index] || 0; 12015 if (min_height <= 0) { 12016 return; 12017 } 12018 node.style.minHeight = min_height + 'px'; 12019 }, 12020 // Snap streaming closed and run the canonical save. Called 12021 // from the SSE `done` event handler. Idempotent â a 12022 // duplicate `done` (e.g. from a cancel race) is safe. 12023 finish_streaming_code(final_code) { 12024 if (typeof final_code !== 'string' && !(this.ai_streaming_buffer || '').trim()) { 12025 this.clear_ai_streaming_starting_state(); 12026 this.ai_streaming = false; 12027 this.release_ai_loading_pane_height_after_render(); 12028 this.ai_streaming_status = 'done'; 12029 this.ai_streaming_buffer = ''; 12030 this._last_flushed_buffer_len = -1; 12031 this.ai_streaming_child_min_heights = []; 12032 if (this._codegen_stream_raf) { 12033 cancelAnimationFrame(this._codegen_stream_raf); 12034 this._codegen_stream_raf = null; 12035 } 12036 if (this.cm_instance) { 12037 this.cm_instance.setOption('readOnly', false); 12038 } 12039 this.ai_stream_event_tick = (this.ai_stream_event_tick + 1) | 0; 12040 return; 12041 } 12042 if (!this.ai_streaming && (this.ai_streaming_status !== 'done')) { 12043 // Late `done` for a stream we never engaged with â 12044 // usually because the panel auto-applied via the 12045 // separate apply_generated_code path. Still settle 12046 // local loading UI so the launcher / pending overlay 12047 // don't get stranded in "Generatingâ¦". 12048 this.clear_ai_streaming_starting_state(); 12049 this.ai_streaming = false; 12050 this.release_ai_loading_pane_height_after_render(); 12051 this.ai_streaming_status = 'done'; 12052 this.ai_streaming_buffer = ''; 12053 this._last_flushed_buffer_len = -1; 12054 this.ai_streaming_child_min_heights = []; 12055 if (this._codegen_stream_raf) { 12056 cancelAnimationFrame(this._codegen_stream_raf); 12057 this._codegen_stream_raf = null; 12058 } 12059 return; 12060 } 12061 this.snap_streaming_done({ final_code: final_code }); 12062 }, 12063 snap_streaming_done(options) { 12064 let opts = options || {}; 12065 let canonical = (typeof opts.final_code === 'string') 12066 ? this.sanitize_custom_code_html(opts.final_code) 12067 : (this.ai_streaming_buffer || ''); 12068 // Editor + persistence writes can throw (CodeMirror 12069 // teardown race, save failures, etc). Wrap so the 12070 // loading-state convergence in `finally` still runs 12071 // and the launcher never gets stranded in 12072 // "Generatingâ¦". 12073 try { 12074 if (this.cm_instance) { 12075 if (this.cm_instance.getValue() !== canonical) { 12076 this._suppress_change_save = true; 12077 this.cm_instance.setValue(canonical); 12078 this._suppress_change_save = false;
12079 } 12080 this.cm_instance.setOption('readOnly', false); 12081 } 12082 this.custom_code_value = canonical; 12083 this.publish_current_code_to_store(); 12084 this.save_ai_applied_custom_code(canonical); 12085 this.mark_ai_code_revision_changed(); 12086 } finally { 12087 this.ai_streaming = false; 12088 this.release_ai_loading_pane_height_after_render(); 12089 this.ai_streaming_status = opts.canceled ? 'aborted_local' : (opts.error ? 'error' : 'done'); 12090 this.ai_streaming_buffer = ''; 12091 this._last_flushed_buffer_len = -1; 12092 this.ai_streaming_child_min_heights = []; 12093 if (this._codegen_stream_raf) { 12094 cancelAnimationFrame(this._codegen_stream_raf); 12095 this._codegen_stream_raf = null; 12096 } 12097 this.ai_stream_event_tick = (this.ai_stream_event_tick + 1) | 0; 12098 } 12099 // Defer the snapshot teardown â the chat panel's `done`
12100 // subscriber will consume it via apply_generated_code in 12101 // the same JS task. Clearing after 5s guards against 12102 // a stale-message Apply click later misreading it as 12103 // the live editor's previous_code. 12104 if (this._clear_pre_code_timer) { 12105 clearTimeout(this._clear_pre_code_timer); 12106 } 12107 this._clear_pre_code_timer = setTimeout(() => { 12108 this.ai_streaming_pre_code = null; 12109 this._clear_pre_code_timer = null; 12110 }, 5000); 12111 this.$nextTick(() => this.initialize_custom_code_interactions()); 12112 }, 12113 // Local soft-cancel. Tells the streams manager to stop 12114 // emitting deltas to subscribers (the upstream LLM keeps 12115 // running for billing + persistence), then snaps our 12116 // local view of the editor to whatever we have so far. 12117 cancel_streaming(options) { 12118 let opts = options || {}; 12119 if (!opts.initiated_remotely && typeof store !== 'undefined' && store.ai_streams) { 12120 let component_id = this.component && this.component.id; 12121 if (component_id) { 12122 store.ai_streams.cancel_local('codegen:' + component_id); 12123 } 12124 } 12125 this.snap_streaming_done({ canceled: true }); 12126 }, 12127 // Echo the current editor code into the shared store so 12128 // the chat panel's apply-state badge can diff against it 12129 // reactively. Setting on `store` directly is safe â the 12130 // panel uses a manual tick for reactivity anyway, and the 12131 // value is also surfaced via a computed in the panel. 12132 publish_current_code_to_store() { 12133 if (typeof store === 'undefined') { 12134 return; 12135 } 12136 if (this.component) { 12137 let config_latest = this.component.get('config_latest') || {}; 12138 let next_config_latest = Object.assign({}, config_latest, { 12139 custom_code: this.custom_code_value || '', 12140 }); 12141 let ai_description = this.extract_ai_description_from_code(this.custom_code_value || ''); 12142 if (ai_description) { 12143 next_config_latest.ai_description = ai_description; 12144 } 12145 this.component.set('config_latest', next_config_latest); 12146 // Do NOT bump store.component_updates here. That field 12147 // drives the `last_update` prop, whose watcher calls 12148 // back into this method â bumping it would re-trigger 12149 // the watcher and spin a reactive feedback loop on 12150 // every edit (runaway re-render â tab hang). The 12151 // canonical save path (save_changes_for_component) 12152 // owns the re-render signal; the live preview here 12153 // re-renders off custom_code_value directly. 12154 if (this.component.id === store.webbuilder_code_active_component_id) { 12155 store.webbuilder_code_active_component_revision = Date.now(); 12156 } 12157 } 12158 if (typeof store.webbuilder_code_active_component_id !== 'undefined' 12159 && this.component 12160 && this.component.id === store.webbuilder_code_active_component_id) { 12161 store.webbuilder_code_current_code = this.custom_code_value || ''; 12162 } 12163 }, 12164 // Mirror this section's active tab (edit/preview) into the shared 12165 // store so the per-section AI launcher (toolbar-option-component) 12166 // can show "Edit with AI" only while the code editor is open. 12167 publish_active_tab_to_store() { 12168 if (typeof store === 'undefined' || !this.preview_element) { 12169 return; 12170 } 12171 let cid = (this.component && typeof this.component.get === 'function') 12172 ? this.component.get('client_id') 12173 : null; 12174 if (!cid) { 12175 return; 12176 } 12177 if (!store.webbuilder_custom_code_tab_by_client_id) { 12178 store.webbuilder_custom_code_tab_by_client_id = {}; 12179 } 12180 store.webbuilder_custom_code_tab_by_client_id[cid] = this.active_tab; 12181 }, 12182 extract_ai_description_from_code(code) { 12183 if (typeof code !== 'string') { 12184 return ''; 12185 } 12186 let description_regex = new RegExp("^\\s*<!--\\s*cs-ai-description:\\s*([\\s\\S]*?)-->", "i"); 12187 let description_match = code.match(description_regex); 12188 if (!description_match || !description_match[1]) { 12189 return ''; 12190 } 12191 return description_match[1].replace(/\s+/g, ' ').trim().slice(0, 160); 12192 }, 12193 }, 12194 }); 12195</script>
12195 12196 12197<style> 12198 .cs-custom-code-wrap{ 12199 position: relative !important; 12200 isolation: isolate; 12201 z-index: 1; 12202 overflow: visible; 12203 } 12204 /* Overlap masks clip short/non-visual sections (script pixels, etc.). */ 12205 .componentPreview > .cs-custom-code-wrap.custom-code-preview-blank{ 12206 -webkit-mask-image: none !important; 12207 mask-image: none !important; 12208 } 12209 .cs-custom-code-live-host{ 12210 display: block; 12211 width: 100%; 12212 } 12213 .cs-custom-code-wrap.first-page-section{ 12214 isolation: auto; 12215 z-index: auto; 12216 } 12217 .cs-custom-code-wrap.active-ai-thread-target{ 12218 z-index: 3; 12219 } 12220 .cs-custom-code-wrap.active-ai-thread-target::after{ 12221 content: ""; 12222 position: absolute; 12223 inset: 0; 12224 z-index: 120; 12225 pointer-events: none; 12226 border: 3px solid rgba(74, 86, 248, 0.62); 12227 box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.75), 0 0 0 4px rgba(74, 86, 248, 0.12); 12228 } 12229 12230 .custom-code-ai-launcher{ 12231 position: absolute !important; 12232 left: auto !important; 12233 right: 125px !important; 12234 bottom: calc(20px + var(--cs-overlap-chrome-lift, 0px)) !important; 12235 z-index: 99 !important; 12236 display: inline-flex !important; 12237 align-items: center !important; 12238 justify-content: center !important; 12239 gap: 8px !important; 12240 pointer-events: auto !important; 12241 touch-action: manipulation !important; 12242 width: auto !important; 12243 min-width: 0 !important; 12244 height: auto !important; 12245 min-height: 45px !important; 12246 max-height: none !important; 12247 padding: 0 15px !important; 12248 border-radius: 6px !important; 12249 background: linear-gradient(135deg, #4a56f8 0%, #7b58ff 100%) !important; 12250 color: #fff !important; 12251 border: none !important; 12252 font-family: Roboto, Arial, Helvetica, sans-serif !important; 12253 font-size: 14px !important; 12254 font-weight: 600 !important; 12255 font-style: normal !important; 12256 line-height: 45px !important; 12257 letter-spacing: 0 !important; 12258 text-transform: none !important; 12259 white-space: nowrap !important; 12260 box-shadow: 0 12px 40px rgba(40, 40, 41, 0.05), 0 0 0 1px rgba(0, 0, 0, 0.01) !important; 12261 transition: transform 0.12s ease, box-shadow 0.12s ease !important; 12262 } 12263 .custom-code-ai-launcher.has-active-stream{ 12264 box-shadow: 0 12px 40px rgba(74, 86, 248, 0.35), 0 0 0 1px rgba(74, 86, 248, 0.25) !important; 12265 } 12266 .custom-code-ai-launcher-spinner{ 12267 display: inline-block; 12268 width: 12px; 12269 height: 12px; 12270 border-radius: 999px; 12271 border: 2px solid rgba(255,255,255,0.4); 12272 border-top-color: #fff; 12273 animation: csCustomCodeStreamSpin 0.9s linear infinite; 12274 } 12275 .custom-code-welcome-generating-spinner{ 12276 display: inline-block; 12277 width: 30px; 12278 height: 30px; 12279 border-radius: 999px; 12280 border: 3px solid rgba(74, 86, 248, 0.2); 12281 border-top-color: #4a56f8; 12282 animation: csCustomCodeStreamSpin 0.9s linear infinite; 12283 } 12284 @keyframes csCustomCodeStreamSpin{ 12285 from { transform: rotate(0deg); } 12286 to { transform: rotate(360deg); } 12287 } 12288 12289 .custom-code-tab-bar{ 12290 position: relative; 12291 z-index: 101; 12292 } 12293 .cs-custom-code-wrap.preview-mode > .custom-code-preview-pane:not(.custom-code-welcome-pane):not(.custom-code-empty-state-pane):not(.custom-code-loading-pane){ 12294 padding: 0; 12295 } 12296 12297 /* Welcome composer (Build a custom section) is builder-chrome, 12298 not user-rendered hosted-site content. Hosted sites can apply 12299 a custom theme font that would otherwise cascade in here and 12300 make the heading / textarea look unbranded. Pin everything 12301 inside this pane to the Clubspot dashboard font so the 12302 builder UI is always consistent across sites. !important 12303 because some host themes set font-family with high 12304 specificity on `body *` selectors. */ 12305 .custom-code-welcome-pane, 12306 .custom-code-welcome-pane *:not(i):not([class*="fa-"]), 12307 .custom-code-welcome-pane input, 12308 .custom-code-welcome-pane textarea,
12309 .custom-code-welcome-pane button:not([class*="fa-"]){ 12310 font-family: Roboto, Arial, Helvetica, sans-serif !important; 12311 } 12312 /* Re-assert the Font Awesome stack on the icon elements inside 12313 the welcome pane. The wildcard rule above used `!important` 12314 to beat hosted-site themes that set `font-family` with high 12315 specificity on `body *`, which had the side-effect of also 12316 beating Font Awesome's own selectors (.fa-regular, .fa-solid, 12317 etc.) on the icon `<i>` tags â the icon glyphs are unicode 12318 private-use codepoints, so without the FA font-family they 12319 render as empty tofu boxes. Excluding `i` / `[class*="fa-"]` 12320 from the wildcard above lets FA's own font-family win again, 12321 and this explicit fallback covers any host theme that ALSO 12322 pinned font-family on `i` or `.fa-*` with high specificity. */ 12323 .custom-code-welcome-pane i[class*="fa-"]{ 12324 font-family: "Font Awesome 6 Pro", "Font Awesome 6 Free", "Font Awesome 5 Pro", "Font Awesome 5 Free", "FontAwesome" !important; 12325 } 12326 12327 /* Example-prompt suggestions on the welcome composer. The 12328 ai-mcp-panel.html sheet defines a heavier .ai-composer-pill 12329 look intended for the dashboard chat composer, but that 12330 file doesn't load on hosted-sites pages. Style here is 12331 intentionally low-key â dotted border + normal-weight 12332 muted text â so the suggestions read as inline hints 12333 rather than competing call-to-action buttons. Hover 12334 firms up the border + tints the surface to confirm 12335 they're clickable. */ 12336 .cs-custom-code-wrap .ai-composer-pill{ 12337 display: inline-flex; 12338 align-items: center; 12339 max-width: 100%; 12340 padding: 5px 11px; 12341 border: 1px dotted rgba(8, 19, 51, 0.18); 12342 border-radius: 8px; 12343 background: transparent; 12344 color: #7280a0; 12345 font-size: 12.5px; 12346 line-height: 18px; 12347 font-weight: 400; 12348 letter-spacing: 0; 12349 text-align: left; 12350 white-space: normal; 12351 transition: border-color 0.12s ease, background 0.12s ease, color 0.12s ease; 12352 } 12353 .cs-custom-code-wrap .ai-composer-pill:hover{ 12354 border-color: rgba(74, 86, 248, 0.45); 12355 background: rgba(74, 86, 248, 0.04); 12356 color: #4a56f8; 12357 } 12358 .cs-custom-code-wrap .ai-composer-pill:focus-visible{ 12359 outline: 2px solid rgba(74, 86, 248, 0.35); 12360 outline-offset: 1px; 12361 } 12362 12363 /* Send button on the welcome composer. The ai-mcp-panel.html 12364 sheet defines an .ai-submit-button rule too, but that file 12365 isn't loaded on hosted-sites pages â so we redeclare it 12366 locally to guarantee the blue pill renders here. 12367 - flex centering keeps the paper-plane glyph on the 12368 button's optical center 12369 - paper-plane has visual weight on the right tip, so a 12370 1px left nudge looks balanced */ 12371 .cs-custom-code-wrap .ai-submit-button{ 12372 flex: 0 0 auto; 12373 width: 36px; 12374 min-width: 36px; 12375 height: 36px; 12376 padding: 0; 12377 margin: 0; 12378 border: 0; 12379 border-radius: 999px; 12380 background: #4a56f8; 12381 color: #fff; 12382 display: inline-flex; 12383 align-items: center; 12384 justify-content: center; 12385 line-height: 1; 12386 box-shadow: 0 2px 6px rgba(74, 86, 248, 0.30); 12387 cursor: pointer; 12388 transition: background 0.12s ease, box-shadow 0.12s ease, transform 0.12s ease; 12389 } 12390 .cs-custom-code-wrap .ai-submit-button:hover{ 12391 background: #3a45e0; 12392 box-shadow: 0 4px 12px rgba(74, 86, 248, 0.40); 12393 transform: translateY(-1px); 12394 } 12395 .cs-custom-code-wrap .ai-submit-button .fa-paper-plane{ 12396 font-size: 14px; 12397 line-height: 1; 12398 transform: translateX(-1px); 12399 } 12400 .cs-custom-code-wrap .ai-submit-button.inactive, 12401 .cs-custom-code-wrap .ai-submit-button[disabled]{ 12402 background: #c4cbe2; 12403 color: #fff; 12404 box-shadow: none; 12405 cursor: not-allowed; 12406 transform: none; 12407 } 12408 .cs-custom-code-wrap .ai-submit-button.loading{ 12409 background: #4a56f8; 12410 color: #fff; 12411 cursor: progress; 12412 } 12413 12414 /* Pre-first-delta loading pane. This is intentionally a real 12415 preview pane, not an overlay: while waiting for the first code 12416 chunk, the preview window shows only the dots. The chat panel is 12417 already open for context/progress, so avoid duplicating labels or 12418 exposing the stale welcome/current preview underneath. */ 12419 .custom-code-loading-pane{ 12420 display: flex; 12421 align-items: center; 12422 justify-content: center; 12423 min-height: 420px; 12424 padding: 48px 20px; 12425 background: #fbfcff; 12426 } 12427 .custom-code-loading-pane.is-overlay{ 12428 position: absolute !important; 12429 inset: 0 !important; 12430 z-index: 80 !important; 12431 min-height: 0 !important; 12432 padding: 0 !important; 12433 background: rgba(251, 252, 255, 0.62) !important; 12434 backdrop-filter: blur(1px); 12435 pointer-events: none; 12436 } 12437 .custom-code-empty-state-pane{ 12438 display: flex; 12439 align-items: center; 12440 justify-content: center; 12441 min-height: 420px; 12442 padding: 48px 20px; 12443 background: #fbfcff; 12444 } 12445 .custom-code-nonvisual-pane{ 12446 min-height: 88px; 12447 padding: 20px 16px; 12448 } 12449 .custom-code-empty-state-copy{ 12450 display: inline-flex; 12451 align-items: center; 12452 justify-content: center; 12453 flex-wrap: wrap; 12454 gap: 8px; 12455 max-width: 100%; 12456 text-align: center; 12457 color: #9aa5ba; 12458 font-family: Roboto, Arial, Helvetica, sans-serif !important; 12459 font-size: 14px; 12460 font-style: normal; 12461 line-height: 20px; 12462 font-weight: 500; 12463 } 12464 .custom-code-empty-state-copy i{ 12465 font-size: 14px; 12466 color: #b0bad0; 12467 } 12468 .custom-code-pending-dots{ 12469 display: inline-flex; 12470 align-items: center; 12471 gap: 8px; 12472 } 12473 .custom-code-pending-dots span{ 12474 display: inline-block; 12475 width: 10px; 12476 height: 10px; 12477 border-radius: 50%; 12478 background: #4a56f8; 12479 opacity: 0.35; 12480 animation: csCustomCodePendingPulse 1.2s ease-in-out infinite both; 12481 } 12482 .custom-code-pending-dots span:nth-child(2){ animation-delay: 0.15s; } 12483 .custom-code-pending-dots span:nth-child(3){ animation-delay: 0.30s; }
12484 @keyframes csCustomCodePendingPulse{ 12485 0%, 80%, 100% { transform: scale(0.6); opacity: 0.35; } 12486 40% { transform: scale(1.0); opacity: 1.0; } 12487 } 12488 12489 .custom-code-ai-launcher:hover{ 12490 transform: translateY(-1px) !important; 12491 box-shadow: 0 3px 8px rgba(74, 86, 248, 0.34) !important; 12492 } 12493 12494 .custom-code-ai-launcher .fa-sparkles{ 12495 display: inline-block !important; 12496 flex: 0 0 auto !important; 12497 font-family: "Font Awesome 6 Pro", "Font Awesome 6 Free", "Font Awesome 5 Pro", "Font Awesome 5 Free", "FontAwesome" !important; 12498 font-size: 14px !important; 12499 line-height: 1 !important; 12500 margin: 0 7px 0 0 !important; 12501 } 12502 12503 .custom-code-ai-launcher-label{ 12504 display: inline-block !important; 12505 flex: 0 0 auto !important; 12506 font-family: Roboto, Arial, Helvetica, sans-serif !important; 12507 font-style: normal !important; 12508 letter-spacing: 0 !important; 12509 text-transform: none !important; 12510 } 12511 12512 /* 12513 Placeholder pick/upload/paste overlay. Only rendered in PREVIEW 12514 mode by `decorate_asset_placeholders` â the live (public) site 12515 never sees this class because the overlay element is injected 12516 into the rendered preview DOM and is never persisted back to 12517 the `custom_code` source. 12518 */ 12519 .cs-asset-placeholder-host{ 12520 position: relative; 12521 } 12522 .custom-code-preview-pane .cs-asset-background-placeholder-host:hover{ 12523 outline: 2px dashed rgba(74, 86, 248, 0.55) !important; 12524 outline-offset: -4px !important; 12525 } 12526 .custom-code-preview-pane .cs-asset-background-placeholder-chip{ 12527 position: absolute !important; 12528 top: 10px !important; 12529 right: 10px !important; 12530 z-index: 10000 !important; 12531 display: inline-flex !important; 12532 align-items: center !important; 12533 justify-content: center !important; 12534 width: 34px !important; 12535 height: 34px !important; 12536 padding: 0 !important; 12537 margin: 0 !important; 12538 border: 1px solid rgba(74, 86, 248, 0.45) !important; 12539 border-radius: 999px !important; 12540 background: rgba(255, 255, 255, 0.88) !important; 12541 color: #4a56f8 !important; 12542 box-shadow: 0 6px 18px rgba(8, 19, 51, 0.14) !important; 12543 cursor: pointer !important; 12544 } 12545 .custom-code-preview-pane .cs-asset-background-placeholder-chip:hover{ 12546 background: #ffffff !important; 12547 border-color: rgba(74, 86, 248, 0.85) !important; 12548 } 12549 .custom-code-preview-pane .cs-asset-placeholder-overlay{ 12550 position: absolute !important; 12551 inset: 0 !important; 12552 z-index: 125 !important; 12553 display: flex !important; 12554 flex-direction: column !important; 12555 align-items: center !important; 12556 justify-content: center !important; 12557 gap: 4px !important; 12558 margin: 0 !important; 12559 padding: 12px !important; 12560 border: 2px dashed rgba(74, 86, 248, 0.55) !important; 12561 border-radius: 8px !important; 12562 background: rgba(74, 86, 248, 0.08) !important; 12563 color: #2a2f5a !important; 12564 font-family: Roboto, Arial, Helvetica, sans-serif !important; 12565 font-size: 13px !important; 12566 font-style: normal !important; 12567 font-weight: 500 !important; 12568 line-height: 1.25 !important; 12569 letter-spacing: 0 !important; 12570 text-align: center !important; 12571 white-space: normal !important; 12572 cursor: pointer !important; 12573 backdrop-filter: blur(2px) !important; 12574 transition: background 0.12s ease, border-color 0.12s ease !important; 12575 } 12576 .custom-code-preview-pane .cs-asset-placeholder-overlay:hover{ 12577 background: rgba(74, 86, 248, 0.16) !important; 12578 border-color: rgba(74, 86, 248, 0.85) !important; 12579 } 12580 .custom-code-preview-pane .cs-asset-placeholder-icon{ 12581 display: inline-flex !important; 12582 align-items: center !important; 12583 justify-content: center !important; 12584 width: 28px !important; 12585 height: 28px !important; 12586 flex: 0 0 28px !important; 12587 margin-bottom: 4px !important; 12588 border-radius: 50% !important; 12589 background: rgba(255, 255, 255, 0.65) !important; 12590 color: #4a56f8 !important; 12591 font-size: 14px !important; 12592 font-style: normal !important; 12593 line-height: 1 !important; 12594 letter-spacing: 0 !important; 12595 } 12596 .custom-code-preview-pane .cs-asset-placeholder-label{ 12597 display: inline-block !important; 12598 padding: 4px 10px !important; 12599 border-radius: 999px !important; 12600 background: rgba(255, 255, 255, 0.92) !important; 12601 color: #2a2f5a !important; 12602 font-family: Roboto, Arial, Helvetica, sans-serif !important; 12603 font-size: 13px !important; 12604 font-style: normal !important; 12605 font-weight: 600 !important; 12606 line-height: 1.2 !important; 12607 letter-spacing: 0 !important; 12608 min-width: 118px !important; 12609 max-width: 180px !important; 12610 white-space: normal !important; 12611 } 12612 12613 /* 12614 "Change" overlay for filled placeholder slots 12615 (`[data-cs-asset-attached]`). Light-DOM fallback only â preview 12616 chrome for the isolated shadow tree is in 12617 CS_CUSTOM_CODE_PREVIEW_CHROME_CSS. Always visible, matching 12618 image-with-text / image-grid slot chips. 12619 */ 12620 .cs-asset-attached-host{ 12621 position: relative; 12622 } 12623 .custom-code-preview-pane .cs-asset-change-button{ 12624 position: absolute !important; 12625 top: 8px !important; 12626 right: 8px !important; 12627 z-index: 10000 !important; 12628 display: inline-flex !important; 12629 align-items: center !important; 12630 justify-content: center !important; 12631 padding: 6px 10px !important; 12632 margin: 0 !important; 12633 border: none !important; 12634 border-radius: 6px !important; 12635 background: rgba(20, 24, 45, 0.82) !important; 12636 color: #fff !important; 12637 font-family: Roboto, Arial, Helvetica, sans-serif !important; 12638 font-size: 12px !important; 12639 font-weight: 600 !important; 12640 line-height: 1 !important; 12641 cursor: pointer !important; 12642 opacity: 0.92 !important; 12643 pointer-events: auto !important; 12644 box-shadow: 0 4px 12px rgba(8, 19, 51, 0.22) !important; 12645 transition: opacity 0.12s ease, background 0.12s ease !important; 12646 } 12647 .custom-code-preview-pane .cs-asset-change-button:hover, 12648 .custom-code-preview-pane .cs-asset-change-button:focus{ 12649 opacity: 1 !important; 12650 background: rgba(20, 24, 45, 0.95) !important; 12651 } 12652 .custom-code-preview-pane .cs-ai-asset-hover-highlight{ 12653 outline: 2px solid rgba(74, 86, 248, 0.32) !important; 12654 outline-offset: 4px !important; 12655 box-shadow: 0 0 0 6px rgba(74, 86, 248, 0.08) !important; 12656 transition: outline-color 0.12s ease, box-shadow 0.12s ease !important; 12657 } 12658 12659 /* 12660 Click-to-edit text leaves. Only active in PREVIEW mode â the 12661 host class is added by `decorate_editable_text_nodes` after 12662 every preview re-render and never persisted to source. The 12663 hover affordance is a soft dashed outline with cursor:pointer 12664 so admins can tell the text is editable without the section 12665 feeling like a form. 12666 */ 12667 .custom-code-preview-pane .cs-text-editable-host{ 12668 cursor: text !important; 12669 position: relative !important; 12670 transition: outline-color 0.12s ease, background-color 0.12s ease !important; 12671 outline: 1px dashed transparent !important; 12672 outline-offset: 2px !important; 12673 border-radius: 3px !important; 12674 } 12675 .custom-code-preview-pane .cs-text-editable-host:hover{ 12676 outline-color: rgba(74, 86, 248, 0.55) !important; 12677 background-color: rgba(74, 86, 248, 0.06) !important; 12678 } 12679 .custom-code-preview-pane .cs-text-editable-host.cs-text-editable-active{ 12680 outline: 1px solid rgba(74, 86, 248, 0.85) !important; 12681 background-color: rgba(74, 86, 248, 0.12) !important; 12682 } 12683 12684 /* 12685 Inline text-edit popover. Lives at document.body level (fixed 12686 positioning) so it isn't clipped by overflow:hidden ancestors 12687 in the preview pane. Closed on outside click, ESC, scroll, or 12688 a Vue re-render of the preview. 12689 */ 12690 .cs-text-edit-popover{ 12691 position: fixed !important; 12692 z-index: 10000 !important; 12693 box-sizing: border-box !important; 12694 max-width: 92vw !important; 12695 padding: 10px !important; 12696 background: #fff !important; 12697 border: 1px solid rgba(20, 24, 45, 0.12) !important; 12698 border-radius: 8px !important; 12699 box-shadow: 0 18px 48px rgba(20, 24, 45, 0.18), 0 2px 6px rgba(20, 24, 45, 0.08) !important; 12700 font-family: inherit !important; 12701 font-size: 13px !important; 12702 color: #2a2f5a !important; 12703 } 12704 .cs-text-edit-popover-header{ 12705 display: block !important; 12706 margin-bottom: 6px !important; 12707 font-size: 11px !important; 12708 font-weight: 600 !important; 12709 text-transform: uppercase !important; 12710 letter-spacing: 0.04em !important; 12711 color: #4a4f6a !important; 12712 } 12713 .cs-text-edit-popover .cs-text-edit-textarea{ 12714 display: block !important; 12715 box-sizing: border-box !important; 12716 width: 100% !important; 12717 min-height: 60px !important; 12718 max-height: 240px !important; 12719 padding: 8px 10px !important; 12720 margin: 0 !important; 12721 border: 1px solid rgba(20, 24, 45, 0.18) !important; 12722 border-radius: 6px !important; 12723 background: #fff !important; 12724 color: #14182d !important; 12725 font-family: inherit !important; 12726 font-size: 13px !important; 12727 line-height: 1.4 !important; 12728 resize: vertical !important; 12729 outline: none !important; 12730 box-shadow: none !important; 12731 } 12732 .cs-text-edit-popover .cs-text-edit-textarea:focus{ 12733 border-color: rgba(74, 86, 248, 0.7) !important; 12734 box-shadow: 0 0 0 3px rgba(74, 86, 248, 0.15) !important; 12735 } 12736 .cs-text-edit-popover .cs-text-edit-url-field{ 12737 display: block !important; 12738 margin: 8px 0 0 0 !important; 12739 } 12740 .cs-text-edit-popover .cs-text-edit-url-label{ 12741 display: block !important; 12742 margin: 0 0 4px 0 !important; 12743 color: #4a4f6a !important; 12744 font-size: 11px !important; 12745 font-weight: 600 !important; 12746 line-height: 14px !important; 12747 } 12748 .cs-text-edit-popover .cs-text-edit-url-input{ 12749 display: block !important; 12750 box-sizing: border-box !important; 12751 width: 100% !important; 12752 height: 34px !important; 12753 padding: 7px 10px !important; 12754 margin: 0 !important; 12755 border: 1px solid rgba(20, 24, 45, 0.18) !important; 12756 border-radius: 6px !important; 12757 background: #fff !important; 12758 color: #14182d !important; 12759 font-family: inherit !important; 12760 font-size: 13px !important; 12761 line-height: 18px !important; 12762 outline: none !important; 12763 box-shadow: none !important; 12764 } 12765 .cs-text-edit-popover .cs-text-edit-url-input:focus{ 12766 border-color: rgba(74, 86, 248, 0.7) !important; 12767 box-shadow: 0 0 0 3px rgba(74, 86, 248, 0.15) !important; 12768 } 12769 .cs-text-edit-popover-footer{ 12770 display: flex !important; 12771 align-items: center !important; 12772 gap: 6px !important; 12773 margin-top: 8px !important; 12774 } 12775 .cs-text-edit-popover-footer-spacer{ 12776 flex: 1 1 auto !important; 12777 } 12778 .cs-text-edit-popover-footer button{ 12779 appearance: none !important; 12780 -webkit-appearance: none !important; 12781 display: inline-flex !important; 12782 align-items: center !important; 12783 justify-content: center !important; 12784 padding: 6px 12px !important; 12785 margin: 0 !important; 12786 border-radius: 6px !important; 12787 border: 1px solid transparent !important; 12788 font-family: inherit !important; 12789 font-size: 12px !important; 12790 font-weight: 600 !important; 12791 line-height: 1 !important; 12792 cursor: pointer !important; 12793 transition: background 0.12s ease, transform 0.12s ease, border-color 0.12s ease, color 0.12s ease !important; 12794 } 12795 .cs-text-edit-popover .cs-text-edit-ask{ 12796 padding: 6px 8px !important; 12797 background: transparent !important; 12798 border-color: transparent !important; 12799 color: #4a56f8 !important; 12800 gap: 6px !important; 12801 } 12802 .cs-text-edit-popover .cs-text-edit-ask:hover{ 12803 background: rgba(74, 86, 248, 0.08) !important; 12804 color: #3b46d6 !important; 12805 } 12806 .cs-text-edit-popover .cs-text-edit-ask .fa-sparkles{ 12807 font-size: 12px !important; 12808 } 12809 .cs-text-edit-popover .cs-text-edit-cancel{ 12810 background: #fff !important; 12811 border-radius: 999px !important; 12812 border-color: rgba(20, 24, 45, 0.18) !important; 12813 color: #4a4f6a !important; 12814 } 12815 .cs-text-edit-popover .cs-text-edit-cancel:hover{ 12816 background: rgba(20, 24, 45, 0.04) !important; 12817 } 12818 .cs-text-edit-popover .cs-text-edit-save{ 12819 background: linear-gradient(135deg, #4a56f8 0%, #7b58ff 100%) !important; 12820 border-radius: 999px !important; 12821 color: #fff !important; 12822 border-color: transparent !important; 12823 } 12824 .cs-text-edit-popover .cs-text-edit-save:hover{ 12825 transform: translateY(-1px) !important; 12826 } 12827</style> 12828
12828<script type="text/html" id="template-text"> 12829 <div 12830 v-memo="[last_update, last_element_update]" 12831 :class="[{'hidden': (column_index + 1) > number_of_columns }, 'text-column_' + column_index]" 12832 class="cs-text_text-wrap-column cs-element-stack" 12833 :data-column="column_index" 12834 > 12835 <template 12836 v-for="(element, element_index) in nice_elements" 12837 :key="element.client_id || element_index" 12838 > 12839 <template v-if="preview_element"> 12840 <preview-element-component 12841 :element="element" 12842 :element_type="element.type" 12843 :element_content="element.content" 12844 :element_column_count="element.column_count" 12845 :element_column_gap="element.column_gap" 12846 :column_index="column_index" 12847 :element_index="element_index" 12848 :componentObject="component" 12849 ></preview-element-component> 12850 </template> 12851 <template v-if="!preview_element"> 12852 <element-component 12853 :element_type="element.type" 12854 :element_content="element.content" 12855 :element_column_count="element.column_count" 12856 :element_column_gap="element.column_gap" 12857 :diff_changed="element._wb_diff_changed === true" 12858 :element_index="element_index" 12859 :section_element_animation="section_element_animation" 12860 ></element-component> 12861 </template> 12862 </template> 12863 <!-- handle case where there are no elements (admin only) --> 12864 <template v-if="preview_element && elements && elements.length == 0"> 12865 <div class="no-elements-wrap"> 12866 <div 12867 class="tapped-insert-new-element-relative" 12868 @click="tapped_add_element( $event )" 12869 >+</div> 12870 </div> 12871 </template> 12872 </div> 12873</script>
12873 12874
12875<script> 12876 app_content.component('text-component', { 12877 template: '#template-text', 12878 data() { 12879 return {} 12880 }, 12881 props: { 12882 number_of_columns: [String, Number], 12883 preview_element: Boolean, 12884 component: Object, 12885 last_update: Number, //last component update 12886 last_element_update: Number, //last element update 12887 elements: Array, 12888 column_prop: String, 12889 column_index: Number, 12890 section_element_animation: { 12891 type: String, 12892 default: '', 12893 }, 12894 }, 12895 methods:{ 12896 tapped_add_element(){ 12897 var params = {}; 12898 params["component"] = this.component; 12899 params["insert_after"] = 0; 12900 params["column_index"] = this.column_index; 12901 handle_add_element( params ); 12902 } 12903 }, 12904 computed: { 12905 nice_elements(){ 12906 var nice_elements = []; 12907 12908 if (this.elements){ 12909 nice_elements = this.elements; 12910 } else if (this.column_prop && typeof this.column_prop === 'string'){ 12911 nice_elements = JSON.parse(unescape(decodeURIComponent(this.column_prop))); 12912 } 12913 return nice_elements; 12914 } 12915 }, 12916 }); 12917</script>
12917
12917<script type="text/html" id="template-image-with-text"> 12918 <div ref="animationTarget" class="cs-image-with-text flexWrap relative delay5s" :class="[class_string, { 'scroll-fade': !is_admin && !tab_element }, { 'view_hidden': ! is_admin && ( !formatting_obj.animation || formatting_obj.animation !== 'none' ) }, { 'background': image_mat === 'hide' }]" :style="style_string"> 12919 <template v-if="!loading && left_right_orientation === 'right'"> 12920 <!-- text first (image on the right) --> 12921 <div class="cs-image-with-text_text-wrap flexGrowOne"> 12922 <div :style="element_style_string"> 12923 <div v-memo="[last_update, last_element_update]" class="element-wrapper cs-element-stack"> 12924 <template 12925 v-for="(element, element_index) in elements_obj" 12926 :key="element.client_id || element_index" 12927 > 12928 <template v-if="preview_element"> 12929 <preview-element-component 12930 :element="element" 12931 :element_type="element.type" 12932 :element_content="element.content" 12933 :element_column_count="element.column_count" 12934 :element_column_gap="element.column_gap" 12935 :element_index="element_index" 12936 :componentObject="component" 12937 ></preview-element-component> 12938 </template> 12939 <template v-if="!preview_element"> 12940 <element-component 12941 :element_type="element.type" 12942 :element_content="element.content" 12943 :element_column_count="element.column_count" 12944 :element_column_gap="element.column_gap" 12945 :diff_changed="element._wb_diff_changed === true" 12946 :element_index="element_index" 12947 :section_element_animation="section_element_animation" 12948 ></element-component> 12949 </template> 12950 </template> 12951 <!-- handle case where there are no elements (admin only) --> 12952 <template v-if="preview_element && elements_obj && elements_obj.length == 0"> 12953 <div class="no-elements-wrap"> 12954 <div 12955 class="tapped-insert-new-element-relative" 12956 @click="tapped_add_element( $event )" 12957 >+</div> 12958 </div> 12959 </template> 12960 </div> 12961 </div> 12962 </div> 12963 <div class="cs-image-with-text_image-wrap flexGrowOne"> 12964 <div class="cs-image-with-text_image-wrap-inner"> 12965 <div :style="check_full_background()" class="cs-image-with-text_image-slider-outer"> 12966 <div 12967 v-if="formatting_obj.background_size !== 'full'" 12968 :class="['cs-image-with-text-image-mat', { background: image_mat === 'show' }]" 12969 ></div> 12970 <image-slider-component 12971 :client_id="client_id" 12972 :formatting="formatting" 12973 :images_string="images_string" 12974 :preview_element="preview_element" 12975 :component="component" 12976 :last_update="last_update" 12977 ></image-slider-component> 12978 </div> 12979 </div> 12980 </div> 12981 </template> 12982 <template v-if="!loading && left_right_orientation === 'left'"> 12983 <!-- image first (text on the right) --> 12984 <div class="cs-image-with-text_image-wrap flexGrowOne"> 12985 <div class="cs-image-with-text_image-wrap-inner"> 12986 <div :style="check_full_background()" class="cs-image-with-text_image-slider-outer"> 12987 <div 12988 v-if="formatting_obj.background_size !== 'full'" 12989 :class="['cs-image-with-text-image-mat', { background: image_mat === 'show' }]" 12990 ></div> 12991 <image-slider-component 12992 :client_id="client_id" 12993 :formatting="formatting" 12994 :images_string="images_string" 12995 :preview_element="preview_element" 12996 :component="component" 12997 :last_update="last_update" 12998 ></image-slider-component> 12999 </div> 13000 </div> 13001 </div> 13002 <div class="cs-image-with-text_text-wrap flexGrowOne"> 13003 <div :style="element_style_string"> 13004 <div 13005 v-memo="[last_update, last_element_update]" 13006 class="element-wrapper cs-element-stack" 13007 > 13008 <template 13009 v-for="(element, element_index) in elements_obj" 13010 :key="element.client_id || element_index" 13011 > 13012 <template v-if="preview_element"> 13013 <preview-element-component 13014 :element="element" 13015 :element_type="element.type"
13016 :element_content="element.content" 13017 :element_column_count="element.column_count" 13018 :element_column_gap="element.column_gap" 13019 :element_index="element_index" 13020 :componentObject="component" 13021 ></preview-element-component> 13022 </template> 13023 <template v-if="!preview_element"> 13024 <element-component 13025 :element_type="element.type" 13026 :element_content="element.content" 13027 :element_column_count="element.column_count" 13028 :element_column_gap="element.column_gap" 13029 :diff_changed="element._wb_diff_changed === true" 13030 :element_index="element_index" 13031 :section_element_animation="section_element_animation" 13032 ></element-component> 13033 </template> 13034 </template> 13035 <!-- handle case where there are no elements (admin only) --> 13036 <template v-if="preview_element && elements_obj && elements_obj.length == 0"> 13037 <div class="no-elements-wrap"> 13038 <div 13039 class="tapped-insert-new-element-relative" 13040 @click="tapped_add_element( $event )" 13041 >+</div> 13042 </div> 13043 </template> 13044 </div> 13045 </div> 13046 </div> 13047 </template> 13048 </div> 13049</script>
13049 13050 13051<style> 13052 .delay5s{ 13053 animation-delay: 0.5s; 13054 } 13055 13056 .view_hidden{ 13057 opacity: 0; 13058 } 13059 13060 @keyframes fade-left-right { 13061 0% { 13062 opacity: 0; 13063 transform: translateX(-100%) scaleX(0.5); 13064 } 13065 100% { 13066 opacity: 1; 13067 transform: translateX(0) scaleX(1); 13068 } 13069 } 13070 13071 @keyframes fade-right-left { 13072 0% { 13073 opacity: 0; 13074 transform: translateX(100%) scaleX(0.5); 13075 } 13076 100% { 13077 opacity: 1; 13078 transform: translateX(0) scaleX(1); 13079 } 13080 } 13081 13082 .fade-left-right { 13083 animation: fade-left-right 1s ease-in-out forwards; 13084 } 13085 13086 .fade-right-left { 13087 animation: fade-right-left 1s ease-in-out backwards; 13088 } 13089</style> 13090
13091<script> 13092 app_content.component('image-with-text-component', { 13093 template: '#template-image-with-text', 13094 data() { 13095 return { 13096 is_admin: false, 13097 loading: true, 13098 } 13099 }, 13100 props: { 13101 class_string: String, 13102 style_string: String, 13103 element_style_string: String, 13104 elements_string: [String, Object], 13105 left_right_orientation: String, 13106 image_mat: String, 13107 //image slider 13108 client_id: String, 13109 formatting: String, 13110 images_string: String, 13111 //admin 13112 preview_element: Boolean, 13113 component: Object, 13114 last_update: Number, //last component update 13115 last_element_update: Number, //last element update 13116 tab_element: Boolean, 13117 }, 13118 mounted() { 13119 this.loading = false; 13120 this.is_admin = window.location.href.indexOf('website-builder') !== -1; 13121 }, 13122 updated(){ 13123 this.createIntersectionObserver(); 13124 }, 13125 methods: { 13126 check_full_background(){ 13127 if ( this.formatting_obj && this.formatting_obj.background_size === 'full' ){ 13128 return { 13129 top: 0, 13130 bottom: 0, 13131 left: 0, 13132 right: 0, 13133 } 13134 } 13135 }, 13136 tapped_add_element(){ 13137 var params = {}; 13138 params["component"] = this.component; 13139 params["insert_after"] = 0; 13140 params["column_index"] = 0; 13141 handle_add_element( params ); 13142 }, 13143 createIntersectionObserver(){ 13144 const options = { 13145 root: null, 13146 rootMargin: '0px', 13147 threshold: 0.5 //trigger animation when at least 50% of the target is visible 13148 }; 13149 const observer = new IntersectionObserver(this.handleIntersection, options); 13150 //observe the target element 13151 observer.observe(this.$refs.animationTarget); 13152 }, 13153 handleIntersection(entries, observer) { 13154 entries.forEach(entry => { 13155 //check if target is intersecting 13156 if (entry.isIntersecting) { 13157 //apply animation class 13158 entry.target.classList.add(this.formatting_obj.animation); 13159 entry.target.classList.remove('view_hidden'); 13160 //stop observing to prevent re-triggering 13161 observer.unobserve(entry.target); 13162 } 13163 }); 13164 }, 13165 }, 13166 computed: { 13167 elements_obj(){ 13168 if (this.elements_string && typeof this.elements_string === 'string'){ 13169 return JSON.parse(unescape(decodeURIComponent(this.elements_string))); 13170 } else if (this.elements_string){ 13171 return this.elements_string; 13172 } else { 13173 return {}; 13174 } 13175 }, 13176 formatting_obj(){ 13177 if (this.formatting){ 13178 return JSON.parse(this.formatting); 13179 } else { 13180 return {}; 13181 } 13182 }, 13183 section_element_animation(){ 13184 let component_animation = resolve_web_builder_component_animation(this.formatting_obj); 13185 return section_element_animation_token(component_animation); 13186 }, 13187 }, 13188 }); 13189</script>
13189
13189<script type="text/html" id="template-image-slider"> 13190 <div class="cs-image-slider-wrap background"> 13191 <div 13192 class="cs-image-slider" 13193 :class="[ 'cs-image-slider-' + filtered_images_length, 'cs-image-slider_' + client_id ]" 13194 :data-client-id="client_id" 13195 data-interval="8000" 13196 data-slider-index="0" 13197 > 13198 <!-- now add the images --> 13199 <template v-if="!loading && filtered_images_length > 0" v-for="(image, index) in filtered_images"> 13200 <template v-if="image && typeof image !== 'object'"> 13201 <template v-if="image && image.indexOf('video') >= 1"> 13202 <div 13203 class="cs-image-slider-card" 13204 :class="[ 13205 'cs-image-slider-card-' + index, 13206 { 'active': index === display_index }, 13207 { 'cs-image-slot-attached-host': preview_element && !is_hero_banner } 13208 ]" 13209 style="display:flex;align-items:center;justify-content:center;" 13210 @click.stop="preview_element && !is_hero_banner && change_image_slot(index)" 13211 > 13212 <video class="cs-background-video" autoplay playsinline loop muted :src="image" style="display:initial;"></video> 13213 <button 13214 v-if="preview_element && !is_hero_banner" 13215 type="button" 13216 class="cs-image-slot-change-chip exclude-from-custom-button-styles" 13217 @click.stop="change_image_slot(index)" 13218 ><i class="fa-regular fa-image"></i><span>Change</span></button> 13219 </div> 13220 </template> 13221 <template v-if="image && typeof image === 'string'"> 13222 <div 13223 class="cs-image-slider-card" 13224 :class="[ 13225 'cs-image-slider-card-' + index, 13226 { 'active': index === display_index }, 13227 { 'cs-image-slot-attached-host': preview_element && !is_hero_banner } 13228 ]" 13229 @click.stop="preview_element && !is_hero_banner && change_image_slot(index)" 13230 > 13231 <div class="cs-background-image-photo" :style="`background-image: url(${image})`"></div> 13232 <button 13233 v-if="preview_element && !is_hero_banner" 13234 type="button" 13235 class="cs-image-slot-change-chip exclude-from-custom-button-styles" 13236 @click.stop="change_image_slot(index)" 13237 ><i class="fa-regular fa-image"></i><span>Change</span></button> 13238 </div> 13239 </template> 13240 </template> 13241 <template v-if="is_image_with_text && !image"> 13242 <div 13243 class="cs-image-slider-card" 13244 :class="[ 13245 'cs-image-slider-card-' + index, 13246 { 'active': index === display_index }, 13247 { 'cs-image-slot-placeholder-host': preview_element } 13248 ]" 13249 @click.stop="fill_image_slot(index)" 13250 > 13251 <div v-if="preview_element" class="cs-image-slot-placeholder"> 13252 <span class="cs-image-slot-placeholder-icon"><i class="fa-regular fa-image"></i></span> 13253 <span class="cs-image-slot-placeholder-label">Add image</span> 13254 </div> 13255 <div v-else class="cs-background-image-photo default-image-icon" style="background-size:30%!important;"></div> 13256 </div> 13257 </template> 13258 </template> 13259 <template v-for="(layer, layer_index) in overlay_layers" :key="'overlay-layer-' + layer_index + '-' + (layer.class_string || layer.kind || '')"> 13260 <div 13261 class="cs-image-overlay" 13262 :class="(layer.class_string || '').trim().split(/\s+/).filter(
13262Boolean)" 13263 :style="layer.style_obj" 13264 ></div> 13265 </template> 13266 </div> 13267 </div> 13268</script>
13268 13269 13270<style> 13271 /* Preview-only "Change" chip for hero-banner / image-with-text slots. */ 13272 .cs-image-slot-attached-host { 13273 cursor: pointer; 13274 } 13275 .cs-image-slot-change-chip { 13276 position: absolute; 13277 top: 12px; 13278 right: 12px; 13279 z-index: 2000; 13280 display: inline-flex; 13281 align-items: center; 13282 gap: 5px; 13283 padding: 6px 10px; 13284 border: none; 13285 border-radius: 6px; 13286 background: rgba(20, 24, 45, 0.82); 13287 color: #fff; 13288 font-family: 'Roboto', Arial, Helvetica, sans-serif; 13289 font-size: 12px; 13290 font-weight: 600; 13291 font-style: normal; 13292 line-height: 1; 13293 letter-spacing: 0; 13294 text-transform: none; 13295 cursor: pointer; 13296 opacity: 0.92; 13297 pointer-events: auto; 13298 box-shadow: 0 4px 12px rgba(8, 19, 51, 0.22); 13299 transition: opacity 0.12s ease, background 0.12s ease; 13300 } 13301 .cs-image-slot-attached-host:hover .cs-image-slot-change-chip, 13302 .cs-image-slot-change-chip:hover, 13303 .cs-image-slot-change-chip:focus { 13304 opacity: 1; 13305 background: rgba(20, 24, 45, 0.95); 13306 } 13307</style> 13308
13309<script> 13310 /* ----- COMPONENT ----- */ 13311 app_content.component('image-slider-component', { 13312 template: '#template-image-slider', 13313 data() { 13314 return { 13315 loading: true, 13316 display_index: 0, 13317 } 13318 }, 13319 props: { 13320 client_id: String, 13321 formatting: String, 13322 images_string: String, 13323 //for preview element 13324 preview_element: { 13325 type: Boolean, 13326 default: false, 13327 }, 13328 component: { 13329 type: Object, 13330 default: null, 13331 }, 13332 last_update: { 13333 type: Number, 13334 default: null, 13335 }, 13336 }, 13337 mounted() { 13338 this.loading = false; 13339 this.sync_display_index_with_config_and_images(); 13340 }, 13341 methods: { 13342 parsed_images(){ 13343 if( !this.images_string ){ 13344 return []; 13345 } 13346 try { 13347 let parsed = JSON.parse(this.images_string); 13348 return Array.isArray(parsed) ? parsed : []; 13349 } catch (error) { 13350 console.error('Error parsing image slider images: ', error); 13351 return []; 13352 } 13353 }, 13354 // image-with-text keeps an empty slot in preview for the Add-image card. 13355 // Hero has no image-layer fill â empty state is the section color_scheme. 13356 preview_images_with_slots(){ 13357 let parsed = this.parsed_images(); 13358 if( !this.preview_element || (!this.is_hero_banner && !this.is_image_with_text) ){ 13359 return parsed; 13360 } 13361 if( this.is_hero_banner ){ 13362 return parsed.filter((image) => { 13363 if( image ){ 13364 return true; 13365 } 13366 return false; 13367 }); 13368 } 13369 if( !parsed.length ){ 13370 return [null]; 13371 } 13372 return parsed; 13373 }, 13374 return_first_filled_index( images ){ 13375 if( !Array.isArray(images) || images.length === 0 ){ 13376 return 0; 13377 } 13378 for( let i = 0; i < images.length; i++ ){ 13379 if( images[i] ){ 13380 return i; 13381 } 13382 } 13383 return 0; 13384 }, 13385 sync_display_index_with_config_and_images(){ 13386 let config_latest = this.component ? (this.component.get('config_latest') || {}) : {}; 13387 let saved_display_index = Number(config_latest.display_index); 13388 let has_saved_display_index = config_latest.display_index === 0 || !isNaN(saved_display_index); 13389 let fallback_index = this.display_index; 13390 if( this.preview_element && this.is_image_with_text ){ 13391 fallback_index = this.return_first_filled_index(this.parsed_images()); 13392 } 13393 let target_index = has_saved_display_index ? saved_display_index : fallback_index; 13394 let max_index = this.filtered_images_length > 0 ? this.filtered_images_length - 1 : 0; 13395 this.display_index = Math.min(Math.max(target_index, 0), max_index); 13396 }, 13397 // Write `value` (a URL string or null) into slot `index`, preserving 13398 // the fixed 5-slot string|null shape the settings panel expects. 13399 write_image_slot( index, value ){ 13400 let component = this.component; 13401 if( !component ){ 13402 return; 13403 } 13404 let current = (component.get('config_latest') && component.get('config_latest').images) || []; 13405 let next = []; 13406 for( let i = 0; i <= 4; i++ ){ 13407 if( i === index ){ 13408 next.push( value ); 13409 } else if( current[i] ){ 13410 next.push( current[i] ); 13411 } else { 13412 next.push( null ); 13413 } 13414 } 13415 this.display_index = index; 13416 save_changes_for_component( component, 'images', next ); 13417 save_changes_for_component( component, 'display_index', index ); 13418 }, 13419 // Empty-slot picker for image-with-text / hero-banner. 13420 fill_image_slot( index ){ 13421 if( !this.preview_element ){ 13422 return; 13423 } 13424 if( typeof webbuilder_open_image_slot_picker !== 'function' ){ 13425 if( typeof displayMessage === 'function' ){ 13426 displayMessage('Image picker is not loaded. Refresh the page and try again.', true, 5000); 13427 } 13428 return; 13429 } 13430 if( !this.component ){ 13431 return; 13432 } 13433 webbuilder_open_image_slot_picker({ 13434 label: 'image', 13435 mode: 'placeholder', 13436 on_pick: ( url ) => { 13437 if( !url ){ 13438 return; 13439 } 13440 this.write_image_slot( index, url ); 13441 }, 13442 }); 13443 }, 13444 // Filled-slot "Change" action â swap or remove the image in slot 13445 // `index` via the shared picker (pick/paste/copy-external/remove), 13446 // matching custom-code's attached-asset menu. 13447 change_image_slot( index ){ 13448 if( !this.preview_element ){ 13449 return; 13450 } 13451 if( typeof webbuilder_open_image_slot_picker !== 'function' ){ 13452 if( typeof displayMessage === 'function' ){ 13453 displayMessage('Image picker is not loaded. Refresh the page and try again.', true, 5000); 13454 } 13455 return; 13456 } 13457 let component = this.component; 13458 if( !component ){ 13459 return; 13460 } 13461 let current = (component.get('config_latest') && component.get('config_latest').images) || []; 13462 let current_url = typeof current[index] === 'string' ? current[index] : ''; 13463 webbuilder_open_image_slot_picker({ 13464 label: 'image', 13465 mode: 'attached', 13466 current_url: current_url, 13467 on_pick: ( url ) => { 13468 if( !url ){ 13469 return; 13470 } 13471 this.write_image_slot( index, url ); 13472 }, 13473 on_remove: () => { 13474 this.write_image_slot( index, null ); 13475 }, 13476 }); 13477 }, 13478 }, 13479 created(){ 13480 setInterval(() => { 13481 if (!this.preview_element){ 13482 if (this.display_index < this.filtered_images_length - 1){ 13483 this.display_index++; 13484 } else { 13485 this.display_index = 0; 13486 } 13487 } 13488 }, 8000); 13489 }, 13490 computed: { 13491 component_type(){ 13492 if( this.component && typeof this.component.get === 'function' ){ 13493 return this.component.get('type'); 13494 } 13495 return null; 13496 }, 13497 is_hero_banner(){ 13498 return this.component_type === 'hero-banner'; 13499 }, 13500 is_image_with_text(){ 13501 return this.component_type === 'image-with-text'; 13502 }, 13503 formatting_obj(){ 13504 if (this.formatting){ 13505 return JSON.parse(this.formatting); 13506 } 13507 }, 13508 overlay_layers(){ 13509 let formatting = this.formatting_obj || {}; 13510 if( typeof build_web_builder_overlay_layers === 'function' ){ 13511 return build_web_builder_overlay_layers(formatting).layers || []; 13512 } 13513 if( typeof build_web_builder_overlay_attrs === 'function' ){ 13514 let attrs = build_web_builder_overlay_attrs(formatting); 13515 return attrs.has_overlay ? [{ class_string: attrs.class_string, style_obj: attrs.style_obj }] : []; 13516 } 13517 let opacity = Number(formatting.overlay_opacity) || 0; 13518 if( opacity > 0 ){ 13519 return [{ class_string: '', style_obj: { 'opacity': opacity / 100 } }]; 13520 } 13521 return []; 13522 }, 13523 filtered_images(){ 13524 if (!this.preview_element){ 13525 return this.parsed_images().filter(image => { 13526 if ( image !== null ){ 13527 return image; 13528 } 13529 }); 13530 } else { 13531 return this.preview_images_with_slots(); 13532 } 13533 return []; 13534 }, 13535 filtered_images_length(){ 13536 if (!this.preview_element){ 13537 return this.parsed_images().filter(image => { 13538 if ( image !== null ){ 13539 return image; 13540 } 13541 }).length; 13542 } else { 13543 return this.preview_images_with_slots().length; 13544 } 13545 return 0; 13546 }, 13547 }, 13548 watch: { 13549 last_update(newVal, oldVal){ 13550 if (newVal !== oldVal){ 13551 this.sync_display_index_with_config_and_images(); 13552 } 13553 }, 13554 } 13555 }); 13556</script>
13556
13556<script type="text/html" id="template-hero-banner"> 13557 <div class="cs-background-image background relative" :class="[class_string, { 'scroll-fade': !is_admin && !tab_element }]" :style="style_string"> 13558 <image-slider-component 13559 :client_id="client_id" 13560 :formatting="formatting" 13561 :images_string="images_string" 13562 :preview_element="preview_element" 13563 :component="component" 13564 :last_update="last_update" 13565 ></image-slider-component> 13566 <div 13567 class="element-wrapper" 13568 :class="'element-wrapper_' + component_type" 13569 :style="element_style_string" 13570 > 13571 <div v-memo="[last_update, last_element_update]" class="cs-element-stack"> 13572 <!-- loop through elements --> 13573 <template 13574 v-for="(element, element_index) in elements_obj" 13575 :key="element.client_id || element_index" 13576 > 13577 <template v-if="preview_element"> 13578 <preview-element-component 13579 :element="element" 13580 :element_type="element.type" 13581 :element_content="element.content" 13582 :element_column_count="element.column_count" 13583 :element_column_gap="element.column_gap" 13584 :element_index="element_index" 13585 :componentObject="component" 13586 ></preview-element-component> 13587 </template> 13588 <template v-if="!preview_element"> 13589 <element-component 13590 :element_type="element.type" 13591 :element_content="element.content" 13592 :element_column_count="element.column_count" 13593 :element_column_gap="element.column_gap" 13594 :diff_changed="element._wb_diff_changed === true" 13595 :element_index="element_index" 13596 :section_element_animation="section_element_animation" 13597 ></element-component> 13598 </template> 13599 </template> 13600 <!-- handle case where there are no elements (admin only) --> 13601 <template v-if="preview_element && elements_obj.length == 0"> 13602 <div class="no-elements-wrap"> 13603 <div 13604 class="tapped-insert-new-element-relative" 13605 @click="tapped_add_element( $event )" 13606 >+</div> 13607 </div> 13608 </template> 13609 </div> 13610 </div> 13611 </div> 13612</script>
13612 13613
13614<script> 13615 app_content.component('hero-banner-component', { 13616 template: '#template-hero-banner', 13617 data() { 13618 return { 13619 is_admin: false, 13620 loading: true, 13621 } 13622 }, 13623 props: { 13624 class_string: String, 13625 style_string: String, 13626 element_style_string: String, 13627 component_type: String, 13628 elements_string: [String, Object], 13629 //image slider 13630 client_id: String, 13631 formatting: String, 13632 images_string: String, 13633 //admin 13634 preview_element: Boolean, 13635 component: Object, 13636 last_update: Number, //last component update 13637 last_element_update: Number, //last element update 13638 tab_element: Boolean, 13639 }, 13640 mounted() { 13641 this.loading = false; 13642 this.is_admin = window.location.href.indexOf('website-builder') !== -1; 13643 }, 13644 methods:{ 13645 tapped_add_element(){ 13646 var params = {}; 13647 params["component"] = this.component; 13648 params["insert_after"] = 0; 13649 params["column_index"] = 0; 13650 handle_add_element( params ); 13651 }, 13652 parsed_hero_images(){ 13653 if( !this.images_string ){ 13654 return []; 13655 } 13656 try { 13657 let parsed = JSON.parse(this.images_string); 13658 return Array.isArray(parsed) ? parsed : []; 13659 } catch (error) { 13660 console.error('Error parsing hero banner images: ', error); 13661 return []; 13662 } 13663 }, 13664 // Prefer the first filled slot so multi-image heroes edit the visible one. 13665 hero_image_slot_index(){ 13666 let images = this.parsed_hero_images(); 13667 for( let i = 0; i < images.length; i++ ){ 13668 if( images[i] ){ 13669 return i; 13670 } 13671 } 13672 return 0; 13673 }, 13674 write_hero_image_slot( index, value ){ 13675 let component = this.component; 13676 if( !component ){ 13677 return; 13678 } 13679 let current = (component.get('config_latest') && component.get('config_latest').images) || []; 13680 let next = []; 13681 for( let i = 0; i <= 4; i++ ){ 13682 if( i === index ){ 13683 next.push( value ); 13684 } else if( current[i] ){ 13685 next.push( current[i] ); 13686 } else { 13687 next.push( null ); 13688 } 13689 } 13690 save_changes_for_component( component, 'images', next ); 13691 }, 13692 tapped_change_hero_image(){ 13693 if( !this.preview_element ){ 13694 return; 13695 } 13696 if( typeof webbuilder_open_image_slot_picker !== 'function' ){ 13697 if( typeof displayMessage === 'function' ){ 13698 displayMessage('Image picker is not loaded. Refresh the page and try again.', true, 5000); 13699 } 13700 return; 13701 } 13702 if( !this.component ){ 13703 return; 13704 } 13705 let index = this.hero_image_slot_index(); 13706 let current = (this.component.get('config_latest') && this.component.get('config_latest').images) || []; 13707 let current_url = typeof current[index] === 'string' ? current[index] : ''; 13708 if( current_url ){ 13709 webbuilder_open_image_slot_picker({ 13710 label: 'image', 13711 mode: 'attached', 13712 current_url: current_url, 13713 on_pick: ( url ) => { 13714 if( !url ){ 13715 return; 13716 } 13717 this.write_hero_image_slot( index, url ); 13718 }, 13719 on_remove: () => { 13720 this.write_hero_image_slot( index, null ); 13721 }, 13722 }); 13723 return; 13724 } 13725 webbuilder_open_image_slot_picker({ 13726 label: 'image', 13727 mode: 'placeholder', 13728 on_pick: ( url ) => { 13729 if( !url ){ 13730 return; 13731 } 13732 this.write_hero_image_slot( index, url ); 13733 }, 13734 }); 13735 }, 13736 }, 13737 computed: { 13738 elements_obj(){ 13739 if (this.elements_string && typeof this.elements_string === 'string'){ 13740 return JSON.parse(unescape(decodeURIComponent(this.elements_string))) 13741 } else { 13742 return this.elements_string 13743 } 13744 return []; 13745 }, 13746 formatting_obj(){ 13747 if( this.formatting && typeof this.formatting === 'string' ){ 13748 try { 13749 return JSON.parse(this.formatting); 13750 } catch (error) { 13751 return {}; 13752 } 13753 } 13754 if( this.formatting && typeof this.formatting === 'object' ){ 13755 return this.formatting; 13756 } 13757 return {}; 13758 }, 13759 section_element_animation(){ 13760 let component_animation = resolve_web_builder_component_animation(this.formatting_obj); 13761 return section_element_animation_token(component_animation); 13762 }, 13763 }, 13764 }); 13765</script>
13765
13765<script type="text/html" id="template-element"> 13766 <template v-if="element_type === 'accordion_header'"> 13767 <div 13768 class="cs-accordion-header" 13769 :class="[element_anim_class, { 'wb-diff-element-changed': diff_changed }]" 13770 :style="element_anim_inline_style" 13771 > 13772 <div class="cs-accordion-header-toggle-wrap flexWrap"> 13773 <div class="cs-accordion-header-toggle">+</div> 13774 </div> 13775 <span v-html="element_content"></span> 13776 </div> 13777 </template> 13778 <template v-if="element_type === 'accordion_body'"> 13779 <div 13780 class="cs-accordion-body" 13781 :class="[element_anim_class, { 'wb-diff-element-changed': diff_changed }]" 13782 v-html="element_content" 13783 :style="element_body_inline_style" 13784 ></div> 13785 </template> 13786 <template v-else-if="element_type !== 'accordion_header' && element_type !== 'accordion_body'"> 13787 <div 13788 :class="[element_anim_class, { 'wb-diff-element-changed': diff_changed }]" 13789 :style="element_body_inline_style" 13790 > 13791 <span v-html="element_content"></span> 13792 </div> 13793 </template> 13794</script>
13794 13795
13796<script> 13797 app_content.component('element-component', { 13798 template: '#template-element', 13799 data() { 13800 return { 13801 loading: true, 13802 } 13803 }, 13804 props: { 13805 element_type: String, 13806 element_content: String, 13807 element_column_count: Number, 13808 element_column_gap: Number, 13809 diff_changed: Boolean, 13810 element_index: { 13811 type: [Number, String], 13812 default: 0, 13813 }, 13814 section_element_animation: { 13815 type: String, 13816 default: '', 13817 }, 13818 }, 13819 mounted() { 13820 this.loading = false; 13821 }, 13822 computed: { 13823 uses_section_element_animation(){ 13824 return this.section_element_animation === 'up' 13825 || this.section_element_animation === 'fade_up' 13826 || this.section_element_animation === 'fade_in'; 13827 }, 13828 element_anim_class(){ 13829 if( this.uses_section_element_animation ){ 13830 return 'cs-el-anim-target'; 13831 } 13832 return ''; 13833 }, 13834 el_index_style_fragment(){ 13835 if( this.uses_section_element_animation ){ 13836 return '--el-index:' + ( Number(this.element_index) || 0 ) + ';'; 13837 } 13838 return ''; 13839 }, 13840 element_anim_inline_style(){ 13841 return this.el_index_style_fragment; 13842 }, 13843 element_body_inline_style(){ 13844 return 'column-count: ' + this.element_column_count + ' !important; column-gap: ' + this.element_column_gap + 'em;' + this.el_index_style_fragment; 13845 }, 13846 }, 13847 }); 13848</script>
13848 13849
13849<script type="text/html" id="template-unlayer-component"> 13850 <template v-if="preview_element"> 13851 <div class="cs-unlayer-component-wrap unlayer-content" :class="class_string"> 13852 <!-- hover button to open unlayer builder --> 13853 <div class="darken-overlay easeFast"> 13854 <div class="hover-toolbar"> 13855 <div class="hover-button flexNoWrap centeredBlock pointer" @click="openUnlayerBuilder()"> 13856 <div class="hover-button-text"> 13857 Update <i class="fa-regular fa-pen-to-square" style="padding-left:3px;"></i> 13858 </div> 13859 </div> 13860 </div> 13861 </div> 13862 <!-- if there is content --> 13863 <div v-if="!loading && unlayerHtml" class="relative" > 13864 <div 13865 :id="'freeform_id_' + component.id" 13866 v-html="return_html_with_css()" 13867 ></div> 13868 </div> 13869 <!-- if there is no content --> 13870 <div v-else-if="!loading"> 13871 <div class="addFreeformContentZone" @click="openUnlayerBuilder()"> 13872 <div 13873 class="quick-action-wrap noMaxWidth centeredBlock" 13874 > 13875 <div class="quick-action-icon-wrap"> 13876 <i class="fa-light fa-scribble"></i> 13877 </div> 13878 <p class="quick-action-title" style="font-family:roboto;">Freeform section</p> 13879 </div> 13880 </div> 13881 </div> 13882 </div> 13883 </template> 13884 <template v-if="!preview_element"> 13885 <div class="cs-unlayer-component-wrap"> 13886 <div class="relative"></div> 13887 <div v-if="!loading" v-html="return_html_with_css()"></div> 13888 </div> 13889 </template> 13890</script>
13890 13891
13892<script> 13893 13894 function createDebounce() { 13895 let timeout = null; 13896 return function (fnc, delayMs) { 13897 clearTimeout(timeout); 13898 timeout = setTimeout(() => { 13899 fnc(); 13900 }, delayMs || 800); 13901 }; 13902 } 13903 app_content.component('unlayer-component', { 13904 template: '#template-unlayer-component', 13905 data() { 13906 return { 13907 unlayerHtml: null, 13908 loading: true, 13909 debounce: createDebounce(), 13910 template: null, 13911 unlayer: '', 13912 } 13913 }, 13914 props: { 13915 unlayer_chunks: Object, 13916 class_string: String, 13917 preview_element: { 13918 type: Boolean, 13919 default: false, 13920 }, 13921 component: { 13922 type: Object, 13923 default: null, 13924 }, 13925 }, 13926 mounted() { 13927 if(!this.unlayer_chunks){ 13928 // find template for component id 13929 this.return_unlayer_data() 13930 } 13931 this.loading = false; 13932 }, 13933 methods: { 13934 return_unlayer_data(){ 13935 let config_latest = this.component.get('config_latest') || {}; 13936 if(!config_latest.unlayer_component || !config_latest.unlayer_component.chunks){ 13937 return; 13938 } 13939 this.unlayerHtml = config_latest.unlayer_component.chunks.body || ''; 13940 this.unlayerHtml = this.unlayerHtml.replace('style="min-height: 100vh;', 'style="min-height: auto;'); 13941 }, 13942 openUnlayerBuilder(){ 13943 let jsonContent; 13944 let config_latest = this.component.get('config_latest') || {}; 13945 if(config_latest.unlayer_component){ 13946 jsonContent = config_latest.unlayer_component.design; 13947 } 13948 let params = { 13949 jsonContent: jsonContent, 13950 fn: this.saveUnlayerContent 13951 } 13952 launch_unlayer_builder(params) 13953 }, 13954 saveUnlayerContent(content){ 13955 let promise = Promise.resolve().then(() => { 13956 let htmlContent = content && content.chunks ? (content.chunks.body || '') : ''; 13957 htmlContent = htmlContent.replace('style="min-height: 100vh;', 'style="min-height: auto;'); 13958 this.unlayerHtml = htmlContent; 13959 save_changes_for_component( this.component, 'unlayer_component', content ); 13960 }) 13961 return promise; 13962 }, 13963 return_html_with_css(){ 13964 let data; 13965 if(this.preview_element){ 13966 let config_latest = this.component.get('config_latest') || {}; 13967 if(!config_latest.unlayer_component || !config_latest.unlayer_component.chunks){ 13968 return ''; 13969 } 13970 data = config_latest.unlayer_component.chunks; 13971 }else{ 13972 data = this.unlayer_chunks; 13973 } 13974 if(!data){ 13975 return ''; 13976 } 13977 let css = data.css || ''; 13978 let body = data.body || ''; 13979 let html_with_css = juice.inlineContent(body, css); 13980 html_with_css = html_with_css.replace('100vh', 'auto') 13981 return html_with_css 13982 } 13983 }, 13984 computed: { 13985 13986 } 13987 }); 13988</script>
13988 13989 13990<style scoped> 13991 13992 @media screen and (max-width: 768px) { 13993 .u-row:not(.no-stack) .u-col:not(:only-child) { 13994 flex: 0 0 50% !important; 13995 max-width: 50% !important; 13996 } 13997 } 13998 @media screen and (max-width: 480px) { 13999 .u-col { 14000 flex: 0 0 100% !important; /* One column per row */ 14001 max-width: 100% !important; 14002 } 14003 } 14004 14005</style>
14005<script type="text/html" id="template-form"> 14006 <div class="form-wrapper centeredBlock centeredText"> 14007 <template v-if="!loading && is_admin"> 14008 <div 14009 v-if="!form_id" 14010 @click="link_form()" 14011 class="noObjectsMSG centeredBlock centeredText" 14012 style="padding: 5em;" 14013 > 14014 <p style="cursor: pointer;"><i class="fa-light fa-list-dropdown smallMarginRight"></i>Click to link a form</p> 14015 </div> 14016 </template> 14017 <template v-if="!loading"> 14018 <div 14019 v-if="form_id && json_form" 14020 id="form-template" 14021 class="cs-min-section-width" 14022 :style="{ 'max-width': formatting_obj.section_width + '%' }" 14023 > 14024 <div 14025 class="form-color-theme" 14026 :style="{ 'background-color': color_theme }"> 14027 </div> 14028 <div class="form-header-wrapper"> 14029 <!-- name --> 14030 <div v-if="json_form && json_form.name" class="form-name flex-row-start"> 14031 <h3 class="default-font-css" style="font-size: 32px; padding: 5px; text-align: left;">{{ json_form.name }}</h3> 14032 </div> 14033 <!-- description --> 14034 <div v-if="json_form && json_form.description" class="form-description flex-row-start tinyMarginTop"> 14035 <p class="default-font-css" style="padding: 5px; text-align: left;">{{ json_form.description }}</p> 14036 </div> 14037 <!-- disabled notice --> 14038 <div v-if="form_disabled" class="form-description flex-row-start tinyMarginTop"> 14039 <p class="extra-copy-v2" style="padding: 5px; text-align: left;">This form is currently disabled and not accepting responses.</p> 14040 </div> 14041 <template v-if="!valid_membership"> 14042 <div 14043 @click="tapped_log_in()" 14044 class="noObjectsMSG leftText" 14045 style="padding: 5px; min-width: fit-content;" 14046 > 14047 <p style="cursor: pointer;"><i class="fa-light fa-circle-exclamation smallMarginRight"></i>This form is restricted to members only. Please <a :href="login_url">click here to log in</a> and view this form.</p> 14048 </div> 14049 </template> 14050 </div> 14051 14052 <template v-if="valid_membership && !pending_redirect_url"> 14053 14054 <!-- default fields --> 14055 <template v-if="collect_default_fields && selected_fields.length > 0"> 14056 <default-field-component-wb 14057 :form="json_form" 14058 :is_admin="is_admin" 14059 :default_fields="selected_fields" 14060 :rerender_key="rerender_key" 14061 ></default-field-component-wb> 14062 </template> 14063 <!-- custom fields --> 14064 <template v-if="json_form.custom_fields" v-for="(custom_field, index) in custom_fields"> 14065 <custom-field-component-wb 14066 :id="custom_field.id" 14067 :form="json_form" 14068 :is_admin="is_admin" 14069 :custom_field="custom_field" 14070 :last_update="last_update" 14071 ref="custom_field_components" 14072 @field-updated="handle_field_update" 14073 ></custom-field-component-wb> 14074 </template> 14075 <!-- submit --> 14076 <div style="width: 100%; display: flex; justify-content: space-between; align-items: center;" class="tinyMarginTop"> 14077 <button 14078 v-if="!loading_submission" 14079 @click="tapped_submit_form()" 14080 :style="{ 'background-color': button_disabled ? 'var(--custom-field-grey-2)' : color_theme }" 14081 :class="[ 'default-font-css', 'submit-button', { 'inactive': button_disabled } ]" 14082 :disabled="is_admin" 14083 >Submit</button> 14084 <i 14085 v-if="loading_submission" 14086 class="fa-sharp-duotone fa-solid fa-spinner-third fa-spin smallMarginLeft" 14087 :style="{ 'color': color_theme, 'font-size': '1.2em', 'animation-duration': '1.2s' }" 14088 ></i> 14089 <p 14090 @click="!loading_submission && tapped_clear_form()" 14091 :style="{ 'color': loading_submission ? 'var(--custom-field-grey-2)': color_theme }" 14092 style="font-size: 14px; font-weight: 600; cursor: pointer;" 14093 >Clear form</p> 14094 </div> 14095 </template> 14096 <template v-if="valid_membership && pending_redirect_url"> 14097 <div class="centeredText" style="padding: 2em 5px;"> 14098 <i 14099 class="fa-solid fa-circle-check solid-circle-check" 14100 style="font-size: 48px;" 14101 ></i> 14102 <h3 class="default-font-css smallMarginTop">Response submitted</h3> 14103 <p class="extra-copy-v2 tinyMarginTop">Redirecting...</p> 14104 </div> 14105 </template> 14106 </div> 14107 <div 14108 v-if="form_id && !json_form" 14109 class="noObjectsMSG centeredBlock centeredText" 14110 style="padding: 5em;" 14111 > 14112 <p style="cursor: pointer;"><i class="fa-light fa-list-dropdown smallMarginRight"></i>No form found.</p> 14113 </div> 14114 </template> 14115 </div> 14116</script>
14116 14117 14118<style> 14119 14120 #form-template{ 14121 display: flex; 14122 flex-direction: column; 14123 align-items: center; 14124 justify-content: flex-start; 14125 margin: 0.5em 0.5em 1em 0.5em; 14126 padding: 0 0 1.5em 0; 14127 width: 100%; 14128 max-width: 700px; 14129 height: 100%; 14130 } 14131 #form-template .form-color-theme{ 14132 color: #fff; 14133 height: 10px; 14134 border-top-left-radius: 8px; 14135 border-top-right-radius: 8px; 14136 width: 100%; 14137 padding: 0 1.5em 0 1.5em; 14138 box-sizing: border-box; 14139 } 14140 #form-template .default-font-css{ 14141 font-family: Roboto, Arial, Helvetica, sans-serif; 14142 font-size: 16px; 14143 font-weight: 400; 14144 } 14145 #form-template .flex-row-start{ 14146 display: flex; 14147 flex-direction: row; 14148 align-items: center; 14149 justify-content: flex-start; 14150 } 14151 #form-template .submit-button{ 14152 color: #fff; 14153 border-radius: 6px; 14154 font-size: 14px; 14155 padding: 8px 24px; 14156 cursor: pointer; 14157 border: none; 14158 } 14159 #form-template .submit-button.inactive{ 14160 color: #fff; 14161 border-radius: 6px; 14162 font-size: 14px; 14163 padding: 8px 24px; 14164 cursor: auto; 14165 border: none; 14166 } 14167 .form-wrapper{ 14168 display: flex; 14169 align-items: center; 14170 justify-content: center; 14171 height: fit-content; 14172 width: 100%; 14173 } 14174 .form-header-wrapper{ 14175 border-width: 0px 1px 1px 1px; 14176 border-color: rgba(195, 195, 195, 0.7); 14177 border-style: solid; 14178 width: 100%; 14179 border-bottom-left-radius: 8px; 14180 border-bottom-right-radius: 8px; 14181 padding: 1.45em; 14182 box-sizing: border-box; 14183 } 14184 .form-name{ 14185 color: rgb(32, 33, 36); 14186 box-sizing: border-box; 14187 font-weight: 400; 14188 font-size: 32px; 14189 width: 100%; 14190 min-height: 21px; 14191 border-bottom: 1.5px solid transparent; 14192 transition: border-bottom 0.1s; 14193 } 14194 .form-description{ 14195 color: rgb(32, 33, 36); 14196 box-sizing: border-box; 14197 font-weight: 400; 14198 font-size: 14px; 14199 width: 100%; 14200 line-height: 1.5; 14201 letter-spacing: 0; 14202 min-height: 21px; 14203 border-bottom: 1.5px solid transparent; 14204 transition: border-bottom 0.1s; 14205 } 14206 .form-name.input-is-focused, .form-description.input-is-focused{ 14207 border-bottom: 1.5px solid rgba(195, 195, 195, 0.7); 14208 } 14209</style> 14210
14211<script> 14212 app_content.component("form-component", { 14213 template: "#template-form", 14214 data() { 14215 return { 14216 global: store, 14217 loading: false, 14218 loading_submission: false, 14219 json_form: null, 14220 invalid_membership: false, 14221 selected_fields: [ 14222 { 14223 label: 'First name', 14224 value: 'firstName', 14225 selected: false, 14226 input_value: null, 14227 }, 14228 { 14229 label: 'Last name', 14230 value: 'lastName', 14231 selected: false, 14232 input_value: null, 14233 }, 14234 { 14235 label: 'Email', 14236 value: 'email', 14237 selected: false, 14238 input_value: null, 14239 }, 14240 { 14241 label: 'Mobile', 14242 value: 'mobile', 14243 selected: false, 14244 input_value: null, 14245 }, 14246 ], 14247 login_url: null, 14248 pending_redirect_url: null, 14249 redirect_timeout: null, 14250 }; 14251 14252 }, 14253 props: { 14254 client_id: String, 14255 is_admin: Boolean, 14256 component: Object, 14257 form: Object, 14258 formatting: String, 14259 last_update: String, 14260 }, 14261 mounted() { 14262 this.load_forms( this.client_id ); 14263 }, 14264 beforeUnmount() { 14265 if (this.redirect_timeout) { 14266 clearTimeout(this.redirect_timeout); 14267 } 14268 }, 14269 methods: { 14270 load_forms( client_id, overrides = {} ){ 14271 this.loading = true; 14272 var promise = Parse.Promise.as(); 14273 var thisPromise = promise.then(() => { 14274 var params = {}; 14275 params["client_id"] = client_id; 14276 params["form_id"] = this.form_id; 14277 params["overrides"] = overrides; 14278 params["request_url"] = window.location.href; 14279 return Parse.Cloud.run('retrieve_data_for_component', params); 14280 }).then(retrieved_form => { 14281 this.json_form = retrieved_form; 14282 if(this.json_form.members_only){ 14283 //check if theres a logged in member and redirect them if needed 14284 return this.check_valid_membership().then(is_member => { 14285 if(is_member){ 14286 this.invalid_membership = false; 14287 this.set_default_fields(); 14288 if ( this.is_admin ){ 14289 return Parse.Promise.as(); 14290 } 14291 return this.prefill_member_fields(); 14292 } 14293 }).catch(error => { 14294 if(error === "invalid_membership"){ 14295 this.invalid_membership = true; 14296 this.loading = false; 14297 this.set_login_url(); 14298 // return the error 14299 return Parse.Promise.error("invalid_membership"); 14300 } 14301 }); 14302 }else{ 14303 this.set_default_fields(); 14304 return Parse.Promise.as(); 14305 } 14306 14307 }).then(() => { 14308 this.loading = false; 14309 }, error => { 14310 checkParseError(error); 14311 this.loading = false; 14312 }); 14313 return thisPromise; 14314 }, 14315 prefill_member_fields(){ 14316 let query = new Parse.Query(Parse.User); 14317 query.include('memberObject'); 14318 return query.get(Parse.User.current().id).then(fullUser => { 14319 let memberObject = fullUser.get('memberObject'); 14320 if ( !memberObject ){ 14321 return Promise.resolve(); 14322 } 14323 let member_data = { 14324 firstName: memberObject.get('firstName') || null, 14325 lastName: memberObject.get('lastName') || null, 14326 email: memberObject.get('email') || null, 14327 mobile: memberObject.get('mobile') || null, 14328 };
14329 this.selected_fields.flat().forEach(field => { 14330 if ( member_data[field.value] ){ 14331 field.input_value = member_data[field.value]; 14332 } 14333 }); 14334 return Promise.resolve(); 14335 }); 14336 }, 14337 check_valid_membership(){ 14338 var club_id = $('body').data('clubId'); 14339 var user = Parse.User.current(); 14340 14341 var memberObject; 14342 var promise = Parse.Promise.as(); 14343 var thisPromise = promise.then(() => { 14344 if( user && user.get('memberObject') ){ 14345 //ok, get the member and membership 14346 var query = new Parse.Query("members"); 14347 query.select('membershipObject.status'); 14348 query.select('membershipObject.memberNumber'); 14349 query.select('membershipObject.tierObject'); 14350 query.select('clubObject.web_settings.custom_domain'); 14351 query.include('membershipObject'); 14352 query.include('clubObject.web_settings'); 14353 return query.get( user.get('memberObject').id ); 14354 }else{ 14355 //redirect to login 14356 return Promise.reject("invalid_membership"); 14357 } 14358 }).then(( retrievedMember )=>{ 14359 if( retrievedMember && retrievedMember.get('clubObject')?.id == club_id ){ 14360 //ok, they're legit 14361 memberObject = retrievedMember; 14362 } 14363 14364 if( ! memberObject ){ 14365 //redirect to login 14366 return Promise.reject("invalid_membership"); 14367 } 14368 //check membership 14369 let membershipObject = memberObject.get('membershipObject'); 14370 if( ! membershipObject ){ 14371 return Promise.reject("invalid_membership"); 14372 } 14373 if( membershipObject.get('status') == "canceled" ){ 14374 return Promise.reject("invalid_membership"); 14375 } 14376 var valid_membership = true; 14377 //ok, they're a legit member 14378 return Parse.Promise.as(valid_membership); 14379 }); 14380 return thisPromise; 14381 }, 14382 set_login_url(){ 14383 const clubId = $('body').data('clubId'); // or wherever you're storing it 14384 const query = new Parse.Query("clubs"); 14385 query.include("web_settings"); 14386 query.get(clubId).then(club => { 14387 const webSettings = club.get('web_settings'); 14388 const customDomain = webSettings?.get('custom_domain'); 14389 const currentUrl = window.location.href; 14390 14391 let redirectBase = customDomain 14392 ? `https://${customDomain}` 14393 : "https://theclubspot.com"; 14394 14395 const loginUrl = `${redirectBase}/login?next=${encodeURIComponent(currentUrl)}&msg=${encodeURIComponent("Please log in to view and complete this form.")}`; 14396 this.login_url = loginUrl; 14397 }); 14398 14399 14400 }, 14401 tapped_clear_form(){ 14402 if ( this.collect_default_fields && this.selected_fields.length > 0 ){ 14403 this.selected_fields.flat().forEach(default_field => { 14404 default_field.input_value = null; 14405 }) 14406 } 14407 if ( this.custom_fields && this.$refs.custom_field_components ){ 14408 this.$refs.custom_field_components.forEach(ref => { 14409 ref.clear_field(); 14410 }); 14411 } 14412 }, 14413 maybe_redirect_after_submit(redirect_url){ 14414 if (!redirect_url) { 14415 return false;
14416 } 14417 this.pending_redirect_url = redirect_url; 14418 this.redirect_timeout = setTimeout(() => { 14419 window.location.href = redirect_url; 14420 }, 2000); 14421 return true; 14422 }, 14423 tapped_submit_form(){ 14424 if ( this.form_disabled ){ 14425 return displayMessage("This form is no longer accepting submissions.", true, 4000); 14426 } 14427 if ( this.button_disabled ){ 14428 return displayMessage("Required fields are missing.", true, 4000); 14429 } 14430 let club_id = $('body').data('clubId'); 14431 if ( !club_id || !this.form_id ){ 14432 return displayMessage("There was an issue submitting your form. Please try again, or contact support if the problem persists.", true, 4000); 14433 } 14434 this.loading_submission = true; 14435 if ( this.custom_fields && this.custom_fields.length > 0 ){ 14436 for ( const custom_field of this.custom_fields ){ 14437 if ( custom_field.type === 'number' ){ 14438 if ( custom_field.input_value !== null && custom_field.input_value !== undefined && custom_field.input_value !== '' ){ 14439 if ( isNaN(Number(custom_field.input_value)) ){ 14440 this.loading_submission = false; 14441 displayMessage("Please enter a valid number.", true, 4000); 14442 return; 14443 } 14444 } 14445 } 14446 } 14447 } 14448 let club_timezone = store.clubObject && store.clubObject.get ? (store.clubObject.get('timezone') || 'UTC') : 'UTC'; 14449 return this.prepare_custom_fields_for_submit(this.custom_fields || []).then(preparedCustomFields => { 14450 let params = { 14451 club_id: club_id, 14452 form_id: this.form_id, 14453 selected_fields: this.selected_fields, 14454 collect_default_fields: this.collect_default_fields, 14455 custom_fields: preparedCustomFields, 14456 club_timezone: club_timezone 14457 }; 14458 return Parse.Cloud.run('submit_form_response', params); 14459 }).then((result) => { 14460 this.loading_submission = false; 14461 if (this.maybe_redirect_after_submit(result && result.redirect_url)) { 14462 return; 14463 } 14464 displayMessage('Your response has been successfully submitted', false, 6000); 14465 this.tapped_clear_form(); 14466 }).catch(error => { 14467 this.loading_submission = false; 14468 checkParseError(error); 14469 displayMessage(error.message || error, true, 4000); 14470 }); 14471 }, 14472 prepare_custom_fields_for_submit(custom_fields){ 14473 let promise = Promise.resolve(); 14474 let thisPromise = promise.then(() => { 14475 if( !custom_fields || custom_fields.length === 0 ){ 14476 return Promise.resolve([]); 14477 } 14478 let preparedCustomFields = []; 14479 let promises = []; 14480 for (let index = 0; index < custom_fields.length; index++) { 14481 let methodToRun = (customField, index) => { 14482 let innerPromise = promise.then(() => { 14483 let preparedCustomField = { 14484 id: customField.id, 14485 name: customField.name, 14486 type: customField.type, 14487 required: customField.required, 14488 input_value: customField.input_value, 14489 custom_field_options: customField.custom_field_options || [], 14490 order: index, 14491 }; 14492 if( customField.type !== 'file_upload' ){ 14493 preparedCustomFields.push(preparedCustomField); 14494 return Promise.resolve(); 14495 } 14496 preparedCustomField.input_value = ''; 14497 if( customField.document_id ){ 14498 preparedCustomField.document_id = customField.document_id; 14499 preparedCustomFields.push(preparedCustomField); 14500 return Promise.resolve(); 14501 } 14502 let uploadFile = this.return_upload_file_from_custom_field(customField); 14503 if( !uploadFile || !uploadFile.name ){ 14504 preparedCustomFields.push(preparedCustomField); 14505 return Promise.resolve(); 14506 } 14507 let uploadParams = { 14508 file: uploadFile, 14509 fileName: uploadFile.name 14510 }; 14511 return upload_private_file_v2(uploadParams).then(uploadResult => { 14512 preparedCustomField.uploaded_file_url = uploadResult.url; 14513 preparedCustomField.uploaded_file_key = uploadResult.file_key; 14514 preparedCustomField.uploaded_file_name = uploadFile.name; 14515 preparedCustomFields.push(preparedCustomField); 14516 return Promise.resolve(); 14517 }); 14518 }); 14519 promises.push(innerPromise); 14520 }; 14521 methodToRun(custom_fields[index], index); 14522 } 14523 return Promise.all(promises).then(() => { 14524 return Promise.resolve(preparedCustomFields); 14525 }); 14526 }); 14527 return thisPromise; 14528 }, 14529 return_upload_file_from_custom_field(customField){ 14530 if( !customField ){ 14531 return null; 14532 } 14533 if( customField.input_value && customField.input_value.name ){ 14534 return customField.input_value; 14535 } 14536 if( Array.isArray(customField.input_value) && customField.input_value.length > 0 ){ 14537 let firstFile = customField.input_value[0]; 14538 if( firstFile && firstFile.name ){ 14539 return firstFile; 14540 } 14541 } 14542 if( customField.input_value && customField.input_value.file && customField.input_value.file.name ){ 14543 return customField.input_value.file; 14544 } 14545 if( customField.input_value && customField.input_value.value && customField.input_value.value.name ){ 14546 return customField.input_value.value; 14547 } 14548 return null; 14549 }, 14550 returnIsVideo(filename) { 14551 var ext = this.getExtension(filename); 14552 switch (ext.toLowerCase()) { 14553 case 'm4v': 14554 case 'avi': 14555 case 'mpg': 14556 case 'mp4': 14557 case 'mp3': 14558 case 'mov': 14559 // etc 14560 return true; 14561 } 14562 return false;
14563 }, 14564 getExtension(filename) { 14565 var parts = filename.split('.'); 14566 return parts[parts.length - 1]; 14567 }, 14568 load_clean_image( file, type, newFileName ){ 14569 return new Promise((resolve, reject) => { 14570 setTimeout(() => { 14571 loadImage(file, canvas => { 14572 if (canvas) { 14573 this.clean_image_callback(canvas, type, newFileName, file) 14574 .then(url => resolve(url)) 14575 .catch(err => reject(err)); 14576 } else { 14577 reject(new Error('Failed to load image')); 14578 } 14579 }, 14580 { orientation: 1, canvas: true } 14581 ); 14582 }, 500); 14583 }); 14584 }, 14585 clean_image_callback( canvas, type, newFileName, file ){ 14586 return new Promise((resolve, reject) => { 14587 if (canvas) { 14588 var url = canvas.toDataURL(type); 14589 canvas.toBlob(blob => { 14590 uploadFileToAWSV2( blob, newFileName ) 14591 .then(uploadUrl => resolve(uploadUrl)) 14592 .catch(err => reject(err)); 14593 }, type); 14594 } else { 14595 reject(new Error('canvas is null')); 14596 } 14597 }); 14598 }, 14599 link_form(){ 14600 store.components.link_form.component = this.component; 14601 store.overlays.link_form = true; 14602 }, 14603 set_default_fields(){ 14604 if ( this.json_form && this.default_fields.length > 0 ){ 14605 this.selected_fields = this.selected_fields.map(default_field => { 14606 if ( this.default_fields.indexOf(default_field.value ) !== -1 ){ 14607 return { 14608 ...default_field, 14609 selected: true, 14610 } 14611 } else { 14612 return { 14613 ...default_field, 14614 selected: false, 14615 } 14616 } 14617 }); 14618 } 14619 if ( this.default_configuration === 'list' ){ 14620 //list 14621 this.selected_fields = this.selected_fields.filter(field => field.selected); 14622 } else if ( this.default_configuration === 'grid' ){ 14623 //grid 14624 this.selected_fields = this.selected_fields.filter(field => field.selected).reduce((acc, field) => { 14625 if ( field.value === 'firstName' || field.value === 'lastName' ){ 14626 acc[0].push(field); 14627 } else { 14628 acc[1].push(field); 14629 } 14630 return acc; 14631 }, [[], []]) 14632 } 14633 }, 14634 handle_field_update({ id, value }){ 14635 const field = this.custom_fields.find(f => f.id === id); 14636 if (field) { 14637 field.input_value = value; 14638 } 14639 }, 14640 }, 14641 computed: { 14642 form_id(){ 14643 if ( typeof( this.form ) === 'string' ){ 14644 return JSON.parse(this.form).id; 14645 } 14646 return this.form.id || null; 14647 }, 14648 color_theme(){ 14649 if ( this.json_form && this.json_form.color_theme ){ 14650 return this.json_form.color_theme; 14651 } 14652 return '#5280ff'; 14653 }, 14654 custom_fields(){ 14655 if ( this.json_form && this.json_form.custom_fields ){ 14656 return this.json_form.custom_fields; 14657 } 14658 return []; 14659 }, 14660 default_fields(){ 14661 if ( this.json_form && this.json_form.default_fields ){ 14662 return this.json_form.default_fields; 14663 } 14664 return []; 14665 }, 14666 collect_default_fields(){ 14667 if ( this.json_form && this.json_form.collect_default_fields ){ 14668 return this.json_form.collect_default_fields; 14669 } 14670 return false;
14671 }, 14672 default_configuration(){ 14673 if ( this.json_form && this.json_form.default_configuration ){ 14674 return this.json_form.default_configuration; 14675 } 14676 return 'list'; 14677 }, 14678 missing_default_field(){ 14679 if ( this.json_form && this.collect_default_fields && this.selected_fields.length > 0 ){ 14680 let default_values = this.default_configuration === 'list' 14681 ? this.selected_fields.map(field => field.input_value) 14682 : this.selected_fields.flatMap(arr => arr.map(field => field.input_value)); 14683 return default_values.includes(null) || default_values.includes(''); 14684 } 14685 return false; 14686 }, 14687 missing_custom_field(){ 14688 if ( this.json_form && this.custom_fields ){ 14689 for ( let i = 0; i < this.custom_fields.length; i++ ){ 14690 let custom_field = this.custom_fields[i]; 14691 let option_values = custom_field.custom_field_options && custom_field.custom_field_options.length > 0 14692 ? custom_field.custom_field_options.map(option => option.selected) 14693 : []; 14694 if ( custom_field.required && ( !custom_field.input_value && option_values.indexOf(true) === -1 ) ){ 14695 return true; 14696 } 14697 } 14698 return false; 14699 } 14700 }, 14701 form_disabled(){ 14702 if ( this.json_form && this.json_form.disabled ){ 14703 return true; 14704 } 14705 return false; 14706 }, 14707 button_disabled(){ 14708 if ( this.is_admin ){ 14709 return true; 14710 }else{ 14711 return this.form_disabled || this.missing_custom_field || this.missing_default_field; 14712 } 14713 }, 14714 formatting_obj(){ 14715 if ( this.formatting ){ 14716 return JSON.parse(this.formatting); 14717 } 14718 return; 14719 }, 14720 valid_membership(){ 14721 if(this.json_form && this.json_form.members_only){ 14722 return !this.invalid_membership; 14723 } 14724 //not members only, doesn't matter 14725 return true; 14726 }, 14727 }, 14728 watch: { 14729 form_id(newVal, oldVal){ 14730 if (newVal !== oldVal){ 14731 this.load_forms( this.client_id ); 14732 } 14733 } 14734 } 14735 }) 14736</script>
14736
14736<script type="text/html" id="template-tab-bar"> 14737 <template v-if="is_web_builder()"> 14738 <template v-if="config_latest()['tabs']?.length > 0"> 14739 <div 14740 class="tab-bar-wrapper" 14741 :style="{ 14742 gap: (config_latest().formatting.section_width || 50) + 'px', 14743 height: (config_latest().formatting.section_height || 50) + 'px', 14744 }" 14745 > 14746 <div 14747 v-for="tab in config_latest()['tabs']" 14748 :key="tab.id" 14749 style="width: 200px; display: flex; align-items: center; justify-content: center;" 14750 > 14751 <div 14752 @click="selected_tab_id = tab.id" 14753 style="width: 100%; text-align: center;" 14754 class="tab-bar-label" 14755 :class="{ 'active': selected_tab_id === tab.id }" 14756 > 14757 <span> 14758 <preview-element-component 14759 v-if="tab.elements" 14760 :element="tab.elements" 14761 :element_type="tab.elements.type" 14762 :element_content="tab.elements.content" 14763 :componentObject="component" 14764 :disable_remove="true" 14765 :disable_column="true" 14766 :disable_new_element="true" 14767 ></preview-element-component> 14768 </span> 14769 </div> 14770 </div> 14771 </div> 14772 <web-builder-components 14773 v-if="selected_tab_id" 14774 :component="return_page_component(selected_tab_id)" 14775 :component_index="0" 14776 :preview_element="true" 14777 ></web-builder-components> 14778 </template> 14779 <template v-else> 14780 <div 14781 class="noObjectsMSG centeredBlock centeredText" 14782 style="padding: 5em;" 14783 > 14784 <p style="cursor: pointer;"><i class="fa-light fa-table-columns smallMarginRight"></i>Use the left side menu to start adding tabs</p> 14785 </div> 14786 </template> 14787 </template> 14788 <template v-if="!is_web_builder() && !loading"> 14789 <template v-if="hosted_config()['tabs']?.length > 0"> 14790 <div 14791 class="tab-bar-wrapper" 14792 :style="{ 14793 gap: (hosted_config().formatting.section_width || 50) + 'px', 14794 height: (hosted_config().formatting.section_height || 50) + 'px', 14795 }" 14796 > 14797 <div 14798 v-for="tab in hosted_config()['tabs']" 14799 :key="tab.id" 14800 style="width: 200px; display: flex; align-items: center; justify-content: center;" 14801 > 14802 <div 14803 @click="selected_tab_id = tab.id" 14804 style="width: 100%; text-align: center;" 14805 class="tab-bar-label" 14806 :class="{ 'active': selected_tab_id === tab.id }" 14807 > 14808 <element-component 14809 v-if="tab.elements" 14810 :element="tab.elements" 14811 :element_type="tab.elements.type" 14812 :element_content="tab.elements.content" 14813 :componentObject="component" 14814 :disable_remove="true" 14815 :disable_column="true" 14816 :disable_new_element="true" 14817 ></element-component> 14818 </div> 14819 </div> 14820 </div> 14821 <web-builder-components 14822 v-if="selected_tab_id" 14823 :component="return_hosted_page_component(selected_tab_id)" 14824 :component_index="0" 14825 :tab_element="true" 14826 :preview_element="false" 14827 >
14827</web-builder-components> 14828 </template> 14829 </template> 14830</script>
14830 14831 14832<style> 14833 .tab-bar-wrapper{ 14834 display: flex; 14835 flex-direction: row; 14836 align-items: center; 14837 justify-content: center; 14838 border-top: 1.5px solid rgb(204, 203, 203); 14839 border-bottom: 1.5px solid rgb(204, 203, 203); 14840 box-sizing: border-box; 14841 } 14842 .tab-bar-label span { 14843 position: relative; 14844 display: inline-block; 14845 padding-bottom: 4px; 14846 border-bottom: none !important; 14847 text-decoration: none !important; 14848 } 14849 .tab-bar-label span::after { 14850 content: ""; 14851 position: absolute; 14852 bottom: 0; 14853 left: 50%; 14854 width: 0; 14855 height: 1px; 14856 background-color: #000; 14857 transform: translateX(-50%); 14858 transition: width 0.3s ease; 14859 } 14860 .tab-bar-label.active span::after { 14861 width: 100%; 14862 } 14863</style> 14864
14865<script> 14866 app_content.component('tab-bar-component', { 14867 template: '#template-tab-bar', 14868 data() { 14869 return { 14870 loading: true, 14871 selected_tab_id: null, 14872 hosted_tab_component: null, 14873 hosted_page_components: [], 14874 } 14875 }, 14876 props: { 14877 client_id: String, 14878 component: { 14879 type: Object, 14880 default: null, 14881 }, 14882 last_update: Number, 14883 }, 14884 mounted() { 14885 if(this.is_web_builder()){ 14886 this.selected_tab_id = this.config_latest()?.tabs?.[0]?.id || null; 14887 }else{ 14888 this.load_tab_bar( this.client_id ); 14889 } 14890 }, 14891 methods: { 14892 load_tab_bar( client_id, overrides = {} ){ 14893 this.loading = true; 14894 var promise = Parse.Promise.as(); 14895 var thisPromise = promise.then(() => { 14896 var params = {}; 14897 params["client_id"] = client_id; 14898 params["overrides"] = overrides; 14899 params["request_url"] = window.location.href; 14900 return Parse.Cloud.run('retrieve_data_for_component', params); 14901 }).then(response => { 14902 this.hosted_tab_component = response.tab_component || null; 14903 this.hosted_page_components = response.page_components || []; 14904 this.selected_tab_id = this.hosted_config()?.tabs?.[0]?.id || null; 14905 }).catch(error => { 14906 checkParseError(error); 14907 }).finally(() => { 14908 this.loading = false; 14909 }); 14910 return thisPromise; 14911 }, 14912 return_page_component(id){ 14913 return store.page_components_latest.find(c => c.id === id) 14914 }, 14915 return_hosted_page_component(id){ 14916 return this.hosted_page_components.find(c => c.id === id) 14917 }, 14918 config_latest(){ 14919 if(this.component && this.component.get('config_latest')){ 14920 return this.component.get('config_latest'); 14921 } 14922 }, 14923 hosted_config(){ 14924 if(this.hosted_tab_component && this.hosted_tab_component.get('config')){ 14925 return this.hosted_tab_component.get('config'); 14926 } 14927 }, 14928 is_web_builder(){ 14929 return window.location.href.includes('website-builder'); 14930 }, 14931 }, 14932 watch: { 14933 last_update(newVal) { 14934 if (this.is_web_builder()) { 14935 this.selected_tab_id = this.config_latest()?.tabs?.[0]?.id || null; 14936 } 14937 } 14938 } 14939 }); 14940</script>
14940
14940<script type="text/html" id="template-web-builder-components"> 14941 <div 14942 :id="'componentPreview_' + component.get('client_id')" 14943 class="componentPreview" 14944 :class="{'hidePreview': tab_element}" 14945 :data-client-id="component.get('client_id')" 14946 :style="return_preview_style(component)" 14947 > 14948 <div 14949 v-if="component.get('visibility') === 'members_only'" 14950 class="event-page-callout" 14951 :class="{ 'no-hover': !can_open_visibility_settings }" 14952 style="width:fit-content;font-size:12px;position: absolute;z-index:100;display:flex;align-items:center;justify-content:center;" 14953 :title="can_open_visibility_settings ? 'Edit visibility' : null" 14954 @click.stop="open_members_only_visibility_settings" 14955 > 14956 <i class="fa-solid fa-user-shield"></i> 14957 <p class="tinyMarginLeft" style="font-family:'Roboto';font-size:12px;">{{ (component.get('allowed_categories') && component.get('allowed_categories').length > 0) ? 'Members only (categories)' : 'Members only' }}</p> 14958 </div> 14959 <template v-if="component.get('type') === 'iframe'"> 14960 <iframe-component 14961 :class_string="return_attributes(component, 'classString')" 14962 :style_string="return_attributes(component, 'style_string')" 14963 :iframe_src="return_attributes(component, 'config_latest').iframe_src" 14964 :component="component" 14965 :formatting_string="return_attributes(component, 'formatting')" 14966 :last_update="return_last_update(component)" 14967 ></iframe-component> 14968 <toolbar-option-component 14969 @click.stop 14970 :component="component" 14971 :is_first="component_index === 0" 14972 :disable_options="disable_options(component)" 14973 ></toolbar-option-component> 14974 </template> 14975 <template v-if="component.get('type') === 'camera-feed'"> 14976 <camera-feed-component 14977 :class_string="return_attributes(component, 'classString')" 14978 :style_string="return_attributes(component, 'style_string')" 14979 :client_id="return_attributes(component, 'client_id')" 14980 :camera_feed_id="return_attributes(component, 'config_latest').camera_feed_id" 14981 :refresh_interval_ms="return_attributes(component, 'config_latest').refresh_interval_ms" 14982 :component="component" 14983 :formatting_string="return_attributes(component, 'formatting')" 14984 :last_update="return_last_update(component)" 14985 ></camera-feed-component> 14986 <toolbar-option-component 14987 @click.stop 14988 :component="component" 14989 :is_first="component_index === 0" 14990 :disable_options="disable_options(component)" 14991 ></toolbar-option-component> 14992 </template> 14993 <template v-if="component.get('type') === 'blog-list'"> 14994 <blog-list-component 14995 :client_id="return_attributes(component, 'client_id')" 14996 :class_string="return_attributes(component, 'classString')" 14997 :style_string="return_attributes(component, 'style_string')" 14998 :element_style_string="return_attributes(component, 'element_style')" 14999 :formatting="return_attributes(component, 'formatting')" 15000 :component_type="return_attributes(component, 'component_type')" 15001 :is_larchmont="global.is_larchmont" 15002 :component="component" 15003 :last_update="return_last_update(component)" 15004 ></blog-list-component> 15005 <toolbar-option-component 15006 @click.stop 15007 :component="component" 15008 :is_first="component_index === 0" 15009 :disable_options="disable_options(component)" 15010 ></toolbar-option-component> 15011 </template> 15012 <template v-if="component.get('type') === 'calendar'"> 15013 <membership-calendar-component 15014 :class_string="return_attributes(component, 'classString')" 15015 :client_id="return_attributes(component, 'client_id')" 15016 :style_string="return_attributes(component, 'style_string')" 15017 :component="component" 15018 :last_update="return_last_update(component)" 15019 ></membership-calendar-component> 15020 <toolbar-option-component 15021 @click.stop 15022 :component="component" 15023 :is_first="component_index === 0" 15024 :disable_options="disable_options(component)" 15025 ></toolbar-option-component> 15026 </template> 15027 <template v-if="component.get('type') === 'membership-directory'"> 15028 <membership-directory-component 15029 :formatting="JSON.parse(return_attributes(component, 'formatting'))" 15030 :class_string="return_attributes(component, 'classString')" 15031 :client_id="return_attributes(component, 'client_id')" 15032 :style_string="return_attributes(component, 'style_string')" 15033 :element_style_string="return_attributes(component, 'element_style')" 15034 :component_type="return_attributes(component, 'component_type')" 15035 :last_update="return_last_update(component)" 15036 ></membership-directory-component> 15037 <toolbar-option-component 15038 @click.stop 15039 :component="component" 15040 :is_first="component_index === 0" 15041 :disable_options="disable_options(component)" 15042 ></toolbar-option-component> 15043 </template> 15044 <template v-if="component.get('type') === 'event-list'"> 15045 <event-list-component 15046 :class_string="return_attributes(component, 'classString')" 15047 :style_string="return_attributes(component, 'style_string')" 15048 :element_style_string="return_attributes(component, 'element_style')" 15049 :formatting="return_attributes(component, 'formatting')" 15050 :filter_types="return_attributes(component, 'filter_types')" 15051 :filter_tag_ids="return_attributes(component, 'filter_tag_ids')" 15052 :event_dates="return_attributes(component, 'event_dates')" 15053 :component_type="return_attributes(component, 'component_type')" 15054 :client_id="return_attributes(component, 'client_id')" 15055 :component="component" 15056 :last_update="return_last_update(component)" 15057 ></event-list-component> 15058 <toolbar-option-component 15059 @click.stop 15060 :component="component" 15061 :is_first="component_index === 0" 15062 :disable_options="disable_options(component)" 15063 ></toolbar-option-component> 15064 </template> 15065 <template v-if="component.get('type') === 'image-grid'"> 15066 <image-grid-component 15067 :client_id="return_attributes(component, 'client_id')" 15068 :class_string="return_attributes(component, 'classString')" 15069 :image_grid_layout="return_attributes(component, 'image_grid_layout')" 15070 :images_per_row="return_attributes(component, 'images_per_row')" 15071 :caption_size="return_attributes(component, 'caption_size')" 15072 :subtext_size="return_attributes(component, 'subtext_size')" 15073 :link_label_size="return_attributes(component, 'link_label_size')" 15074 :text_position="return_attributes(component, 'text_position')" 15075 :caption_font_weight="return_attributes(component, 'caption_font_weight')" 15076 :image_grid_opacity="return_attributes(component, 'image_grid_overlay_opacity')" 15077 :image_grid_border_radius="return_attributes(component, 'image_grid_border_radius')" 15078 :formatting="return_attributes(component, 'formatting')" 15079 :is_admin="preview_element" 15080 :data_source_type="return_attributes(component, 'data_source')['type']" 15081 :images_arr="return_attributes(component, 'images')" 15082 :images_string="JSON.stringify(return_attributes(component, 'images'))" 15083 :component="component" 15084 :last_update="return_last_update(component)" 15085 ></image-grid-component> 15086 <toolbar-option-component 15087 @click.stop 15088 :component="component" 15089 :is_first="component_index === 0" 15090 :disable_options="disable_options(component)" 15091 ></toolbar-option-component> 15092 </template> 15093 <template v-if="component.get('type') === 'photo-gallery'"> 15094 <photo-gallery-component 15095 :client_id="return_attributes(component, 'client_id')" 15096 :component="component" 15097 :photo_album="return_attributes(component, 'photo_album')"
15098 :show_caption="return_attributes(component, 'show_caption')" 15099 :caption_size="return_attributes(component, 'caption_size')" 15100 :caption_font_weight="return_attributes(component, 'caption_font_weight')" 15101 :photos_per_row="return_attributes(component, 'photos_per_row')" 15102 :is_admin="preview_element" 15103 :last_update="return_last_update(component)" 15104 ></photo-gallery-component> 15105 <toolbar-option-component 15106 @click.stop 15107 :component="component" 15108 :is_first="component_index === 0" 15109 :disable_options="disable_options(component)" 15110 ></toolbar-option-component> 15111 </template> 15112 <template v-if="component.get('type') === 'form'"> 15113 <form-component 15114 :client_id="return_attributes(component, 'client_id')" 15115 :component="component" 15116 :form="return_attributes(component, 'form')" 15117 :formatting="return_attributes(component, 'formatting')" 15118 :is_admin="preview_element" 15119 :last_update="return_last_update(component)" 15120 ></form-component> 15121 <toolbar-option-component 15122 @click.stop 15123 :component="component" 15124 :is_first="component_index === 0" 15125 :disable_options="disable_options(component)" 15126 ></toolbar-option-component> 15127 </template> 15128 <template v-if="component.get('type') === 'image-with-text'"> 15129 <image-with-text-component 15130 :client_id="return_attributes(component, 'client_id')" 15131 :formatting="return_attributes(component, 'formatting')" 15132 :images_string="JSON.stringify(return_attributes(component, 'images'))" 15133 :class_string="return_attributes(component, 'classString')" 15134 :style_string="return_attributes(component, 'style_string')" 15135 :element_style_string="return_attributes(component, 'element_style')" 15136 :image_mat="return_attributes(component, 'image_mat')" 15137 :elements_string="return_attributes(component, 'elements')" 15138 :left_right_orientation="return_attributes(component, 'left_right_orientation')" 15139 :preview_element="preview_element" 15140 :component="component" 15141 :last_update="return_last_update(component)" 15142 :last_element_update="return_last_element_update( component )" 15143 :tab_element="tab_element" 15144 ></image-with-text-component> 15145 <toolbar-option-component 15146 @click.stop 15147 :component="component" 15148 :is_first="component_index === 0" 15149 :disable_options="disable_options(component)" 15150 ></toolbar-option-component> 15151 </template> 15152 <template v-if="component.get('type') === 'text' || component.get('type') === 'table'"> 15153 <div 15154 class="cs-text background flexWrap relative" 15155 :class="return_attributes(component, 'classString')" 15156 :style="return_attributes(component, 'style_string')" 15157 > 15158 <div 15159 v-if="return_attributes(component, 'columns').length > 0" 15160 class="element-wrapper flexNoWrap flexWrapOnMobile" 15161 :class="'element-wrapper_' + return_attributes(component, 'component_type')" 15162 :style="return_attributes(component, 'element_style')" 15163 > 15164 <template 15165 v-for="(elements, column_index) in return_attributes(component, 'columns')" 15166 :key="column_index" 15167 > 15168 <text-component 15169 :number_of_columns="return_attributes(component, 'number_of_columns')" 15170 :preview_element="preview_element" 15171 :component="component" 15172 :last_update="return_last_update(component)" 15173 :last_element_update="return_last_element_update( component )" 15174 :elements="elements" 15175 :column_index="column_index" 15176 :section_element_animation="return_attributes(component, 'section_element_animation')" 15177 ></text-component> 15178 </template> 15179 </div> 15180 </div> 15181 <toolbar-option-component 15182 @click.stop 15183 :component="component" 15184 :is_first="component_index === 0" 15185 :disable_options="disable_options(component)" 15186 ></toolbar-option-component> 15187 </template> 15188 <template v-if="component.get('type') === 'hero-banner'"> 15189 <hero-banner-component 15190 ref="heroBanner" 15191 :client_id="return_attributes(component, 'client_id')" 15192 :formatting="return_attributes(component, 'formatting')" 15193 :class_string="return_attributes(component, 'classString')" 15194 :style_string="return_attributes(component, 'style_string')" 15195 :element_style_string="return_attributes(component, 'element_style')" 15196 :component_type="return_attributes(component, 'component_type')" 15197 :elements_string="return_attributes(component, 'elements')" 15198 :images_string="JSON.stringify(return_attributes(component, 'images'))" 15199 :preview_element="preview_element" 15200 :component="component" 15201 :last_update="return_last_update(component)" 15202 :last_element_update="return_last_element_update( component )" 15203 :tab_element="tab_element" 15204 ></hero-banner-component> 15205 <button 15206 v-if="preview_element" 15207 type="button" 15208 class="cs-image-slot-change-chip cs-hero-image-change-chip exclude-from-custom-button-styles" 15209 @click.stop="tapped_change_hero_image()" 15210 ><i class="fa-regular fa-image"></i><span>Change</span></button> 15211 <toolbar-option-component 15212 @click.stop 15213 :component="component" 15214 :is_first="component_index === 0" 15215 :disable_options="disable_options(component)" 15216 ></toolbar-option-component> 15217 </template> 15218 <template v-if="component.get('type') === 'custom-code'"> 15219 <custom-code-component 15220 :class_string="return_attributes(component, 'classString')" 15221 :preview_element="preview_element" 15222 :is_first="is_first_visible_component(component)" 15223 :component="component" 15224 :last_update="return_last_update(component)" 15225 ></custom-code-component> 15226 <toolbar-option-component 15227 @click.stop 15228 :component="component" 15229 :is_first="component_index === 0" 15230 :disable_options="disable_options(component)" 15231 ></toolbar-option-component> 15232 </template> 15233 <template v-if="component.get('type') === 'unlayer-component'"> 15234 <unlayer-component 15235 :class_string="return_attributes(component, 'classString')" 15236 :preview_element="preview_element" 15237 :component="component" 15238 :last_update="return_last_update(component)" 15239 ></unlayer-component> 15240 <toolbar-option-component 15241 @click.stop 15242 :component="component" 15243 :is_first="component_index === 0" 15244 :disable_options="disable_options(component)" 15245 ></toolbar-option-component> 15246 </template> 15247 <!-- <template v-if="component.get('type') === 'tab-bar'"> 15248 <tab-bar-component 15249 :client_id="return_attributes(component, 'client_id')" 15250 :preview_element="preview_element" 15251 :component="component" 15252 :last_update="return_last_update(component)" 15253 ></tab-bar-component> 15254 <toolbar-option-component 15255 @click.stop 15256 :component="component" 15257 :is_first="component_index === 0" 15258 :disable_options="disable_options(component)" 15259 ></toolbar-option-component> 15260 </template> --> 15261 </div> 15262</script>
15262 15263 15264<style> 15265 /* Hero Change chip: sibling of the section under .componentPreview so 15266 overlap masks / fades can't cover it. */ 15267 .componentPreview > .cs-hero-image-change-chip { 15268 position: absolute; 15269 top: auto; 15270 right: auto; 15271 bottom: calc(12px + var(--cs-overlap-chrome-lift, 0px)); 15272 left: 50%; 15273 transform: translateX(-50%); 15274 z-index: 3000; 15275 display: inline-flex; 15276 align-items: center; 15277 gap: 5px; 15278 padding: 6px 10px; 15279 border: none; 15280 border-radius: 6px; 15281 background: rgba(20, 24, 45, 0.82); 15282 color: #fff; 15283 font-family: 'Roboto', Arial, Helvetica, sans-serif; 15284 font-size: 12px; 15285 font-weight: 600; 15286 font-style: normal; 15287 line-height: 1; 15288 letter-spacing: 0; 15289 text-transform: none; 15290 cursor: pointer; 15291 opacity: 0; 15292 pointer-events: none; 15293 box-shadow: 0 4px 12px rgba(8, 19, 51, 0.22); 15294 transition: opacity 0.12s ease, background 0.12s ease; 15295 } 15296 .componentPreview:hover > .cs-hero-image-change-chip, 15297 .componentPreview > .cs-hero-image-change-chip:focus { 15298 opacity: 0.92; 15299 pointer-events: auto; 15300 } 15301 .componentPreview:hover > .cs-hero-image-change-chip:hover, 15302 .componentPreview > .cs-hero-image-change-chip:focus { 15303 opacity: 1; 15304 background: rgba(20, 24, 45, 0.95); 15305 } 15306 .componentPreview.settings-active > .cs-hero-image-change-chip { 15307 opacity: 0 !important; 15308 visibility: hidden !important; 15309 pointer-events: none !important; 15310 } 15311</style> 15312
15313<script> 15314 app_content.component('web-builder-components', { 15315 template: '#template-web-builder-components', 15316 data() { 15317 return { 15318 global: store, 15319 is_admin: false, 15320 } 15321 }, 15322 props: { 15323 component: Object, 15324 component_index: Number, 15325 tab_element: Boolean, 15326 preview_element: Boolean, 15327 last_update: Number, 15328 }, 15329 methods: { 15330 component_is_linked_to_tab_bar( componentObject ){ 15331 if (!componentObject || !Array.isArray(store.page_components_latest)) { 15332 return false; 15333 } 15334 return store.page_components_latest.some(c => { 15335 if (!c || !c.get || c.get('type') !== 'tab-bar') { 15336 return false; 15337 } 15338 let tabs = (c.get('config_latest') && c.get('config_latest').tabs) || []; 15339 return tabs.some(tab => { 15340 return tab && tab.id === componentObject.id; 15341 }); 15342 }); 15343 }, 15344 is_first_visible_component( componentObject ){ 15345 if (!componentObject || !Array.isArray(store.page_components_latest)) { 15346 return this.component_index === 0; 15347 } 15348 let first_visible = store.page_components_latest.find(c => { 15349 return c && !this.component_is_linked_to_tab_bar(c); 15350 }); 15351 return !!(first_visible && first_visible.id === componentObject.id); 15352 }, 15353 disable_options( componentObject ){ 15354 return this.component_is_linked_to_tab_bar(componentObject); 15355 }, 15356 open_members_only_visibility_settings(){ 15357 if( typeof window.wb_open_component_visibility_settings !== 'function' ){ 15358 return; 15359 } 15360 window.wb_open_component_visibility_settings(this.component); 15361 }, 15362 tapped_change_hero_image(){ 15363 let hero = this.$refs.heroBanner; 15364 if( !hero || typeof hero.tapped_change_hero_image !== 'function' ){ 15365 return; 15366 } 15367 hero.tapped_change_hero_image(); 15368 }, 15369 return_last_element_update( componentObject ){ 15370 return store.element_updates ? store.element_updates[ componentObject.id ] : 0; 15371 }, 15372 return_last_update(componentObject){ 15373 return store.component_updates ? store.component_updates[ componentObject.id ] : 0; 15374 }, 15375 return_preview_style(componentObject){ 15376 let type = componentObject.get('type'); 15377 if ( type === 'hero-banner' || type === 'image-with-text' ){ 15378 return this.return_attributes(componentObject, 'background_image_style'); 15379 } 15380 }, 15381 return_attributes(componentObject, attribute){ 15382 let attributes = {}; 15383 if(componentObject){ 15384 var type = componentObject.get('type'); 15385 var config_latest = componentObject.get('config_latest') || returnDefaultConfigForComponent( type ); 15386 var images = config_latest.images; 15387 if(!images){ 15388 // images = []; 15389 images = [null, null, null, null, null] 15390 } 15391 var formatting = config_latest.formatting; 15392 if( ! formatting ){ 15393 formatting = returnDefaultFormattingForComponent( type ); 15394 } 15395 var color_scheme = config_latest.color_scheme; 15396 if( !color_scheme ){ 15397 let default_config = returnDefaultConfigForComponent( type ); 15398 color_scheme = (default_config && default_config.color_scheme) ? default_config.color_scheme : 'white'; 15399 } 15400 if (store.is_larchmont && store.pageObject.id === 'QAcpSXHP5G'){ 15401 color_scheme = 'larchmont_secondary'; 15402 } 15403 //start the class string up here 15404 var classString = ''; 15405 classString += ' color_scheme_' + color_scheme; 15406 //now generate markup 15407 var background_image_style = ""; 15408 var style_string = ""; 15409 if( formatting.section_height ){ 15410 // Browser vh is the outer window; the canvas is #previewZoneHeight. 15411 // Use cqh (preview container) so heights match iframe/live viewport, 15412 // including absolute/sticky header overlay. Keep vh as fallback. 15413 let section_vh = ( formatting.section_height ).toString(); 15414 style_string += "min-height:" + section_vh + "vh;"; 15415 style_string += "min-height:calc(var(--wb-preview-vh, 1vh) * " + section_vh + ");"; 15416 style_string += "min-height:" + section_vh + "cqh;"; 15417 } 15418 if( formatting.align_items 15419 && ( typeof is_web_builder_element_stack_component !== 'function' 15420 || is_web_builder_element_stack_component( type ) ) ){ 15421 style_string += "align-items:" + formatting.align_items + ";" ; 15422 } 15423 if ( formatting.background_size === 'inset' ){ 15424 background_image_style += "padding: 30px;" ; 15425 } else if ( formatting.background_size === 'cover' ){ 15426 background_image_style += "padding: 0px;" ; 15427 } 15428 //element wrapper 15429 var element_style = ""; 15430 //extra_columns 15431 var extra_columns = config_latest.extra_columns; 15432 if( extra_columns ){ 15433 for (var c = extra_columns.length - 1; c >= 0; c--) { 15434 classString += ' show_' + extra_columns[c]; 15435 } 15436 } 15437 //images per row (image grid) 15438 var images_per_row = config_latest.images_per_row; 15439 if( ! images_per_row ){ 15440 images_per_row = 3; 15441 } 15442 //photos per row (photo gallery) 15443 var photos_per_row = config_latest.photos_per_row; 15444 if( ! photos_per_row ){ 15445 photos_per_row = 4; 15446 } 15447 //left/right orientation (image with text) 15448 var left_right_orientation = config_latest.left_r
15448ight_orientation; 15449 if( ! left_right_orientation ){ 15450 left_right_orientation = "left"; 15451 } 15452 classString += " orientation-" + left_right_orientation; 15453 //number of columns (text) 15454 var number_of_columns = config_latest.number_of_columns; 15455 if( ! number_of_columns ){ 15456 number_of_columns = 3; 15457 } 15458 classString += " number_of_columns-" + number_of_columns; 15459 15460 // Opt-in refinement tokens (padding, fade edges, scroll/element 15461 // anim, max-width cap). Absent keys leave classString/styles unchanged. 15462 let refinement_result = apply_web_builder_refinement_formatting({ 15463 formatting: formatting, 15464 class_string: classString, 15465 element_style: element_style, 15466 activate_immediately: ( typeof window !== 'undefined' && window.location.href.indexOf('website-builder') !== -1 ), 15467 is_first_section: this.is_first_visible_component(componentObject), 15468 }); 15469 classString = refinement_result.class_string; 15470 element_style = refinement_result.element_style; 15471 15472 //overlay opacity 15473 var opacity = 0; 15474 if( formatting.overlay_opacity ){ 15475 opacity = formatting.overlay_opacity; 15476 } 15477 //photo album id 15478 var photo_album = config_latest.photo_album; 15479 if( ! photo_album ){ 15480 photo_album = { 15481 id: null, 15482 name: null, 15483 }; 15484 } 15485 //form id 15486 var form = config_latest.form; 15487 if( ! form ){ 15488 form = { 15489 id: null, 15490 name: null, 15491 }; 15492 } 15493 //image with text 15494 var image_mat = config_latest.image_mat ? config_latest.image_mat : 'show'; 15495 //filter types (event list) 15496 var filter_types = config_latest.filter_types ? JSON.stringify(config_latest.filter_types) : null; 15497 //filter tag IDs (event list) 15498 var filter_tag_ids = config_latest.filter_tag_ids ? JSON.stringify(config_latest.filter_tag_ids) : null; 15499 //event_dates (event list) 15500 var event_dates = config_latest.event_dates ? config_latest.event_dates : 'upcoming'; 15501 //caption style 15502 if( formatting.caption_style ){ 15503 classString += " caption_style-" + formatting.caption_style; 15504 } 15505 //caption alignment 15506 if( formatting.caption_align && ( formatting.caption_align == "top" || formatting.caption_align == "bottom" || formatting.caption_align == "under" ) ){ 15507 classString += ' ' + formatting.caption_align + '-align-image-caption'; 15508 } 15509 var show_caption = formatting.show_caption; 15510 if( ! show_caption ){ 15511 show_caption = false; 15512 } 15513 var caption_size = formatting.caption_size; 15514 if( ! caption_size ){ 15515 caption_size = "24"; 15516 } 15517 var subtext_size = formatting.subtext_size; 15518 if( ! subtext_size ){ 15519 subtext_size = "24"; 15520 } 15521 var link_label_size = formatting.link_label_size; 15522 if( ! link_label_size ){ 15523 link_label_size = "24"; 15524 } 15525 var text_position = formatting.text_position; 15526 if( ! text_position ){ 15527 text_position = "center"; 15528 } 15529 var caption_font_weight = formatting.caption_font_weight; 15530 if( ! caption_font_weight ){ 15531 caption_font_weight = "400"; 15532 } 15533 var image_grid_overlay_opacity = formatting.image_grid_overlay_opacity; 15534 if( ! image_grid_overlay_opacity ){ 15535 image_grid_overlay_opacity = "40"; 15536 } 15537 var image_grid_border_radius = formatting.image_grid_border_radius; 15538 if ( ! image_grid_border_radius ){ 15539 image_grid_border_radius = "0" 15540 } 15541 var image_grid_layout = formatting.layout; 15542 if ( ! image_grid_layout ){ 15543 image_grid_layout = "grid" 15544 } 15545 //add all variables to attribute object 15546 attributes['client_id'] = componentObject.get('client_id'); 15547 attributes['component_type'] = componentObject.get('type'); 15548 if( componentObject.get('config') && componentObject.get('config').event_types ){ 15549 attributes['event_types'] = componentObject.get('config').event_types; 15550 } 15551 // attributes['images'] = componentObject.get('images'); 15552 attributes['type'] = type; 15553 attributes['config_latest'] = config_latest; 15554 attributes['data_source'] = config_latest['data_source']; 15555 attributes['images'] = images; 15556 attributes['formatting'] = JSON.stringify(formatting); 15557 attributes['color_scheme'] = color_scheme; 15558 attributes['classString'] = classString; 15559 attributes['style_string'] = style_string; 15560 attributes['elements'] = config_latest['elements']; 15561 attributes['element_style'] = element_style; 15562 attributes['opacity'] = opacity;
15563 attributes['extra_columns'] = extra_columns; 15564 attributes['images_per_row'] = images_per_row; 15565 attributes['photos_per_row'] = photos_per_row; 15566 attributes['left_right_orientation'] = left_right_orientation; 15567 attributes['columns'] = config_latest.columns; 15568 attributes['number_of_columns'] = number_of_columns; 15569 attributes['show_caption'] = show_caption; 15570 attributes['caption_size'] = caption_size; 15571 attributes['subtext_size'] = subtext_size; 15572 attributes['link_label_size'] = link_label_size; 15573 attributes['text_position'] = text_position; 15574 attributes['caption_font_weight'] = caption_font_weight; 15575 attributes['image_grid_overlay_opacity'] = image_grid_overlay_opacity; 15576 attributes['image_grid_border_radius'] = image_grid_border_radius; 15577 attributes['image_grid_layout'] = image_grid_layout; 15578 attributes['background_image_style'] = background_image_style; 15579 attributes['photo_album'] = photo_album; 15580 attributes['form'] = form; 15581 attributes['filter_types'] = filter_types; 15582 attributes['filter_tag_ids'] = filter_tag_ids; 15583 attributes['event_dates'] = event_dates; 15584 attributes['image_mat'] = image_mat; 15585 let section_element_animation = refinement_result.section_element_animation || ''; 15586 attributes['section_element_animation'] = section_element_animation; 15587 //return requested attribute 15588 return attributes[attribute]; 15589 } 15590 }, 15591 }, 15592 computed: { 15593 can_open_visibility_settings(){ 15594 return typeof window.wb_open_component_visibility_settings === 'function'; 15595 }, 15596 }, 15597 }); 15598</script>
15598 15599 <!-- overlays --> 15600
15600<script type="text/html" id="template-view-all-events"> 15601 <bottom-sheet-overlay 15602 id="overlay_view_all_events" 15603 @dismiss_overlay="dismiss_overlay()" 15604 > 15605 <template #header> 15606 <div class="sticky-wrap overflowHidden"> 15607 <div class="posScrollbar table-tab-wrap noPaddingBottom"> 15608 <div class="flexNoWrap table-tab-inner"> 15609 <div class="table-tab-option" :class="{'active': date_filter === 'all'}" @click="handleDatesFilter('all')" style="margin-left:22px;">All</div> 15610 <div class="table-tab-option noMarginLeft" :class="{'active': date_filter === 'upcoming'}" @click="handleDatesFilter('upcoming')">Upcoming</div> 15611 <div class="table-tab-option noMarginLeft" :class="{'active': date_filter === 'past'}" @click="handleDatesFilter('past')">Past</div> 15612 </div> 15613 </div> 15614 </div> 15615 <div class="searchBarWrap inline-search relative noMargin overflowHidden"> 15616 <div class="inputWrap searchBar searchBar_event-list flexGrowOne boxShadowBottom maxFourHundo"> 15617 <input 15618 class="easeFast" 15619 placeholder="Search events..." 15620 v-model="search" 15621 > 15622 </div> 15623 </div> 15624 </template> 15625 15626 <!-- body content --> 15627 <template #body> 15628 <div 15629 class="event-list-outer-contain lightBorderTop scrollable" 15630 :class="class_string" 15631 :style="style_string" 15632 > 15633 <div 15634 class="event-list-wrap centeredBlock centeredText event-list-inner-contain" 15635 > 15636 <div class="fff noPadding overflowHidden"> 15637 <div v-if="!loading && sorted_event_data.length >= 1" class="tableInsert hundop flexWrapEvent"> 15638 <template 15639 v-for="(event_data, index) in sorted_event_data" 15640 :key="event_key(event_data)" 15641 > 15642 <p 15643 v-if="is_first_visible_event_of_month(event_data)" 15644 class="cs-section-header" 15645 >{{ month_string( event_data ) }}</p> 15646 <event-list-event-component 15647 :event_data="event_data" 15648 :event_props="return_event_props( event_data )" 15649 @tapped-event-list-event="tapped_event_list_event" 15650 ></event-list-event-component> 15651 </template> 15652 </div> 15653 <no-objects-found 15654 v-if="!loading && sorted_event_data.length == 0" 15655 subtext="No events found" 15656 icon_class="fa-light fa-calendar-days" 15657 ></no-objects-found> 15658 </div> 15659 <div v-if="loading" class="cardBodySpinnerWrap active relative"> 15660 <div class="cardBodySpinner centeredBlock"></div> 15661 </div> 15662 </div> 15663 </div> 15664 </template> 15665 15666 </bottom-sheet-overlay> 15667</script>
vendor: 1 bytes, line 15667
15667
15668<script> 15669 15670 function view_all_events( params ){ 15671 store.components.view_all_events.event_data = params.event_data; 15672 store.components.view_all_events.class_string = params.class_string; 15673 store.components.view_all_events.style_string = params.style_string; 15674 store.components.view_all_events.element_style_string = params.element_style_string; 15675 store.components.view_all_events.props_generator = params.props_generator; 15676 store.overlays.view_all_events = true; 15677 } 15678 //global store 15679 store.components["view_all_events"] = { 15680 event_data: [], 15681 date_filter: 'upcoming', 15682 search: '', 15683 loading: false, 15684 sortObject: { 15685 order: 'ascending', 15686 key: 'startDate' 15687 }, 15688 //passed props 15689 class_string: '', 15690 style_string: '', 15691 element_style_string: '', 15692 props_generator: null, //function to generate props for the individual events 15693 }; 15694 15695 //define the component 15696 app_overlays.component("view-all-events-overlay", { 15697 template: "#template-view-all-events", 15698 data() { 15699 return store.components.view_all_events 15700 }, 15701 mounted(){ 15702 15703 }, 15704 methods: { 15705 month_string( event_data ){ 15706 let start_date = new Date(event_data.ts); 15707 return format_utc_date_v2({ 15708 "utcDate": start_date, 15709 "timezone": store.timezone, 15710 "formatString": "MMMM YYYY" 15711 }); 15712 }, 15713 event_key( event_data ){ 15714 return event_data.event.id + '_' + event_data.ts; 15715 }, 15716 is_first_visible_event_of_month( event_data ){ 15717 return this.first_visible_events.includes( this.event_key(event_data) ); 15718 }, 15719 return_event_props( event_data ){ 15720 return this.props_generator( event_data ); 15721 }, 15722 sortEventList( event_data, sortObject ){ 15723 event_data = [... event_data]; 15724 if( ! sortObject ){ 15725 sortObject = {} 15726 } 15727 if( ! sortObject.key ){ 15728 sortObject.key = "startDate"; 15729 } 15730 if( ! sortObject.order ){ 15731 sortObject.order = "ascending" 15732 } 15733 if( sortObject.key == "name" ){ 15734 if( sortObject.order == "ascending" ){ 15735 return event_data.sort( this.sortByEventName_ascending ); 15736 }else{ 15737 return event_data.sort( this.sortByEventName_descending ); 15738 } 15739 }else if( sortObject.key == "startDate" ){ 15740 if( sortObject.order == "ascending" ){ 15741 return event_data.sort( this.sortByStartDate_event_ascending ); 15742 }else{ 15743 return event_data.sort( this.sortByStartDate_event_descending ); 15744 } 15745 }else if( sortObject.key == "endDate" ){ 15746 if( sortObject.order == "ascending" ){ 15747 return event_data.sort( this.sortByEndDate_event_ascending ); 15748 }else{ 15749 return event_data.sort( this.sortByEndDate_event_descending ); 15750 } 15751 } 15752 return event_data; 15753 }, 15754 sortByEventName_ascending( a, b ){ 15755 let event_a = a.event; 15756 let event_b = b.event; 15757 var aName = ''; 15758 if( event_a.get('name') ){ 15759 aName += event_a.get('name').trim().toLowerCase(); 15760 } 15761 var bName = ''; 15762 if( event_b.get('name') ){ 15763 bName += event_b.get('name').trim().toLowerCase(); 15764 } 15765 if(aName < bName) { return -1; } 15766 if(aName > bName) { return 1; } 15767 //they had the same name 15768 return 0; 15769 }, 15770 sortByEventName_descending( a, b ){ 15771 let event_a = a.event; 15772 let event_b = b.event; 15773 var aName = ''; 15774 if( event_a.get('name') ){ 15775 aName += event_a.get('name').trim().toLowerCase(); 15776 } 15777 var bName = ''; 15778 if( event_b.get('name') ){ 15779 bName += event_b.get('name').trim().toLowerCase(); 15780 } 15781 if(aName > bName) { return -1; } 15782 if(aName < bName) { return 1; } 15783 //they had the same name 15784 return 0; 15785 }, 15786 sortByStartDate_event_ascending( a, b ){ 15787 let a_ts = a.ts; 15788 let b_ts = b.ts; 15789 if(a_ts < b_ts) { return -1; } 15790 if(a_ts > b_ts) { return 1; } 15791 return 0; 15792 }, 15793 sortByStartDate_event_descending( a, b ){ 15794 let a_ts = a.ts; 15795 let b_ts = b.ts; 15796 if(a_ts > b_ts) { return -1; } 15797 if(a_ts < b_ts) { return 1; } 15798 return 0; 15799 }, 15800 sortByEndDate_event_ascending( a, b ){ 15801 let a_end_ts = a.end_ts; 15802 let b_end_ts = b.end_ts; 15803 if(a_end_ts < b_end_ts) { return -1; } 15804 if(a_end_ts > b_end_ts) { return 1; } 15805 return 0; 15806 }, 15807 sortByEndDate_event_descending( a, b ){ 15808 let a_end_ts = a.end_ts; 15809 let b_end_ts = b.end_ts; 15810 if(a_end_ts < b_end_ts) { return 1; } 15811 if(a_end_ts > b_end_ts) { return -1; } 15812 return 0; 15813 }, 15814 tapped_event_list_event( event_data ){ 15815 let event = event_data.event; 15816 if(event && event.className == 'events') { 15817 $('#overlay_event-description-popup h3').text(event.get('name')); 15818 let start_date = this.start_date( event_data ); 15819 let nice_date = format_utc_date_v2({ 15820 "utcDate": start_date, 15821 "timezone": store.timezone, 15822 "formatString": "MMM D, YYYY" 15823 }); 15824 $('#overlay_event-description-popup .metric_start_date').text( nice_date ); 15825 if( this.show_time( event ) ){ 15826 let event_time = format_utc_date_v2({ 15827 "utcDate": start_date, 15828 "timezone": store.timezone, 15829 "formatString": "h:mm A" 15830 }); 15831 $('#overlay_event-description-popup .metric_start_date_time').text( event_time ).removeClass('incomplete'); 15832 }else{ 15833 $('#overlay_event-description-popup .metric_start_date_time').text('Not provided').addClass('incomplete'); 15834 } 15835 if(event.get('description')) { 15836 $('#overlay_event-description-popup .metric_description').html(event.get('description')).removeClass('incomplete'); 15837 } else { 15838 $('#overlay_event-description-popup .metric_description').text('Not provided').addClass('incomplete'); 15839 } 15840 fadeIn('#overlay_event-description-popup'); 15841 } 15842 }, 15843 start_date( event_data ){ 15844 if( event_data.ts ){ 15845 return new Date(event_data.ts); 15846 } 15847 return null; 15848 }, 15849 show_time( event ){ 15850 if( 15851 event.get('calendarOnly') && event.get('all_day') 15852 ){ 15853 return false;
15854 }else if( 15855 event.className == "camps" || 15856 event.className == "regattas" || 15857 event.className == "subevents" 15858 ){ 15859 return false; 15860 } 15861 return true; 15862 }, 15863 handleDatesFilter( option ){ 15864 this.date_filter = option; 15865 // handle sortObject["order"] here instead of in loadEventList post-retrieval for better UX 15866 if ( option === 'upcoming' ){ 15867 this.sortObject["order"] = "ascending"; 15868 } else { 15869 this.sortObject["order"] = "descending"; 15870 } 15871 }, 15872 dismiss_overlay(){ 15873 store.overlays.view_all_events = false; 15874 } 15875 }, 15876 computed: { 15877 first_visible_events(){ 15878 let first_visible_events = []; 15879 let handled_keys = []; 15880 //loop through the event_data 15881 this.sorted_event_data.forEach(event_data => { 15882 let start_date = new Date(event_data.ts); 15883 let start_components = return_date_components_v2({ 15884 "date": start_date, 15885 "timezone": store.timezone 15886 }); 15887 let key = start_components.year + '_' + start_components.month; 15888 if( ! handled_keys.includes(key) ){ 15889 first_visible_events.push( this.event_key(event_data) ); 15890 handled_keys.push(key); 15891 } 15892 }); 15893 return first_visible_events; 15894 }, 15895 pre_filtered_event_data(){ 15896 //filter by date first 15897 let pre_filtered_event_data = [...this.event_data]; 15898 var timestamp = new Date().getTime(); 15899 if ( this.date_filter !== 'all' ){ 15900 pre_filtered_event_data = pre_filtered_event_data.filter(event_data => { 15901 let start = event_data.ts; 15902 let end = event_data.end_ts; 15903 if (this.date_filter === 'past'){ 15904 if( start < timestamp && ( end < timestamp || ! end || end == "undefined" ) ){ 15905 return true; 15906 } 15907 } else if (this.date_filter === 'upcoming'){ 15908 if ( start > timestamp || end > timestamp ){ 15909 return true; 15910 } 15911 // Single-day events: ts and end_ts are both midnight, so they get filtered out 15912 // when the event is today. Include if the event's date is today or later. 15913 if ( start === end || !end ){ 15914 let startOfEventDay = new Date(start); 15915 startOfEventDay.setUTCHours(0, 0, 0, 0); 15916 let startOfToday = new Date(); 15917 startOfToday.setUTCHours(0, 0, 0, 0); 15918 if ( startOfEventDay.getTime() >= startOfToday.getTime() ){ 15919 return true; 15920 } 15921 } 15922 } 15923 return false; 15924 }) 15925 } 15926 return pre_filtered_event_data; 15927 }, 15928 filtered_event_data(){ 15929 let filtered_event_data = this.pre_filtered_event_data; 15930 //filter by search 15931 if (this.search.length > 0){ 15932 filtered_event_data = filtered_event_data.filter(event_data => { 15933 let name = event_data.event.get('name').toLowerCase(); 15934 if ( name.includes(this.search.toLowerCase()) ){ 15935 return event_data; 15936 } 15937 }) 15938 } 15939 // handle tab selection using filteredEvents 15940 var yesterday = new Date(); 15941 yesterday.setDate( yesterday.getDate() - 1 ); 15942 var timestamp = yesterday.getTime(); 15943 15944 //filter by tag 15945 if( this.selected_event_tag_id ){ 15946 filtered_event_data = filtered_event_data.filter(event_data => { 15947 let event_tag_ids = event_data.event_tag_ids; 15948 return event_tag_ids && event_tag_ids.includes(this.selected_event_tag_id); 15949 }) 15950 } 15951 //return them 15952 return filtered_event_data; 15953 }, 15954 sorted_event_data(){ 15955 return this.sortEventList( this.filtered_event_data, this.sortObject ); 15956 }, 15957 } 15958 }); 15959 15960</script>
15960
15960<script type="text/html" id="template-view-photo-carousel"> 15961 <div 15962 id="overlay_view-photo-carousel" 15963 class="carousel-v-overlay" 15964 :class="{ 'carousel-v-overlay-blur': should_blur_background }" 15965 @touchstart="touched_overlay()" 15966 @mousedown="touched_overlay()" 15967 @click.self="clicked_overlay()" 15968 > 15969 <div 15970 class="carousel-container" 15971 @click.self="clicked_overlay()" 15972 > 15973 <div class="actions-container" @click.stop> 15974 <div 15975 class="event-page-callout close-action-container" 15976 :class="{ 'carousel-action-icon-only': use_icon_only_actions }" 15977 style="color: #fff;" 15978 @click.stop="dismiss_overlay()" 15979 > 15980 <i class="fa-regular fa-xmark" style="color: #fff;"></i> 15981 <template v-if="!use_icon_only_actions">Close</template> 15982 </div> 15983 <div 15984 v-if="can_show_download" 15985 class="event-page-callout" 15986 :class="{ 'carousel-action-icon-only': use_icon_only_actions }" 15987 style="color: #fff;" 15988 @click.stop="tapped_download()" 15989 > 15990 <template v-if="!downloading"> 15991 <i class="fa-regular fa-arrow-down-to-line" style="color: #fff;"></i> 15992 <template v-if="!use_icon_only_actions">Download</template> 15993 </template> 15994 <template v-else> 15995 <i class="fa-regular fa-check" style="color: #fff;"></i> 15996 <template v-if="!use_icon_only_actions">Downloaded</template> 15997 </template> 15998 </div> 15999 <div 16000 v-if="can_show_share" 16001 class="event-page-callout" 16002 :class="{ 'carousel-action-icon-only': use_icon_only_actions }" 16003 style="color: #fff;" 16004 @click.stop="tapped_share()" 16005 > 16006 <template v-if="!sharing"> 16007 <i class="fa-regular fa-arrow-up-from-bracket" style="color: #fff;"></i> 16008 <template v-if="!use_icon_only_actions">Share</template> 16009 </template> 16010 <template v-else> 16011 <i class="fa-regular fa-check" style="color: #fff;"></i> 16012 <template v-if="!use_icon_only_actions">Link copied</template> 16013 </template> 16014 </div> 16015 </div> 16016 <i v-if="!hide_nav_arrows" class="fa-light fa-chevron-left nav-arrow" @click.stop="prev()"></i> 16017 <div v-if="current_photo" class="carousel-photo-container" @click="clicked_overlay()"> 16018 <div class="carousel-photo-frame" @click.stop> 16019 <video 16020 v-if="returnIsVideo(current_photo.file_name) && current_photo.url" 16021 class="carousel-photo" 16022 :class="{ 'is-loaded': detail_photo_loaded }" 16023 autoplay muted loop playsinline 16024 :key="'video-' + current_index" 16025 @loadeddata="detail_photo_loaded = true" 16026 > 16027 <source :src="current_photo.url" type="video/mp4"> 16028 </video> 16029 <img 16030 v-else-if="current_photo.url" 16031 class="carousel-photo" 16032 :class="{ 'is-loaded': detail_photo_loaded }" 16033 :src="current_photo.url" 16034 :key="'photo-' + current_index" 16035 alt="" 16036 ref="detail_photo" 16037 @load="detail_photo_loaded = true" 16038 > 16039 <template v-if="show_caption"> 16040 <p class="carousel-caption" v-if="current_photo.caption" :style="{ 'font-size': caption_size + 'px', 'font-weight': caption_font_weight }">{{ current_photo.caption }}</p> 16041 </template> 16042 </div> 16043 </div> 16044 <div v-else class="carousel-photo-container" @click="clicked_overlay()"> 16045 <p class="carousel-caption">No media found</p> 16046 </div> 16047 <i v-if="!hide_nav_arrows" class="fa-light fa-chevron-right nav-arrow" @click.stop="next()"></i> 16048 </div> 16049 </div> 16050</script>
16050 16051 16052<style> 16053 .carousel-v-overlay{ 16054 display: flex; 16055 align-items: center; 16056 justify-content: center; 16057 cursor: auto; 16058 background-color: transparent; 16059 position: fixed; 16060 z-index: 1000 !important; 16061 left: 0px; 16062 right: 0px; 16063 top: 0px; 16064 bottom: 0px; 16065 width: 100vw; 16066 height: 100vh; 16067 } 16068 .carousel-v-overlay::before{ 16069 content: ''; 16070 position: absolute; 16071 inset: 0; 16072 z-index: 0; 16073 pointer-events: none; 16074 background-color: rgba(8,8,19,0.64); 16075 opacity: 0; 16076 animation: carousel-overlay-fade 0.3s ease forwards; 16077 } 16078 .carousel-v-overlay-blur::before{ 16079 backdrop-filter: blur(6px); 16080 } 16081 @keyframes carousel-overlay-fade { 16082 0% { 16083 opacity: 0; 16084 } 16085 100% { 16086 opacity: 1; 16087 } 16088 } 16089 @keyframes carousel-chrome-fade { 16090 0% { 16091 opacity: 0; 16092 } 16093 100% { 16094 opacity: 1; 16095 } 16096 } 16097 @keyframes carousel-photo-scale { 16098 0% { 16099 transform: scale(0.8); 16100 } 16101 100% { 16102 transform: scale(1); 16103 } 16104 } 16105 .carousel-container{ 16106 display: flex; 16107 justify-content: space-between; 16108 align-items: center; 16109 height: 100vh; 16110 width: 98vw; 16111 position: relative; 16112 z-index: 1; 16113 overflow: hidden; 16114 cursor: auto; 16115 background-color: rgba(0, 0, 0, 0); 16116 } 16117 .carousel-container > .nav-arrow { 16118 font-size: 28px; 16119 padding: 1em; 16120 color: #fff; 16121 z-index: 1; 16122 opacity: 0; 16123 animation: carousel-nav-fade 0.3s ease forwards; 16124 transition: opacity 0.3s ease; 16125 cursor: pointer; 16126 } 16127 @keyframes carousel-nav-fade { 16128 0% { 16129 opacity: 0; 16130 } 16131 100% { 16132 opacity: 0.6; 16133 } 16134 } 16135 .carousel-container > .nav-arrow:hover { 16136 opacity: 1; 16137 } 16138 .carousel-photo-container { 16139 display: flex; 16140 flex-direction: column; 16141 justify-content: center; 16142 align-items: center; 16143 flex: 1 1 auto; 16144 width: auto; 16145 height: 70%; 16146 min-width: 0; 16147 min-height: 200px; 16148 cursor: pointer; 16149 } 16150 .carousel-photo-frame { 16151 display: flex; 16152 flex-direction: column; 16153 justify-content: center; 16154 align-items: center; 16155 max-width: 100%; 16156 max-height: 100%; 16157 cursor: auto; 16158 animation: carousel-photo-scale 0.3s ease forwards; 16159 } 16160 .carousel-photo { 16161 width: auto; 16162 height: auto; 16163 max-width: 100%; 16164 max-height: 70vh; 16165 object-fit: contain; 16166 background-size: contain; 16167 background-repeat: no-repeat; 16168 background-position: center; 16169 opacity: 0; 16170 transition: opacity 0.35s ease; 16171 } 16172 .carousel-photo.is-loaded { 16173 opacity: 1; 16174 } 16175 .carousel-caption{ 16176 margin: 10px 0 0 0; 16177 text-transform: uppercase; 16178 letter-spacing: 2px; 16179 color: #fff; 16180 padding: 5px; 16181 border-radius: 2px; 16182 background-color: rgba(0, 0 ,0, 0.1); 16183 } 16184 .actions-container{ 16185 position: fixed; 16186 top: 9px; 16187 right: 9px; 16188 display: flex; 16189 align-items: center; 16190 justify-content: center; 16191 opacity: 0; 16192 animation: carousel-chrome-fade 0.3s ease forwards; 16193 } 16194 .actions-container > .event-page-callout.close-action-container{ 16195 position: fixed; 16196 top: 9px; 16197 left: 9px; 16198 } 16199 .actions-container > .event-page-callout{ 16200 padding-left: 10px; 16201 padding-right: 10px; 16202 border-radius: 5px; 16203 border: 1px solid #e4ebf1; 16204 font-size: 13px; 16205 margin: 6px; 16206 color: #6a7482; 16207 padding-bottom: 8px; 16208 padding-top: 8px; 16209 cursor: pointer; 16210 } 16211 .actions-container > .event-page-callout i{ 16212 padding-right:3px; 16213 } 16214 .actions-container > .event-page-callout:hover{ 16215 color: #081333; 16216 } 16217 .actions-container > .event-page-callout.carousel-action-icon-only{ 16218 border: none; 16219 background: transparent; 16220 padding: 6px; 16221 margin: 4px; 16222 min-width: auto; 16223 } 16224 .actions-container > .event-page-callout.carousel-action-icon-only i{ 16225 padding-right: 0; 16226 font-size: 28px; 16227 } 16228</style> 16229
16230<script> 16231 16232 function downloadImage( url, filename ) { 16233 return fetch(url, { mode: 'cors' }) 16234 .then(response => { 16235 if (!response.ok) { 16236 throw new Error(`Network response was not ok: ${response.statusText}`); 16237 } 16238 return response.blob(); 16239 }) 16240 .then(blob => { 16241 const url = window.URL.createObjectURL(blob); 16242 const a = document.createElement('a'); 16243 a.style.display = 'none'; 16244 a.href = url; 16245 a.download = filename; 16246 // Append the anchor to the body 16247 document.body.appendChild(a); 16248 a.click(); 16249 // Remove the anchor from the body 16250 document.body.removeChild(a); 16251 window.URL.revokeObjectURL(url); 16252 }) 16253 .catch(err => console.error('Error downloading the image:', err)); 16254 } 16255 16256 store.components.view_photo_carousel = { 16257 json_photos: [], 16258 current_index: 0, 16259 downloading: false, 16260 sharing: false, 16261 detail_photo_loaded: false, 16262 json_config: { 16263 allow_share: false, 16264 allow_download: false, 16265 context: '', 16266 }, 16267 } 16268 16269 app_overlays.component("view-photo-carousel", { 16270 template: "#template-view-photo-carousel", 16271 data() { 16272 return store.components.view_photo_carousel; 16273 }, 16274 mounted() { 16275 this.bound_keydown = (event) => { 16276 this.handle_detail_keydown(event); 16277 }; 16278 window.addEventListener('keydown', this.bound_keydown); 16279 this.$nextTick(() => { 16280 this.check_detail_photo_cached(); 16281 }); 16282 }, 16283 unmounted() { 16284 if( this.bound_keydown ){ 16285 window.removeEventListener('keydown', this.bound_keydown); 16286 } 16287 }, 16288 watch: { 16289 current_index(){ 16290 this.detail_photo_loaded = false; 16291 this.$nextTick(() => { 16292 this.check_detail_photo_cached(); 16293 }); 16294 }, 16295 }, 16296 methods: { 16297 return_config_value( key, default_value ){ 16298 if ( this.json_config && this.json_config[key] !== undefined && this.json_config[key] !== null ){ 16299 return this.json_config[key]; 16300 } 16301 return default_value; 16302 }, 16303 handle_detail_keydown( event ){ 16304 if( !event ){ 16305 return; 16306 } 16307 let tag = event.target && event.target.tagName; 16308 if( tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' ){ 16309 return; 16310 } 16311 if( event.key === 'ArrowLeft' ){ 16312 event.preventDefault(); 16313 this.prev(); 16314 return; 16315 } 16316 if( event.key === 'ArrowRight' ){ 16317 event.preventDefault(); 16318 this.next(); 16319 } 16320 }, 16321 check_detail_photo_cached(){ 16322 if( this.current_photo && this.returnIsVideo(this.current_photo.file_name) ){ 16323 this.detail_photo_loaded = true; 16324 return; 16325 } 16326 let el = this.$refs.detail_photo; 16327 if( el && el.complete && el.naturalWidth ){ 16328 this.detail_photo_loaded = true; 16329 } 16330 }, 16331 async tapped_download(){ 16332 if ( !this.current_photo || !this.current_photo.url ){ 16333 return; 16334 } 16335 let url = this.current_photo.url; 16336 let file_name = this.current_photo.file_name || ('photo-' + (this.current_index + 1)); 16337 this.downloading = true; 16338 try { 16339 downloadImage( url, file_name ).then(() => { 16340 displayMessage("File downloaded.", false, 6000); 16341 this.downloading = false;
16342 }).catch(() => { 16343 displayMessage("Sorry, something went wrong with your download. Please try again, or contact support if the problem persists.", true, 8000); 16344 this.downloading = false; 16345 }) 16346 } catch (error) { 16347 displayMessage("Sorry, something went wrong with your download. Please try again, or contact support if the problem persists.", true, 8000); 16348 } 16349 }, 16350 returnIsVideo(filename) { 16351 var ext = this.getExtension(filename); 16352 switch (ext.toLowerCase()) { 16353 case 'm4v': 16354 case 'avi': 16355 case 'mpg': 16356 case 'mp4': 16357 case 'mp3': 16358 case 'mov': 16359 // etc 16360 return true; 16361 } 16362 return false; 16363 }, 16364 getExtension(filename) { 16365 var parts = filename.split('.'); 16366 return parts[parts.length - 1]; 16367 }, 16368 tapped_share(){ 16369 copyToClipboard( window.location.href ); 16370 displayMessage("Link copied.", false, 6000); 16371 this.sharing = true; 16372 setTimeout(() => { 16373 this.sharing = false; 16374 }, 3000); 16375 }, 16376 prev(){ 16377 if ( this.current_index === 0 ){ 16378 this.current_index = this.json_photos.length - 1; 16379 } else { 16380 this.current_index -= 1; 16381 } 16382 }, 16383 next(){ 16384 if ( this.current_index === this.json_photos.length - 1 ){ 16385 this.current_index = 0; 16386 } else { 16387 this.current_index += 1; 16388 } 16389 }, 16390 dismiss_overlay(){ 16391 store.overlays.view_photo_carousel = false; 16392 }, 16393 clicked_overlay(){ 16394 if( ! should_close_overlay( this.last_touch ) ){ 16395 return 16396 } 16397 this.dismiss_overlay(); 16398 }, 16399 touched_overlay(){ 16400 this.last_touch = new Date().getTime(); 16401 }, 16402 }, 16403 computed: { 16404 current_photo(){ 16405 if ( this.json_photos && this.json_photos.length > 0 && this.json_photos[this.current_index] ){ 16406 return this.json_photos[this.current_index]; 16407 } 16408 return null; 16409 }, 16410 can_show_download(){ 16411 var default_value = false; 16412 return this.return_config_value('allow_download', default_value) === true; 16413 }, 16414 can_show_share(){ 16415 var default_value = true; 16416 return this.return_config_value('allow_share', default_value) === true; 16417 }, 16418 show_caption(){ 16419 var default_value = false; 16420 return this.return_config_value('show_caption', default_value) === true; 16421 }, 16422 caption_size(){ 16423 var default_value = 16; 16424 return this.return_config_value('caption_size', default_value); 16425 }, 16426 caption_font_weight(){ 16427 var default_value = 400; 16428 return this.return_config_value('caption_font_weight', default_value); 16429 }, 16430 should_blur_background(){ 16431 var default_value = false; 16432 return this.return_config_value('blur_background', default_value) === true; 16433 }, 16434 use_icon_only_actions(){ 16435 var default_value = false; 16436 return this.return_config_value('icon_only_actions', default_value) === true; 16437 }, 16438 hide_nav_arrows(){ 16439 let default_value = false; 16440 return this.return_config_value('hide_nav_arrows', default_value) === true; 16441 }, 16442 } 16443 }); 16444 16445</script>
16445
16445<script type="text/html" id="template-bottom-sheet-overlay"> 16446 16447 <div 16448 class="v-overlay scrollable hugePaddingBottom active" 16449 @touchstart.self="touched_overlay()" 16450 @mousedown.self="touched_overlay()" 16451 @click.self="clicked_overlay()" 16452 :class="[{'noPointer': prevent_close}, {'bottom-sheet-blur': blur_background}]" 16453 > 16454 <div 16455 class="standardOverlayCard centeredBlock bottom-sheet" 16456 :class="[ 16457 {'short': height == 'short' || is_short}, 16458 {'narrow': narrow}, 16459 {'noMaxWidth': full_width} 16460 ]" 16461 > 16462 16463 <!-- generic header content --> 16464 <div class="bottom-sheet-overlay-header"> 16465 16466 <div 16467 v-if="! prevent_close" 16468 class="relative" 16469 > 16470 <div 16471 class="overlay-header-handle-wrap centeredBlock" 16472 @click.stop="toggle_height()" 16473 > 16474 <div class="overlay-header-handle"></div> 16475 </div> 16476 <i 16477 class="overlay-x-button fa-regular fa-xmark pointer" 16478 @click.stop="dismiss_overlay()" 16479 ></i> 16480 </div> 16481 16482 <!-- header content injected here --> 16483 <slot name="header"></slot> 16484 16485 </div> 16486 16487 <!-- body content injected here --> 16488 <slot name="body"></slot> 16489 16490 <!-- footer content injected here --> 16491 <slot name="footer"></slot> 16492 16493 </div> 16494 </div> 16495 16496</script>
16496 16497 16498<style> 16499 16500 .standardOverlayCard.bottom-sheet.narrow{ 16501 max-width: 600px; 16502 } 16503 16504 .standardOverlayCard.bottom-sheet.medium{ 16505 max-width: 800px; 16506 } 16507 16508 .standardOverlayCard.bottom-sheet{ 16509 position: fixed; 16510 bottom: 0px; 16511 top: 50px; 16512 left: 0px; 16513 right: 0px; 16514 z-index: 1001; 16515 background-color: #fff; 16516 box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); 16517 transition: bottom 0.3s cubic-bezier(0.25, 0.1, 0.25, 1); 16518 border-top-left-radius: 10px; 16519 border-top-right-radius: 10px; 16520 padding-bottom: 10px; 16521 padding-top: 10px; 16522 max-width: 1100px; 16523 margin-top: 0px; 16524 border-bottom-left-radius: 0px; 16525 border-bottom-right-radius: 0px; 16526 } 16527 16528 .standardOverlayCard.bottom-sheet.short{ 16529 top: 50%; 16530 } 16531 16532 @media (max-height: 600px) { 16533 16534 .standardOverlayCard.bottom-sheet.short{ 16535 top: 150px; 16536 } 16537 16538 } 16539 16540 .standardOverlayCard.bottom-sheet h3{ 16541 padding-top:0px; 16542 } 16543 16544 .standardOverlayCard.bottom-sheet{ 16545 display: flex; 16546 flex-direction: column; 16547 padding-bottom:0px; 16548 padding-top: 0px; 16549 } 16550 16551 .standardOverlayCard.bottom-sheet .bottom-sheet-overlay-card-header 16552 { 16553 flex: 0 0 auto; /* Fixed height, doesn't grow or shrink */ 16554 } 16555 16556 .standardOverlayCard.bottom-sheet .standardCardFooter{ 16557 position: absolute; 16558 bottom: 0px; 16559 padding-bottom: var(--cs-safe-area-bottom, 10px); 16560 left: 0px; 16561 right: 0px; 16562 background-color: #fff; 16563 border-top: 1px solid #e4ebf1; 16564 z-index: 10; 16565 } 16566 16567 .standardOverlayCard.bottom-sheet .standardCardBody{ 16568 flex: 1 1 auto; /* Flexible height, can grow and shrink */ 16569 padding-bottom: 90px !important; 16570 } 16571 16572 .overlay-header-handle-wrap{ 16573 padding: 10px; 16574 cursor: pointer; 16575 max-width: 120px; 16576 } 16577 .overlay-header-handle{ 16578 width: 70px; 16579 height: 3px; 16580 border-radius: 10px; 16581 background-color: #acb3ba; 16582 margin-left: auto; 16583 margin-right: auto; 16584 margin-top: 8px; 16585 margin-bottom: 8px; 16586 } 16587 .overlay-x-button{ 16588 position: absolute; 16589 right: 14px; 16590 color: #535e6c; 16591 font-size: 20px; 16592 top: 12px; 16593 } 16594</style> 16595
16596<script> 16597 16598 //define the component 16599 app_overlays.component("bottom-sheet-overlay", { 16600 template: "#template-bottom-sheet-overlay", 16601 data() { 16602 return { 16603 height: "tall" 16604 } 16605 }, 16606 props: { 16607 blur_background: { 16608 type: Boolean, 16609 default: false 16610 }, 16611 narrow: { 16612 type: Boolean, 16613 default: false 16614 }, 16615 prevent_close: { 16616 type: Boolean, 16617 default: false 16618 }, 16619 full_width: { 16620 type: Boolean, 16621 default: false 16622 }, 16623 is_short: { 16624 type: Boolean, 16625 default: false 16626 } 16627 }, 16628 mounted(){ 16629 const universalEditOverlay = document.getElementById('overlay_universal-edit'); 16630 if (universalEditOverlay) { 16631 universalEditOverlay.style.zIndex = "1000000"; 16632 } 16633 const membershipValidationOverlay = document.getElementById('overlay_verifyMembership'); 16634 if (membershipValidationOverlay) { 16635 membershipValidationOverlay.style.zIndex = "1000000"; 16636 } 16637 }, 16638 beforeUnmount() { 16639 //reset the z-index 16640 const universalEditOverlay = document.getElementById('overlay_universal-edit'); 16641 if (universalEditOverlay) { 16642 universalEditOverlay.style.zIndex = ""; // Revert to default 16643 } 16644 const membershipValidationOverlay = document.getElementById('overlay_verifyMembership'); 16645 if (membershipValidationOverlay) { 16646 membershipValidationOverlay.style.zIndex = ""; // Revert to default 16647 } 16648 }, 16649 methods: { 16650 dismiss_overlay(){ 16651 this.$emit('dismiss_overlay'); 16652 }, 16653 toggle_height(){ 16654 if( this.height === "tall" ){ 16655 this.height = "short"; 16656 }else{ 16657 this.height = "tall"; 16658 } 16659 }, 16660 clicked_overlay(){ 16661 if( this.prevent_close ){ 16662 return; 16663 } 16664 if (should_close_overlay(this.last_touch)) { 16665 this.dismiss_overlay(); 16666 } 16667 }, 16668 touched_overlay(){ 16669 this.last_touch = new Date().getTime(); 16670 }, 16671 }, 16672 }); 16673 16674 16675</script>
16675 16676 16677<style> 16678 .bottom-sheet-blur{ 16679 backdrop-filter: blur(6px); 16680 } 16681 16682 @keyframes bottom-sheet-fade-in { 16683 from { opacity: 0; } 16684 to { opacity: 1; } 16685 } 16686</style>
16686<script type="text/html" id="template-show-event-details-in-member-cal"> 16687 <bottom-sheet-overlay 16688 @dismiss_overlay="dismiss_overlay()" 16689 :narrow="true" 16690 > 16691 <!-- header content --> 16692 <template #header></template> 16693 16694 <!-- body content --> 16695 <template #body> 16696 <div 16697 class="standardCardBody fff scrollable " 16698 > 16699 <h2 v-if="event_json.title" class="event-page-name centeredText marginTop">{{ event_json.title }}</h2> 16700 <div class="flexWrap smallMarginBottom tinyMarginTop"> 16701 <p v-if="date_string" class="hundop flexNoWrapLeftAlign"> 16702 <i class="fa-light fa-calendar-week" style="font-size:20px;"></i> 16703 <span class="leftText tinyMarginLeft">{{ date_string }}</span> 16704 </p> 16705 <p v-if="time_string" class="hundop flexNoWrapLeftAlign" style="margin-top: 12px;"> 16706 <i class="fa-light fa-clock" style="font-size:20px;"></i> 16707 <span class="leftText tinyMarginLeft">{{ time_string }}</span> 16708 </p> 16709 <!-- in-portal booking links (eg. group classes surfaced in the member 16710 portal booking hub) navigate within the SPA instead of opening the 16711 standalone page in a new tab --> 16712 <p v-if="event_json.in_portal_path" class="hundop flexNoWrapLeftAlign" style="margin-top: 12px;"> 16713 <i class="fa-light fa-link" style="font-size:20px;"></i> 16714 <span class="leftText tinyMarginLeft"> 16715 <a class="pointer" @click="tapped_in_portal_link()">Follow link</a> 16716 </span> 16717 </p> 16718 <p v-else-if="event_json.external_event_url" class="hundop flexNoWrapLeftAlign" style="margin-top: 12px;"> 16719 <i class="fa-light fa-link" style="font-size:20px;"></i> 16720 <span class="leftText tinyMarginLeft"> 16721 <a target="_blank" :href="event_json.external_event_url">Follow link</a> 16722 </span> 16723 </p> 16724 </div> 16725 16726 <div 16727 v-if="event_image_url" 16728 style="display:flex; width:100%; justify-content:center;" 16729 > 16730 <div 16731 v-if="image_crop_style == 'cover'" 16732 style="width:100%; max-width:580px; height:400px; position:relative; overflow:hidden; border:1px solid #e4ebf1; border-radius:5px; display:flex; align-items:center; justify-content:center;" 16733 > 16734 <img 16735 :src="event_image_url" 16736 style="width:100%; height:100%; display:block; object-fit:cover;" 16737 @error="console.log('event image failed to load:', event_image_url)" 16738 /> 16739 </div> 16740 16741 <div 16742 v-else 16743 style="width:100%; max-width:580px; height:400px; position:relative; overflow:hidden; border:1px solid #e4ebf1; border-radius:5px;" 16744 > 16745 <div 16746 :style="{ backgroundImage: 'url(' + event_image_url + ')', position: 'absolute', top: '0', left: '0', right: '0', bottom: '0', backgroundRepeat: 'no-repeat', backgroundPosition: 'center', backgroundSize: 'cover', filter: 'blur(6px)' }" 16747 > 16748 <div 16749 style="position:absolute; top:0; right:0; left:0; bottom:0; background-color: rgba(253,253,253,0.8);" 16750 ></div> 16751 </div> 16752 <div 16753 :style="{ backgroundImage: 'url(' + event_image_url + ')', position: 'absolute', top: '0', left: '0', right: '0', bottom: '0', backgroundRepeat: 'no-repeat', backgroundPosition: 'center', backgroundSize: 'contain' }" 16754 ></div> 16755 </div> 16756 </div> 16757 <div 16758 class="event-page-details flex-col" 16759 > 16760 <div class="event-page-details-wrapper maxSixFifty flex-col centeredText"> 16761 <p 16762 v-if="event_json.description" 16763 class="smallMarginTop tiptap-container marginBottom" 16764 style="line-height: 1.5;" 16765 v-html="event_json.description"> 16766 </p> 16767 </div> 16768 </div> 16769 </div> 16770 </template> 16771 16772 16773 </bottom-sheet-overlay> 16774</script>
16774 16775 16776
16777<script> 16778 16779 function follow_member_cal_event_link(event_json) { 16780 if (event_json.in_portal_path) { 16781 updateURL(event_json.in_portal_path); 16782 displayViewForUrlPath(); 16783 return true; 16784 } 16785 if (event_json.external_event_url) { 16786 window.open(event_json.external_event_url, '_blank'); 16787 return true; 16788 } 16789 return false; 16790 } 16791 16792 function show_event_details_in_member_cal(params) { 16793 store.components.show_event_details_in_member_cal.event_json = params.event_json; 16794 store.overlays.show_event_details_in_member_cal = true; 16795 } 16796 16797 store.components["show_event_details_in_member_cal"] = { 16798 event_json: null, 16799 } 16800 16801 app_overlays.component("show-event-details-in-member-cal-overlay", { 16802 template: "#template-show-event-details-in-member-cal", 16803 data() { 16804 return store.components["show_event_details_in_member_cal"]; 16805 }, 16806 methods: { 16807 dismiss_overlay(){ 16808 store.overlays.show_event_details_in_member_cal = false; 16809 }, 16810 tapped_in_portal_link(){ 16811 this.dismiss_overlay(); 16812 follow_member_cal_event_link(this.event_json); 16813 }, 16814 }, 16815 computed: { 16816 event_image_url(){ 16817 if(this.event_json && this.event_json.image && this.event_json.image.url){ 16818 return this.event_json.image.url; 16819 } 16820 return null; 16821 }, 16822 image_crop_style(){ 16823 if(this.event_json && this.event_json.image && this.event_json.image.crop){ 16824 return this.event_json.image.crop; 16825 } 16826 return null; 16827 }, 16828 date_string(){ 16829 var start_date_string = format_utc_date_v2({ 16830 utcDate: this.event_json.ts, 16831 formatString: 'MMMM D, YYYY', 16832 timezone: store.clubObject.get('timezone') 16833 }); 16834 if(this.event_json.isAllDay){ 16835 //it could span multiple days, check if we should show the end date 16836 var endDate = this.event_json.end_ts || null; 16837 if(endDate){ 16838 //compare the date/month/year only 16839 var startDateObj = new Date(this.event_json.ts); 16840 var endDateObj = new Date(endDate); 16841 if(startDateObj.getUTCFullYear() !== endDateObj.getUTCFullYear() ||startDateObj.getUTCMonth() !== endDateObj.getUTCMonth() || startDateObj.getUTCDate() !== endDateObj.getUTCDate()){ 16842 //different dates, show the end date 16843 start_date_string += ` - ${ format_utc_date_v2({ 16844 utcDate: endDate, 16845 formatString: 'MMMM D, YYYY', 16846 timezone: store.clubObject.get('timezone') 16847 })}`; 16848 } 16849 } 16850 } 16851 return start_date_string; 16852 }, 16853 time_string(){ 16854 if(this.event_json.isAllDay){ 16855 return ""; 16856 } 16857 var startDate = this.event_json.ts; 16858 var endDate = this.event_json.end_ts || null; 16859 var time_string = format_utc_date_v2({ 16860 utcDate: this.event_json.ts, 16861 formatString: 'h:mm A', 16862 timezone: store.clubObject.get('timezone') 16863 }); 16864 if(endDate && endDate !== startDate) { 16865 time_string += ` - ${ format_utc_date_v2({ 16866 utcDate: endDate, 16867 formatString: 'h:mm A', 16868 timezone: store.clubObject.get('timezone') 16869 })}`; 16870 } 16871 return time_string; 16872 } 16873 }, 16874 }); 16875</script>
16875 16876 16877 16878 <!-- define/register shared vue components --> 16879 16880
16880<script type="text/html" id="template-calendar-date-picker"> 16881 <vue-date-picker 16882 v-model="selected_date" 16883 :mode="mode" 16884 :modes="['year', 'month']" 16885 :monthPicker="true" 16886 :yearPicker="true" 16887 @update:modelValue="date_changed()" 16888 :clearable="false" 16889 format="MMM-d-yyyy" 16890 auto-apply 16891 :teleport="true" 16892 :month-change-on-scroll="false" 16893 menu-class-name="cs-calendar-date-picker-menu" 16894 input-class-name="cs-calendar-date-picker-input" 16895 > 16896 <template #input-icon> 16897 <i class="fa-regular fa-calendar-arrow-down"></i> 16898 </template> 16899 <template #calendar-icon> 16900 <i class="fa-regular fa-arrow-turn-down-left"></i> 16901 </template> 16902 <template #arrow-left> 16903 <i class="fa-regular fa-chevron-left"></i> 16904 </template> 16905 <template #arrow-right> 16906 <i class="fa-regular fa-chevron-right"></i> 16907 </template> 16908 </vue-date-picker> 16909 </script>
16909 16910 16911 <style> 16912 16913 .cs-calendar-date-picker-menu .dp__btn{ 16914 color: #000000 !important; 16915 background: none !important; 16916 font: inherit !important; 16917 line-height: normal !important; 16918 } 16919 16920 .cs-calendar-date-picker-input{ 16921 width: 0px; 16922 padding-left: 26px; 16923 padding-right: 22px; 16924 text-align: center; 16925 margin-right: 8px; 16926 overflow: hidden; 16927 padding-top: 7px; 16928 padding-bottom: 7px; 16929 border: 1px solid #e4ebf1; 16930 } 16931 16932 .cs-calendar-menu-wrap .dp__input_icon i{ 16933 font-size: 16px !important; 16934 transform: none; 16935 } 16936 16937 </style> 16938 16939
16939<script> 16940 16941 app_content.component("calendar-date-picker-component", { 16942 template: "#template-calendar-date-picker", 16943 data() { 16944 return { 16945 selected_date: new Date(), 16946 mode: 'month', 16947 } 16948 }, 16949 props: { 16950 handle_button_click: Function, 16951 }, 16952 methods: { 16953 date_changed(){ 16954 this.handle_button_click( 'date-picker', this.selected_date ); 16955 }, 16956 }, 16957 }); 16958 16959 </script>
16959 16960 16961
16961<script> 16962 app_content.component('VueDatePicker', VueDatePicker); 16963 </script>
16963 16964
16964<script type="text/html" id="template-calendar-grid"> 16965 <div class="centeredOnMobile smallMarginTop" :class="{'ninetyPercent': !is_membership_cal, 'standardCard': !is_membership_cal}"> 16966 <!-- main options header // stays consistent across all views --> 16967 <div class="standardCardBody noBorderTop calendar noPadding fff" style="overflow: none;"> 16968 <!-- options for non-membership cal --> 16969 <div v-if="filterOptions?.length >= 1 && !is_membership_cal" class="cal-filter-option-wrap scrollable paddingLeftRight tinyPaddingTop smallPaddingBottom"> 16970 <div class="flexNoWrap"> 16971 <template v-for="option in filterOptions"> 16972 <div 16973 class="cs-cal-filter-option" 16974 :class="[ 16975 'cs-cal-filter-option_' + option.data_option, 16976 { 'active': selectedFilterOptions.indexOf(option.data_option) !== -1 } 16977 ]" 16978 :data-option="option.data_option" 16979 @click="$emit('handleFilterSelection', option)" 16980 > 16981 {{ option.name }} 16982 </div> 16983 </template> 16984 </div> 16985 </div> 16986 <!-- main calendar header // stays consistent across all views --> 16987 <!-- options for membership cal --> 16988 <div class="cs-calendar-wrap overflowHidden" :class="[{ 'wrapBorderBottom': is_membership_cal }, 'cs-calendar-wrap_' + client_id, class_string]" :style="generateWrapStyle('cs-calendar-wrap')" :data-client-id="client_id"> 16989 <div v-if="filterOptions?.length >= 1 && is_membership_cal" class="scrollable posScrollbar lightBorderTop noPaddingBottom"> 16990 <div class="flexNoWrap" :style="generateWrapStyle('options')"> 16991 <template v-for="option in filterOptions"> 16992 <div 16993 class="cs-cal-filter-option" 16994 :class="{ 16995 ['cs-cal-filter-option_' + option.data_option]: true, 16996 'active': selectedFilterOptions.indexOf(option.data_option) !== -1 16997 }" 16998 :data-option="option.data_option" 16999 @click="$emit('handleFilterSelection', option)" 17000 > 17001 {{ option.name }} 17002 </div> 17003 </template> 17004 </div> 17005 </div> 17006 <div class="cs-calendar-menu-wrap flexNoWrap" :style="generateWrapStyle('cs-calendar-menu-wrap')"> 17007 <div class="flexWrapLeftAlign"> 17008 <div class="flexNoWrap"> 17009 <div class="cs-calendar-button roboto hideOnMobile" data-option="today" @click="handleButtonClick('today')">Today</div> 17010 <div class="cs-calendar-button cs-calendar-button-previous roboto" data-option="previous" @click="handleButtonClick('previous')"></div> 17011 <div class="cs-calendar-button cs-calendar-button-next roboto" data-option="next" @click="handleButtonClick('next')"></div> 17012 </div> 17013 <p v-if="calendarTitle" class="cs-calendar-date-range-title roboto">{{ calendarTitle }}</p> 17014 </div> 17015 <div class="flexGrowOne roboto"></div> 17016 <!-- calendar date picker --> 17017 <calendar-date-picker-component 17018 :handle_button_click="handleButtonClick" 17019 ></calendar-date-picker-component> 17020 <div class="vue-cs-calendar-select-wrap onWhite roboto"> 17021 <select 17022 class="vue-cs-calendar-view-select roboto" 17023 v-model="view_preference" 17024 @change="changeViewPreference" 17025 > 17026 <option value="Month">Month</option> 17027 <option value="Week">Week</option> 17028 <option value="Day">Day</option> 17029 </select> 17030 </div> 17031 </div> 17032 <!-- inject month view --> 17033 <div v-if="view_preference === 'Month' && !loading" class="flexGrid"> 17034 <!-- header -->
17035 <div class="daysOfWeek-grid"> 17036 <template v-for="day in daysOfWeek"> 17037 <calendar-cell-component 17038 class="grid-cell dayOfWeek" 17039 :class="{ 'borderClose': day === 'Sat' }" 17040 :day="day.toUpperCase()" 17041 cellType="dayOfWeek" 17042 :view_preference="view_preference" 17043 ></calendar-cell-component> 17044 </template> 17045 </div> 17046 <!-- events --> 17047 <div class="calendar-grid"> 17048 <template v-for="(calendarObj, i) in calendar"> 17049 <calendar-cell-component 17050 class="grid-cell" 17051 :client_id="client_id" 17052 :date="calendarObj.date" 17053 :class="{ 17054 'notCurrentMonth': calendarObj.class === 'prevMonthDate' || calendarObj.class === 'nextMonthDate', 17055 'currentMonth': calendarObj.class === 'currentMonthDate', 17056 'borderClose': calendarObj.date.day() === 6, 17057 'borderTopRightBottomClose': i >= 35 && i <= 40, 17058 'borderTopBottomClose': i === calendar.length - 1 17059 }" 17060 :month_class="calendarObj.class" 17061 :day="calendarObj.day" 17062 :events="calendarObj.events" 17063 cellType="calendarObj" 17064 :view_preference="view_preference" 17065 :defaultDate="calendarObj.defaultDate" 17066 :monthIndex="i" 17067 @createSchedule_emitted="createSchedule_emitted" 17068 @clickSchedule_emitted="clickSchedule_emitted" 17069 :is_membership_cal="is_membership_cal" 17070 ></calendar-cell-component> 17071 </template> 17072 </div> 17073 </div> 17074 <!-- inject week view --> 17075 <div v-if="view_preference === 'Week' && !loading" class="flexGrid"> 17076 <!-- header --> 17077 <div class="placeholder-daysOfWeek-grid"> 17078 <template v-for="day in placeholder_daysOfWeek"> 17079 <calendar-cell-component v-if="day.dayOfWeek === null" 17080 class="grid-cell-week dayOfWeek hide" 17081 :day="day" 17082 cellType="dayOfWeek" 17083 :view_preference="view_preference" 17084 ></calendar-cell-component> 17085 <calendar-cell-component v-if="day.dayOfWeek !== null" 17086 class="grid-cell-week dayOfWeek" 17087 :day="day" 17088 cellType="dayOfWeek" 17089 :view_preference="view_preference" 17090 :defaultDate="day.defaultDate" 17091 ></calendar-cell-component> 17092 </template> 17093 <!-- all day events --> 17094 <template v-for="allDay in placeholder_daysOfWeek"> 17095 <calendar-cell-component v-if="allDay.dayOfWeek === null" 17096 class="grid-cell-week allDay" 17097 :allDay="allDay" 17098 cellType="allDay_events" 17099 :view_preference="view_preference" 17100 :is_membership_cal="is_membership_cal" 17101 ></calendar-cell-component> 17102 <calendar-cell-component v-if="allDay.dayOfWeek !== null" 17103 class="grid-cell-week allDay" 17104 :allDay="allDay" 17105 cellType="allDay_events" 17106 :view_preference="view_preference" 17107 @createSchedule_emitted="createSchedule_emitted" 17108 @clickSchedule_emitted="clickSchedule_emitted" 17109 :is_membership_cal="is_membership_cal" 17110 ></calendar-cell-component> 17111 </template> 17112 </div> 17113 <!-- timestamps and events --> 17114 <div class="placeholder-calendar-grid"> 17115 <template v-for="(timestamp, i) in timestamps"> 17116 <template v-for="(calendarObj, j) in calendar"> 17117 <calendar-cell-component 17118 v-if="j === 0" 17119 class="grid-cell-week timestamp" 17120 :date="calendarObj.date" 17121 :timestamp="i % 4 === 0 ? formatShortTime( timestamp.customFormat ) : null" 17122 :events="[]" 17123 cellType="timestamp" 17124 :view_preference="view_preference" 17125 :is_membership_cal="is_membership_cal" 17126 :class="{ 'borderBottomClose': i === timestamps.length - 1 }" 17127 ></calendar-cell-component> 17128 <calendar-cell-component 17129 v-else 17130 class="grid-cell-week" 17131 :style="calendarObj.defaultDate ? { 'background-color': 'rgba( 175, 213, 240, 0.1)' } : null" 17132 :class="{ 17133 'showBorder': i % 4 === 0, 17134 'hideBorder': i === 0 || i % 4 !== 0 17135 }" 17136 :date="calendarObj.date" 17137 :events="generateEventForTimestamp( timestamp.customFormat, calendarObj.events, calendarObj.date )" 17138 cellType="calendarObj" 17139 :view_preference="view_preference" 17140 @createSchedule_emitted="createSchedule_emitted" 17141 @clickSchedule_emitted="clickSchedule_emitted" 17142 ></calendar-cell-component> 17143 </template> 17144 </template> 17145 </div> 17146 </div> 17147 <!-- inject day view --> 17148 <div v-if="view_preference === 'Day' && !loading" class="flexGrid"> 17149 <!-- header -->
17150 <div class="day-daysOfWeek-grid"> 17151 <template v-for="day in today_daysOfWeek"> 17152 <calendar-cell-component v-if="day.dayOfWeek === null" 17153 class="grid-cell-week dayOfWeek hide" 17154 :day="day" 17155 cellType="dayOfWeek" 17156 :view_preference="view_preference" 17157 ></calendar-cell-component> 17158 <calendar-cell-component v-if="day.dayOfWeek !== null" 17159 class="grid-cell-week day" 17160 :day="day" 17161 cellType="dayOfWeek" 17162 :view_preference="view_preference" 17163 :defaultDate="day.defaultDate" 17164 ></calendar-cell-component> 17165 </template> 17166 <!-- all day events --> 17167 <template v-for="allDay in today_daysOfWeek"> 17168 <calendar-cell-component v-if="allDay.dayOfWeek === null" 17169 class="grid-cell-week allDay" 17170 :allDay="allDay" 17171 cellType="allDay_events" 17172 :view_preference="view_preference" 17173 :is_membership_cal="is_membership_cal" 17174 ></calendar-cell-component> 17175 <calendar-cell-component v-if="allDay.dayOfWeek !== null" 17176 class="grid-cell-week allDay" 17177 :allDay="allDay" 17178 cellType="allDay_events" 17179 :view_preference="view_preference" 17180 @createSchedule_emitted="createSchedule_emitted" 17181 @clickSchedule_emitted="clickSchedule_emitted" 17182 :is_membership_cal="is_membership_cal" 17183 ></calendar-cell-component> 17184 </template> 17185 </div> 17186 <!-- timestamps and events --> 17187 <div class="day-calendar-grid"> 17188 <template v-for="(timestamp, i) in timestamps"> 17189 <template v-for="(calendarObj, j) in calendar"> 17190 <calendar-cell-component v-if="j === 0" 17191 class="grid-cell-week timestamp" 17192 :date="calendarObj.date" 17193 :timestamp="i % 4 === 0 ? formatShortTime( timestamp.customFormat ) : null" 17194 :events="[]" 17195 cellType="timestamp" 17196 :view_preference="view_preference" 17197 :is_membership_cal="is_membership_cal" 17198 :class="{ 'borderBottomClose': i === timestamps.length - 1 }" 17199 ></calendar-cell-component> 17200 <calendar-cell-component v-if="j !== 0" 17201 class="grid-cell-week" 17202 :style="calendarObj.defaultDate ? { 'background-color': 'rgba( 175, 213, 240, 0.1)' } : null" 17203 :class="{ 17204 'showBorder': i % 4 === 0, 17205 'hideBorder': i === 0 || i % 4 !== 0 17206 }" 17207 :date="calendarObj.date" 17208 :events="generateEventForTimestamp( timestamp.customFormat, calendarObj.events, calendarObj.date )" 17209 cellType="calendarObj" 17210 :view_preference="view_preference" 17211 @createSchedule_emitted="createSchedule_emitted" 17212 @clickSchedule_emitted="clickSchedule_emitted" 17213 ></calendar-cell-component> 17214 </template> 17215 </template> 17216 </div> 17217 </div> 17218 <!-- spinner while loading --> 17219 <div v-if="loading" class="cardBodySpinnerWrap relative active"> 17220 <div class="cardBodySpinner centeredBlock"></div> 17221 </div> 17222 </div> 17223 </div> 17224 </div> 17225</script>
vendor: 1 bytes, line 17225
17225
17226<script> 17227 /* ----- STORE ----- */ 17228 store.components["calendar_grid"] = { 17229 calendar: [], 17230 loading: true, 17231 view_preference: 'Month', 17232 dropdownOpen: false, 17233 daysOfWeek: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], 17234 placeholder_daysOfWeek: [], 17235 today_daysOfWeek: [], 17236 view_options: [ 'Month', 'Week', 'Day' ], 17237 calendarTitle: null, 17238 //dates and times 17239 currentDate: dayjs() || new Date(), 17240 defaultDate: dayjs() || new Date(), 17241 currentMonth: dayjs().format('MMMM'), 17242 startOfWeek: dayjs().startOf('week'), 17243 endOfWeek: dayjs().endOf('week'), 17244 currentYear: dayjs().year(), 17245 timestamps: [], 17246 currentTime: dayjs().format('HH:mm:ss'), 17247 }; 17248 17249 /* ----- COMPONENT ----- */ 17250 app_content.component("calendar-grid-component", { 17251 template: "#template-calendar-grid", 17252 data() { 17253 return store.components['calendar_grid'] 17254 }, 17255 created(){ 17256 if (this.timestamps.length === 0){ 17257 this.generateTimestamps(); 17258 } 17259 }, 17260 props: { 17261 client_id: String, 17262 class_string: String, 17263 style_string: String, 17264 filterOptions: Array, 17265 selectedFilterOptions: Array, 17266 schedules: Array, 17267 is_membership_cal: Boolean, 17268 load_cal: Function, 17269 handleFilterSelection: Function, 17270 createSchedule: Function, 17271 clickSchedule: Function, 17272 }, 17273 methods: { 17274 createSchedule_emitted( date ){ 17275 this.$emit('createSchedule', date) 17276 }, 17277 clickSchedule_emitted( event ){ 17278 this.$emit('clickSchedule', event ) 17279 }, 17280 formatShortTime( timeString ){ 17281 if (!timeString){ 17282 return '' // placeholder for 11:45-12:00 block 17283 } 17284 // if (timeString === '12:00 AM'){ 17285 // return Intl.DateTimeFormat().resolvedOptions().timeZone; 17286 // } 17287 const [hours, minutes] = timeString.split(':'); 17288 const AM_PM = minutes.split(' ')[1] 17289 return `${hours} ${AM_PM}`; 17290 }, 17291 generateWrapStyle( type ){ 17292 if (type === 'cs-calendar-wrap'){ 17293 return this.style_string === undefined ? {'width': '100%'} : this.style_string; 17294 } else if (type === 'options' && this.is_membership_cal){ 17295 return { 17296 'padding-top': '15px', 17297 'padding-right': '15px', 17298 'padding-left': '15px', 17299 'padding-bottom': '5px', 17300 'border-width': '0 1.5px 0 1.5px', 17301 'border-color': '#e2e8e8', 17302 'border-style': 'solid', 17303 'overflow': 'auto', 17304 } 17305 } else if (type === 'cs-calendar-menu-wrap' && this.is_membership_cal){ 17306 return { 17307 'border-width': '0 1.5px 0 1.5px', 17308 'border-color': '#e2e8e8', 17309 'border-style': 'solid' 17310 } 17311 } 17312 }, 17313 generateEventForTimestamp( timestamp, events, date ){ 17314 // do not want to display all day or multiple day events -- being handled separately 17315 let eventsArr = []; 17316 if (events.length > 0){ 17317 for (let i = 0; i < events.length; i++){ 17318 let event = events[i]; 17319 // normal event 17320 if ((event.hours_mins_start && event.hours_mins_end) && !event.isAllDay && event.calendarOnly) { 17321 let startTime = dayjs().set('hour', event.hours_mins_start.hours).set('minute', event.hours_mins_start.minutes); 17322 let endTime = dayjs().set('hour', event.hours_mins_end.hours).set('minute', event.hours_mins_end.minutes).subtract(15, 'minutes'); 17323 17324 let formattedStartTime = startTime.format('h:mm A'); 17325 let formattedEndTime = endTime.format('h:mm A'); 17326 17327 let timesArray = [formattedStartTime]; 17328 while (startTime.isBefore(endTime)) { 17329 startTime = startTime.add(15, 'minutes'); 17330 timesArray.push(startTime.format('h:mm A')); 17331 } 17332 if ( formattedStartTime === timestamp ){ 17333 eventsArr.push({ event, numOfTimeBlocks: timesArray.length }); 17334 } 17335 // recurring event 17336 }
17336 else if ( (event.start && event.end) && (event.start !== event.end) && !event.isAllDay && !event.isMultipleDay && event.calendarOnly ){ 17337 let startTime = dayjs(event.start); 17338 let endTime = dayjs(event.end).subtract(15, 'minutes'); 17339 17340 let formattedStartTime = startTime.format('h:mm A'); 17341 let formattedEndTime = endTime.format('h:mm A'); 17342 17343 let timesArray = [formattedStartTime]; 17344 17345 while (startTime.isBefore(endTime)) { 17346 startTime = startTime.add(15, 'minutes'); 17347 timesArray.push(startTime.format('h:mm A')); 17348 } 17349 if ( formattedStartTime === timestamp ){ 17350 eventsArr.push({ event, numOfTimeBlocks: timesArray.length }); 17351 } 17352 // non-calendarOnly events 17353 } else if ( (event.start && event.end) && (event.start !== event.end) && !event.calendarOnly ){ 17354 let startTime; 17355 let endTime; 17356 17357 if (dayjs(event.start).startOf('day').$d.toString() === dayjs(date).startOf('day').$d.toString()){ 17358 startTime = dayjs(event.start); 17359 endTime = dayjs(event.end).subtract(15, 'minutes'); 17360 } else if (dayjs(event.end).startOf('day').$d.toString() === dayjs(date).startOf('day').$d.toString()) { 17361 startTime = dayjs(event.end).startOf('day'); 17362 endTime = dayjs(event.end).subtract(15, 'minutes'); 17363 } else { 17364 startTime = dayjs().startOf('day'); 17365 endTime = dayjs().endOf('day'); 17366 } 17367 17368 let formattedStartTime = startTime.format('h:mm A'); 17369 let formattedEndTime = endTime.format('h:mm A'); 17370 17371 let timesArray = [formattedStartTime]; 17372 17373 while (startTime.isBefore(endTime)) { 17374 startTime = startTime.add(15, 'minutes'); 17375 timesArray.push(startTime.format('h:mm A')); 17376 } 17377 if ( formattedStartTime === timestamp ){ 17378 eventsArr.push({ event, numOfTimeBlocks: timesArray.length }); 17379 } 17380 } 17381 } 17382 } 17383 if (eventsArr.length > 0){ 17384 return eventsArr; 17385 } else { 17386 return []; 17387 } 17388 }, 17389 changeViewPreference(){ 17390 this.loading = true; 17391 //and re-load data 17392 this.$emit('load_cal', this.client_id); 17393 }, 17394 calculateTitleRange(){ 17395 if ( this.view_preference === 'Month' ){ 17396 this.calendarTitle = `${this.currentMonth} ${this.currentYear.toString()}`; 17397 } else if ( this.view_preference === 'Week' ){ 17398 this.calendarTitle = `${this.startOfWeek.format('MMMM')} ${this.startOfWeek.$D} - ${this.endOfWeek.format('MMMM')} ${this.endOfWeek.$D}`; 17399 } else if ( this.view_preference === 'Day' ){ 17400 this.calendarTitle = `${this.currentMonth} ${this.currentDate.$D}, ${this.currentYear}`; 17401 } 17402 }, 17403 handleButtonClick( option, selectedDate ){ 17404 this.loading = true; 17405 if (this.view_preference === 'Month'){ 17406 if( option === "today" ){ 17407 this.currentDate = dayjs(); 17408 this.currentMonth = this.currentDate.format('MMMM') 17409 this.currentYear = this.currentDate.$y 17410 this.startOfWeek = this.currentDate.startOf('week'); 17411 this.endOfWeek = this.currentDate.endOf('week'); 17412 }else if( option === "next" ){ 17413 this.currentDate = this.currentDate.add(1, 'month').startOf('month'); 17414 this.currentMonth = this.currentDate.format('MMMM') 17415 this.currentYear = this.currentDate.$y 17416 this.startOfWeek = this.currentDate.startOf('week'); 17417 this.endOfWeek = this.currentDate.endOf('week'); 17418 17419 }else if( option === "previous" ){ 17420 this.currentDate = this.currentDate.subtract(1, 'month').startOf('month'); 17421 this.currentMonth = this.currentDate.format('MMMM') 17422 this.currentYear = this.currentDate.$y 17423 this.startOfWeek = this.currentDate.startOf('week'); 17424 this.endOfWeek = this.currentDate.endOf('week'); 17425 }else if ( option === "date-picker" ){ 17426 let { month, year } = selectedDate; 17427 this.currentDate = dayjs(new Date(year, month, 1)); 17428 this.currentMonth = this.currentDate.format('MMMM') 17429 this.currentYear = this.currentDate.$y 17430 this.startOfWeek = this.currentDate.startOf('week'); 17431 this.endOfWeek = this.currentDate.endOf('week'); 17432 } 17433 } else if (this.view_preference === 'Week'){ 17434 if( option === "today" ){ 17435 this.currentDate = dayjs(); 17436 this.currentMonth = this.currentDate.format('MMMM') 17437 this.currentYear = this.currentDate.$y 17438 this.startOfWeek = this.currentDate.startOf('week'); 17439 this.endOfWeek = this.currentDate.endOf('week'); 17440 }else if( option === "next" ){ 17441 this.currentDate = this.currentDate.add(1, 'week'); 17442 this.currentMonth = this.currentDate.format('MMMM') 17443 this.currentYear = this.currentDate.$y 17444 this.startOfWeek = this.currentDate.startOf('week'); 17445 this.endOfWeek = this.currentDate.endOf('week'); 17446 }else if( option === "previous" ){ 17447 this.currentDate = this.currentDate.subtract(1, 'week'); 17448 this.currentMonth = this.currentDate.format('MMMM') 17449 this.currentYear = this.currentDate.$y 17450 this.startOfWeek = this.currentDate.startOf('week'); 17451 this.endOfWeek = this.currentDate.endOf('week'); 17452 }else if ( option === "date-picker" ){ 17453 let { month, year } = selectedDate; 17454 this.currentDate = dayjs(new Date(year, month, 1)); 17455 this.currentMonth = this.currentDate.format('MMMM') 17456 this.currentYear = this.currentDate.$y 17457 this.startOfWeek = this.currentDate.startOf('week'); 17458 this.endOfWeek = this.currentDate.endOf('week'); 17459 } 17460 } else if (this.view_preference === 'Day'){ 17461 if( option === "today" ){ 17462 this.currentDate = dayjs(); 17463 this.currentMonth = this.currentDate.format('MMMM') 17464 this.currentYear = this.currentDate.$y 17465 this.startOfWeek = this.currentDate.startOf('week'); 17466 this.endOfWeek = this.currentDate.endOf('week'); 17467 }else if( option === "next" ){ 17468 this.currentDate = this.currentDate.add(1, 'day'); 17469 this.currentMonth = this.currentDate.format('MMMM') 17470 this.currentYear = this.currentDate.$y 17471 this.startOfWeek = this.currentDate.startOf('week'); 17472 this.endOfWeek = this.currentDate.endOf('week'); 17473 }else if( option === "previous" ){ 17474 this.currentDate = this.currentDate.subtract(1, 'day'); 17475 this.currentMonth = this.currentDate.format('MMMM') 17476 this.currentYear = this.currentDate.$y 17477 this.startOfWeek = this.currentDate.startOf('week'); 17478 this.endOfWeek = this.currentDate.endOf('week'); 17479 }else if ( option === "date-picker" ){ 17480 let { month, year } = selectedDate; 17481 this.currentDate = dayjs(new Date(year, month, 1)); 17482 this.currentMonth = this.currentDate.format('MMMM') 17483 this.currentYear = this.currentDate.$y 17484 this.startOfWeek = this.currentDate.startOf('week'); 17485 this.endOfWeek = this.currentDate.endOf('week'); 17486 } 17487 } 17488 //and re-load data 17489 this.$emit('load_cal', this.client_id); 17490 }, 17491 /* ----- date methods ----- */ 17492 generateTimestamps(){ 17493 const startTime = dayjs().startOf('day'); 17494 const endTime = dayjs().endOf('day'); 17495 let currentTime = startTime; 17496 while (currentTime.isBefore(endTime)) { 17497 this.timestamps.push({ 17498 customFormat: currentTime.format('h:mm A'), 17499 militaryFormat: currentTime.format('HH:mm:ss'), 17500 }); 17501 currentTime = currentTime.add(15, 'minutes'); 17502 } 17503 this.timestamps.push({ 17504 customFormat: null, 17505 militaryFormat: null, 17506 }) 17507 }, 17508 generateCalendar( ){ 17509 console.log("generate calendar") 17510 this.loading = true; 17511 if ( this.view_preference === 'Month'){ 17512 // current month start date, end date, total days calculation 17513 const start = this.currentDate.startOf('month'); 17514 const end = start.endOf('month'); 17515 const totalDays = end.diff(start, 'day') + 1; 17516 // number of calendar days to add before and after current month days 17517 const spaceBefore = start.day(); 17518 const spaceAfter = 42 - (spaceBefore + totalDays); 17519 // generate number of days for grid cell of 42 17520 this.calendar = [...Array(spaceBefore)].map((_, i) => { 17521 const prevMonthDate = start.subtract(spaceBefore - i, 'day'); 17522 return { 17523 date: prevMonthDate, 17524 day: prevMonthDate.$D, 17525 class: 'prevMonthDate', 17526 events: [], 17527 defaultDate: false, 17528 } 17529 }).concat([...Array(totalDays)].map((_, i) => { 17530 const currentMonthDate = start.add(i, 'day'); 17531 if (this.defaultDate.startOf('day').$d.toString() === currentMonthDate.$d.toString()){ 17532 return { 17533 date: currentMonthDate, 17534 day: currentMonthDate.$D === 1 ? `${currentMonthDate.format('MMM')} ${currentMonthDate.$D}` : currentMonthDate.$D, 17535 class: 'currentMonthDate', 17536 events: [], 17537 defaultDate: true, 17538 } 17539 }else { 17540 return { 17541 date: currentMonthDate, 17542 day: currentMonthDate.$D === 1 ? `${currentMonthDate.format('MMM')} ${currentMonthDate.$D}` : currentMonthDate.$D, 17543 class: 'currentMonthDate', 17544 events: [], 17545 defaultDate: false, 17546 } 17547 } 17548 })).concat([...Array(spaceAfter)].map((_, i) => { 17549 const nextMonthDate = end.add(i + 1, 'day'); 17550 return { 17551 date: nextMonthDate, 17552 day: nextMonthDate.$D === 1 ? `${nextMonthDate.format('MMM')} ${nextMonthDate.$D}` : nextMonthDate.$D, 17553 class: 'nextMonthDate', 17554 events: [], 17555 defaultDate: false, 17556 } 17557 })) 17558 } else if ( this.view_preference === 'Week' ){ 17559 console.log("generate week A") 17560 // start and end of current week 17561 const startOfWeek = this.currentDate.startOf('week'); 17562 const endOfWeek = this.currentDate.endOf('week'); 17563 17564 const weekDays = [{ date: null, day: null, events: [] }]; 17565 let currentDay = startOfWeek; 17566 while (currentDay.isBefore(endOfWeek) || currentDay.isSame(endOfWeek, 'day')) { 17567 weekDays.push(currentDay); 17568 currentDay = currentDay.add(1, 'day'); 17569 } 17570 this.placeholder_daysOfWeek = [null, 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], 17571 this.placeholder_daysOfWeek = this.placeholder_daysOfWeek.map((dayOfTheWeek, i) => { 17572 if (JSON.stringify(this.defaultDate.startOf('day').$d) === JSON.stringify(weekDays[i].$d)){ 17573 return { 17574 dayOfWeek: dayOfTheWeek, 17575 day: weekDays[i].$D, 17576 date: weekDays[i], 17577 events: [], 17578 defaultDate: true, 17579 } 17580 } else { 17581 return { 17582 dayOfWeek: dayOfTheWeek, 17583 day: weekDays[i].$D, 17584 date: weekDays[i], 17585 events: [], 17586 defaultDate: false, 17587 } 17588 } 17589 }) 17590 console.log("generate week B") 17591 this.mapAllDayEvents(); 17592 this.calendar = weekDays.map(date => { 17593 if(JSON.stringify(this.defaultDate.startOf('day').$d) === JSON.stringify(date.$d)){ 17594 return { 17595 date: date, 17596 events: [], 17597 defaultDate: true, 17598 } 17599 } else { 17600 return { 17601 date: date, 17602 events: [], 17603 defaultDate: false, 17604 } 17605 } 17606 }) 17607 console.log("generate week C") 17608 } else if ( this.view_preference === 'Day' ){ 17609 this.today_daysOfWeek = [null, this.daysOfWeek[this.currentDate.day()]] 17610 this.today_daysOfWeek = this.today_daysOfWeek.map((dayOfTheWeek, i) => { 17611 if (i === 1 && this.defaultDate.startOf('day').$d.toString() === this.currentDate.startOf('day').$d.toString()){ 17612 return { 17613 dayOfWeek: dayOfTheWeek, 17614 day: dayOfTheWeek !== null ? this.currentDate.$D : null, 17615 date: dayOfTheWeek !== null ? this.currentDate : null, 17616 events: [], 17617 defaultDate: true, 17618 } 17619 } else { 17620 return { 17621 dayOfWeek: dayOfTheWeek,
17621 17622 day: dayOfTheWeek !== null ? this.currentDate.$D : null, 17623 date: dayOfTheWeek !== null ? this.currentDate : null, 17624 events: [], 17625 defaultDate: false, 17626 } 17627 } 17628 }) 17629 this.mapAllDayEvents(); 17630 this.calendar = [{ date: null, day: null, events: [] }, this.currentDate ].map((date, i) => { 17631 if(i === 1 && this.defaultDate.startOf('day').$d.toString() === date.startOf('day').$d.toString()){ 17632 return { 17633 date: date, 17634 events: [], 17635 defaultDate: true, 17636 } 17637 } else { 17638 return { 17639 date: date, 17640 events: [], 17641 defaultDate: false, 17642 } 17643 } 17644 }) 17645 } 17646 // map events onto calendar days 17647 this.mapCalendarEvents_v2(); 17648 }, 17649 // mapCalendarEvents(){ 17650 // let multipleDaySchedules = []; 17651 // this.calendar.map(day => { 17652 // let calendarDay; 17653 // if ( day.date !== null ){ 17654 // calendarDay = dayjs(day.date.$d).format('MMMM DD, YYYY'); 17655 // } 17656 // this.schedules.forEach(schedule => { 17657 // let scheduleStart = dayjs( schedule.start ).format('MMMM DD, YYYY'); 17658 // let scheduleEnd = dayjs( schedule.end ).format('MMMM DD, YYYY'); 17659 // let scheduleOnDay = scheduleStart === calendarDay && scheduleStart === scheduleEnd; 17660 // let scheduleOnDay_isAllDay = scheduleStart === calendarDay && schedule.isAllDay; 17661 // // check to see if there are any multiple event days 17662 // if ( multipleDaySchedules.length > 0 ){ 17663 // multipleDaySchedules.forEach((multipleDaySchedule, i) => { 17664 // if ( multipleDaySchedule.daysRemaining > 0 && calendarDay === multipleDaySchedule.scheduleStart.format('MMMM DD, YYYY')){ 17665 // day.events.push(multipleDaySchedule.schedule) 17666 // multipleDaySchedule.scheduleStart = multipleDaySchedule.scheduleStart.add(1, 'day') 17667 // multipleDaySchedule.daysRemaining--; 17668 // } else if ( multipleDaySchedule.daysRemaining === 0 ){ 17669 // multipleDaySchedules.splice(i, 1) 17670 // } 17671 // }) 17672 // } 17673 // // there is an event or all day event on this day 17674 // if ( scheduleOnDay || scheduleOnDay_isAllDay ){ 17675 // day.events.push(schedule) 17676 // // there is a multiple day event that starts on this day 17677 // } else if ( scheduleStart === calendarDay && scheduleStart !== scheduleEnd && !schedule.isAllDay ){ 17678 // multipleDaySchedules.push({ 17679 // schedule: schedule, 17680 // scheduleStart: dayjs(scheduleStart, { format: 'MMMM DD, YYYY' }).add(1, 'day'), 17681 // daysRemaining: dayjs(scheduleEnd, { format: 'MMMM DD, YYYY' }).diff(dayjs(scheduleStart, { format: 'MMMM DD, YYYY' }), 'day') - 1 17682 // }) 17683 // schedule.isMultipleDay = true; 17684 // day.events.push(schedule); 17685 // } 17686 // }) 17687 // return day; 17688 // }) 17689 // console.log('this is final mapping: ', this.calendar) 17690 // this.loading = false;
17691 // }, 17692 mapCalendarEvents_v2(){ 17693 console.log("map calendar events v2"); 17694 let previousDayEventOrder = []; 17695 this.calendar.map((day, dayIndex) => { 17696 let calendarDay; 17697 if ( day.date !== null ){ 17698 calendarDay = dayjs(day.date.$d) 17699 } 17700 this.schedules.forEach(schedule => { 17701 let scheduleStart = dayjs( schedule.start ) 17702 let scheduleEnd = dayjs( schedule.end ) 17703 let scheduleOnDay = scheduleStart.format('MMMM DD, YYYY') === calendarDay.format('MMMM DD, YYYY') && scheduleStart.format('MMMM DD, YYYY') === scheduleEnd.format('MMMM DD, YYYY'); 17704 let scheduleOnDay_isAllDay = scheduleStart.format('MMMM DD, YYYY') === calendarDay.format('MMMM DD, YYYY') && schedule.isAllDay; 17705 // event falls on current day OR it's an all day event 17706 // added scheduleOnDay && scheduleOnDay_isAllDay 1/25/24 for regatta subevents start start and end on the same day 17707 if ( scheduleOnDay && !scheduleOnDay_isAllDay || scheduleOnDay && scheduleOnDay_isAllDay ){ 17708 day.events.push(schedule) 17709 } 17710 // it's the first day of a multiple day event 17711 // these checks are not thorough enough 17712 else if (scheduleStart.format('MMMM DD, YYYY') === calendarDay.format('MMMM DD, YYYY') && scheduleStart.format('MMMM DD, YYYY') !== scheduleEnd.format('MMMM DD, YYYY')){ 17713 schedule.isMultipleDay = true; 17714 day.events.push(schedule) 17715 } 17716 // it's a multiple day event and the current date falls between the start and end date 17717 else if ( scheduleStart.format('MMMM DD, YYYY') !== calendarDay.format('MMMM DD, YYYY') && (calendarDay.isAfter(scheduleStart) && calendarDay.isBefore(scheduleEnd)) ){ 17718 schedule.isMultipleDay = true; 17719 day.events.push(schedule) 17720 } 17721 }) 17722 17723 /* ui stacking logic for month view */ 17724 if (this.view_preference === 'Month'){ 17725 if ( dayIndex !== 0 ){ 17726 let currentDayEventOrder; 17727 let currentDayIndexOrder = {}; 17728 let notMatchingEvents = []; 17729 let matchingEvents = []; 17730 for (let i = 0; i < day.events.length; i++){ 17731 let currentDayEventId = day.events[i].id; 17732 let previousDayIndex = previousDayEventOrder?.indexOf(currentDayEventId); 17733 if (previousDayIndex === -1){ 17734 notMatchingEvents.push(day.events[i]) 17735 } else if (previousDayIndex !== -1) { 17736 currentDayIndexOrder[previousDayIndex] = day.events[i]; 17737 matchingEvents.push(day.events[i]); 17738 } 17739 } 17740 17741 let missingIndexes = []; 17742 for (let j = 0; j < 3; j++ ){ 17743 if (Object.keys(currentDayIndexOrder).indexOf(j.toString()) === -1 ){ 17744 missingIndexes.push(j) 17745 } 17746 } 17747 17748 let placeholder = { 17749 bgColor: '', 17750 calendarId: '', 17751 calendarOnly: '', 17752 category: '', 17753 className: '', 17754 color: '', 17755 end: '', 17756 id: 'placeholder', 17757 raw: {dot_color: ''}, 17758 start: '', 17759 title: 'placeholder', 17760 }; 17761 17762 for (let k = 0; k < missingIndexes.length; k++){ 17763 let missingIndexValue = missingIndexes[k]; 17764 currentDayIndexOrder[missingIndexValue] = notMatchingEvents[k] || placeholder; 17765 } 17766 17767 let remainingIndexes = day.events.length - 3; 17768 let calculateRemainingEvents = matchingEvents.length > 3 ? matchingEvents.concat(notMatchingEvents).splice(3) : [...notMatchingEvents].splice(missingIndexes.length); 17769 17770 if (remainingIndexes > 0){ 17771 for (let l = 3; l < 3 + remainingIndexes; l++){ 17772 let targetIndex = matchingEvents.length > 3 ? l - 3 : l - 3; 17773 //added placeholder for white lake yacht club bugs 17774 currentDayIndexOrder[l] = calculateRemainingEvents[targetIndex] || placeholder; 17775 } 17776 } 17777 17778 const sortedObj = Object.fromEntries( 17779 Object.entries(currentDayIndexOrder).sort(([keyA], [keyB]) => parseInt(keyA) - parseInt(keyB)) 17780 ); 17781 currentDayEventOrder = Object.values(sortedObj); 17782 //set order of previous day events using event ids 17783 previousDayEventOrder = currentDayEventOrder.map(event =>
17783 { return event.id }); 17784 //set current day events to newly sorted order 17785 day.events = currentDayEventOrder.filter((event, i, self) => event.id === 'placeholder' || (event.id !== 'placeholder' && i === self.findIndex(e => e.id === event.id))); 17786 } 17787 } 17788 17789 return day; 17790 }) 17791 this.loading = false; 17792 }, 17793 mapAllDayEvents(){ 17794 let daysOfWeekArr; 17795 if (this.view_preference === 'Week'){ 17796 daysOfWeekArr = this.placeholder_daysOfWeek; 17797 } else if (this.view_preference === 'Day'){ 17798 daysOfWeekArr = this.today_daysOfWeek; 17799 } 17800 let multipleDaySchedules = []; 17801 daysOfWeekArr = daysOfWeekArr.map((day, i) => { 17802 this.schedules.forEach((schedule, i) => { 17803 if ( (i !== 0 && this.view_preference === 'Day' || i !== 0 && this.view_preference === 'Week') && ( schedule.calendarOnly || schedule.category === 'allday') ){ 17804 let calendarDay = dayjs(day.date) 17805 let scheduleStart = dayjs( schedule.start ) 17806 let scheduleEnd = dayjs( schedule.end ) 17807 let scheduleOnDay_isAllDay = scheduleStart.format('MMMM DD, YYYY') === calendarDay.format('MMMM DD, YYYY') && schedule.isAllDay; 17808 // it's an all day event 17809 if ( scheduleOnDay_isAllDay ){ 17810 day.events.push(schedule) 17811 } 17812 // it's the first day of a multiple day event 17813 else if (scheduleStart.format('MMMM DD, YYYY') === calendarDay.format('MMMM DD, YYYY') && scheduleStart.format('MMMM DD, YYYY') !== scheduleEnd.format('MMMM DD, YYYY')){ 17814 schedule.isMultipleDay = true; 17815 day.events.push(schedule) 17816 } 17817 // it's a multiple day event and the current date falls between the start and end date 17818 else if ( scheduleStart.format('MMMM DD, YYYY') !== calendarDay.format('MMMM DD, YYYY') && (calendarDay.isAfter(scheduleStart) && calendarDay.isBefore(scheduleEnd)) ){ 17819 schedule.isMultipleDay = true; 17820 day.events.push(schedule) 17821 } 17822 } 17823 }) 17824 return day; 17825 }) 17826 if (this.view_preference === 'Week'){ 17827 this.placeholder_daysOfWeek = daysOfWeekArr; 17828 } else if (this.view_preference === 'Day'){ 17829 this.today_daysOfWeek = daysOfWeekArr; 17830 } 17831 }, 17832 }, 17833 }) 17834</script>
17834
17834<script type="text/html" id="template-calendar-cell"> 17835 <div @click="$emit('createSchedule_emitted', date)"> 17836 <!-- cell for month view --> 17837 <template v-if="view_preference === 'Month'"> 17838 <div class="cell-content" :class="cellType"> 17839 <div v-if="day" class="cell-day" :class="cellType" :style="checkDefaultDate( day, defaultDate, 'month' )">{{ day }}</div> 17840 <!-- all events --> 17841 <div v-if="cellType === 'calendarObj' && events.length > 0" class="cell-events-month" ref="cell" @click.stop> 17842 <template v-for="(event, index) in events"> 17843 <span 17844 v-if="index <= 2" 17845 :class="{ 17846 'cell-event-month': event.id !== 'placeholder', 17847 'transparent-event': event.id === 'placeholder' 17848 }" 17849 :style="generateEventStyleForMonth( event, 'event_body', date, monthIndex )" 17850 @click="handleScheduleClickForMonth( event )" 17851 ><div :style="generateEventStyleForMonth( event, 'status_dot', '', '', month_class )"></div><div class="event-title" :style="generateFontSize('eventTitle', event)">{{ generateEventTitle( event ) }}</div></span> 17852 <span v-if="index >= 3 && index === events.length - 1" class="overflow-cell-events" ref="overflow-cell" @click="toggleOverflowView( date, events, $event )"> 17853 <div :style="[ { display: 'flex' }, generateFontSize('eventTitle') ]" class="event-title">{{ index - 2 }} more</div> 17854 </span> 17855 </template> 17856 </div> 17857 </div> 17858 </template> 17859 <!-- cell for week view --> 17860 <template v-if="view_preference === 'Week'"> 17861 <div class="cell-content-week" :class="cellType"> 17862 <div v-if="day" :class="{ 'week-cellDay-container' : day.dayOfWeek !== null }"> 17863 <div class="cell-day numberHeader" :class="cellType" :style="[{ 'font-size': '25px', 'font-weight': '400' }, checkDefaultDate(day, defaultDate, 'week_header')]">{{ day.day }}</div> 17864 <div class="cell-day dayHeader" :class="cellType">{{ day.dayOfWeek !== null ? day.dayOfWeek.toUpperCase() : null }}</div> 17865 </div> 17866 <div v-if="timestamp" class="cell-day" :class="cellType"> 17867 <div class="timestamp" :style="generateFontSize('timestamp')">{{ timestamp }}</div> 17868 </div> 17869 <!-- all day events --> 17870 <div v-if="allDay"> 17871 <div v-if="allDay.dayOfWeek === null" class="allDay_events" :class="cellType" :style="generateFontSize('allDay')">All Day</div> 17872 <div class="cell-events-month"> 17873 <template v-if="allDay.dayOfWeek !== null" v-for="event in allDay.events"> 17874 <span 17875 :style="generateEventStyleForWeek( event, 'allDay_event_body' )" 17876 class="allDay-cell-event" 17877 @click="$emit('clickSchedule_emitted', event)" 17878 >{{ generateEventTitle( event ) }}</span> 17879 </template> 17880 </div> 17881 </div> 17882 <!-- all events --> 17883 <div v-if="cellType === 'calendarObj' && events.length > 0" class="cell-events-week" @click.stop> 17884 <template v-for="(event, index) in events"> 17885 <span 17886 class="cell-event-week" 17887 :style="generateEventStyleForWeek( event, 'event_body' )" 17888 @click="$emit('clickSchedule_emitted', event.event)" 17889 ><div></div>{{ generateEventTitle( event.event ) }}</span> 17890 </template> 17891 </div> 17892 </div> 17893 </template> 17894 <!-- cell for day view --> 17895 <template v-if="view_preference === 'Day'"> 17896 <div class="cell-content-week" :class="cellType"> 17897 <div v-if="day" :class="{ 'day-cellDay-container' : day.dayOfWeek !== null }"> 17898 <div class="cell-day numberHeader" :class="cellType" :style="[{ 'font-size': '25px', 'font-weight': '400' }, checkDefaultDate(day, defaultDate, 'day_header')]">{{ day.day }}</div> 17899 <div class="cell-day dayHeader" :class="cellType">{{ day.dayOfWeek !== null ? day.dayOfWeek.toUpperCase() : null }}</div> 17900 </div> 17901 <div v-if="timestamp" class="cell-day" :class="cellType"> 17902 <div class="timestamp" :style="generateFontSize('timestamp')">{{ timestamp }}</div> 17903 </div> 17904 <!-- all day events --> 17905 <div v-if="allDay"> 17906 <div v-if="allDay.dayOfWeek === null" class="allDay_events" :class="cellType" :style="generateFontSize('allDay')">All Day</div> 17907 <div class="cell-events-month"> 17908 <template v-if="allDay.dayOfWeek !== null" v-for="event in allDay.events">
17909 <span 17910 :style="generateEventStyleForWeek( event, 'allDay_event_body' )" 17911 class="allDay-cell-event" 17912 @click="$emit('clickSchedule_emitted', event)" 17913 >{{ generateEventTitle( event ) }}</span> 17914 </template> 17915 </div> 17916 </div> 17917 <!-- all events --> 17918 <div v-if="cellType === 'calendarObj' && events.length > 0" class="cell-events-week" @click.stop> 17919 <template v-for="(event, index) in events"> 17920 <span 17921 class="cell-event-week" 17922 :style="generateEventStyleForWeek( event, 'event_body' )" 17923 @click="$emit('clickSchedule_emitted', event.event )" 17924 ><div></div>{{ generateEventTitle( event.event ) }}</span> 17925 </template> 17926 </div> 17927 </div> 17928 </template> 17929 </div> 17930</script>
17930 17931
17932<script> 17933 /* ----- STORE ----- */ 17934 store.components["calendar_cell"] = { 17935 cellWidth: this.getCellWidth, 17936 foo: null, 17937 }; 17938 /* ----- COMPONENT ----- */ 17939 app_content.component("calendar-cell-component", { 17940 template: "#template-calendar-cell", 17941 data() { 17942 return store.components['calendar_cell'] 17943 }, 17944 mounted(){ 17945 window.addEventListener('resize', this.handleResize); 17946 this.$nextTick(() => { 17947 this.handleResize(); 17948 }); 17949 }, 17950 beforeDestroy(){ 17951 window.removeEventListener('resize', this.handleResize); 17952 }, 17953 props: { 17954 client_id: String, 17955 date: Object, 17956 defaultDate: Boolean, 17957 day: { 17958 type: [String, Number, Object], 17959 required: false, 17960 }, 17961 allDay: Object, 17962 timestamp: String, 17963 currentTime: String, 17964 events: Array, 17965 cellType: String, 17966 view_preference: String, 17967 monthIndex: Number, 17968 month_class: String, 17969 is_membership_cal: Boolean, 17970 cell_emitted: Function, 17971 createSchedule_emitted: Function, 17972 clickSchedule_emitted: Function, 17973 }, 17974 methods: { 17975 handleResize(){ 17976 this.cellWidth = this.getCellWidth(); 17977 }, 17978 getCellWidth(){ 17979 const cell = this.$refs.cell; 17980 // Get the computed width of the cell 17981 if (cell){ 17982 this.cellWidth = cell.getBoundingClientRect().width; 17983 return cell.getBoundingClientRect().width; 17984 } 17985 }, 17986 checkDefaultDate( day, defaultDate, type ){ 17987 if( defaultDate ){ 17988 if ( type === 'month' ){ 17989 if ( day.length > 2){ 17990 return { 17991 'padding': '2.5px', 17992 'background-color': '#4a56f8', 17993 'color': '#fff', 17994 'min-width': 'fit-content', 17995 'max-width': 'fit-content', 17996 'min-height': 'fit-content', 17997 'max-height': 'fit-content', 17998 } 17999 } else { 18000 return { 18001 'background-color': '#4a56f8', 18002 'color': '#fff', 18003 'min-width': '22px', 18004 'max-width': '22px', 18005 'min-height': '22px', 18006 'max-height': '22px', 18007 } 18008 } 18009 } else if ( type === 'week_header' || type === 'day_header' ){ 18010 return { 18011 'font-weight': '575', 18012 // 'color': '#4a56f8', 18013 } 18014 } 18015 } else if ( !defaultDate ){ 18016 if ( type === 'month' ){ 18017 if ( day.length > 2){ 18018 return { 18019 'padding': '2.5px', 18020 'min-width': 'fit-content', 18021 'max-width': 'fit-content', 18022 'min-height': 'fit-content', 18023 'max-height': 'fit-content', 18024 } 18025 } else { 18026 return { 18027 'min-width': '22px', 18028 'max-width': '22px', 18029 'min-height': '22px', 18030 'max-height': '22px', 18031 } 18032 } 18033 } 18034 } 18035 }, 18036 generateEventTitle( eventObject ){ 18037 if ( !eventObject.isAllDay && !eventObject.isMultipleDay ) { 18038 return eventObject.raw.nice_time !== undefined ? `${eventObject.raw.nice_time} ${eventObject.title}` : `${eventObject.title}`; 18039 } else if ( eventObject.isAllDay || eventObject.isMultipleDay ) { 18040 return eventObject.title || 'Not Provided'; 18041 } 18042 }, 18043 generateFontSize( type, eventObject ){ 18044 if (this.is_membership_cal){ 18045 if (type === 'timestamp') { 18046 return { 18047 'font-size': '12px', 18048 'font-weight': '300', 18049 } 18050 } else if (type === 'allDay'){ 18051 return { 18052 'font-size': '13px', 18053 } 18054 } else if (type === 'eventTitle'){ 18055 if (eventObject?.id === 'placeholder'){ 18056 return { 18057 'color': 'transparent', 18058 'font-size': '13px', 18059 } 18060 } 18061 return { 18062 'font-size': '13px', 18063 } 18064 } 18065 } else { 18066 if (type === 'timestamp') { 18067 return { 18068 'font-size': '10px', 18069 'font-weight': '300', 18070 } 18071 } else if (type === 'allDay'){ 18072 return { 18073 'font-size': '11px', 18074 } 18075 }else if (type === 'eventTitle'){ 18076 if (eventObject?.id === 'placeholder'){ 18077 return { 18078 'color': 'transparent', 18079 'font-size': '12.5px', 18080 } 18081 } 18082 return { 18083 'font-size': '12.5px', 18084 } 18085 } 18086 } 18087 }, 18088 generateEventStyleForMonth( eventObject, type, date = null, monthIndex = null, month_class = null ){ 18089 let styleObject = {}; 18090 const dotColor = eventObject.raw.dot_color; 18091 const bgColor = eventObject.bgColor || '#e8edf1'; 18092 const color = /* eventObject.color || */ this.get_contrast_color( bgColor ) || 'inherit'; 18093 if ( type === 'status_dot' ){ 18094 if ( eventObject.id === 'placeholder' ){ 18095 styleObject = { 18096 'color': 'trasparent', 18097 } 18098 } else if ( !eventObject.isAllDay && !eventObject.isMultipleDay ) { 18099 if ( month_class === 'currentMonthDate' ){ 18100 styleObject = { 18101 'min-width': '2.2px', 18102 'max-width': '2.2px', 18103 'min-height': '2.2px', 18104 'max-height': '2.2px', 18105 'border': `2.2px solid ${dotColor || '#333'}`, 18106 'border-radius': '50%', 18107 'background-color': `${dotColor || '#333'}`, 18108 'margin': '0 5px 0 5px', 18109 } 18110 } else if ( month_class === 'prevMonthDate' || month_class === 'nextMonthDate' ){ 18111 styleObject = { 18112 'min-width': '2.2px', 18113 'max-width': '2.2px', 18114 'min-height': '2.2px', 18115 'max-height': '2.2px', 18116 'border': `2.2px solid rgb(232, 237, 241)`, 18117 'border-radius': '50%', 18118 'background-color': `rgb(232, 237, 241)`, 18119 'margin': '0 5px 0 5px', 18120 } 18121 } 18122 } else if ( eventObject.isAllDay || eventObject.isMultipleDay ){ 18123 styleObject = { 18124 'margin-right': '3.5px', 18125 } 18126 } 18127 } else if ( type === 'event_body' ){ 18128 if ( eventObject.isAllDay && !eventObject.isMultipleDay ) { 18129 styleObject = { 18130 'background-color': bgColor, 18131 'color': color, 18132 }; 18133 } else if ( eventObject.isMultipleDay ){ 18134 let calendarDay = dayjs(date.$d).format('MMMM DD, YYYY'); 18135 let scheduleStart = dayjs( eventObject.start ).format('MMMM DD, YYYY'); 18136 let scheduleEnd = dayjs( eventObject.end ).format('MMMM DD, YYYY'); 18137 let isOneDay = ((dayjs( eventObject.start ).format('HH:mm:ss') === dayjs( eventObject.end ).format('HH:mm:ss')) && (dayjs( eventObject.start ).format('HH:mm:ss') === dayjs( eventObject.start ).startOf('day').format('HH:mm:ss'))) && dayjs( eventObject.start ).add(1, 'day').format('MMMM DD, YYYY') === scheduleEnd; 18138 let endsAtMidnight = dayjs( eventObject.end ).format('HH:mm:ss') === dayjs().startOf('day').format('HH:mm:ss'); 18139 if (calendarDay === scheduleStart || monthIndex % 7 === 0 ){ 18140 let remainingDays = dayjs(scheduleEnd, { format: 'MMMM DD, YYYY' }).diff(dayjs(calendarDay, { format: 'MMMM DD, YYYY' }), 'day'); 18141 //handle non-calendarOnly events since end time is different than calendarOnly events 18142 if (!eventObject.calendarOnly && !isOneDay && !endsAtMidnight ){ 18143 remainingDays += 1; 18144 } 18145 if (this.cellWidth === undefined){ 18146 styleObject = { 18147 'background-color': bgColor, 18148 'color': color, 18149 'z-index': '5', 18150 'width' : `${remainingDays*this.getCellWidth()}px` 18151 }; 18152 } else { 18153 styleObject = { 18154 'background-color': bgColor, 18155 'color': color, 18156 'z-index': '5', 18157 'width': `${remainingDays*this.cellWidth}px` 18158 }; 18159 } 18160 } 18161 } 18162 } 18163 return styleObject 18164 }, 18165 generateEventStyleForWeek( eventObject, type ){ 18166 const event = eventObject.event || eventObject; 18167 const numOfTimeBlocks = eventObject.numOfTimeBlocks; 18168 18169 let styleObject = {}; 18170 const dotColor = event?.raw.dot_color; 18171 const bgColor = event?.bgColor || '#e8edf1'; 18172 const color = /* event?.color */ this.get_contrast_color( bgColor ) || 'inherit'; 18173 18174 if ( type === 'event_body' ){ 18175 if ( event ) { 18176 styleObject = { 18177 'background-color': dotColor || bgColor, 18178 'color': color, 18179 'width': '50%', 18180 'border-width': '1px 1px 1px 1.5px', 18181 'border-left-color': 'black', 18182 'border-top-color':'white', 18183 'border-right-color':'transparent', 18184 'border-bottom-color': 'white', 18185 'border-style': 'solid', 18186 'border-radius': '2px', 18187 'opacity': '85%', 18188 'z-index': '5', 18189 'height': `${(numOfTimeBlocks*12) - 5}px`, /* (number of 15 min increments * .placeholder-calendar-grid > grid-auto-rows) - .cell-event-week > padding(top) */ 18190 }; 18191 } 18192 } else if ( type === 'allDay_event_body' ){ 18193 if ( event ) { 18194 styleObject = { 18195 'background-color': dotColor || bgColor, 18196 'color': color, 18197 }; 18198 } 18199 } 18200 return styleObject 18201 }, 18202 toggleOverflowView( date, eventsArr, event ){ 18203 if (store.overlays.overflow_overlay){
18204 store.overlays.overflow_overlay = false; 18205 } else { 18206 store.components.overflow_overlay.cellPosition = { 18207 top: (event.clientY + window.scrollY)-(230/2), //230 overlay height 18208 left: (event.clientX + window.scrollX)-(230/2), //230 overlay width 18209 } 18210 if (this.is_membership_cal){ 18211 store.components.overflow_overlay.is_membership_cal = true; 18212 } 18213 store.components.overflow_overlay.client_id = this.client_id; 18214 store.components.overflow_overlay.date = date; 18215 store.components.overflow_overlay.events = eventsArr; 18216 store.overlays.overflow_overlay = true; 18217 } 18218 }, 18219 handleScheduleClickForMonth( event ){ 18220 if (event.id !== 'placeholder') { 18221 this.$emit('clickSchedule_emitted', event); 18222 } else { 18223 this.$emit('createSchedule_emitted', this.date); 18224 } 18225 }, 18226 //methods to determine font color in contrast to bg color 18227 calculate_luminance(color) { 18228 const hex = color.substring(1); //remove # 18229 const rgb = parseInt(hex, 16); //convert to RGB 18230 const r = (rgb >> 16) & 0xff; //extract the red channel 18231 const g = (rgb >> 8) & 0xff; //extract the green channel 18232 const b = (rgb >> 0) & 0xff; //extract the blue channel 18233 //convert RGB to linear values 18234 const sR = r / 255; 18235 const sG = g / 255; 18236 const sB = b / 255; 18237 //calculate luminance 18238 const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; 18239 return luminance; 18240 }, 18241 get_contrast_color(background) { 18242 const backgroundLuminance = this.calculate_luminance(background); 18243 const fontColor = backgroundLuminance > 0.5 ? '#000000' : '#ffffff'; 18244 return fontColor; 18245 } 18246 }, 18247 }) 18248</script>
18248
18248<script type="text/html" id="template-overflow-overlay"> 18249 <div v-if="global.overlays.overflow_overlay" id="overlay_overflow_overlay" :style="generateStyle()" class="calendar-overlay active" @click.self="global.overlays.overflow_overlay=false;"> 18250 <div class="calendarOverlayCard"> 18251 <div class="overlay-events-contain"> 18252 <div v-if="me.date" class="overflow-date-contain"> 18253 <span class="overflow-dateOfWeek">{{ me.date.format('ddd') }}</span> 18254 <span class="overflow-day">{{ me.date.$D }}</span> 18255 </div> 18256 </div> 18257 <template v-for="event in me.events"> 18258 <template v-if="event.id !== 'placeholder'"> 18259 <span 18260 class="cell-event-overlay" 18261 :style="generateEventStyleForMonth( event, 'event_body' )" 18262 @click="clickSchedule( event )" 18263 ><div :style="generateEventStyleForMonth( event, 'status_dot' )"></div><div class="event-title">{{ generateEventTitle( event ) }}</div></span> 18264 </template> 18265 </template> 18266 </div> 18267 <div class="xButton-calendarOverlay" @click.self="global.overlays.overflow_overlay=false;"></div> 18268 </div> 18269 </div> 18270</script>
18270 18271
18272<script> 18273 //global store 18274 store.components["overflow_overlay"] = { 18275 client_id: null, 18276 date: null, 18277 events: [], 18278 cellPosition: {}, 18279 loading: false, 18280 is_membership_cal: false, 18281 }; 18282 //define the component 18283 app_overlays.component("overflow-overlay", { 18284 template: "#template-overflow-overlay", 18285 data() { 18286 return { 18287 global: store, 18288 me: store.components.overflow_overlay 18289 } 18290 }, 18291 methods: { 18292 generateStyle(){ 18293 const cellPosition = this.me.cellPosition; 18294 const viewportHeight = window.innerHeight; 18295 const viewportWidth = window.innerWidth; 18296 const overlayHeight = 230; 18297 const overlayWidth = 230; 18298 18299 let top = cellPosition.top; 18300 let left = cellPosition.left; 18301 18302 // Adjust top position if overlay goes off-screen 18303 // if ( top + overlayHeight > viewportHeight) { 18304 // } 18305 18306 // Adjust left position if overlay goes off-screen 18307 if ( left + overlayWidth > viewportWidth) { 18308 let difference = left + overlayWidth; 18309 left -= difference - viewportWidth; // 20px margin from right 18310 } 18311 return { 18312 'top': `${top}px`, 18313 'left': `${left}px` 18314 }; 18315 }, 18316 generateEventTitle( eventObject ){ 18317 if ( !eventObject.isAllDay && !eventObject.isMultipleDay ) { 18318 return eventObject.raw.nice_time !== undefined ? `${eventObject.raw.nice_time} ${eventObject.title}` : `${eventObject.title}`; 18319 } else if ( eventObject.isAllDay || eventObject.isMultipleDay ) { 18320 return eventObject.title || 'Not Provided'; 18321 } 18322 }, 18323 generateEventStyleForMonth( eventObject, type ){ 18324 let styleObject = {}; 18325 const dotColor = eventObject.raw.dot_color; 18326 const bgColor = eventObject.bgColor || '#e8edf1'; 18327 const color = /* eventObject.color */ this.get_contrast_color( bgColor ) || 'inherit'; 18328 if ( type === 'status_dot' ){ 18329 if ( !eventObject.isAllDay && !eventObject.isMultipleDay ) { 18330 styleObject = { 18331 'min-width': '2.2px', 18332 'max-width': '2.2px', 18333 'min-height': '2.2px', 18334 'max-height': '2.2px', 18335 'border': `2.2px solid ${dotColor || '#333'}`, 18336 'border-radius': '50%', 18337 'background-color': `${dotColor || '#333'}`, 18338 'margin': '0 5px 0 5px', 18339 } 18340 } else if ( eventObject.isAllDay || eventObject.isMultipleDay ){ 18341 styleObject = { 18342 'margin-right': '3.5px', 18343 } 18344 } 18345 } else if ( type === 'event_body' ){ 18346 if ( eventObject.isAllDay || eventObject.isMultipleDay ) { 18347 styleObject = { 18348 'background-color': bgColor, 18349 'color': color, 18350 'height': '22px', 18351 }; 18352 } 18353 } 18354 return styleObject 18355 }, 18356 clickSchedule(event){ 18357 if ( this.me.client_id === 'primary' ){ 18358 store.eventBus.$emit('clickSchedule_events_cal', event) 18359 store.overlays.overflow_overlay = false; 18360 } else if ( this.me.client_id === 'banquets' ){ 18361 store.eventBus.$emit('clickSchedule_banquets_cal', event) 18362 store.overlays.overflow_overlay = false; 18363 } else if ( this.me.client_id === 'bookings' ){ 18364 store.eventBus.$emit('clickSchedule_reservations_cal', event)
18365 store.overlays.overflow_overlay = false; 18366 } else if ( this.me.client_id === 'time_blocks' ){ 18367 store.eventBus.$emit('clickSchedule_time_blocks_cal', event) 18368 store.overlays.overflow_overlay = false; 18369 } else if (this.me.is_membership_cal ){ 18370 store.eventBus.$emit('clickSchedule_membership_cal', event) 18371 store.overlays.overflow_overlay = false; 18372 } 18373 }, 18374 //methods to determine font color in contrast to bg color 18375 calculate_luminance(color) { 18376 const hex = color.substring(1); //remove # 18377 const rgb = parseInt(hex, 16); //convert to RGB 18378 const r = (rgb >> 16) & 0xff; //extract the red channel 18379 const g = (rgb >> 8) & 0xff; //extract the green channel 18380 const b = (rgb >> 0) & 0xff; //extract the blue channel 18381 //convert RGB to linear values 18382 const sR = r / 255; 18383 const sG = g / 255; 18384 const sB = b / 255; 18385 //calculate luminance 18386 const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; 18387 return luminance; 18388 }, 18389 get_contrast_color(background) { 18390 const backgroundLuminance = this.calculate_luminance(background); 18391 const fontColor = backgroundLuminance > 0.5 ? '#000000' : '#ffffff'; 18392 return fontColor; 18393 } 18394 }, 18395 }); 18396 18397 18398</script>
18398
18398<script type="text/html" id="template-custom-field-wb"> 18399 <div class="custom-field-wrapper tinyMarginTop" :id="custom_field.id"> 18400 <div class="flex-row-start" style="width: 100%;"> 18401 <!-- question --> 18402 <div class="flex-column-start tinyMarginRight" style="width: 100%;"> 18403 <div class="custom-field flex-row-start"> 18404 <p v-if="custom_field && custom_field.name" class="default-font-body">{{ custom_field.name }}</p> 18405 <span v-if="custom_field && custom_field.required" class="flex-row-start"> 18406 <p style="color: rgba(255, 0, 0, 0.885); margin: 0 5.5px 0 3px;">*</p> 18407 <p class="default-font-body" style="font-size: 14px; color: rgb(150, 152, 154); font-style: italic;">(required)</p> 18408 </span> 18409 </div> 18410 </div> 18411 </div> 18412 <!-- response options --> 18413 <div style="width: 100%;"> 18414 <!-- short response --> 18415 <template v-if="custom_field && custom_field.type === 'text'"> 18416 <span class="flex-row-start tinyMarginBottom"> 18417 <input 18418 type="text" 18419 placeholder="Your answer" 18420 class="border-bottom-grey-2 default-font-body" 18421 style="width: 50%; line-height: 24px;" 18422 v-model="input_value" 18423 :disabled="is_admin" 18424 > 18425 </span> 18426 </template> 18427 <!-- paragraph --> 18428 <template v-if="custom_field && custom_field.type === 'textarea'"> 18429 <span class="flex-row-start tinyMarginBottom"> 18430 <input 18431 type="text" 18432 placeholder="Your answer" 18433 class="border-bottom-grey-2 default-font-body" 18434 style="width: 100%; line-height: 24px;" 18435 v-model="input_value" 18436 :disabled="is_admin" 18437 > 18438 </span> 18439 </template> 18440 <!-- multiple choice, checkbox --> 18441 <div v-if="custom_field.type === 'radio' || custom_field.type === 'checkbox'" class="flex-column-start"> 18442 <template v-for="(option, index) in custom_field_options" :id="index"> 18443 <span class="flex-row-start" style="position: relative; width: 100%; left: -5px;"> 18444 <!-- multiple choice --> 18445 <template v-if="custom_field.type === 'radio'"> 18446 <span v-if="!option.selected" @click="select_mc_option( true, option )" class="flex-row-start"> 18447 <i class="fa-light fa-circle options-icon"></i> 18448 <p v-if="option && option.name" class="default-font-body">{{ option.name }}</p> 18449 </span> 18450 <span v-if="option.selected" @click="select_mc_option( false, option )" class="flex-row-start"> 18451 <i class="fa-solid fa-circle-dot options-icon"></i> 18452 <p v-if="option && option.name" class="default-font-body">{{ option.name }}</p> 18453 </span> 18454 </template> 18455 <!-- checkbox --> 18456 <template v-if="custom_field.type === 'checkbox'"> 18457 <span v-if="!option.selected" @click="option.selected = true;" class="flex-row-start"> 18458 <i class="fa-light fa-square options-icon"></i> 18459 <p v-if="option && option.name" class="default-font-body">{{ option.name }}</p> 18460 </span> 18461 <span v-if="option.selected" @click="option.selected = false;" class="flex-row-start"> 18462 <i class="fa-solid fa-square-check options-icon"></i> 18463 <p v-if="option && option.name" class="default-font-body">{{ option.name }}</p> 18464 </span> 18465 </template> 18466 </span> 18467 </template> 18468 </div> 18469 <!-- dropdown --> 18470 <template v-if="custom_field.type === 'select' && custom_field_options"> 18471 <div id="response-dropdown" :style="{ 18472 borderBottomLeftRadius: dropdown_open ? '0' : '4px', 18473 borderBottomRightRadius: dropdown_open ? '0' : '4px' 18474 }"> 18475 <!-- dropdown closed --> 18476 <div id="selected-response" @click="dropdown_open = !dropdown_open;"> 18477 <p v-if="selected_dropdown_index < 0" class="response-option default-font-body">Choose</p> 18478 <p v-if="selected_dropdown_index >= 0 && custom_field_options && custom_field_options[selected_dropdown_index]" class="response-option default-font-body"> 18479 {{ custom_field_options[selected_dropdown_index].name }} 18480 </p> 18481 <i v-if="!dropdown_open" class="fa-solid fa-sort-down" style="color: rgb(95, 95, 95)"></i> 18482 <i v-if="dropdown_open" class="fa-solid fa-sort-up" style="color: rgba(95, 95, 95)"></i> 18483 </div> 18484 <!-- dropdown open --> 18485 <div v-if="dropdown_open" id="response-options"> 18486 <div 18487 class="response-option border-bottom-grey-2" 18488 @click="select_dropdown_option( null, -1 )" 18489 :style="[ return_selected_style( -1 ) ]" 18490 > 18491 <p class="default-font-body" style="color: rgb(150, 152, 154);">Choose</p> 18492 </div> 18493 <div 18494 v-for="(option, index) in custom_field_options" 18495 class="response-option" 18496 :key="option.value" 18497 @click="select_dropdown_option( option, index )" 18498 :style="[ return_selected_style( index ) ]" 18499 > 18500 <p class="default-font-body">{{ option.name }}</p> 18501 </div> 18502 </div> 18503 </div> 18504 </template> 18505 <!-- searchable dropdown (same option model as select) --> 18506 <template v-if="custom_field && custom_field.type === 'searchable_select' && custom_field_options"> 18507 <div class="tinyMarginBottom" style="width: 100%;"> 18508 <rich-select-dropdown
18509 :value="get_searchable_select_value()" 18510 :options="rich_wb_dropdown_options()" 18511 :placeholder="custom_field.name || 'Select...'" 18512 @input="set_searchable_select_from_rich($event)" 18513 :showFieldLabelWhenSelected="true" 18514 :searchable="true" 18515 :searchSubtext="false" 18516 ></rich-select-dropdown> 18517 </div> 18518 </template> 18519 <!-- numeric value --> 18520 <template v-if="custom_field && custom_field.type === 'number'"> 18521 <span class="flex-row-start tinyMarginBottom"> 18522 <input 18523 type="text" 18524 placeholder="Number" 18525 class="border-bottom-grey-2 inherit-font-css" 18526 style="width: 20%; line-height: 24px;" 18527 v-model="input_value" 18528 @input="check_input_is_num()" 18529 :disabled="is_admin" 18530 > 18531 <span v-if="display_num_warning" class="flex-row-start" @mouseover="display_popup = true;" @mouseleave="display_popup = false;"> 18532 <i class="fa-solid fa-triangle-exclamation" style="color: rgba(255, 0, 0, 0.885); font-size: 1em; cursor: pointer;"></i> 18533 </span> 18534 <div v-if="display_popup" class="display-popup" style="font-size: 12px;">This value must be a number.</div> 18535 </span> 18536 </template> 18537 <!-- file upload --> 18538 <template v-if="custom_field && custom_field.type === 'file_upload'"> 18539 <file-upload-component 18540 @file_selected="file_selected" 18541 :valid_file_types="valid_file_types" 18542 ></file-upload-component> 18543 </template> 18544 <!-- date value --> 18545 <template v-if="custom_field && custom_field.type === 'date'"> 18546 <span class="flex-row-start tinyMarginBottom"> 18547 <input 18548 type="date" 18549 placeholder="mm/dd/yyyy" 18550 class="border-bottom-grey-2 default-font-body" 18551 style="width: 20%; line-height: 24px;" 18552 v-model="input_value" 18553 :disabled="is_admin" 18554 > 18555 </span> 18556 </template> 18557 <!-- rating --> 18558 <template v-if="custom_field && custom_field.type === 'rating'"> 18559 <div class="flex-row-start tinyMarginBottom" style="gap: 12px; padding-top: 4px;"> 18560 <i 18561 v-for="star in 5" 18562 :key="star" 18563 :class="star <= input_value ? 'fa-solid fa-star' : 'fa-regular fa-star'" 18564 style="font-size: 28px; color: #4a56f8; cursor: pointer;" 18565 @click="set_rating( star )" 18566 ></i> 18567 </div> 18568 </template> 18569 </div> 18570 </div> 18571</script>
vendor: 1 bytes, line 18571
18571
18572<script> 18573 app_content.component("custom-field-component-wb", { 18574 template: "#template-custom-field-wb", 18575 data() { 18576 return { 18577 selected_dropdown_index: -1, //default 18578 dropdown_open: false, 18579 custom_field_name: '', 18580 custom_field_required: false, 18581 display_num_warning: false, 18582 display_popup: false, 18583 input_value: '', 18584 } 18585 }, 18586 props: { 18587 form: Object, 18588 is_admin: Boolean, 18589 custom_field: Object, 18590 last_update: Number, 18591 }, 18592 methods: { 18593 return_selected_style( index ){ 18594 if ( index === this.selected_dropdown_index ){ 18595 return { 18596 'background-color': 'rgba(26, 115, 232, .09)', 18597 } 18598 } 18599 }, 18600 clear_dropdown_index(){ 18601 this.selected_dropdown_index = -1; 18602 }, 18603 clear_field(){ 18604 this.input_value = ''; 18605 this.selected_dropdown_index = -1; 18606 this.display_num_warning = false; 18607 if ( this.custom_field_options ){ 18608 this.custom_field_options.forEach(option => { 18609 option.selected = false; 18610 }); 18611 } 18612 }, 18613 select_dropdown_option( option, index ){ 18614 this.selected_dropdown_index = index; 18615 this.dropdown_open = false; 18616 if ( option ){ 18617 option.selected = true; 18618 this.unselect_other_options( option, false ); 18619 } else if ( !option ){ 18620 this.unselect_other_options( option, true ); 18621 } 18622 }, 18623 select_mc_option( select, option ){ 18624 if ( select ){ 18625 option.selected = true; 18626 this.unselect_other_options( option, false ); 18627 } else if ( !select ){ 18628 option.selected = false; 18629 } 18630 }, 18631 get_searchable_select_value(){ 18632 if ( !this.custom_field_options || !this.custom_field_options.length ) return null; 18633 const selected = this.custom_field_options.find(o => o.selected); 18634 return selected ? selected.id : null; 18635 }, 18636 rich_wb_dropdown_options(){ 18637 if ( !this.custom_field_options ) return []; 18638 return this.custom_field_options.map(o => ({ id: o.id, name: o.name || '' })); 18639 }, 18640 set_searchable_select_from_rich(val){ 18641 let id = null; 18642 if ( val != null && typeof val === 'object' && val.id != null ){ 18643 id = val.id; 18644 } else { 18645 id = val; 18646 } 18647 if ( !id ){ 18648 this.select_dropdown_option(null, -1); 18649 return; 18650 } 18651 const idx = this.custom_field_options.findIndex(o => o.id === id); 18652 if ( idx === -1 ){ 18653 this.select_dropdown_option(null, -1); 18654 return; 18655 } 18656 this.select_dropdown_option(this.custom_field_options[idx], idx); 18657 }, 18658 unselect_other_options( option, clear_all = false ){ 18659 if ( clear_all ){ 18660 this.custom_field_options.forEach( custom_field_option => { 18661 custom_field_option.selected = false; 18662 }) 18663 } else if ( !clear_all ){ 18664 this.custom_field_options.forEach( custom_field_option => { 18665 if ( custom_field_option.id !== option.id ){ 18666 custom_field_option.selected = false; 18667 } 18668 }) 18669 } 18670 }, 18671 check_input_is_num(){ 18672 let number = Number(this.input_value); 18673 if ( isNaN(number) || !this.input_value ){ 18674 this.display_num_warning = true; 18675 } else { 18676 this.input_value = number; 18677 this.display_num_warning = false;
18678 } 18679 }, 18680 file_selected( data ){ 18681 let file = data.value; 18682 if (file) { 18683 this.input_value = file; 18684 } 18685 }, 18686 set_rating( star ){ 18687 if ( this.is_admin ){ 18688 return; 18689 } 18690 this.input_value = star; 18691 }, 18692 }, 18693 computed: { 18694 custom_field_options(){ 18695 if ( this.custom_field && this.custom_field.custom_field_options ){ 18696 return this.custom_field.custom_field_options; 18697 } 18698 return []; 18699 }, 18700 valid_file_types(){ 18701 return [ ]; 18702 }, 18703 }, 18704 watch: { 18705 input_value( newValue ){ 18706 this.$emit('field-updated', { 18707 id: this.custom_field.id, 18708 value: newValue 18709 }); 18710 } 18711 } 18712 }) 18713</script>
18713 18714 18715 18716<style scoped> 18717 :root { 18718 --custom-field-grey-1: rgba(180, 180, 180, 0.8); 18719 --custom-field-grey-2: rgba(195, 195, 195, 0.7); 18720 --custom-field-grey-3: rgb(154, 160, 166); 18721 --custom-field-primary: #202124; 18722 } 18723 .custom-field-wrapper{ 18724 width: 100%; 18725 border: 1px solid rgba(195, 195, 195, 0.7); 18726 border-radius: 8px; 18727 padding: 1.45em; 18728 padding-top: 10px; 18729 background-color: #fff; 18730 box-sizing: border-box; 18731 } 18732 .custom-field-wrapper .options-icon{ 18733 color: var(--custom-field-grey-3); 18734 margin-right: 8px; 18735 padding: 7.5px; 18736 cursor: pointer; 18737 border-radius: 50%; 18738 font-size: 1.1em; 18739 } 18740 .custom-field-wrapper .options-icon:hover{ 18741 background-color: rgba(150, 150, 150, 0.1); 18742 } 18743 .custom-field-wrapper .default-font-body{ 18744 font-family: Roboto, Arial, Helvetica, sans-serif; 18745 font-size: 15px; 18746 font-weight: 400; 18747 text-align: left; 18748 } 18749 .custom-field-wrapper .file-upload-wrapper{ 18750 height: 90px; 18751 width: 50%; 18752 border: 1px dashed var(--custom-field-grey-1); 18753 } 18754 .custom-field-wrapper .inherit-font-css{ 18755 font-family: inherit; 18756 font-size: inherit; 18757 font-weight: inherit; 18758 } 18759 .custom-field-wrapper .flex-row-center{ 18760 display: flex; 18761 flex-direction: row; 18762 align-items: center; 18763 justify-content: center; 18764 } 18765 .custom-field-wrapper .flex-column-center{ 18766 display: flex; 18767 flex-direction: column; 18768 align-items: center; 18769 justify-content: center; 18770 } 18771 .custom-field-wrapper .flex-row-start{ 18772 display: flex; 18773 flex-direction: row; 18774 align-items: center; 18775 justify-content: flex-start; 18776 } 18777 .custom-field-wrapper .flex-column-start{ 18778 display: flex; 18779 flex-direction: column; 18780 align-items: flex-start; 18781 justify-content: center; 18782 } 18783 .custom-field-wrapper .custom-field{ 18784 color: rgb(32, 33, 36); 18785 box-sizing: border-box; 18786 font-weight: 400; 18787 font-size: 14px; 18788 width: 100%; 18789 line-height: 2.5; 18790 letter-spacing: 0; 18791 } 18792 .custom-field-wrapper .custom-field > .custom-field-input{ 18793 font-family: inherit; 18794 font-size: inherit; 18795 font-weight: inherit; 18796 width: inherit; 18797 line-height: inherit; 18798 background-color: rgb(248, 249, 250); 18799 padding: 5px 0 5px 15px; 18800 } 18801 .custom-field-wrapper .border-bottom-grey-2, .custom-field-wrapper .input-is-focused{ 18802 border-bottom: 1.25px solid var(--custom-field-grey-2); 18803 } 18804 .custom-field-wrapper input { 18805 transition: border-color 0.2s; 18806 } 18807 #response-dropdown{ 18808 position: relative; 18809 display: flex; 18810 flex-direction: column; 18811 align-items: flex-start; 18812 justify-content: center; 18813 font-size: 14px; 18814 color: var(--custom-field-primary); 18815 background-color: #fff; 18816 width: 33%; 18817 padding: 12px; 18818 border: 1px solid var(--custom-field-grey-1); 18819 border-top-left-radius: 4px; 18820 border-top-right-radius: 4px; 18821 } 18822 #response-dropdown > #selected-response{ 18823 display: flex; 18824 flex-direction: row; 18825 align-items: center; 18826 justify-content: space-between; 18827 width: 100%; 18828 cursor: pointer; 18829 } 18830 #response-options{ 18831 position: absolute; 18832 top: 100%; 18833 left: -1px; 18834 z-index: 10; 18835 background-color: #fff; 18836 border: 1px solid var(--custom-field-grey-1); 18837 border-bottom-left-radius: 4px; 18838 border-bottom-right-radius: 4px; 18839 width: 100%; 18840 } 18841 #response-options > .response-option{ 18842 display: flex; 18843 flex-direction: row; 18844 align-items: center; 18845 justify-content: flex-start; 18846 padding: 12px; 18847 font-family: Roboto; 18848 cursor: pointer; 18849 } 18850 #response-options > .response-option:hover{ 18851 background-color: rgba(150, 150, 150, 0.2); 18852 } 18853 .display-popup{ 18854 background-color: rgb(245, 245, 245); 18855 padding: 5px; 18856 border-radius: 6px; 18857 position: relative; 18858 left: -100px; 18859 bottom: 30px; 18860 z-index: 10; 18861 width: fit-content; 18862 } 18863</style> 18864 18865
18865<script type="text/html" id="template-rich-select-dropdown"> 18866 <div class="relative"> 18867 <div v-if="showFieldLabelWhenSelected && has_selected_value" class="rich-select-floating-label"> 18868 <span class="rich-select-trigger-title">{{ label || placeholder }}</span> 18869 </div> 18870 <div 18871 class="rich-select-dropdown relative" ref="container" 18872 style="width: 100%;" 18873 > <!-- Closed state: box showing selected option(s) --> 18874 <div 18875 class="rich-select-trigger flexNoWrap align-items-center pointer" 18876 :class="[triggerClass, { 'rich-select-trigger-open': isOpen, 'rich-select-trigger-borderless': borderless, 'rich-select-trigger-disabled': disabled }]" 18877 @click="toggle()" 18878 > 18879 <slot name="indicator"></slot> 18880 <template v-if="multiple"> 18881 <template v-if="selectedOptionsForDisplay.length === 0"> 18882 <span class="rich-select-placeholder">{{ label || placeholder }}</span> 18883 </template> 18884 <template v-else-if="selectedOptionsForDisplay.length === 1"> 18885 <div v-if="optionImage(selectedOptionsForDisplay[0])" class="rich-select-option-icon-wrap"> 18886 <img v-if="optionImageIsUrl(selectedOptionsForDisplay[0])" :src="optionImage(selectedOptionsForDisplay[0])" class="rich-select-option-icon" alt=""> 18887 <i v-else :class="optionImage(selectedOptionsForDisplay[0])" class="rich-select-option-icon fa"></i> 18888 </div> 18889 <div class="rich-select-trigger-text"> 18890 <span class="rich-select-trigger-title">{{ optionTitle(selectedOptionsForDisplay[0]) }}</span> 18891 <span v-if="optionSubtext(selectedOptionsForDisplay[0])" class="rich-select-trigger-subtext">{{ optionSubtext(selectedOptionsForDisplay[0]) }}</span> 18892 </div> 18893 </template> 18894 <template v-else> 18895 <span class="rich-select-trigger-title">{{ selectedOptionsForDisplay.length }} selected</span> 18896 </template> 18897 </template> 18898 <template v-else> 18899 <template v-if="selectedOptionSingle"> 18900 <div v-if="optionImage(selectedOptionSingle)" class="rich-select-option-icon-wrap"> 18901 <img v-if="optionImageIsUrl(selectedOptionSingle)" :src="optionImage(selectedOptionSingle)" class="rich-select-option-icon" alt=""> 18902 <i v-else :class="optionImage(selectedOptionSingle)" class="rich-select-option-icon fa"></i> 18903 </div> 18904 <div class="rich-select-trigger-text"> 18905 <span class="rich-select-trigger-title" :class="{ 'rich-select-trigger-title-no-subtext': !optionSubtext(selectedOptionSingle) }">{{ optionTitle(selectedOptionSingle) }}</span> 18906 <span v-if="optionSubtext(selectedOptionSingle)" class="rich-select-trigger-subtext">{{ optionSubtext(selectedOptionSingle) }}</span> 18907 </div> 18908 </template> 18909 <template v-else-if="disabled && disabledSubtext"> 18910 <div class="rich-select-trigger-text"> 18911 <span class="rich-select-placeholder">{{ label || placeholder }}</span> 18912 <span class="rich-select-trigger-subtext">{{ disabledSubtext }}</span> 18913 </div> 18914 </template> 18915 <template v-else> 18916 <span class="rich-select-placeholder">{{ label || placeholder }}</span> 18917 </template> 18918 </template> 18919 <div class="flexGrowOne"></div> 18920 <i class="fa-light fa-chevron-down rich-select-chevron" :class="{ 'rich-select-chevron-open': isOpen }" style="z-index: 10;"></i> 18921 </div> 18922 18923 <!-- Open state: dropdown panel (hovering above content) --> 18924 <div 18925 v-show="isOpen"
18926 class="rich-select-dropdown-panel boxShadowBottom moreBlur" 18927 :class="{ 'rich-select-dropdown-panel--fixed': uses_fixed_dropdown_panel && isOpen }" 18928 :style="fixedDropdownPanelInlineStyle" 18929 ref="dropdownPanel" 18930 @mouseover="isMouseOver = true" 18931 @mouseout="isMouseOver = false" 18932 tabindex="-1" 18933 @focusout="scheduleClose" 18934 > 18935 <div v-if="searchable" class="rich-select-search-wrap relative"> 18936 <input 18937 ref="searchInput" 18938 v-model="searchQuery" 18939 class="rich-select-search-input" 18940 :aria-label="search_placeholder || 'Search...'" 18941 @click.stop 18942 > 18943 <span 18944 v-if="!searchQuery" 18945 class="unclickable" 18946 style="position:absolute; left:18px; right:18px; top:50%; transform:translateY(-50%); color:#9ca4b7; font-size:14px; font-family:roboto, sans-serif; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; pointer-events:none;" 18947 >{{ search_placeholder || 'Search...' }}</span> 18948 </div> 18949 <div class="rich-select-dropdown-list scrollable" ref="dropdownList"> 18950 <div v-if="loading" class="rich-select-option flexNoWrap align-items-center unclickable"> 18951 <div class="rich-select-option-text"> 18952 <span class="rich-select-option-subtext">Searching...</span> 18953 </div> 18954 </div> 18955 <div 18956 v-if="showNoneOption" 18957 class="rich-select-option flexNoWrap align-items-center" 18958 :class="{ 'rich-select-option-selected': value == null }" 18959 @click="selectOption(null)" 18960 > 18961 <div class="rich-select-option-text"> 18962 <span class="rich-select-option-title">None</span> 18963 </div> 18964 </div> 18965 <!-- Suggested section: only renders when the parent passes 18966 lookup-derived candidates AND the user hasn't typed a 18967 search query. Header + divider use inline styles per 18968 the no-new-classes rule for HTML files. --> 18969 <template v-if="visibleSuggestedOptions.length > 0"> 18970 <p style="margin: 0; padding: 8px 14px 4px 14px; font-size: 10px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: #7a8298;">{{ suggested_label }}</p> 18971 <div 18972 v-for="option in visibleSuggestedOptions" 18973 :key="'suggested-' + optionId(option)" 18974 class="rich-select-option flexNoWrap align-items-center" 18975 :class="{ 'rich-select-option-selected': isSelected(option), 'unclickable': option && option.disabled }" 18976 @click="selectOption(option)" 18977 > 18978 <template v-if="multiple"> 18979 <i v-if="isSelected(option)" class="fa-solid fa-square-check rich-select-option-check"></i> 18980 <i v-else class="fa-light fa-square rich-select-option-check"></i> 18981 </template> 18982 <div v-if="optionImage(option)" class="rich-select-option-icon-wrap"> 18983 <img v-if="optionImageIsUrl(option)" :src="optionImage(option)" class="rich-select-option-icon" alt=""> 18984 <i v-else :class="optionImage(option)" class="rich-select-option-icon fa"></i> 18985 </div> 18986 <div class="rich-select-option-text"> 18987 <span class="rich-select-option-title" :class="optionTitleClass(option)">{{ optionTitle(option) }}</span>
18988 <span v-if="optionSubtext(option)" class="rich-select-option-subtext">{{ optionSubtext(option) }}</span> 18989 </div> 18990 <slot name="option-action" :option="option"></slot> 18991 </div> 18992 <div style="height: 1px; background-color: #e4ebf1; margin: 4px 0;"></div> 18993 </template> 18994 <div 18995 v-for="option in nonSuggestedFilteredOptions" 18996 :key="optionId(option)" 18997 class="rich-select-option flexNoWrap align-items-center" 18998 :class="{ 'rich-select-option-selected': isSelected(option), 'unclickable': option && option.disabled }" 18999 @click="selectOption(option)" 19000 > 19001 <template v-if="multiple"> 19002 <i v-if="isSelected(option)" class="fa-solid fa-square-check rich-select-option-check"></i> 19003 <i v-else class="fa-light fa-square rich-select-option-check"></i> 19004 </template> 19005 <div v-if="optionImage(option)" class="rich-select-option-icon-wrap"> 19006 <img v-if="optionImageIsUrl(option)" :src="optionImage(option)" class="rich-select-option-icon" alt=""> 19007 <i v-else :class="optionImage(option)" class="rich-select-option-icon fa"></i> 19008 </div> 19009 <div class="rich-select-option-text"> 19010 <span class="rich-select-option-title" :class="optionTitleClass(option)">{{ optionTitle(option) }}</span> 19011 <span v-if="optionSubtext(option)" class="rich-select-option-subtext">{{ optionSubtext(option) }}</span> 19012 </div> 19013 <slot name="option-action" :option="option"></slot> 19014 </div> 19015 <div v-if="!loading && nonSuggestedFilteredOptions.length == 0" class="rich-select-option flexNoWrap align-items-center unclickable"> 19016 <div class="rich-select-option-text"> 19017 <span class="rich-select-option-subtext">{{ empty_text || "No options found" }}</span> 19018 </div> 19019 </div> 19020 </div> 19021 <slot name="panel-footer"></slot> 19022 </div> 19023 </div> 19024 </div> 19025</script>
19025 19026
19027<script> 19028(function() { 19029 function optionId(option) { 19030 if (!option) return ''; 19031 return option.id != null ? option.id : (option.get ? option.get('id') : option); 19032 } 19033 function optionTitle(option) { 19034 if (!option) return ''; 19035 return option.title != null ? option.title : (option.name != null ? option.name : (option.get ? (option.get('title') || option.get('name')) : '')); 19036 } 19037 function optionSubtext(option) { 19038 if (!option) return ''; 19039 return option.subtext != null ? option.subtext : (option.get ? option.get('subtext') : ''); 19040 } 19041 function optionTitleClass(option) { 19042 if (!option) return ''; 19043 return option.title_class != null ? option.title_class : (option.get ? option.get('title_class') : ''); 19044 } 19045 function optionImage(option) { 19046 if (!option) return null; 19047 return option.image != null ? option.image : (option.icon != null ? option.icon : (option.get ? (option.get('image') || option.get('icon')) : null)); 19048 } 19049 function optionImageIsUrl(option) { 19050 var img = optionImage(option); 19051 return img && (typeof img === 'string' && (img.indexOf('http') === 0 || img.indexOf('/') === 0 || img.indexOf('data:') === 0)); 19052 } 19053 19054 var richSelectDropdownConfig = { 19055 template: "#template-rich-select-dropdown", 19056 emits: ['input', 'update:value', 'change', 'search'], 19057 data() { 19058 return { 19059 isOpen: false, 19060 isMouseOver: false, 19061 closeTimeout: null, 19062 searchQuery: '', 19063 fixedPanelPositionStyle: null, 19064 _fixedPanelScrollCleanup: null, 19065 auto_fix_dropdown_panel: false, 19066 }; 19067 }, 19068 mounted() { 19069 this.auto_fix_dropdown_panel = !!this.$refs.container.closest('.wb-inline-controls'); 19070 }, 19071 watch: { 19072 isOpen: function(is_open) { 19073 if (!this.uses_fixed_dropdown_panel) { 19074 return; 19075 } 19076 if (is_open) { 19077 this.scroll_trigger_into_safe_view_if_needed(); 19078 this.sync_fixed_dropdown_panel(); 19079 this.$nextTick(() => { 19080 this.sync_fixed_dropdown_panel(); 19081 this.teardown_fixed_dropdown_panel_listeners(); 19082 this._fixedPanelScrollCleanup = this.setup_fixed_dropdown_panel_listeners(); 19083 }); 19084 } else { 19085 this.fixedPanelPositionStyle = null; 19086 this.teardown_fixed_dropdown_panel_listeners(); 19087 } 19088 }, 19089 searchQuery: function(updated_query) { 19090 if (!this.searchable) { 19091 return; 19092 } 19093 this.$emit('search', updated_query); 19094 } 19095 }, 19096 props: { 19097 options: { 19098 type: Array, 19099 default: function() { return []; } 19100 }, 19101 value: { 19102 type: [Object, Array, String, Number], 19103 default: null 19104 }, 19105 multiple: { 19106 type: Boolean, 19107 default: false 19108 }, 19109 placeholder: { 19110 type: String, 19111 default: "Select..." 19112 }, 19113 label: { 19114 type: String, 19115 default: "" 19116 }, 19117 triggerClass: { 19118 type: String, 19119 default: "" 19120 }, 19121 /** If true, options may be Backbone models with .get('key') */ 19122 parse_objects: { 19123 type: Boolean, 19124 default: false 19125 }, 19126 showFieldLabelWhenSelected: { 19127 type: Boolean, 19128 default: false 19129 }, 19130 searchable: { 19131 type: Boolean, 19132 default: false 19133 }, 19134 search_placeholder: { 19135 type: String, 19136 default: "" 19137 }, 19138 remote_search: { 19139 type: Boolean, 19140 default: false 19141 }, 19142 loading: { 19143 type: Boolean, 19144 default: false 19145 }, 19146 empty_text: { 19147 type: String, 19148 default: '' 19149 }, 19150 /** When true, the closed trigger renders without its border/box so it can sit flush inside a table cell. */ 19151 borderless: { 19152 type: Boolean, 19153 default: false 19154 }, 19155 searchSubtext: { 19156 type: Boolean, 19157 default: true 19158 }, 19159 /** When true, the open panel uses position:fixed so it escapes overflow:auto
19159scroll parents (e.g. lodging step). */ 19160 dropdownPanelFixed: { 19161 type: Boolean, 19162 default: false 19163 }, 19164 /** When true, a "None" option is shown at the top of the list to clear the selection. */ 19165 showNoneOption: { 19166 type: Boolean, 19167 default: false 19168 }, 19169 /** 19170 * Optional, ranked list of options to surface at the top of the 19171 * dropdown panel under a "Suggested" header. Items must use the 19172 * same shape as `options` (id + title/name). The component 19173 * doesn't decide what's "suggested" â that's the parent's job 19174 * (e.g. the add-product modal feeds in the top N candidates from 19175 * its similar-products lookup). Hidden once the user starts 19176 * typing in the search input so search results aren't fragmented 19177 * across two sections. 19178 */ 19179 suggested_options: { 19180 type: Array, 19181 default: function() { return []; } 19182 }, 19183 /** Header label rendered above suggested_options. */ 19184 suggested_label: { 19185 type: String, 19186 default: 'Suggested' 19187 }, 19188 /** When true, the trigger can't be opened and renders in a muted state. */ 19189 disabled: { 19190 type: Boolean, 19191 default: false 19192 }, 19193 /** Explanatory line rendered under the placeholder when disabled (e.g. "No products found"). */ 19194 disabledSubtext: { 19195 type: String, 19196 default: null 19197 } 19198 }, 19199 computed: { 19200 uses_fixed_dropdown_panel() { 19201 return this.dropdownPanelFixed || this.auto_fix_dropdown_panel; 19202 }, 19203 fixedDropdownPanelInlineStyle() { 19204 if (!this.uses_fixed_dropdown_panel || !this.isOpen || !this.fixedPanelPositionStyle) { 19205 return null; 19206 } 19207 return this.fixedPanelPositionStyle; 19208 }, 19209 displayOptions() { 19210 return this.options || []; 19211 }, 19212 filteredOptions() { 19213 if (this.remote_search) { 19214 return this.displayOptions; 19215 } 19216 if (!this.searchable || !this.searchQuery.trim()) { 19217 return this.displayOptions; 19218 } 19219 var normalized_search_query = this.searchQuery.trim().toLowerCase(); 19220 var searchSubtext = this.searchSubtext; 19221 return this.displayOptions.filter(function(option) { 19222 var title = (optionTitle(option) || '').toLowerCase(); 19223 if (title.indexOf(normalized_search_query) !== -1) { 19224 return true; 19225 } 19226 if (searchSubtext) { 19227 var option_subtext = (optionSubtext(option) || '').toLowerCase(); 19228 if (option_subtext.indexOf(normalized_search_query) !== -1) { 19229 return true; 19230 } 19231 } 19232 return false; 19233 }); 19234 }, 19235 // Suggested-section visibility. Hidden once the user types so the 19236 // search hit list doesn't get split across two sections. 19237 visibleSuggestedOptions() { 19238 if (!Array.isArray(this.suggested_options) || this.suggested_options.length === 0) { 19239 return []; 19240 } 19241 if (this.searchable && this.searchQuery && this.searchQuery.trim()) { 19242 return []; 19243 } 19244 return this.suggested_options; 19245 }, 19246 // Filtered options minus anything already rendered in the 19247 // suggested section, so suggested items don't appear twice in 19248 // the panel. 19249 nonSuggestedFilteredOptions() { 19250 var suggested = this.visibleSuggestedOptions; 19251 if (!suggested.length) { 19252 return this.filteredOptions; 19253 } 19254 var suggestedIds = {}; 19255 suggested.forEach(function(option) { 19256 suggestedIds[optionId(option)] = true; 19257 }); 19258 return this.filteredOptions.filter(function(option) { 19259 return !suggestedIds[optionId(option)]; 19260 }); 19261 }, 19262 selectedOptionSingle() { 19263 if (this.multiple || !this.options.length) return null; 19264 var v = this.value; 19265 if (v == null) return null; 19266 var id = typeof v === 'object' && v !== null && (v.id != null || (v.get && v.get('id') != null)) 19267 ? (v.id != null ? v.id : v.get('id')) 19268 : v; 19269 return this.options.find(function(o) { return optionId(o) == id; }) || null; 19270 }, 19271 selectedOptionsForDisplay() { 19272 if (!this.multiple || !Array.isArray(this.value)) return []; 19273 var ids = this.value; 19274 return this.options.filter(function(o) { return ids.some(function(id) { return id == optionId(o); }); }); 19275 }, 19276 has_selected_value() { 19277 if (this.multiple) { 19278 return this.selectedOptionsForDisplay.length > 0; 19279 } 19280 return !!this.selectedOptionSingle; 19281 } 19282 }, 19283 methods: { 19284 optionId: function(o) { return optionId(o); }, 19285 optionTitle: function(o) { return optionTitle(o); }, 19286 optionSubtext: function(o) { return optionSubtext(o); }, 19287 optionTitleClass: function(option) { return optionTitleClass(option); },
19288 optionImage: function(o) { return optionImage(o); }, 19289 optionImageIsUrl: function(o) { return optionImageIsUrl(o); }, 19290 isSelected(option) { 19291 var id = optionId(option); 19292 if (this.multiple && Array.isArray(this.value)) 19293 return this.value.some(function(v) { return v == id; }); 19294 var single = this.value; 19295 if (single == null) return false; 19296 var currentId = typeof single === 'object' && single !== null 19297 ? (single.id != null ? single.id : (single.get ? single.get('id') : null)) 19298 : single; 19299 return currentId == id; 19300 }, 19301 toggle() { 19302 if (this.disabled) { 19303 return; 19304 } 19305 if (this.closeTimeout) { 19306 clearTimeout(this.closeTimeout); 19307 this.closeTimeout = null; 19308 } 19309 this.isOpen = !this.isOpen; 19310 if (this.isOpen) { 19311 this.searchQuery = ''; 19312 this.$nextTick(() => { 19313 if (this.$refs.dropdownList) { 19314 this.$refs.dropdownList.scrollTop = 0; 19315 } 19316 if (this.uses_fixed_dropdown_panel && this.searchable) { 19317 requestAnimationFrame(() => { 19318 requestAnimationFrame(() => { 19319 this.focus_open_panel(); 19320 }); 19321 }); 19322 } else { 19323 this.focus_open_panel(); 19324 } 19325 }); 19326 document.addEventListener('click', this.handleClickOutside); 19327 } else { 19328 this.searchQuery = ''; 19329 document.removeEventListener('click', this.handleClickOutside); 19330 } 19331 }, 19332 focus_open_panel() { 19333 if (!this.isOpen) { 19334 return; 19335 } 19336 if (this.searchable && this.$refs.searchInput) { 19337 this.$refs.searchInput.focus(); 19338 } else if (this.$refs.dropdownPanel) { 19339 this.$refs.dropdownPanel.focus(); 19340 } 19341 }, 19342 scheduleClose() { 19343 var self = this; 19344 this.closeTimeout = setTimeout(function() { 19345 if (!self.isMouseOver) { 19346 self.isOpen = false; 19347 document.removeEventListener('click', self.handleClickOutside); 19348 } 19349 }, 150); 19350 }, 19351 handleClickOutside(e) { 19352 var panel = this.$refs.dropdownPanel; 19353 var inPanel = panel && panel.contains(e.target); 19354 var inContainer = this.$refs.container && this.$refs.container.contains(e.target); 19355 if (!inContainer && !inPanel) { 19356 this.isOpen = false; 19357 document.removeEventListener('click', this.handleClickOutside); 19358 } 19359 }, 19360 return_visual_viewport() { 19361 let view = window.visualViewport; 19362 if (view) { 19363 return { 19364 height: view.height, 19365 width: view.width, 19366 offset_top: view.offsetTop || 0, 19367 offset_left: view.offsetLeft || 0, 19368 }; 19369 } 19370 return { 19371 height: window.innerHeight, 19372 width: window.innerWidth, 19373 offset_top: 0, 19374 offset_left: 0, 19375 }; 19376 }, 19377 scroll_trigger_into_safe_view_if_needed() { 19378 if (!this.searchable || !this.$refs.container) { 19379 return; 19380 } 19381 let view = this.return_visual_viewport(); 19382 let trigger_rect = this.$refs.container.getBoundingClientRect(); 19383 if (view.height - trigger_rect.bottom >= 200) { 19384 return; 19385 } 19386 let node = this.$refs.container.parentElement; 19387 while (node && node !== document.documentElement) { 19388 let style = window.getComputedStyle(node); 19389 let overflow_y = style.overflowY; 19390 let overflow = style.overflow; 19391 if (overflow_y === 'auto' || overflow_y === 'scroll' || overflow === 'auto' || overflow === 'scroll') {
19392 let parent_rect = node.getBoundingClientRect(); 19393 let delta = trigger_rect.top - parent_rect.top - 8; 19394 if (delta > 0) { 19395 node.scrollTop = node.scrollTop + delta; 19396 } 19397 return; 19398 } 19399 node = node.parentElement; 19400 } 19401 }, 19402 sync_fixed_dropdown_panel() { 19403 if (!this.uses_fixed_dropdown_panel || !this.isOpen || !this.$refs.container) { 19404 return; 19405 } 19406 let trigger_rect = this.$refs.container.getBoundingClientRect(); 19407 let view = this.return_visual_viewport(); 19408 let edge = 8; 19409 let gap = 4; 19410 let desired_height = 280; 19411 let min_usable = 160; 19412 let space_below = view.height - trigger_rect.bottom - edge; 19413 let space_above = trigger_rect.top - edge; 19414 let visual_top = trigger_rect.bottom + gap; 19415 let max_height = Math.min(desired_height, space_below); 19416 19417 if (space_below < min_usable && space_above > space_below) { 19418 max_height = Math.min(desired_height, space_above); 19419 visual_top = trigger_rect.top - gap - max_height; 19420 } else if (space_below < 80 && space_above >= 80) { 19421 max_height = Math.min(desired_height, space_above); 19422 visual_top = trigger_rect.top - gap - max_height; 19423 } else if (space_below < 80 && space_above < 80) { 19424 visual_top = edge; 19425 max_height = view.height - edge * 2; 19426 } else if (space_below < min_usable) { 19427 max_height = Math.max(space_below, 0); 19428 visual_top = trigger_rect.bottom + gap; 19429 } 19430 19431 if (visual_top < edge) { 19432 max_height = max_height - (edge - visual_top); 19433 visual_top = edge; 19434 } 19435 if (visual_top + max_height > view.height - edge) { 19436 max_height = view.height - edge - visual_top; 19437 } 19438 if (max_height < 80) { 19439 max_height = Math.min(80, Math.max(0, view.height - edge * 2)); 19440 visual_top = Math.min(visual_top, view.height - edge - max_height); 19441 visual_top = Math.max(edge, visual_top); 19442 } 19443 19444 //keyboard pan: getBoundingClientRect is visual, position:fixed is layout 19445 this.fixedPanelPositionStyle = { 19446 position: 'fixed', 19447 top: (visual_top + view.offset_top) + 'px', 19448 left: (trigger_rect.left + view.offset_left) + 'px', 19449 width: trigger_rect.width + 'px', 19450 minWidth: trigger_rect.width + 'px', 19451 maxHeight: max_height + 'px', 19452 zIndex: 10050, 19453 }; 19454 }, 19455 setup_fixed_dropdown_panel_listeners() { 19456 let sync_panel = () => { 19457 this.sync_fixed_dropdown_panel(); 19458 }; 19459 let scroll_nodes = []; 19460 let node = this.$refs.container; 19461 while (node && node !== document.documentElement) { 19462 let style = window.getComputedStyle(node); 19463 let overflow_y = style.overflowY; 19464 let overflow_x = style.overflowX; 19465 let overflow = style.overflow; 19466 if (overflow_y === 'auto' || overflow_y === 'scroll' || overflow_x === 'auto' || overflow_x === 'scroll' || overflow === 'auto' || overflow === 'scroll') { 19467 node.addEventListener('scroll', sync_panel, { passive: true }); 19468 scroll_nodes.push(node); 19469 } 19470 node = node.parentElement; 19471 } 19472 window.addEventListener('resize', sync_panel); 19473 window.addEventListener('scroll', sync_panel, true);
19474 let visual_viewport = window.visualViewport; 19475 if (visual_viewport) { 19476 visual_viewport.addEventListener('resize', sync_panel); 19477 visual_viewport.addEventListener('scroll', sync_panel); 19478 } 19479 return () => { 19480 scroll_nodes.forEach((scroll_node) => { 19481 scroll_node.removeEventListener('scroll', sync_panel); 19482 }); 19483 window.removeEventListener('resize', sync_panel); 19484 window.removeEventListener('scroll', sync_panel, true); 19485 if (visual_viewport) { 19486 visual_viewport.removeEventListener('resize', sync_panel); 19487 visual_viewport.removeEventListener('scroll', sync_panel); 19488 } 19489 }; 19490 }, 19491 teardown_fixed_dropdown_panel_listeners() { 19492 if (this._fixedPanelScrollCleanup) { 19493 this._fixedPanelScrollCleanup(); 19494 this._fixedPanelScrollCleanup = null; 19495 } 19496 }, 19497 selectOption(option) { 19498 if (option && option.disabled) { 19499 return; 19500 } 19501 var id = optionId(option); 19502 if (this.multiple) { 19503 var arr = Array.isArray(this.value) ? this.value.slice() : []; 19504 var idx = arr.indexOf(id); 19505 if (idx >= 0) { 19506 arr.splice(idx, 1); 19507 } else { 19508 arr.push(id); 19509 } 19510 this.$emit('input', arr); 19511 this.$emit('update:value', arr); 19512 this.$emit('change', arr); 19513 } else { 19514 this.$emit('input', option); 19515 this.$emit('update:value', option); 19516 this.$emit('change', option); 19517 this.isOpen = false; 19518 document.removeEventListener('click', this.handleClickOutside); 19519 } 19520 } 19521 }, 19522 beforeDestroy() { 19523 document.removeEventListener('click', this.handleClickOutside); 19524 if (this.closeTimeout) { 19525 clearTimeout(this.closeTimeout); 19526 } 19527 this.teardown_fixed_dropdown_panel_listeners(); 19528 } 19529 }; 19530 app_content.component("rich-select-dropdown", richSelectDropdownConfig); 19531 app_overlays.component("rich-select-dropdown", richSelectDropdownConfig); 19532})(); 19533</script>
19533 19534 19535<style> 19536.rich-select-floating-label { 19537 position: absolute; 19538 left: 10px; 19539 top: -4px; 19540 z-index: 10; 19541 font-size: 11px; 19542 padding-left: 8px; 19543 padding-right: 8px; 19544 background-color: #fff; 19545 color: #8493a0; 19546 text-transform: uppercase; 19547 letter-spacing: 1px; 19548} 19549.rich-select-floating-label .rich-select-trigger-title { 19550 font-size: 11px; 19551 color: #8493a0; 19552} 19553 19554.rich-select-dropdown { 19555 display: inline-block; 19556 min-width: 180px; 19557} 19558 19559.rich-select-trigger { 19560 border: 1px solid #e4ebf1; 19561 border-radius: 8px; 19562 padding: 10px 12px; 19563 min-height: 32px; 19564 -webkit-user-select: none; 19565 user-select: none; 19566 transition: border-color 0.15s ease, box-shadow 0.15s ease; 19567} 19568 19569.rich-select-trigger:hover { 19570 border-color: #c5cdd6; 19571} 19572 19573.rich-select-trigger.rich-select-trigger-borderless, 19574.rich-select-trigger.rich-select-trigger-borderless:hover, 19575.rich-select-trigger.rich-select-trigger-borderless.rich-select-trigger-open { 19576 border-color: transparent; 19577 box-shadow: none; 19578 padding-left: 2px; 19579 padding-right: 2px; 19580} 19581 19582.rich-select-trigger.rich-select-trigger-disabled, 19583.rich-select-trigger.rich-select-trigger-disabled:hover { 19584 background-color: #fbfcfe; 19585 border-color: #e4ebf1; 19586 cursor: default; 19587} 19588 19589.rich-select-trigger.rich-select-trigger-disabled .rich-select-chevron { 19590 opacity: 0.4; 19591} 19592 19593.rich-select-trigger-open, 19594.rich-select-trigger.rich-select-trigger-open { 19595 /* border-color: #2e59d6; */ 19596 /* box-shadow: 0 0 0 1px #2e59d6; */ 19597 border-color: #e4ebf1; 19598 box-shadow: none; 19599 outline: none; 19600} 19601 19602.rich-select-dropdown:focus-within .rich-select-trigger { 19603 border-color: #e4ebf1; 19604 box-shadow: none; 19605 outline: none; 19606} 19607 19608.rich-select-trigger:focus, 19609.rich-select-trigger:focus-visible, 19610.rich-select-dropdown-panel:focus, 19611.rich-select-dropdown-panel:focus-visible { 19612 border-color: #e4ebf1; 19613 box-shadow: none; 19614 outline: none; 19615} 19616 19617.rich-select-trigger-text { 19618 /* flex: 1; */ 19619 min-width: 0; 19620 display: flex; 19621 flex-direction: column; 19622 gap: 2px; 19623} 19624 19625.rich-select-trigger-title { 19626 font-size: 15px; 19627 color: #081333; 19628 font-family: 'roboto', sans-serif; 19629} 19630 19631.rich-select-trigger-title-no-subtext { 19632 font-size: 18px; 19633 font-weight: 100; 19634 color: #081333; 19635 font-family: 'roboto', sans-serif; 19636} 19637 19638.rich-select-trigger-subtext { 19639 font-size: 13px; 19640 color: #83909d; 19641} 19642 19643.rich-select-placeholder { 19644 font-size: 15px; 19645 color: #83909d; 19646} 19647 19648.rich-select-option-icon-wrap { 19649 width: 32px; 19650 height: 32px; 19651 min-width: 32px; 19652 border-radius: 50%; 19653 overflow: hidden; 19654 background-color: #e8eaf1; 19655 display: flex; 19656 align-items: center; 19657 justify-content: center; 19658 margin-right: 10px; 19659} 19660 19661.rich-select-option-icon { 19662 width: 100%; 19663 height: 100%; 19664 object-fit: cover; 19665} 19666 19667.rich-select-option-icon.fa { 19668 width: auto; 19669 height: auto; 19670 font-size: 16px; 19671 color: #52596f; 19672 background: none !important; 19673} 19674.rich-select-option-icon.fa::before { 19675 background: none !important; 19676} 19677 19678.rich-select-chevron { 19679 margin-left: 8px; 19680 font-size: 12px; 19681 color: #83909d; 19682 transition: transform 0.2s ease; 19683} 19684 19685.rich-select-chevron.rich-select-chevron-open { 19686 transform: rotate(180deg); 19687} 19688 19689/* Dropdown panel: floats below trigger, above surrounding content */ 19690.rich-select-dropdown-panel { 19691 position: absolute; 19692 top: 100%; 19693 left: 0; 19694 margin-top: 4px; 19695 z-index: 1001; 19696 background-color: #fff; 19697 border: 1px solid #e4ebf1; 19698 border-radius: 5px; 19699 overflow: hidden; 19700 padding: 6px 0; 19701 max-height: 280px; 19702 min-width: 100%; 19703 display: flex; 19704 flex-direction: column; 19705 transform-origin: top center; 19706 animation: rich-select-dropdown-panel-open 0.11s ease-out; 19707} 19708 19709/* v-show flips display, which restarts the animation on every open */ 19710@keyframes rich-select-dropdown-panel-open { 19711 from { 19712 opacity: 0; 19713 transform: translateY(-5px) scaleY(0.96); 19714 } 19715 to { 19716 opacity: 1; 19717 transform: translateY(0) scaleY(1); 19718 } 19719} 19720 19721/* Fixed panel: escapes overflow:auto ancestors (position + size from inline style) */ 19722.rich-select-dropdown-panel--fixed { 19723 position: fixed; 19724 top: auto; 19725 left: auto; 19726 margin-top: 0; 19727 min-width: 0; 19728 max-height: none; 19729} 19730 19731.rich-select-search-wrap { 19732 padding: 6px 8px; 19733 border-bottom: 1px solid #e4ebf1; 19734 flex-shrink: 0; 19735} 19736 19737.rich-select-search-input { 19738 width: 100%; 19739 border: 1px solid #e4ebf1; 19740 border-radius: 5px; 19741 padding: 8px 10px; 19742 font-size: 14px; 19743 font-family: 'roboto', sans-serif; 19744 outline: none; 19745 box-sizing: border-box; 19746} 19747 19748.rich-select-search-input:focus { 19749 /* border-color: #2e59d6; */ 19750 /* box-shadow: 0 0 0 1px #2e59d6; */ 19751 border-color: #e4ebf1; 19752 box-shadow: none; 19753 outline: none; 19754} 19755 19756.rich-select-dropdown-list { 19757 flex: 1 1 auto; 19758 overflow-y: auto; 19759 overflow-x: hidden; 19760} 19761 19762.rich-select-option { 19763 padding: 10px 14px; 19764 cursor: pointer; 19765 transition: background-color 0.1s ease; 19766} 19767 19768.rich-select-option:hover { 19769 background-color: #f6f8f9; 19770} 19771 19772.rich-select-option-selected { 19773 background-color: #f0f4ff; 19774} 19775 19776.rich-select-option-check { 19777 margin-right: 10px; 19778 font-size: 18px; 19779 color: #52596f; 19780} 19781 19782.rich-select-option-selected .rich-select-option-check { 19783 color: var(--custom-mp-accent, #2e59d6); 19784} 19785 19786.rich-select-option-text { 19787 flex: 1; 19788 min-width: 0; 19789 display: flex; 19790 flex-direction: column; 19791 gap: 2px; 19792} 19793 19794.rich-select-option-title { 19795 font-size: 15px; 19796 color: #081333; 19797 font-family: 'roboto', sans-serif; 19798} 19799 19800.rich-select-option-subtext { 19801 font-size: 13px; 19802 color: #83909d; 19803} 19804</style> 19805
19805<script type="text/html" id="template-default-field-wb"> 19806 <div class="default-field-wrapper tinyMarginTop"> 19807 <span class="flex-row-start"> 19808 <p style="color: rgba(255, 0, 0, 0.885); margin: 0 5.5px 0 3px;">*</p> 19809 <p class="default-font-body" style="font-size: 14px; color: rgb(150, 152, 154); font-style: italic;">(required)</p> 19810 </span> 19811 <!-- default fields --> 19812 <div class="flex-row-start" style="width: 100%;"> 19813 <div class="flex-column-start" style="width: 100%;"> 19814 <template v-if="default_configuration === 'list'" v-for="(field, index) in default_fields" :id="index"> 19815 <div class="flex-row-start smallMarginTop" style="width: 100%;"> 19816 <input 19817 type="text" 19818 :placeholder="field.label" 19819 class="default-field-input border-bottom-grey-2 default-font-body" 19820 style="width: 100%;" 19821 v-model="field.input_value" 19822 :disabled="is_admin" 19823 > 19824 </div> 19825 </template> 19826 <template v-if="default_configuration === 'grid'" v-for="(row, i) in default_fields" :id="i"> 19827 <div class="flex-row-start smallMarginTop" style="width: 100%;"> 19828 <template v-for="(field, j) in row" :id="j"> 19829 <input 19830 type="text" 19831 :placeholder="field.label" 19832 class="default-field-input border-bottom-grey-2 default-font-body marginRight" 19833 style="width: 100%;" 19834 v-model="field.input_value" 19835 :disabled="is_admin" 19836 > 19837 </template> 19838 </div> 19839 </template> 19840 </div> 19841 </div> 19842 </div> 19843</script>
19843 19844 19845<style scoped> 19846 :root { 19847 --default-field-grey-1: rgba(180, 180, 180, 0.8); 19848 --default-field-grey-2: rgba(195, 195, 195, 0.7); 19849 --default-field-grey-3: rgb(154, 160, 166); 19850 --default-field-primary: #202124; 19851 } 19852 .default-field-wrapper{ 19853 width: 100%; 19854 border: 1px solid rgba(195, 195, 195, 0.7); 19855 border-radius: 8px; 19856 padding: 1.45em; 19857 background-color: #fff; 19858 box-sizing: border-box; 19859 } 19860 .default-field-wrapper .default-font-body{ 19861 font-family: Roboto, Arial, Helvetica, sans-serif; 19862 font-size: 15px; 19863 font-weight: 400; 19864 } 19865 .default-field-wrapper .configuration-icon{ 19866 font-size: 1em; 19867 cursor: pointer; 19868 padding: 5px; 19869 border-radius: 50%; 19870 color: rgba(95, 95, 95); 19871 background-color: #fff; 19872 transition: color 0.2s, background-color 0.2s; 19873 } 19874 .default-field-wrapper .configuration-icon:hover{ 19875 color: var(--default-field-primary); 19876 background-color: rgba(180, 180, 180, 0.15); 19877 } 19878 .default-field-wrapper .file-upload-wrapper{ 19879 height: 90px; 19880 width: 50%; 19881 border: 1px dashed var(--default-field-grey-1); 19882 } 19883 .default-field-wrapper .inherit-font-css{ 19884 font-family: inherit; 19885 font-size: inherit; 19886 font-weight: inherit; 19887 } 19888 .default-field-wrapper .flex-row-center{ 19889 display: flex; 19890 flex-direction: row; 19891 align-items: center; 19892 justify-content: center; 19893 } 19894 .default-field-wrapper .flex-column-center{ 19895 display: flex; 19896 flex-direction: column; 19897 align-items: center; 19898 justify-content: center; 19899 } 19900 .default-field-wrapper .flex-row-start{ 19901 display: flex; 19902 flex-direction: row; 19903 align-items: center; 19904 justify-content: flex-start; 19905 } 19906 .default-field-wrapper .flex-row-end{ 19907 display: flex; 19908 flex-direction: row; 19909 align-items: center; 19910 justify-content: flex-end; 19911 } 19912 .default-field-wrapper .flex-column-start{ 19913 display: flex; 19914 flex-direction: column; 19915 align-items: flex-start; 19916 justify-content: center; 19917 } 19918 .default-field-wrapper .default-field{ 19919 color: rgb(32, 33, 36); 19920 box-sizing: border-box; 19921 font-weight: 400; 19922 font-size: 14px; 19923 width: 100%; 19924 line-height: 2.5; 19925 letter-spacing: 0; 19926 } 19927 .default-field-wrapper .default-field > .default-field-input{ 19928 font-family: inherit; 19929 font-size: inherit; 19930 font-weight: inherit; 19931 width: inherit; 19932 line-height: inherit; 19933 background-color: rgb(248, 249, 250); 19934 padding: 5px 0 5px 15px; 19935 } 19936 .default-field-wrapper .border-bottom-grey-2, .default-field-wrapper .input-is-focused{ 19937 border-bottom: 1.25px solid var(--default-field-grey-2); 19938 } 19939 .default-field-wrapper input { 19940 transition: border-color 0.2s; 19941 } 19942</style> 19943
19944<script> 19945 app_content.component("default-field-component-wb", { 19946 template: "#template-default-field-wb", 19947 data() { 19948 return { 19949 19950 } 19951 }, 19952 props: { 19953 form: Object, //json 19954 is_admin: Boolean, 19955 default_fields: Array, 19956 rerender_key: String, 19957 }, 19958 mounted() { 19959 19960 }, 19961 methods: { 19962 19963 }, 19964 computed: { 19965 default_configuration(){ 19966 if ( this.form && this.form.default_configuration ){ 19967 return this.form.default_configuration; 19968 } 19969 return 'list'; 19970 }, 19971 } 19972 }) 19973</script>
19973 19974
19974<script type="text/html" id="template-file-upload-component"> 19975 <div> 19976 <p v-if="label" class="tinyPaddingBottom subtext evenSmaller">{{ label }}</p> 19977 <div 19978 class="file-upload-container lightBorderAllAround overflowHidden" 19979 :class="{ 'file-selected': hasFiles, 'upload-loading': loading }" 19980 @dragover.prevent="!loading && (isDragging = true)" 19981 @dragleave.prevent="!loading && (isDragging = false)" 19982 @drop.prevent="handle_drop" 19983 > 19984 <div 19985 class="file-upload-inner relative flexWrap align-items-center flexGrowOne" 19986 > 19987 <input 19988 type="file" 19989 id="fileInput" 19990 ref="fileInput" 19991 class="absoluteInput" 19992 @change="handle_file_upload" 19993 :multiple="multiple" 19994 :accept="acceptAttribute" 19995 :disabled="loading" 19996 /> 19997 <div v-if="loading" class="file-upload-loading-overlay"> 19998 <div class="cardBodySpinner small-spinner centeredBlock active"></div> 19999 </div> 20000 <div 20001 class="upload-placeholder flexNoWrapCenter align-items-center" 20002 :style="{ opacity: loading ? 0.55 : 1 }" 20003 > 20004 <div> 20005 <i :class="icon ? icon : 'fa-light fa-file-arrow-up'" class="upload-icon tinyMarginBottom"></i> 20006 <p class="upload-text">Drag and drop here or click to upload</p> 20007 <p class="upload-subtext">{{ fileTypeMessage }}</p> 20008 </div> 20009 </div> 20010 </div> 20011 20012 <div 20013 class="selected-file-wrap flexNoWrap lightBorderTop align-items-center" 20014 v-if="hasFiles && !loading" 20015 > 20016 <template v-if="multiple"> 20017 <div class="flexColumn flexGrowOne selected-file-list"> 20018 <div 20019 v-for="(file, index) in selectedFiles" 20020 :key="file.name + index" 20021 class="flexNoWrap align-items-center" 20022 :class="{ 'smallMarginBottom': index !== selectedFiles.length - 1 }" 20023 > 20024 <img 20025 v-if="imagePreviewURLs[return_file_key(file, index)]" 20026 :src="imagePreviewURLs[return_file_key(file, index)]" 20027 class="file-preview-thumb" 20028 alt="Image preview" 20029 > 20030 <p class="selected-file-name flexGrowOne"> 20031 <i class="fa-solid fa-square-check" style="padding-right:3px;"></i> {{ file.name }} 20032 </p> 20033 <div 20034 class="remove-file-wrap pointer" 20035 :class="{ unclickable: loading }" 20036 @click="remove_file(index)" 20037 > 20038 <i class="fa-light fa-trash-can"></i> 20039 </div> 20040 </div> 20041 </div> 20042 </template> 20043 <template v-else> 20044 <div class="flexNoWrap align-items-center flexGrowOne"> 20045 <img 20046 v-if="selectedFiles[0] && imagePreviewURLs[return_file_key(selectedFiles[0], 0)]" 20047 :src="imagePreviewURLs[return_file_key(selectedFiles[0], 0)]" 20048 class="file-preview-thumb" 20049 alt="Image preview" 20050 > 20051 <p class="selected-file-name flexGrowOne"><i class="fa-solid fa-square-check" style="padding-right:3px;"></i> {{ selectedFiles[0].name }}</p> 20052 </div> 20053 <div 20054 class="remove-file-wrap pointer" 20055 :class="{ unclickable: loading }" 20056 @click="remove_file" 20057 > 20058 <i 20059 class="fa-light fa-trash-can" 20060 ></i> 20061 </div> 20062 </template> 20063 </div> 20064 20065 </div> 20066 </div> 20067 20068</script>
20068 20069
20070<script> 20071 20072 var config = { 20073 template: "#template-file-upload-component", 20074 data() { 20075 return { 20076 selectedFiles: [], 20077 imagePreviewURLs: {}, 20078 isDragging: false, 20079 //file type mappings 20080 fileTypeMappings: { 20081 csv: [".csv", "text/csv", "application/csv", "application/vnd.ms-excel"], 20082 pdf: [".pdf", "application/pdf"], 20083 image: [".jpg", ".jpeg", ".png", "image/jpeg", "image/png"], 20084 docx: [".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"], 20085 video: [ 20086 ".mp4", ".webm", ".avi", ".mov", ".mkv", ".flv", ".wmv", 20087 "video/mp4", "video/webm", "video/x-msvideo", "video/quicktime", 20088 "video/x-matroska", "video/x-flv", "video/x-ms-wmv" 20089 ] 20090 } 20091 } 20092 }, 20093 props: { 20094 icon: String, 20095 label: String, 20096 valid_file_types: { 20097 type: Array, 20098 default: () => [] 20099 }, 20100 multiple: { 20101 type: Boolean, 20102 default: false 20103 }, 20104 loading: { 20105 type: Boolean, 20106 default: false, 20107 }, 20108 }, 20109 mounted(){ 20110 20111 }, 20112 beforeUnmount(){ 20113 this.revoke_image_previews(); 20114 }, 20115 beforeDestroy(){ 20116 this.revoke_image_previews(); 20117 }, 20118 methods: { 20119 open_file_picker() { 20120 if( this.loading ) { 20121 return; 20122 } 20123 this.$refs.fileInput.click(); 20124 }, 20125 handle_file_upload(event) { 20126 if( this.loading ) return; 20127 const files = Array.from(event.target.files || []); 20128 this.set_files_from_input(files); 20129 }, 20130 handle_drop(event) { 20131 if( this.loading ) return; 20132 event.preventDefault(); 20133 const files = Array.from(event.dataTransfer.files || []); 20134 this.set_files_from_input(files); 20135 this.isDragging = false; 20136 }, 20137 set_files_from_input(files) { 20138 if (!files.length) { 20139 this.selectedFiles = []; 20140 return; 20141 } 20142 const validFiles = files.filter(file => this.is_valid_file_type(file)); 20143 if (!validFiles.length) { 20144 alert("Invalid file type. Please upload a valid file."); 20145 this.selectedFiles = []; 20146 this.$refs.fileInput.value = ""; 20147 return; 20148 } 20149 if (validFiles.length !== files.length) { 20150 alert("Some files were skipped because they are not allowed."); 20151 } 20152 if (this.multiple) { 20153 // Append rather than replace so re-dropping or "add 20154 // another" grows the batch. Dedupe on name/size/mtime so 20155 // picking the same file twice doesn't create a duplicate. 20156 const existingKeys = new Set( 20157 this.selectedFiles.map(existingFile => `${existingFile.name}_${existingFile.size}_${existingFile.lastModified}`) 20158 ); 20159 const filesToAdd = validFiles.filter(file => { 20160 return !existingKeys.has(`${file.name}_${file.size}_${file.lastModified}`); 20161 }); 20162 this.selectedFiles = this.selectedFiles.concat(filesToAdd); 20163 } else { 20164 this.selectedFiles = [validFiles[0]]; 20165 } 20166 }, 20167 is_valid_file_type(file) { 20168 const fileExtension = `.${file.name.split('.').pop().toLowerCase()}`; 20169 const fileType = (file.type || "").toLowerCase(); 20170 if (!this.valid_file_types.length){ 20171 // If no restrictions, allow all , but limit to standard types for security 20172 const defaultAllowedTypes = this.default_file_types; 20173 return defaultAllowedTypes.includes(fileExtension) || defaultAllowedTypes.includes(fileType); 20174 } 20175 20176 return this.resolvedFileTypes.includes(fileExtension) || this.resolvedFileTypes.includes(fileType); 20177 }, 20178 clear_files() { 20179 this.revoke_image_previews(); 20180 this.selectedFiles = []; 20181 this.$refs.fileInput.value = ""; 20182 }, 20183 return_file_key(file, index = 0){ 20184 if(!file){ 20185 return ''; 20186 } 20187 return `${file.name}_${file.size}_${file.lastModified}_${index}`; 20188 }, 20189 is_image_file(file){ 20190 if(!file || !file.name){ 20191 return false;
20192 } 20193 const fileType = (file.type || "").toLowerCase(); 20194 if(fileType.startsWith("image/")){ 20195 return true; 20196 } 20197 const fileExtension = `.${file.name.split('.').pop().toLowerCase()}`; 20198 return [".jpg", ".jpeg", ".png", ".gif", ".webp"].includes(fileExtension); 20199 }, 20200 refresh_image_previews(files){ 20201 this.revoke_image_previews(); 20202 const nextPreviewURLs = {}; 20203 (files || []).forEach((file, index) => { 20204 if(this.is_image_file(file)){ 20205 const key = this.return_file_key(file, index); 20206 nextPreviewURLs[key] = URL.createObjectURL(file); 20207 } 20208 }); 20209 this.imagePreviewURLs = nextPreviewURLs; 20210 }, 20211 revoke_image_previews(){ 20212 Object.values(this.imagePreviewURLs || {}).forEach((url) => { 20213 try { 20214 URL.revokeObjectURL(url); 20215 } catch (e) { 20216 // no-op 20217 } 20218 }); 20219 this.imagePreviewURLs = {}; 20220 }, 20221 remove_file(index) { 20222 if( this.loading ) return; 20223 if (this.multiple && typeof index === "number") { 20224 this.selectedFiles.splice(index, 1); 20225 if (!this.selectedFiles.length) { 20226 this.$refs.fileInput.value = ""; 20227 } 20228 this.$emit("file_selected", { 20229 value: this.multiple ? this.selectedFiles : this.selectedFiles[0] 20230 }); 20231 } else { 20232 this.clear_files(); 20233 } 20234 } 20235 }, 20236 computed: { 20237 hasFiles() { 20238 return this.selectedFiles && this.selectedFiles.length > 0; 20239 }, 20240 resolvedFileTypes() { 20241 if(this.valid_file_types && this.valid_file_types.length > 0){ 20242 return this.valid_file_types.flatMap(type => this.fileTypeMappings[type] || [type]); 20243 }else{ 20244 return this.default_file_types; 20245 } 20246 }, 20247 acceptAttribute() { 20248 return this.resolvedFileTypes.join(","); 20249 }, 20250 fileTypeMessage() { 20251 if (!this.valid_file_types.length) { 20252 // No restrictions 20253 return ""; 20254 } 20255 20256 // Get only extensions and remove dots for display 20257 const displayTypes = this.valid_file_types 20258 .flatMap(type => this.fileTypeMappings[type] || [type]) 20259 .filter(type => type.startsWith(".")) // Show only file extensions 20260 .map(type => type.replace(".", "").toUpperCase()) // Remove dot and uppercase 20261 20262 return `You may upload ${displayTypes.join(", ")} files`; 20263 }, 20264 default_file_types(){ 20265 return [ 20266 // Documents 20267 ".pdf", "application/pdf", 20268 ".doc", ".docx", 20269 "application/msword", 20270 "application/vnd.openxmlformats-officedocument.wordprocessingml.document", 20271 ".rtf", "application/rtf", 20272 ".txt", "text/plain", 20273 20274 // Spreadsheets 20275 ".xls", ".xlsx", ".csv", 20276 "application/vnd.ms-excel", 20277 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", 20278 "text/csv", "application/csv", "application/vnd.ms-excel", 20279 20280 // Presentations 20281 ".ppt", ".pptx", 20282 "application/vnd.ms-powerpoint", 20283 "application/vnd.openxmlformats-officedocument.presentationml.presentation", 20284 20285 // Images 20286 ".jpg", ".jpeg", ".png", ".gif", ".webp", 20287 "image/jpeg", "image/png", "image/gif", "image/webp", 20288 // Videos 20289 ".mp4", ".webm", ".avi", ".mov", ".mkv", ".flv", ".wmv", 20290 "video/mp4", "video/webm", 20291 ]; 20292 } 20293 }, 20294 watch: { 20295 selectedFiles(newVal, oldVal){ 20296 this.refresh_image_previews(newVal); 20297 this.$emit("file_selected", { 20298 value: this.multiple ? newVal : newVal[0] 20299 }); 20300 } 20301 } 20302 } 20303 20304 //define the component 20305 app_content.component("file-upload-component", config); 20306 app_overlays.component("file-upload-component", config); 20307 20308 20309</script>
20309 20310 20311<style scoped> 20312 20313 .remove-file-wrap{ 20314 padding-left: 4px; 20315 padding-right: 4px; 20316 } 20317 20318 .selected-file-wrap{ 20319 padding: 12px; 20320 background-color: #fff; 20321 } 20322 20323 /* Ensure both children take full width but account for padding */ 20324 .file-upload-inner, .selected-file-wrap { 20325 width: 100%; 20326 flex: 1; /* Make sure they stretch */ 20327 box-sizing: border-box; /* Includes padding in width calculation */ 20328 padding: 12px; /* Add your desired padding */ 20329 } 20330 20331 .selected-file-name{ 20332 color: #292e4a; 20333 font-size: 16px; 20334 line-height: 20px; 20335 } 20336 .file-preview-thumb{ 20337 width: 42px; 20338 height: 42px; 20339 object-fit: cover; 20340 border-radius: 6px; 20341 border: 1px solid #e4ebf1; 20342 margin-right: 10px; 20343 flex-shrink: 0; 20344 } 20345 20346 .file-upload-inner{ 20347 min-height: 160px; 20348 } 20349 .file-upload-container { 20350 display: flex; 20351 flex-direction: column; 20352 align-items: center; 20353 justify-content: center; 20354 width: 100%; 20355 border-radius: 8px; 20356 background-color: #f8f9fb; 20357 cursor: pointer; 20358 transition: all 0.3s ease; 20359 } 20360 .file-upload-container.upload-loading { 20361 cursor: not-allowed; 20362 } 20363 .file-upload-loading-overlay { 20364 position: absolute; 20365 top: 0; 20366 right: 0; 20367 bottom: 0; 20368 left: 0; 20369 z-index: 3; 20370 display: flex; 20371 justify-content: center; 20372 align-items: center; 20373 background: rgba(255, 255, 255, 0.6); 20374 } 20375 .file-upload-container.upload-loading .upload-text, 20376 .file-upload-container.upload-loading .upload-subtext, 20377 .file-upload-container.upload-loading .selected-file-name { 20378 color: transparent !important; 20379 } 20380 .file-upload-container.upload-loading .upload-icon { 20381 color: transparent !important; 20382 } 20383 .file-upload-container:hover:not(:has(.selected-file-wrap:hover)), 20384 .file-upload-container.dragging:not(:has(.selected-file-wrap:hover)) { 20385 border-color: var(--custom-mp-accent, #4a56f8); 20386 } 20387 .file-upload-container:hover .upload-icon:not(:has(.selected-file-wrap:hover)), 20388 .file-upload-container.dragging .upload-icon:not(:has(.selected-file-wrap:hover)){ 20389 color: var(--custom-mp-accent, #4a56f8); 20390 } 20391 20392 .upload-placeholder { 20393 text-align: center; 20394 padding: 20px; 20395 } 20396 20397 .upload-icon{ 20398 color: #788097; 20399 font-size: 28px; 20400 padding-right: 16px; 20401 } 20402 .upload-text { 20403 font-size: 16px; 20404 font-weight: 500; 20405 color: #081333; 20406 } 20407 .upload-subtext { 20408 font-size: 14px; 20409 color: #8890a3; 20410 padding-top: 4px; 20411 line-height: 18px; 20412 } 20413</style> 20414
20414<script type="text/html" id="template-no-objects-found"> 20415 20416 <div 20417 class="marginTopBottom" 20418 style="display: flex; flex-direction: column; justify-content: center; align-items: center;" 20419 > 20420 <i 20421 :class="icon_class" 20422 class="no-objects-icon" 20423 ></i> 20424 <p class="no-objects-subtext">{{ subtext }}</p> 20425 <p v-if="call_to_action" class="no-objects-subtext"><a @click="call_to_action.fn">{{ call_to_action.text }}</a></p> 20426 </div> 20427 20428</script>
20428 20429
20430<script> 20431 20432/* ----- COMPONENT ----- */ 20433 20434let no_objects_config = { 20435 template: "#template-no-objects-found", 20436 data() { 20437 return {} 20438 }, 20439 props: { 20440 icon_class: { 20441 type: String, 20442 required: true, 20443 default: "fa-regular fa-magnifying-glass", 20444 }, 20445 subtext: { 20446 type: String, 20447 required: true, 20448 default: "No items found.", 20449 }, 20450 call_to_action: { 20451 type: Object, 20452 required: false, 20453 default: null, 20454 } 20455 }, 20456 mounted() { 20457 20458 20459 }, 20460 methods: { 20461 20462 20463 } 20464 20465} 20466app_content.component("no-objects-found", no_objects_config); 20467app_overlays.component("no-objects-found", no_objects_config); 20468if (typeof app_ai_mcp !== 'undefined') { 20469 app_ai_mcp.component("no-objects-found", no_objects_config); 20470} 20471 20472</script>
20472 20473 20474<style> 20475 .no-objects-icon{ 20476 font-size: 18px; 20477 padding: 15px; 20478 background-color: rgba(230, 232, 237, 0.667); 20479 border-radius: 50%; 20480 color: #6a7482; 20481 height: 20px; 20482 width: 20px; 20483 text-align: center; 20484 } 20485 .no-objects-subtext{ 20486 font-size: 16px; 20487 padding: 2px; 20488 margin-top: 5px; 20489 color: #9ca4b7; 20490 text-align: center; 20491 } 20492</style> 20493 20494 20495 20496
20496<script type="text/html" id="template-icon-button"> 20497 <button 20498 class="card-header-button-v3 easeFast flexNoWrap align-items-center relative icon-button centeredText flexGrowOne" 20499 :class="[ 20500 {'unclickable': disabled || loading}, 20501 {'full-width': full_width}, 20502 {'warning': warning}, 20503 {'text-hides-on-mobile': hide_text_on_mobile} 20504 ]" 20505 :disabled="disabled || loading" 20506 @click="tapped_button()" 20507 style="width: auto; max-width: fit-content;" 20508 :style="height ? {'height': height + 'px', 'line-height': height + 'px', 'padding': '0 8px'} : {}" 20509 :style="no_margin_right ? {'margin-right': '0'} : {}" 20510 > 20511 <i 20512 v-if="corrected_icon_position == 'left' && ! image_url" 20513 :class="icon_class" 20514 :style="{ opacity: loading ? 0 : 1 }" 20515 ></i> 20516 <div 20517 v-else-if="corrected_icon_position == 'left' && image_url" 20518 class="icon-button-avatar" 20519 :style="{ backgroundImage: 'url(' + image_url + ')', opacity: loading ? 0 : 1 }" 20520 ></div> 20521 <span :style="{ opacity: loading ? 0 : 1 }" :class="{ 'hideOnMobile': hide_text_on_mobile }">{{ label }}</span> 20522 <i 20523 v-if="corrected_icon_position == 'right' && ! image_url" 20524 :class="icon_class" 20525 style="padding-left: 8px;" 20526 :style="{ opacity: loading ? 0 : 1 }" 20527 ></i> 20528 <div 20529 v-else-if="corrected_icon_position == 'right' && image_url" 20530 class="icon-button-avatar icon-button-avatar-right" 20531 :style="{ backgroundImage: 'url(' + image_url + ')', opacity: loading ? 0 : 1 }" 20532 ></div> 20533 <div v-if="loading" class="icon-button-spinner-wrap"> 20534 <div class="card-header-button-v3-spinner easeFast"></div> 20535 </div> 20536 <a v-if="href" :href="href" :target="target" class="absoluteA"></a> 20537 </button> 20538</script>
vendor: 1 bytes, line 20538
20538
20539<script> 20540 var config = { 20541 template: "#template-icon-button", 20542 props: { 20543 disabled: { 20544 type: Boolean, 20545 default: false, 20546 }, 20547 label: { 20548 type: String, 20549 default: 'Done', 20550 }, 20551 icon_class: String, 20552 image_url: { 20553 type: String, 20554 default: null, 20555 }, 20556 icon_position: { 20557 type: String, 20558 default: 'left', 20559 }, 20560 loading: { 20561 type: Boolean, 20562 default: false, 20563 }, 20564 href: { 20565 type: String, 20566 default: null, 20567 }, 20568 target: { 20569 type: String, 20570 default: '', 20571 }, 20572 full_width: { 20573 type: Boolean, 20574 default: false, 20575 }, 20576 warning: { 20577 type: Boolean, 20578 default: false, 20579 }, 20580 no_margin_right: { 20581 type: Boolean, 20582 default: false, 20583 }, 20584 hide_text_on_mobile: { 20585 type: Boolean, 20586 default: false, 20587 } 20588 }, 20589 methods: { 20590 tapped_button() { 20591 if (this.loading || this.disabled) return; 20592 this.$emit('clicked'); 20593 }, 20594 }, 20595 computed: { 20596 corrected_icon_position(){ 20597 return this.icon_position || 'left'; 20598 }, 20599 }, 20600 } 20601 app_content.component("icon-button", config); 20602 if (typeof app_overlays !== "undefined") { 20603 app_overlays.component("icon-button", config); 20604 } 20605 20606</script>
20606 20607 20608<style> 20609 .icon-button.full-width{ 20610 width: 100% !important; 20611 max-width: unset !important; 20612 justify-content: center !important; 20613 text-align: center !important; 20614 line-height: 30px; 20615 font-size: 15px; 20616 } 20617 .icon-button.taller { 20618 padding-top: 12px; 20619 padding-bottom: 12px; 20620 } 20621 .icon-button.warning { 20622 color: #9b1818 !important; 20623 } 20624 .icon-button-spinner-wrap { 20625 position: absolute; 20626 left: 0; 20627 right: 0; 20628 top: 0; 20629 bottom: 0; 20630 display: flex; 20631 justify-content: center; 20632 align-items: center; 20633 } 20634 .icon-button-avatar { 20635 width: 18px; 20636 height: 18px; 20637 border-radius: 50%; 20638 background-size: cover; 20639 background-position: center; 20640 background-repeat: no-repeat; 20641 flex-shrink: 0; 20642 margin-right: 4px; 20643 } 20644 .icon-button-avatar-right { 20645 margin-right: 0; 20646 margin-left: 4px; 20647 } 20648</style> 20649 <!-- new calendar --> 20650 <template id="template-booking-calendar"> 20651 <div 20652 class="centeredOnMobile smallMarginTop" 20653 :class="{'standardCard ninetyPercent': calendar_type !== 'web_component'}" 20654 > 20655 <div 20656 class="standardCardBody noBorderTop calendar noPadding fff native-tab-bar-padding-bottom" 20657 > 20658 <div class="flexNoWrapLeftAlign hundop" style="display: flex; width: 100%;" :style="[{ height: height || '100vh' }]"> 20659 <div 20660 style="flex: 1; min-width: 0;" 20661 :class="{ scrollable: calendar_type !== 'web_component' }" 20662 > 20663 <div 20664 class="cs-calendar-wrap noBorder" 20665 :style="generateWrapStyle('cs-calendar-wrap')" 20666 > 20667 <!-- NAVIGATION MENU --> 20668 <div 20669 class="cs-calendar-menu-wrap flexNoWrap lightBorderBottom" 20670 :style="generateWrapStyle('cs-calendar-menu-wrap')" 20671 > 20672 <div class="flexWrapLeftAlign flexGrowOne smallMarginRight align-items-center"> 20673 <div class="flexNoWrap"> 20674 <div class="card-header-button-v3 roboto hideOnMobile" data-option="today" @click="handleButtonClick('today')">Today</div> 20675 <div class="card-header-button-v3 cs-calendar-button-previous roboto" data-option="previous" @click="handleButtonClick('previous')"></div> 20676 <div class="card-header-button-v3 cs-calendar-button-next roboto" data-option="next" @click="handleButtonClick('next')"></div> 20677 </div> 20678 <p 20679 v-if="calendarTitle" 20680 class="cs-calendar-date-range-title roboto textLeft noMarginLeft hideOnMobile" 20681 >{{ calendarTitle }}</p> 20682 <p 20683 v-if="calendarTitle_noYear" 20684 class="cs-calendar-date-range-title roboto textLeft noMarginLeft showOnMobile" 20685 >{{ calendarTitle_noYear }}</p> 20686 <div class="flexGrowOne hideOnMobile" style="max-width: fit-content;"> 20687 <refresh-component 20688 @refresh-table="refresh_calendar" 20689 style="margin-left: 2px;" 20690 ></refresh-component> 20691 </div> 20692 </div> 20693 20694 <!-- nav butttons --> 20695 <div class="flexWrapLeftAlign align-items-center"> 20696 <div class="flexGrowOne"></div> 20697 <div class="flexWrap align-items-center calendar-menu-v2"> 20698 <calendar-date-picker-component 20699 :handle_button_click="handleButtonClick" 20700 ></calendar-date-picker-component> 20701 </div> 20702 20703 <div 20704 class="hideOnMobile card-header-button-v3 pointer noWrap centeredText relative" 20705 > 20706 {{ view_preference }} 20707 <i class="fa-light fa-chevron-down" style="padding-left: 3px;"></i> 20708 <select 20709 v-model="v_model_view_preference" 20710 @change="changeViewPreference" 20711 class="pointer" 20712 style="position:absolute;
20712left:0px;top:0px;width:100%;height:100%;opacity:0;" 20713 > 20714 <option value="Month">Month</option> 20715 <option value="Week">Week</option> 20716 <option value="Day">Day</option> 20717 </select> 20718 </div> 20719 <div 20720 v-if="!show_calendar_tags && !disable_filtering" 20721 class="flexWrap align-items-center calendar-menu-v2 tinyMarginTop" 20722 > 20723 <h3 20724 class="calendar-list menu-icon" 20725 > 20726 <i 20727 class="fa-regular fa-bars pointer" 20728 @click="show_calendar_tags = !show_calendar_tags" 20729 ></i> 20730 </h3> 20731 </div> 20732 </div> 20733 20734 </div> 20735 20736 <div 20737 class="relative" 20738 > 20739 <!-- Progress Bar --> 20740 <div v-if="loading_parent.calendar || loading_parent.ui_refresh || loading_parent.calendar_tags || loading_parent.filter" class="progress-bar"></div> 20741 </div> 20742 20743 <!-- CALENDAR VIEWS --> 20744 20745 <!-- month view --> 20746 <template v-if="view_preference == 'Month'"> 20747 <!-- days of week header --> 20748 <div 20749 class="daysOfWeek-grid relative" 20750 > 20751 <div v-for="(day, index) in daysOfWeek" :key="index" class="grid-cell dayOfWeek noBorderTop"> 20752 {{ day }} 20753 </div> 20754 </div> 20755 20756 <!-- calendar grid --> 20757 <div 20758 class="calendar-grid month-view" 20759 :style="{ '--calendar-row-height': calendarRowHeight() }" 20760 > 20761 <template v-for="(day, index) in days_array" :key="index"> 20762 <booking-calendar-cell 20763 :date="day.date" 20764 :day="day.day" 20765 :date_components="day.date_components" 20766 :day_of_week="day.day_of_week" 20767 :events="day.events" 20768 :slots="day.slots" 20769 :timezone="timezone" 20770 :days_array="days_array" 20771 :is_current_month="day.date.month() === current_date.month()" 20772 @clicked_event="clicked_event" 20773 @clicked_show_more_events="clicked_show_more_events" 20774 @add_event="add_event" 20775 @zoom_in_to_date="zoom_in_to_date" 20776 /> 20777 </template> 20778 </div> 20779 20780 </template> 20781 20782 <!-- week view --> 20783 <template v-else-if="view_preference === 'Week'" > 20784 20785 <!-- multi/all day events section --> 20786 <div class="flexNoWrap"> 20787 <div style="width:60px;" class="flexWrap align-items-center"> 20788 <div class="time-label">All day</div> 20789 </div> 20790 <div class="flexGrowOne"> 20791 <!-- days of week header --> 20792 <div 20793 class="daysOfWeek-grid relative" 20794 > 20795 <div v-for="(day, index) in daysOfWeek" :key="index" class="grid-cell dayOfWeek noBorderTop"> 20796 {{ day }} 20797 <br>
20798 <span class="day-date-label">{{ current_date.clone().startOf("week").add(index, "day").date() }}</span> 20799 </div> 20800 </div> 20801 <div 20802 class="all-day-week-wrap" 20803 > 20804 <div v-for="(day, index) in days_array" :key="index" class="grid-cell noBorderTop"> 20805 <div class="events"> 20806 <!-- handle all day events --> 20807 <!-- render content for each slot --> 20808 <template 20809 v-for="(slot, slot_index) in day.slots" 20810 :key="slot_index" 20811 > 20812 <!-- ok, we have an event for this slot--> 20813 <template v-if="slot.event"> 20814 <div 20815 class="event" 20816 :class="[{'all_day': slot.event.isAllDay}, {'partial-day': ! slot.event.isAllDay}]" 20817 @click.stop="clicked_event( slot.event )" 20818 :style="return_multi_day_event_style( slot.event, day.date )" 20819 style="position:relative;z-index:1;" 20820 > 20821 <div 20822 class="event-title-v2 flexNoWrap align-items-center" 20823 style="font-size:12.5px;" 20824 > 20825 <!-- event title --> 20826 {{ slot.event.title }} 20827 </div> 20828 </div> 20829 </template> 20830 <template v-else-if="should_hold_slot(slot, slot_index, day)"> 20831 <div 20832 class="event" 20833 > 20834 <!-- add a placeholder if there's an event in this slot but it doesn't start on this day --> 20835 <div 20836 class="event-title-v2 flexNoWrap align-items-center easeSuperFast" 20837 style="font-size:12.5px; visibility: hidden;" 20838 > </div><!-- adds a hidden, non-breaking space --> 20839 </div> 20840 </template> 20841 20842 <!-- no event for this slot --> 20843 <template v-else> 20844 <div 20845 class="event partial-day" 20846 ></div> 20847 </template> 20848 20849 </template> 20850 20851 </div> 20852 </div> 20853 </div> 20854 </div> 20855 </div> 20856 20857 <!-- main content --> 20858 <div 20859 class="flexNoWrap" 20860 > 20861 <!-- times --> 20862 <div 20863 class="time-label-wrap" 20864 style="width: 60px;" 20865 > 20866 <div v-for="hour in 24" :key="hour" class="time-slot"> 20867 <div 20868 class="time-label" 20869 >{{ formatHour( hour - 1 ) }}</div> 20870 </div> 20871 </div> 20872 20873 <!-- main content --> 20874 <div class="flexGrowOne"> 20875 20876 <!-- day columns --> 20877 <div class="flexNoWrap"> 20878 <template 20879 v-for="(day, index) in days_array" 20880 :key="index" 20881 > 20882 <booking-calendar-day-column 20883 :events="day.events" 20884 :date_components="day.date_components" 20885 :timezone="timezone" 20886 :view_preference="view_preference" 20887 @clicked_event="clicked_event" 20888 @add_event="add_event" 20889 ></booking-calendar-day-column> 20890 </template> 20891 </div> 20892 20893 </div> 20894 </div> 20895 </template> 20896 20897 <!-- day view --> 20898 <template v-else-if="view_preference === 'Day'"> 20899 <div class="showOnMobile"> 20900 <icon-button 20901 :label="'Back to month'" 20902 icon_class="fa-regular fa-chevron-left" 20903 @clicked="back_to_month" 20904 :style="[{ marginLeft: '8px' }, calendar_type !== 'web_component' ? { maxHeight: '28px' } : {}]" 20905 ></icon-button> 20906 </div> 20907 <div 20908 class="flexNoWrap tinyPaddingTop" 20909 > 20910 <booking-calendar-day-column 20911 :events="days_array[0].events" 20912 :date_components="days_array[0].date_components" 20913 :view_preference="view_preference" 20914 :timezone="timezone" 20915 @clicked_event="clicked_event" 20916 @add_event="add_event" 20917 /> 20918 </div> 20919 </template> 20920 </div> 20921 </div> 20922 <!-- start of new component --> 20923 <booking-calendar-filters 20924 v-if="!disable_filtering"
20925 :show_calendar_tags="show_calendar_tags" 20926 :default_calendar_tags="default_calendar_tags" 20927 :default_calendar_tags_secondary="default_calendar_tags_secondary" 20928 :show_catch_all_custom_tags_option="show_catch_all_custom_tags_option" 20929 :catch_all_custom_tag_selected="catch_all_custom_tag_selected" 20930 :ui_catch_all_custom_tag_selected="ui_catch_all_custom_tag_selected" 20931 :ui_tag_ids_default_secondary="ui_tag_ids_default_secondary" 20932 :view_preference="view_preference" 20933 :calendar_tags="calendar_tags" 20934 :selected_tags="selected_tags" 20935 :ui_tag_ids_default="ui_tag_ids_default" 20936 :ui_tag_ids_custom="ui_tag_ids_custom" 20937 :collapsible_menus_open="collapsible_menus_open" 20938 :loading_parent="loading_parent" 20939 :calendar_type="calendar_type" 20940 :view_only="view_only" 20941 @clicked_default_tag="clicked_default_tag" 20942 @clicked_default_tag_secondary="clicked_default_tag_secondary" 20943 @clicked_calendar_tag="clicked_calendar_tag" 20944 @clicked_catch_all_custom_tag="clicked_catch_all_custom_tag" 20945 @clicked_add_calendar_tag="clicked_add_calendar_tag" 20946 @clicked_remove_tag="clicked_remove_tag" 20947 @isolate_default_tag="isolate_default_tag" 20948 @isolate_default_tag_secondary="isolate_default_tag_secondary" 20949 @select_all_default_tags="select_all_default_tags" 20950 @select_all_default_tags_secondary="select_all_default_tags_secondary" 20951 @select_visible_default_tags_secondary="select_visible_default_tags_secondary" 20952 @clear_all_default_tags="clear_all_default_tags" 20953 @clear_all_default_tags_secondary="clear_all_default_tags_secondary" 20954 @clear_visible_default_tags_secondary="clear_visible_default_tags_secondary" 20955 @select_visible_default_tags="select_visible_default_tags" 20956 @clear_visible_default_tags="clear_visible_default_tags" 20957 @isolate_calendar_tag="isolate_calendar_tag" 20958 @isolate_catch_all_custom="isolate_catch_all_custom" 20959 @select_all_custom_tags="select_all_custom_tags" 20960 @clear_all_custom_tags="clear_all_custom_tags" 20961 @select_visible_custom_tags="select_visible_custom_tags" 20962 @clear_visible_custom_tags="clear_visible_custom_tags" 20963 @add_event="add_event" 20964 @change_view_preference="changeViewPreference" 20965 @change_show_calendar_tags="change_show_calendar_tags" 20966 @close="show_calendar_tags = false" 20967 ></booking-calendar-filters> 20968 </div> 20969 </div> 20970 </div> 20971 </template> 20972 20973 <style scoped> 20974 .cs-calendar-menu-wrap{ 20975 position: sticky; 20976 top: 0px; 20977 background-color: #fff; 20978 z-index: 3; 20979 } 20980 .calendar-list.menu-icon{ 20981 min-width: 38px !important; 20982 max-width: 38px !important; 20983 width: 38px !important; 20984 min-height: 38px !important; 20985 max-height: 38px !important; 20986 height: 38px !important; 20987 padding: 0; 20988 display: flex; 20989 justify-content: center; 20990 align-items: center; 20991 cursor: pointer; 20992 } 20993 20994 .day-date-label{ 20995 font-size: 20px; 20996 color: #081333; 20997 } 20998 20999 .cs-calendar-date-range-title { 21000 font-size: 18px !important; 21001 } 21002 21003 .calendar-menu-v2 .cs-calendar-date-picker-input{ 21004 border-radius: 30px; 21005 overflow: hidden !important; 21006 padding-bottom: 8px; 21007 padding-top: 8px; 21008 } 21009 21010 .cs-calendar-button-next, 21011 .cs-calendar-button-previous{ 21012 min-width: 38px !important; 21013 max-width: 38px !important; 21014 width: 38px !important; 21015 min-height: 38px !important; 21016 max-height: 38px !important; 21017 height: 38px !important; 21018 } 21019
21020 .daysOfWeek-grid{ 21021 grid-auto-rows: initial; 21022 } 21023 21024 .all-day-week-wrap{ 21025 display: grid; 21026 grid-template-columns: repeat(7, minmax(10%, 1fr)); 21027 border-bottom: 1px solid #e7ecf0; 21028 } 21029 21030 .progress-bar { 21031 position: absolute; 21032 top: 0; 21033 left: 0; 21034 height: 2px; /* Thin progress bar */ 21035 background-color: #5774f5; /* Primary color */ 21036 z-index: 10; /* Ensure it appears above the calendar */ 21037 animation: progress-bar-animation 0.5s linear infinite; /* Animation */ 21038 } 21039 21040 /* Animation Keyframes */ 21041 @keyframes progress-bar-animation { 21042 0% { 21043 left: -100%; /* Start outside of the viewport */ 21044 width: 30%; /* Start with a small bar */ 21045 } 21046 50% { 21047 left: 25%; /* Move towards the center */ 21048 width: 50%; /* Expand the bar */ 21049 } 21050 100% { 21051 left: 100%; /* End outside of the viewport */ 21052 width: 30%; /* Shrink the bar */ 21053 } 21054 } 21055 21056 .calendar-grid.month-view{ 21057 grid-auto-rows: var(--calendar-row-height, 130px); 21058 } 21059 21060 .grid-cell.dayOfWeek{ 21061 border-radius: 5px; 21062 } 21063 21064 .time-content { 21065 position: relative; 21066 height: 100%; /* Full height of the hour */ 21067 flex-grow: 1; 21068 border-left: 1px solid #e7ecf0; 21069 } 21070 21071 /* Day Header */ 21072 .day-header { 21073 background-color: white; 21074 padding: 16px; 21075 border-bottom: 1px solid #e7ecf0; 21076 } 21077 21078 .day-title { 21079 margin: 0; 21080 font-size: 24px; 21081 font-weight: bold; 21082 color: #202124; 21083 } 21084 21085 /* All Day Section */ 21086 .all-day-section { 21087 padding: 12px; 21088 border-bottom: 1px solid #e7ecf0; 21089 width: 100%; 21090 } 21091 21092 .all-day-section h3 { 21093 font-size: 16px; 21094 font-weight: bold; 21095 color: #5f6368; 21096 margin-bottom: 8px; 21097 } 21098 21099 .all-day-events { 21100 display: flex; 21101 flex-wrap: wrap; 21102 gap: 8px; 21103 padding-left: 12px; 21104 padding-right: 12px; 21105 } 21106 21107 .no-events { 21108 color: #9e9e9e; 21109 font-size: 12px; 21110 } 21111 21112 /* Time Slots */ 21113 .time-slots { 21114 flex-grow: 1; 21115 overflow-y: auto; 21116 background-color: white; 21117 width:100%; 21118 } 21119 21120 .time-slot { 21121 display: flex; 21122 border-bottom: 1px solid #e7ecf0; 21123 height: 60px; /* One slot per hour */ 21124 } 21125 21126 .time-label { 21127 width: 60px; 21128 text-align: center; 21129 font-size: 12px; 21130 padding-top: 8px; 21131 color: #5f6368; 21132 } 21133 21134 </style> 21135 21136
21136<script> 21137 app_content.component("calendar-booking-component", { 21138 template: "#template-booking-calendar", 21139 data() { 21140 return { 21141 show_calendar_tags: true, 21142 v_model_view_preference: this.view_preference, 21143 daysOfWeek: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], 21144 loading: { 21145 calendar_tags: true, 21146 }, 21147 catch_all_custom_tag_selected: true, 21148 ui_catch_all_custom_tag_selected: true, 21149 collapsible_menus_open: { 21150 default_primary: true, 21151 default_secondary: true, 21152 custom: true, 21153 }, 21154 }; 21155 }, 21156 mounted(){ 21157 this.check_if_mobile(); 21158 }, 21159 props: { 21160 filter_logic: { 21161 type: String, 21162 default: "or" //and vs. or 21163 }, 21164 height: String, //optional 21165 timezone: { 21166 type: String, 21167 default: moment.tz.guess(), 21168 }, 21169 loading_parent: Object, //object with several loading attributes 21170 view_preference: String, 21171 current_date: Date, 21172 events: Array, 21173 is_membership_cal: Boolean, 21174 calendar_type: String, 21175 default_calendar_tags: Object, 21176 default_calendar_tags_secondary: Object, 21177 calendar_tags: Array, 21178 selected_tags: Object, 21179 ui_tag_ids_default: Object, 21180 ui_tag_ids_default_secondary: Object, 21181 ui_tag_ids_custom: Array, 21182 is_admin: Boolean, 21183 view_only: { 21184 type: Boolean, 21185 default: false 21186 }, 21187 disable_filtering: { 21188 type: Boolean, 21189 default: false 21190 } 21191 }, 21192 methods: { 21193 back_to_month(){ 21194 this.$emit("change_view_preference", "Month"); 21195 }, 21196 check_if_mobile(){ 21197 if(this.disable_filtering){ 21198 this.show_calendar_tags = false; 21199 return; 21200 } 21201 var is_mobile = window.innerWidth < 720; 21202 if(is_mobile || this.calendar_type === 'web_component'){ 21203 this.show_calendar_tags = false; 21204 }else{ 21205 this.show_calendar_tags = true; 21206 } 21207 }, 21208 clicked_default_tag(tag) { 21209 this.$emit('clicked_default_tag', tag); 21210 }, 21211 clicked_default_tag_secondary(tag) { 21212 this.$emit('clicked_default_tag_secondary', tag); 21213 }, 21214 isolate_default_tag_secondary(tag) { 21215 this.$emit('isolate_default_tag_secondary', tag); 21216 }, 21217 select_all_default_tags_secondary() { 21218 this.$emit('select_all_default_tags_secondary'); 21219 }, 21220 select_visible_default_tags_secondary(visibleIds) { 21221 this.$emit('select_visible_default_tags_secondary', visibleIds); 21222 }, 21223 clear_all_default_tags_secondary() { 21224 this.$emit('clear_all_default_tags_secondary'); 21225 }, 21226 clear_visible_default_tags_secondary(visibleIds) { 21227 this.$emit('clear_visible_default_tags_secondary', visibleIds); 21228 }, 21229 clicked_calendar_tag(tag) { 21230 this.$emit('clicked_calendar_tag', tag); 21231 }, 21232 clicked_catch_all_custom_tag(){ 21233 this.ui_catch_all_custom_tag_selected = !this.ui_catch_all_custom_tag_selected; 21234 requestAnimationFrame(() => { 21235 // second RAF helps ensure weâre past layout/paint on Chrome/Safari 21236 requestAnimationFrame(() => { 21237 this.catch_all_custom_tag_selected = !this.catch_all_custom_tag_selected; 21238 }); 21239 }); 21240 }, 21241 remaining_days_in_event(event, date) { 21242 const cellDate = moment.tz(date, this.timezone).startOf("day"); 21243 const eventEnd = moment.tz(event.end_ts, this.timezone).startOf("day"); 21244 if (cellDate.isAfter(eventEnd)) { 21245 //already ended 21246 return 0; 21247 } 21248 // Calculate the remaining days, including the current day 21249 return eventEnd.diff(cellDate, "days") + 1; 21250 }, 21251 remaining_visible_days_in_event(event, date) { 21252 const cellDate = moment.tz(date, this.timezone).startOf("day"); 21253 const eventEnd = moment.tz(event.end_ts, this.timezone).endOf("day"); 21254 const eventStart = moment.tz(event.ts, this.timezone).startOf("day"); 21255 const startOfWeek = this.current_date.clone().startOf("week"); 21256 const endOfWeek = this.current_date.clone().endOf("week"); 21257 21258 // Determine the latest starting point between cellDate and eventStart 21259 const visibleStart = moment.max(cellDate, eventStart, startOfWeek); 21260 21261 // Determine the earliest end point between eventEnd and endOfWeek 21262 const visibleEnd = moment.min(eventEnd, endOfWeek); 21263 21264 // If the visible start is after the visible end, the event isn't visible 21265 if (visibleStart.isAfter(visibleEnd)) { 21266 return 0; 21267 } 21268 21269 // Calculate number of visible days the event should span, inclusive 21270 return visibleEnd.diff(visibleStart, "days") + 1; 21271 }, 21272 return_multi_day_event_style( event, date ){ 21273 if( ! this.is_multi_day_event( event ) ){ 21274 return { 21275 "background": event.bgColors && event.bgColors.length > 0 21276 ? `repeating-linear-gradient( 21277 -45deg, 21278 ${event.bgColors.map((color, i) => `${color} ${i * 20}px, ${color} ${(i + 1) * 20}px`).join(",")} 21279 )` 21280 : event.bgColor || "", 21281 }; 21282 } 21283 let day_of_week = moment.tz(date, this.timezone).day(); 21284 let days_until_end_of_week = 7 - day_of_week; 21285 let number_of_days = this.remaining_visible_days_in_event(event, date); 21286 let is_first_day = this.is_first_day_of_event( event, date ); 21287 if( is_first_day ){ 21288 //figure out how many days it spans 21289 let days_to_span = number_of_days; 21290 if( days_to_span > days_until_end_of_week ){ 21291 days_to_span = days_until_end_of_week; 21292 } 21293 return {
21294 "width": `calc(${days_to_span * 100}% - 4px)`, 21295 "zIndex": 1, 21296 "background": event.bgColors && event.bgColors.length > 0 21297 ? `repeating-linear-gradient( 21298 -45deg, 21299 ${event.bgColors.map((color, i) => `${color} ${i * 20}px, ${color} ${(i + 1) * 20}px`).join(",")} 21300 )` 21301 : event.bgColor || "", 21302 } 21303 }else if( day_of_week == 0 ){ 21304 //not the first day of the event 21305 //.. but see it's the first day of a new week 21306 //... figure out which day of the event this is, and how many days are left (including this day) 21307 let remaining_days_in_event = this.remaining_days_in_event( event, date ); 21308 let days_to_span = remaining_days_in_event; 21309 if( days_to_span > days_until_end_of_week ){ 21310 days_to_span = days_until_end_of_week; 21311 } 21312 return { 21313 "width": `calc(${days_to_span * 100}% - 4px)`, 21314 "zIndex": 1, 21315 "background": event.bgColors && event.bgColors.length > 0 21316 ? `repeating-linear-gradient( 21317 -45deg, 21318 ${event.bgColors.map((color, i) => `${color} ${i * 20}px, ${color} ${(i + 1) * 20}px`).join(",")} 21319 )` 21320 : event.bgColor || "", 21321 } 21322 }else{ 21323 return { 21324 "visibility":"hidden" 21325 } 21326 } 21327 }, 21328 zoom_in_to_date( date ){ 21329 this.$emit("zoom_in_to_date", date); 21330 }, 21331 clicked_event( event ){ 21332 this.$emit('clicked_event', event); 21333 }, 21334 clicked_show_more_events( date, day_of_week, events ){ 21335 this.$emit('clicked_show_more_events', date, day_of_week, events); 21336 }, 21337 add_event( date_components ){ 21338 this.$emit('add_event', date_components); 21339 }, 21340 formatHour(hour) { 21341 const baseDate = moment().startOf('day').hour(hour); 21342 return baseDate.format("h:mm A"); 21343 }, 21344 changeViewPreference( event ){ 21345 this.$emit("change_view_preference", event.target.value); 21346 }, 21347 return_number_of_days_for_event( event ){ 21348 let start = moment.tz(event.ts, this.timezone); 21349 let end = moment.tz(event.end_ts, this.timezone); 21350 //continue 21351 return end.diff(start, 'days') + 1; 21352 }, 21353 is_first_day_of_event( event, date ) { 21354 // Compare the event start date with the cell's date 21355 const eventStart = moment.tz(event.ts, this.timezone).startOf("day"); 21356 const cellDate = moment.tz(date, this.timezone).startOf("day"); 21357 return eventStart.isSame(cellDate, "day"); 21358 }, 21359 sort_events_for_day(events){ 21360 //multi day and all day events first, then by start time 21361 return events.sort((a, b) => { 21362 const aAllDay = a.isAllDay; 21363 const bAllDay = b.isAllDay; 21364 21365 const aMulti = this.is_multi_day_event(a); 21366 const bMulti = this.is_multi_day_event(b); 21367 21368 // Sort all-day events first 21369 if (aAllDay && !bAllDay) return -1; 21370 if (!aAllDay && bAllDay) return 1; 21371 21372 // Then multi-day events 21373 if (aMulti && !bMulti) return -1; 21374 if (!aMulti && bMulti) return 1; 21375 21376 // Otherwise, by start time 21377 return moment.tz(a.ts, this.timezone).isBefore(moment.tz(b.ts, this.timezone)) ? -1 : 1; 21378 }); 21379 }, 21380 is_multi_day_event( event ){ 21381 return moment.tz(event.ts, this.timezone).isBefore(moment.tz(event.end_ts, this.timezone), 'day'); 21382 }, 21383 handleButtonClick(option, new_date) { 21384 if (option === "today") { 21385 //vReplace with a new moment object 21386 var new_date = moment.tz( this.timezone ).startOf("day"); 21387 } else if (option === "next") { 21388 // Add time and replace with a new object 21389 var new_date = moment(this.current_date).add(1, this.view_preference.toLowerCase()); 21390 } else if (option === "previous") { 21391 // Subtract time and replace with a new object 21392 var new_date = moment(this.current_date).subtract(1, this.view_preference.toLowerCase()); 21393 }else if( option === 'date-picker' ){ 21394 var new_date = moment.tz( new_date, this.timezone ).startOf("day"); 21395 } 21396 if( new_date ){ 21397 this.$emit("change_date", new_date); 21398 } 21399 }, 21400 generateWrapStyle( type ){ 21401 if (type === 'cs-calendar-wrap'){ 21402 return {'width': '100%'} 21403 } else if (type === 'options' && this.is_membership_cal){ 21404 return { 21405 'padding-top': '15px', 21406 'padding-right': '15px', 21407 'padding-left': '15px', 21408 'padding-bottom': '5px', 21409 'border-width': '0 1.5px 0 1.5px', 21410 'border-color': '#e2e8e8', 21411 'border-style': 'solid', 21412 'overflow': 'auto', 21413 } 21414 } else if (type === 'cs-calendar-menu-wrap' && this.is_membership_cal){ 21415 return { 21416 'border-width': '0 1.5px 0 1.5px', 21417 'border-color': '#e2e8e8', 21418 'border-style': 'solid' 21419 } 21420 } 21421 }, 21422 // tapped_add_to_cal(){ 21423 // var params = {}; 21424 // params["title"] = "Create calendar item"; 21425 // params["hide_on_selection"] = true; 21426 // params["retrieve_sections_fn"] = this.return_sections_for_handle_common_grid; 21427 // handle_common_grid( params ); 21428 // }, 21429 // return_sections_for_handle_common_grid(){ 21430 // var sections = []; 21431 // var options = [ 21432 // { 21433 // text: "Event", 21434 // fn: this.add_event, 21435 // icon_class: "fa-regular fa-calendar-plus", 21436 // }, 21437 // { 21438 // text: "Calendar tag", 21439 // fn: this.clicked_add_calendar_tag, 21440 // icon_class: "fa-regular fa-tags", 21441 // }, 21442 // ] 21443 // sections.push({ 21444 // options: options 21445 // }); 21446 // return sections; 21447 // }, 21448 clicked_add_calendar_tag(){ 21449 store.components.tag_event.type_text = "this calendar"; 21450 store.components.tag_event.handle_selected_event_tag = this.handle_selected_event_tag; 21451 store.components.tag_event.event_tag_input = ''; 21452 store.components.tag_event.event_source = this.calendar_type; 21453 store.overlays.tag_event = true; 21454 }, 21455 handle_selected_event_tag(tag_data, event_tags){ 21456 //emit 21457 this.$emit('added_calendar_tag', tag_data, event_tags); 21458 },
21459 refresh_calendar(){ 21460 this.$emit('refresh_calendar'); 21461 }, 21462 clicked_remove_tag(tag){ 21463 this.$emit('remove_calendar_tag', tag); 21464 }, 21465 isolate_calendar_tag(tag){ 21466 this.ui_catch_all_custom_tag_selected = false; 21467 this.$emit('isolate_calendar_tag', tag); 21468 }, 21469 isolate_default_tag(tag){ 21470 this.$emit('isolate_default_tag', tag); 21471 }, 21472 select_all_custom_tags(){ 21473 this.ui_catch_all_custom_tag_selected = true; 21474 this.$emit('select_all_custom_tags'); 21475 }, 21476 select_all_default_tags(){ 21477 this.$emit('select_all_default_tags'); 21478 }, 21479 clear_all_default_tags(){ 21480 this.$emit('clear_all_default_tags'); 21481 }, 21482 select_visible_default_tags(visibleIds){ 21483 this.$emit('select_visible_default_tags', visibleIds); 21484 }, 21485 clear_visible_default_tags(visibleIds){ 21486 this.$emit('clear_visible_default_tags', visibleIds); 21487 }, 21488 clear_all_custom_tags(){ 21489 this.$emit('clear_all_custom_tags'); 21490 }, 21491 select_visible_custom_tags(visibleIds){ 21492 this.$emit('select_visible_custom_tags', visibleIds); 21493 }, 21494 clear_visible_custom_tags(visibleIds){ 21495 this.$emit('clear_visible_custom_tags', visibleIds); 21496 }, 21497 isolate_catch_all_custom(){ 21498 this.$emit('unselect_all_custom_tags'); 21499 this.ui_catch_all_custom_tag_selected = true; 21500 }, 21501 calendarRowHeight() { 21502 let maxSlots = 0; 21503 this.days_array.forEach((d) => { 21504 var used_slots = 0; 21505 if (Array.isArray(d.slots)) { 21506 used_slots = d.slots.filter(slot => slot.event).length; 21507 } 21508 maxSlots = Math.max(maxSlots, used_slots); 21509 }); 21510 const baseHeight = 100; // Base height for the calendar row 21511 var slotHeight = 10; // Height for each slot 21512 if(maxSlots >= 6){ 21513 slotHeight = 20; //extra height for more slots 21514 } 21515 return `${baseHeight + (maxSlots * slotHeight)}px`; 21516 }, 21517 should_hold_slot(slot, index, day) { 21518 var date = day.date; 21519 const usedSlots = this.return_slots_in_use_for_week(slot, index, date); 21520 return usedSlots.includes(index); 21521 }, 21522 return_slots_in_use_for_week(slot, index, date) { 21523 const startOfWeek = moment.tz(date, this.timezone).startOf("week"); 21524 const endOfWeek = moment.tz(date, this.timezone).endOf("week"); 21525 21526 const slotsUsed = new Set(); 21527 21528 this.days_array.forEach(day => { 21529 const dayMoment = moment.tz(day.date, this.timezone).startOf("day"); 21530 21531 // Only consider days in the current week 21532 if (dayMoment.isBetween(startOfWeek, endOfWeek, null, '[]')) { 21533 if (Array.isArray(day.slots)) { 21534 day.slots.forEach(slotObj => { 21535 if (slotObj.event) { 21536 slotsUsed.add(slotObj.slot); 21537 } 21538 }); 21539 } 21540 } 21541 }); 21542 21543 return Array.from(slotsUsed); 21544 }, 21545 change_show_calendar_tags( ){ 21546 this.show_calendar_tags = !this.show_calendar_tags; 21547 }, 21548 // default_tag_selected(tag) { 21549 // return this.ui_tag_ids_default && this.ui_tag_ids_default.includes(tag.id); 21550 // }, 21551 }, 21552 computed: { 21553 show_catch_all_custom_tags_option() { 21554 //if we have AND logic, we want to show an option that's a catch-all for all custom tags 21555 return this.filter_logic == 'and'; 21556 }, 21557 selected_tags_set() { 21558 let selected_tags_set = new Set( this.selected_tags.custom ); 21559 return selected_tags_set; 21560 }, 21561 selected_filter_set() { 21562 let selected_filter_set = new Set( this.selected_tags.default ); 21563 return selected_filter_set; 21564 }, 21565 selected_filter_set_secondary() { 21566 return new Set( this.selected_tags.default_secondary || [] ); 21567 }, 21568 dates_for_days_array(){ 21569 //create a basic structure of dates that does not change so that when filters change, we can just update events and slots instead of recreating every date object 21570 //create an object with the following structure for every day in the given date range 21571 /** 21572 { 21573 "date": "2025-07-27T07:00:00.000Z", 21574 "date_components": { 21575 "year": 2025, 21576 "month": 6, 21577 "day": 27 21578 }, 21579 "day": 27, 21580 "day_of_week": "Sunday", 21581 "slots": [ 21582 { 21583 "slot": 0, 21584 "event": null 21585 }, 21586 { 21587 "slot": 1, 21588 "event": null 21589 }, 21590 { 21591 "slot": 2, 21592 "event": null 21593 }, 21594 { 21595 "slot": 3, 21596 "event": null 21597 }, 21598 { 21599 "slot": 4, 21600 "event": null 21601 }, 21602 { 21603 "slot": 5, 21604 "event": null 21605 }, 21606 { 21607 "slot": 6, 21608 "event": null 21609 }, 21610 { 21611 "slot": 7, 21612 "event": null 21613 }, 21614 { 21615 "slot": 8, 21616 "event": null 21617 }, 21618 { 21619 "slot": 9, 21620 "event": null 21621 } 21622 ], 21623 "events": [] 21624 } **/ 21625 21626 const start = this.current_date.clone().startOf("month").startOf("week"); 21627 const end = this.current_date.clone().endOf("month").endOf("week"); 21628 let current = start.clone(); 21629 let days_array = []; 21630 while (current.isBefore(end) || current.isSame(end, "day")) { 21631 //create the day object 21632 let day = { 21633 date: current.clone(), 21634 date_components: { 21635 year: current.year(), 21636 month: current.month(), 21637 day: current.date(), 21638 }, 21639 day: current.date(), 21640 day_of_week: current.format("dddd"), 21641 slots: Array.from({ length: 10 }, (_, i) => ({ 21642 slot: i, 21643 event: null, // Initially no event 21644 })), 21645 events: [] 21646 }; 21647 //add the day object to the array 21648 days_array.push(day); 21649 current.add(1, "day"); 21650 } 21651 return days_array; 21652 }, 21653 days_array() { 21654 //leverage days_array_dates 21655 //make sure previous things get addressed first and start by filling up the slots with any allday/multi day events that started before the calendar start date but take place and end within the current month/week 21656 if (this.view_preference === "Month" || this.view_preference == "Week") { 21657 //always start at the beginning/end of the month, EVEN IN WEEK VIEW 21658 //.. so that we can generate slots effectively 21659 //... Then, we only push the day to the array in week view if it's in the current week 21660 21661 const start = this.current_date.clone().startOf("month").startOf("week"); 21662 const end = this.current_date.clone().endOf("month").endOf("week"); 21663 21664 21665 let days_array = []; 21666 let visible_slots = 10; 21667 if( this.view_preference == "Week" ){ 21668 visible_slots = 100; 21669 } 21670 let reserved_slots = {}; 21671 let highest_reserved_slot_in_week = 0; 21672 /* 21673 //example of reserved_slots 21674 { 21675 0: { 21676 event: Object, 21677 days_remaining: 3 21678 }, 21679 1: { 21680 event: Object, 21681 days_remaining: 1 21682 }, 21683 2: { 21684 event: Object, 21685 days_remaining: 4 21686 }, 21687 } 21688 */ 21689 21690 var dates = this.dates_for_days_array;
21691 dates.forEach(day => { 21692 let current = day.date.clone(); 21693 let day_is_in_week = current.isSame( this.current_date, "week" ); 21694 let events_for_day = this.visible_events_by_day[current.format("YYYY-MM-DD")] || []; 21695 let handled_events = []; //events we have already managed to slot in 21696 //generate slots for the day 21697 let slots = []; 21698 for (let i = 0; i <= visible_slots - 1; i++) { 21699 //check for a reserved slot already 21700 if( reserved_slots[ i ] ){ 21701 //slot is reserved 21702 slots.push({ 21703 slot: i, 21704 event: reserved_slots[ i ].event 21705 }) 21706 //update it 21707 reserved_slots[ i ].days_remaining -= 1; 21708 }else{ 21709 //slot is open 21710 //.. check for an event 21711 //.. if there is an event, reserve the slot 21712 let event_to_use = null; 21713 for (let e = 0; e < events_for_day.length; e++) { 21714 const this_event = events_for_day[ e ]; 21715 if( this.view_preference == "Week" && ! this_event.isAllDay ){ 21716 //we only slot all day events for the week view 21717 continue; 21718 } 21719 //keep going 21720 if( ! handled_events.includes( this_event.id ) ){ 21721 //haven't used it yet 21722 if( ! this.is_multi_day_event( this_event ) ){ 21723 //it's a single-day event 21724 //.. slot it right in! 21725 event_to_use = this_event; 21726 break; 21727 }else{ 21728 //check if it's the first visible day of the month calendar 21729 var is_first_of_month = current.isSame(this.current_date.clone().startOf("month").startOf("week"), 'day'); 21730 //ok, it's a multi-day event 21731 //.. see if it's the first day of a new event 21732 var remaining_days = this.remaining_days_in_event( this_event, current ); 21733 if(is_first_of_month){ 21734 //check for events that started before the month started but are still ongoing 21735 //.. and slot them 21736 if( remaining_days > 0 ){ 21737 //it's an ongoing event that started before the month started 21738 event_to_use = this_event; 21739 reserved_slots[ i ] = { 21740 event: this_event, 21741 days_remaining: Math.max(0, this.remaining_days_in_event(this_event, current) - 1) 21742 } 21743 break; 21744 } 21745 }else if( this.is_first_day_of_event( this_event, current ) ){ 21746 //it's the first day of a multi-day event 21747 //.. reserve the slot 21748 event_to_use = this_event; 21749 reserved_slots[ i ] = { 21750 event: this_event, 21751 days_remaining: Math.max(0, this.remaining_days_in_event(this_event, current) - 1) 21752 } 21753 break; 21754 } 21755 } 21756 } 21757 } 21758 if( event_to_use ){ 21759 slots.push({ 21760 slot: i, 21761 event: event_to_use 21762 }) 21763 handled_events.push( event_to_use.id ); 21764 if( day_is_in_week && i > highest_reserved_slot_in_week ){
21765 highest_reserved_slot_in_week = i; 21766 } 21767 }else{ 21768 //push it, even if we don't have an event 21769 slots.push({ 21770 slot: i, 21771 event: null 21772 }) 21773 } 21774 } 21775 if( reserved_slots[ i ] && reserved_slots[ i ].days_remaining === 0 ){ 21776 //remove the slot 21777 delete reserved_slots[ i ]; 21778 } 21779 } 21780 //end slots 21781 day.slots = slots; //visible events for the day 21782 day.events = events_for_day; //all events for the day 21783 if( this.view_preference == "Month" || day_is_in_week){ 21784 days_array.push(day); 21785 } 21786 }); 21787 21788 /** 21789 let current = start.clone(); 21790 while (current.isBefore(end) || current.isSame(end, "day")) { 21791 let day_is_in_week = current.isSame( this.current_date, "week" ); 21792 //find all events for this day 21793 let events_for_day = this.visible_events_by_day[current.format("YYYY-MM-DD")] || []; 21794 let handled_events = []; //events we have already managed to slot in 21795 //generate slots for the day 21796 let slots = []; 21797 for (let i = 0; i <= visible_slots - 1; i++) { 21798 //check for a reserved slot already 21799 if( reserved_slots[ i ] ){ 21800 //slot is reserved 21801 slots.push({ 21802 slot: i, 21803 event: reserved_slots[ i ].event 21804 }) 21805 //update it 21806 reserved_slots[ i ].days_remaining -= 1; 21807 }else{ 21808 //slot is open 21809 //.. check for an event 21810 //.. if there is an event, reserve the slot 21811 let event_to_use = null; 21812 for (let e = 0; e < events_for_day.length; e++) { 21813 const this_event = events_for_day[ e ]; 21814 if( this.view_preference == "Week" && ! this_event.isAllDay ){ 21815 //we only slot all day events for the week view 21816 continue; 21817 } 21818 //keep going 21819 if( ! handled_events.includes( this_event.id ) ){ 21820 //haven't used it yet 21821 if( ! this.is_multi_day_event( this_event ) ){ 21822 //it's a single-day event 21823 //.. slot it right in! 21824 event_to_use = this_event; 21825 break; 21826 }else{ 21827 //check if it's the first visible day of the month calendar 21828 var is_first_of_month = current.isSame(this.current_date.clone().startOf("month").startOf("week"), 'day'); 21829 //ok, it's a multi-day event 21830 //.. see if it's the first day of a new event 21831 var remaining_days = this.remaining_days_in_event( this_event, current ); 21832 if(is_first_of_month){ 21833 //check for events that started before the month
21833started but are still ongoing 21834 //.. and slot them 21835 if( remaining_days > 0 ){ 21836 //it's an ongoing event that started before the month started 21837 event_to_use = this_event; 21838 reserved_slots[ i ] = { 21839 event: this_event, 21840 days_remaining: remaining_days 21841 } 21842 break; 21843 } 21844 }else if( this.is_first_day_of_event( this_event, current ) ){ 21845 //it's the first day of a multi-day event 21846 //.. reserve the slot 21847 event_to_use = this_event; 21848 reserved_slots[ i ] = { 21849 event: this_event, 21850 days_remaining: this.return_number_of_days_for_event( this_event ) 21851 } 21852 break; 21853 } 21854 } 21855 } 21856 } 21857 if( event_to_use ){ 21858 slots.push({ 21859 slot: i, 21860 event: event_to_use 21861 }) 21862 handled_events.push( event_to_use.id ); 21863 if( day_is_in_week && i > highest_reserved_slot_in_week ){ 21864 highest_reserved_slot_in_week = i; 21865 } 21866 }else{ 21867 //push it, even if we don't have an event 21868 slots.push({ 21869 slot: i, 21870 event: null 21871 }) 21872 } 21873 } 21874 if( reserved_slots[ i ] && reserved_slots[ i ].days_remaining === 0 ){ 21875 //remove the slot 21876 delete reserved_slots[ i ]; 21877 } 21878 } 21879 //end slots 21880 //see if we should push to the days array 21881 if( this.view_preference == "Month" || day_is_in_week){ 21882 days_array.push({ 21883 date: current.clone(), 21884 date_components: { 21885 year: current.year(), 21886 month: current.month(), 21887 day: current.date(), 21888 }, 21889 day: current.date(), 21890 day_of_week: current.format("dddd"), 21891 slots: slots, //visible events for the day 21892 events: events_for_day, //all events for the day 21893 }); 21894 } 21895 //increment it 21896 current.add(1, "day"); 21897 } 21898 21899 */ 21900 //remove slots that are greater than highest actually used slot 21901 if (this.view_preference === "Week") { 21902 let highest_used_slot_index = 0; 21903 days_array.forEach(day => {
21904 day.slots.forEach(slot => { 21905 if (slot.event && slot.slot > highest_used_slot_index) { 21906 highest_used_slot_index = slot.slot; 21907 } 21908 }); 21909 }); 21910 days_array.forEach(day => { 21911 day.slots = day.slots.filter(slot => { 21912 return slot.slot <= highest_used_slot_index; 21913 }); 21914 }); 21915 } 21916 return days_array; 21917 } else if (this.view_preference === "Day") { 21918 // Generate only the current day 21919 return [ 21920 { 21921 date: this.current_date.clone(), 21922 date_components: { 21923 year: this.current_date.year(), 21924 month: this.current_date.month(), 21925 day: this.current_date.date(), 21926 }, 21927 day: this.current_date.date(), 21928 day_of_week: this.current_date.format("dddd"), 21929 events: this.visible_events_by_day[this.current_date.format("YYYY-MM-DD")] || [], 21930 }, 21931 ]; 21932 } 21933 return []; 21934 }, 21935 calendarTitle() { 21936 if (this.view_preference === "Month") { 21937 return this.current_date.format("MMMM YYYY"); 21938 } else if (this.view_preference === "Week") { 21939 // Set the title to the range of the week 21940 const start = this.current_date.clone().startOf("week"); 21941 const end = this.current_date.clone().endOf("week"); 21942 return `${start.format("MMM D")} - ${end.format("MMM D")}`; 21943 } else if (this.view_preference === "Day") { 21944 return this.current_date.format("MMMM D, YYYY"); 21945 } 21946 return null; 21947 }, 21948 calendarTitle_noYear(){ 21949 if (this.view_preference === "Month") { 21950 return this.current_date.format("MMMM"); 21951 } else if (this.view_preference === "Week") { 21952 // Set the title to the range of the week 21953 const start = this.current_date.clone().startOf("week"); 21954 const end = this.current_date.clone().endOf("week"); 21955 return `${start.format("MMM D")} - ${end.format("MMM D")}`; 21956 } else if (this.view_preference === "Day") { 21957 return this.current_date.format("MMMM D"); 21958 } 21959 return null; 21960 }, 21961 events_by_day(){ 21962 //create an object with keys as days and values as arrays of events 21963 let events_by_day = {}; 21964 this.events.forEach(event => { 21965 //get the start and end dates of the event 21966 const start = moment.tz(event.ts, this.timezone).startOf("day"); 21967 const end = moment.tz(event.end_ts, this.timezone).endOf("day"); 21968 //iterate through the days from start to end 21969 let current = start.clone(); 21970 while (current.isSameOrBefore(end)) { 21971 const date_key = current.format("YYYY-MM-DD"); 21972 if (!events_by_day[date_key]) { 21973 events_by_day[date_key] = []; 21974 } 21975 events_by_day[date_key].push(event); 21976 current.add(1, "day"); 21977 } 21978 }); 21979 //sort array for each day 21980 for (let date in events_by_day) { 21981 if(date && events_by_day[date] && events_by_day[date].length > 0){ 21982 //sort the events for the day 21983 events_by_day[date] = this.sort_events_for_day(events_by_day[date]); 21984 } 21985 } 21986 return events_by_day; 21987 }, 21988 visible_events_by_day() { 21989 const visible_events_by_day = {}; 21990 const defaultFilterIDs = this.selected_tags?.default || []; 21991 const selectedCalendarTagIDs = this.selected_tags?.custom || []; 21992 21993 for (const [dayKey, events] of Object.entries(this.events_by_day)) { 21994 visible_events_by_day[dayKey] = events.filter(event => { 21995 if(event.type !== 'booking'){ 21996 21997 var hasMatchingDefault = false;
21998 var hasMatchingDefaultSecondary = true; 21999 // Two default groups (e.g. lodging requests: room type + room with AND) 22000 if (this.default_calendar_tags_secondary) { 22001 var room_type_ids = event.room_type_ids || []; 22002 var room_ids = event.room_ids || []; 22003 if (room_type_ids.length || room_ids.length) { 22004 var is_day_trip_request_filter = this.calendar_type === 'lodging_requests' && room_type_ids.indexOf('day_trip') !== -1; 22005 if (this.default_calendar_tags && room_type_ids.length) { 22006 for (var ri = 0; ri < room_type_ids.length; ri++) { 22007 if (this.selected_filter_set.has(room_type_ids[ri])) { 22008 hasMatchingDefault = true; 22009 break; 22010 } 22011 } 22012 } else if (!this.default_calendar_tags || (this.selected_filter_set && this.selected_filter_set.size === 0)) { 22013 hasMatchingDefault = true; 22014 } 22015 if (is_day_trip_request_filter) { 22016 hasMatchingDefaultSecondary = true; 22017 } else { 22018 var secSet = this.selected_filter_set_secondary; 22019 if (secSet && secSet.size > 0) { 22020 hasMatchingDefaultSecondary = false; 22021 if (secSet.has('no_room') && room_ids.length === 0) { 22022 hasMatchingDefaultSecondary = true; 22023 } 22024 for (var rj = 0; rj < room_ids.length; rj++) { 22025 if (secSet.has(room_ids[rj])) { 22026 hasMatchingDefaultSecondary = true; 22027 break; 22028 } 22029 } 22030 } 22031 } 22032 } else { 22033 hasMatchingDefault = true; 22034 hasMatchingDefaultSecondary = true; 22035 } 22036 } else if (this.default_calendar_tags) { 22037 // Lodging special-case: if grouped, match ANY of the group's room ids (have to handle case where we group mult. rooms for one member) 22038 if ( 22039 (this.calendar_type === 'lodging' || (this.calendar_type === 'reservations' && event.type === "lodging_reservation")) && 22040 event.filter_ids && 22041 event.filter_ids.length > 0 22042 ) { 22043 const filter_ids = event.filter_ids || []; 22044 for (const filter of filter_ids) { 22045 if (this.selected_filter_set.has(filter)) { 22046 hasMatchingDefault = true; 22047 if( this.filter_logic == "or" && hasMatchingDefault ){ 22048 return true; 22049 } 22050 } 22051 } 22052 } else { 22053 const filter_ids = [event.filter_id]; 22054 for (const filter of filter_ids) { 22055 if (this.selected_filter_set.has(filter)) { 22056 hasMatchingDefault = true; 22057 if( this.filter_logic == "or" && hasMatchingDefault ){ 22058 return true; 22059 } 22060 } 22061 } 22062 } 22063 } 22064 if (this.default_calendar_tags_secondary && (!hasMatchingDefault || !hasMatchingDefaultSecondary)) { 22065 return false;
22066 } 22067 //see if we should exit early, based on default 22068 if( this.filter_logic == "or" && hasMatchingDefault ){ 22069 return true; 22070 } 22071 //now check if one of the events tags are selected 22072 var hasMatchingCalendar = false; 22073 const event_tag_ids = event.event_tag_ids || []; 22074 if( 22075 this.show_catch_all_custom_tags_option && 22076 this.catch_all_custom_tag_selected && 22077 event_tag_ids.length === 0 22078 ){ 22079 //ok, they have the default (no tags) option selected 22080 //.. AND this event isn't tagged 22081 //... it's a match 22082 hasMatchingCalendar = true; 22083 }else{ 22084 //ok, do a full check on the tags 22085 if( this.calendar_tags && this.calendar_tags.length >= 1 ){ 22086 //ok, we have tags 22087 //.. it actually needs to match one of the tags 22088 if( selectedCalendarTagIDs.length > 0 && selectedCalendarTagIDs.some(tagId => event_tag_ids.includes(tagId)) ){ 22089 hasMatchingCalendar = true; 22090 } 22091 }else if( this.filter_logic == "and" ){ 22092 //if no tags, it's effectively a match 22093 hasMatchingCalendar = true; 22094 } 22095 } 22096 //check logic 22097 if( this.filter_logic == "and" ){ 22098 //AND logic 22099 return hasMatchingDefault && hasMatchingCalendar; 22100 }else{ 22101 //OR logic 22102 return hasMatchingDefault || hasMatchingCalendar; 22103 } 22104 } else if (this.calendar_tags && this.calendar_tags.length > 0) { 22105 22106 const event_tag_ids = event.event_tag_ids || []; 22107 for (const tag_id of event_tag_ids) { 22108 if (this.selected_tags_set.has(tag_id)) { 22109 return true; // exit early 22110 } 22111 } 22112 return false; 22113 22114 } 22115 return false; 22116 }); 22117 } 22118 return visible_events_by_day; 22119 }, 22120 22121 }, 22122 watch: { 22123 view_preference(newVal) { 22124 this.v_model_view_preference = newVal; 22125 }, 22126 } 22127 }); 22128 </script>
22128
22128<script type="text/html" id="template-booking-calendar-filters"> 22129 <!-- DESKTOP VERSION --> 22130 <div 22131 v-if="show_calendar_tags" 22132 class="hideOnMobile calendar-tag-picker lightBorderLeft scrollable native-tab-bar-padding-bottom" 22133 style="width: 240px;" 22134 :class="{'flexible-height-version scrollable': calendar_type === 'web_component'}" 22135 > 22136 <div class="relative flexNoWrapLeftAlign" style="align-items: baseline;"> 22137 22138 <div style="position: sticky;top: 0;background-color: #fff;z-index: 10;" > 22139 <div v-if="can_edit_tags" class="flexWrapLeftAlign align-items-center tinyPaddingBottom" style="padding-top: 10px;padding-bottom: 6px; margin-right: 10px;"> 22140 22141 <div class="smallPaddingLeft"> 22142 <icon-button 22143 label="Add event" 22144 icon_class="fa-regular fa-plus" 22145 @clicked="add_event()" 22146 style="max-height: fit-content;" 22147 ></icon-button> 22148 </div> 22149 <div class="flexGrowOne"></div> 22150 <div class="flexWrap align-items-center calendar-menu-v2 tinyMarginTop"> 22151 <h3 22152 class="calendar-list menu-icon pointer" 22153 @click="change_show_calendar_tags()" 22154 > 22155 <i 22156 class="fa-regular fa-bars pointer" 22157 ></i> 22158 </h3> 22159 </div> 22160 </div> 22161 <h3 22162 class="flexWrapLeftAlign align-items-center" 22163 :class="{'lightBorderBottom': calendar_type !== 'web_component'}" 22164 style="padding-top: 10px;"> 22165 Calendars 22166 <template v-if="!can_edit_tags"> 22167 <div class="flexGrowOne"></div> 22168 <div class="flexWrap align-items-center calendar-menu-v2 tinyMarginTop"> 22169 <h3 22170 class="calendar-list menu-icon pointer" 22171 style="margin-right: 10px;" 22172 @click="change_show_calendar_tags()" 22173 > 22174 <i 22175 class="fa-regular fa-bars pointer" 22176 ></i> 22177 </h3> 22178 </div> 22179 </template> 22180 </h3> 22181 </div> 22182 22183 <div class="calendar-tag-picker-input bigPaddingLeftRight scrollable paddingBottom"> 22184 <div v-if="default_calendar_tags && default_calendar_tags.title" class="smallMarginTop"> 22185 <h4 class="smallBlockLetters leftText tinyMarginBottom pointer" @click="collapsible_menus_open.default_primary = !collapsible_menus_open.default_primary"> 22186 {{ default_calendar_tags.title }} 22187 <i 22188 class="fa fa-chevron-down rotate-chevron" 22189 :class="{ down: collapsible_menus_open.default_primary }" 22190 style="margin-left: 6px;" 22191 ></i> 22192 </h4> 22193 <div v-if="collapsible_menus_open.default_primary && loading_parent.filter" class="coming-soon">xxx</div> 22194 <div v-if="collapsible_menus_open.default_primary && !loading_parent.filter" class="calendar-filter-search-wrap tinyMarginBottom"> 22195 <div 22196 class="calendar-filter-search-container relative" 22197 :class="{ 'active': filter_search_default_focused }" 22198 > 22199 <i class="fa-light fa-magnifying-glass calendar-filter-search-icon" style="z-index: 8;"></i> 22200 <input 22201 class="calendar-filter-search-input" 22202 :placeholder="default_search_placeholder" 22203 v-model="filter_search_default" 22204 @focus="filter_search_default_focused = true" 22205 @blur="filter_search_default_focused = false" 22206 autocomplete="off" 22207 autocorrect="off" 22208 autocapitalize="none" 22209 spellcheck="false" 22210 > 22211 </div> 22212 </div> 22213 <div class="flexWrapLeftAlign align-items-center"> 22214 <p 22215 v-if="collapsible_menus_open.default_primary && !loading_parent.filter" 22216 @click="select_default_tags()" 22217 class="smallBlockLetters tinyMarginBottom" 22218 style="margin-right: 12px; font-size: 12px;" 22219 > 22220 <a> 22221 <i class="fa-regular fa-check" style="margin-right: 4px;"></i>{{ default_select_all_label }} 22222 </a> 22223 </p> 22224 <p 22225 v-if="collapsible_menus_open.default_primary && (has_default_search ? true : has_any_default_filters_selected)" 22226 @click="clear_default_tags()" 22227 class="smallBlockLetters tinyMarginBottom" 22228 style="margin-right: 12px; font-size: 12px;" 22229 > 22230 <a> 22231 <i class="fa-regular fa-xmark" style="margin-right: 4px;"></i>{{ default_clear_label }} 22232 </a> 22233 </p> 22234 </div> 22235 <template 22236 v-if="collapsible_menus_open.default_primary && filtered_default_options?.length > 0" 22237 v-for="tag in filtered_default_options" 22238 :key="tag.id" 22239 > 22240 <div 22241 @click="clicked_default_tag(tag)" 22242 class="calendar-tag-item flexWrapLeftAlign align-items-center tinyPaddingTop relative pointer" 22243 style="display: flex;" 22244 > 22245 <label class="calendar-tag-item flexWrapLeftAlign align-items-center" style="display: flex; align-items: center; flex: 1;"> 22246 <div 22247 class="custom-checkbox" 22248 :style="{ 22249 backgroundColor: ui_tag_ids_default && ui_tag_ids_default[tag.id] 22250 ? (tag.color || '#5774f5') 22251 : '', 22252 border: '2px solid ' + (tag.color || '#5774f5') 22253 }" 22254 > 22255 <i class="fa-regular fa-check" 22256 style="color: white; position: absolute; top: 2px; left: 2px; font-size: 12px;" 22257 ></i> 22258 </div>
22259 <span class="calendar-tag-name" style="flex: 1 1 auto; word-break: break-word;"> 22260 {{ tag.label }} 22261 </span> 22262 </label> 22263 <!-- Default tag context menu trigger --> 22264 <div 22265 class="calendar-tag-remove-icon" 22266 style="margin-left: 4px; flex-shrink: 0; padding-left: 4px; padding-right: 4px; z-index: 3;" 22267 @click.stop="toggleTagMenu(tag, $event)" 22268 > 22269 <i class="fa-regular fa-ellipsis-vertical"></i> 22270 </div> 22271 <!-- Default tag context menu dropdown (only isolate option) --> 22272 <div 22273 v-if="open_menu_for_tag_id === tag.id" 22274 class="tag-context-menu" 22275 @click.stop 22276 > 22277 <button 22278 type="button" 22279 class="tag-context-menu-item" 22280 @click.stop="closeTagMenu(); isolate_default_tag(tag)" 22281 > 22282 Display this only 22283 </button> 22284 </div> 22285 </div> 22286 </template> 22287 </div> 22288 <!-- Secondary default group (e.g. Room for lodging requests: room type + room with AND) --> 22289 <div v-if="default_calendar_tags_secondary && default_calendar_tags_secondary.title" class="smallMarginTop"> 22290 <h4 class="smallBlockLetters leftText tinyMarginBottom pointer" @click="collapsible_menus_open.default_secondary = !collapsible_menus_open.default_secondary"> 22291 {{ default_calendar_tags_secondary.title }} 22292 <i class="fa fa-chevron-down rotate-chevron" :class="{ down: collapsible_menus_open.default_secondary }" style="margin-left: 6px;"></i> 22293 </h4> 22294 <div v-if="collapsible_menus_open.default_secondary" class="calendar-filter-search-wrap tinyMarginBottom"> 22295 <div class="calendar-filter-search-container relative" :class="{ 'active': filter_search_default_secondary_focused }"> 22296 <i class="fa-light fa-magnifying-glass calendar-filter-search-icon" style="z-index: 8;"></i> 22297 <input class="calendar-filter-search-input" :placeholder="'Search ' + (default_calendar_tags_secondary.title || '').toLowerCase()" v-model="filter_search_default_secondary" @focus="filter_search_default_secondary_focused = true" @blur="filter_search_default_secondary_focused = false" autocomplete="off" autocorrect="off" autocapitalize="none" spellcheck="false"> 22298 </div> 22299 </div> 22300 <div class="flexWrapLeftAlign align-items-center"> 22301 <p v-if="collapsible_menus_open.default_secondary" @click="select_default_tags_secondary()" class="smallBlockLetters tinyMarginBottom" style="margin-right: 12px; font-size: 12px;"><a><i class="fa-regular fa-check" style="margin-right: 4px;"></i>Select all</a></p> 22302 <p v-if="collapsible_menus_open.default_secondary && (filter_search_default_secondary || '').trim() && filtered_default_secondary_options?.length" @click="clear_visible_default_tags_secondary()" class="smallBlockLetters tinyMarginBottom" style="margin-right: 12px; font-size: 12px;"><a><i class="fa-regular fa-xmark" style="margin-right: 4px;"></i>Clear</a></p> 22303 <p v-if="collapsible_menus_open.default_secondary && !(filter_search_default_secondary || '').trim() && has_any_default_secondary_filters_selected" @click="clear_default_tags_secondary()" class="smallBlockLetters tinyMarginBottom" style="margin-right: 12px; font-size: 12px;"><a><i class="fa-regular fa-xmark" style="margin-right: 4px;"></i>Clear</a></p> 22304 </div> 22305 <template v-if="collapsible_menus_open.default_secondary && filtered_default_secondary_options?.length > 0" v-for="tag in filtered_default_secondary_options" :key="tag.id"> 22306 <div @click="clicked_default_tag_secondary(tag)" class="calendar-tag-item flexWrapLeftAlign align-items-center tinyPaddingTop relative pointer" style="display: flex;"> 22307 <label class="calendar-tag-item flexWrapLeftAlign align-items-center" style="display: flex; align-items: center; flex: 1;"> 22308 <div class="custom-checkbox" :style="{ backgroundColor: ui_tag_ids_default_secondary && ui_tag_ids_default_secondary[tag.id] ? (tag.color || '#5774f5') : '', border: '2px solid ' + (tag.color || '#5774f5') }"> 22309 <i class="fa-regular fa-check" style="color: white; position: absolute; top: 2px; left: 2px; font-size: 12px;"></i> 22310 </div>
22311 <span class="calendar-tag-name" style="flex: 1 1 auto; word-break: break-word;">{{ tag.label }}</span> 22312 </label> 22313 <div class="calendar-tag-remove-icon" style="margin-left: 4px; flex-shrink: 0; padding-left: 4px; padding-right: 4px; z-index: 3;" @click.stop="toggleTagMenu(tag, $event)"> 22314 <i class="fa-regular fa-ellipsis-vertical"></i> 22315 </div> 22316 <div v-if="open_menu_for_tag_id === tag.id" class="tag-context-menu" @click.stop> 22317 <button type="button" class="tag-context-menu-item" @click.stop="closeTagMenu(); isolate_default_tag_secondary(tag)">Display this only</button> 22318 </div> 22319 </div> 22320 </template> 22321 </div> 22322 <!-- Other custom calendars (event tags) --> 22323 <div v-if="show_other_filters" class="marginTop"> 22324 <h4 class="smallBlockLetters leftText tinyMarginBottom pointer noWrap" @click="collapsible_menus_open.custom = !collapsible_menus_open.custom"> 22325 {{ calendar_tag_label }} 22326 <i 22327 v-if="can_edit_tags" 22328 class="fa-regular fa-plus pointer" 22329 style="margin-left: 6px; margin-right: 3px;" 22330 @click.stop="clicked_add_calendar_tag()" 22331 ></i> 22332 <i 22333 class="fa fa-chevron-down rotate-chevron" 22334 :class="{ down: collapsible_menus_open.custom }" 22335 style="margin-left: 6px;" 22336 ></i> 22337 </h4> 22338 <div v-if="collapsible_menus_open.custom && loading_parent.calendar_tags" class="coming-soon">xxx</div> 22339 <div v-if="collapsible_menus_open.custom && !loading_parent.calendar_tags" class="calendar-filter-search-wrap tinyMarginBottom"> 22340 <div 22341 class="calendar-filter-search-container relative" 22342 :class="{ 'active': filter_search_custom_focused }" 22343 > 22344 <i class="fa-light fa-magnifying-glass calendar-filter-search-icon" style="z-index: 8;"></i> 22345 <input 22346 class="calendar-filter-search-input" 22347 :placeholder="'Search tags'" 22348 v-model="filter_search_custom" 22349 @focus="filter_search_custom_focused = true" 22350 @blur="filter_search_custom_focused = false" 22351 autocomplete="off" 22352 autocorrect="off" 22353 autocapitalize="none" 22354 spellcheck="false" 22355 > 22356 </div> 22357 </div> 22358 <div v-if="collapsible_menus_open.custom && !loading_parent.calendar_tags" class="flexWrapLeftAlign align-items-center tinyMarginBottom"> 22359 <p @click="select_custom_tags()" class="smallBlockLetters tinyMarginBottom" style="margin-right: 12px; font-size: 12px;"> 22360 <a><i class="fa-regular fa-check" style="margin-right: 4px;"></i>{{ custom_select_all_label }}</a> 22361 </p> 22362 <p 22363 v-if="has_custom_search || has_any_custom_filters_selected" 22364 @click="clear_custom_tags()" 22365 class="smallBlockLetters tinyMarginBottom" 22366 style="margin-right: 12px; font-size: 12px;" 22367 > 22368 <a><i class="fa-regular fa-xmark" style="margin-right: 4px;"></i>{{ custom_clear_label }}</a> 22369 </p> 22370 </div> 22371 22372 <!-- default (no tags) option --> 22373 <div 22374 v-if="show_catch_all_custom_tags_option && collapsible_menus_open.custom && show_no_tags_in_custom" 22375 @click="clicked_catch_all_custom_tag()" 22376 class="calendar-tag-item flexWrapLeftAlign align-items-center tinyPaddingTop relative pointer" 22377 style="display: flex;" 22378 > 22379 <label class="calendar-tag-item flexWrapLeftAlign align-items-center" style="display: flex; align-items: center; flex: 1;"> 22380 <div 22381 class="custom-checkbox" 22382 :style="{ 22383 backgroundColor: ui_catch_all_custom_tag_selected 22384 ? '#5774f5' 22385 : '', 22386 border: '2px solid #5774f5' 22387 }" 22388 > 22389 <i class="fa-regular fa-check" 22390 v-if="ui_catch_all_custom_tag_selected" 22391 style="color: white; position: absolute; top: 2px; left: 2px; font-size: 12px;" 22392 ></i> 22393 </div>
22394 <span class="calendar-tag-name" style="flex: 1 1 auto; word-break: break-word;"> 22395 No tags 22396 </span> 22397 </label> 22398 <!-- Tag context menu trigger --> 22399 <div 22400 class="calendar-tag-remove-icon" 22401 style="margin-left: 4px; flex-shrink: 0; padding-left: 4px; padding-right: 4px; z-index: 3;" 22402 @click.stop="toggleTagMenu({id: 'catch_all_custom'}, $event)" 22403 > 22404 <i class="fa-regular fa-ellipsis-vertical"></i> 22405 </div> 22406 <!-- Tag context menu dropdown --> 22407 <div 22408 v-if="open_menu_for_tag_id === 'catch_all_custom'" 22409 class="tag-context-menu" 22410 @click.stop 22411 > 22412 <button 22413 type="button" 22414 class="tag-context-menu-item" 22415 @click.stop="closeTagMenu(); isolate_catch_all_custom()" 22416 > 22417 Display this only 22418 </button> 22419 </div> 22420 </div> 22421 <!-- end default (no tags) option --> 22422 22423 <template 22424 v-if="collapsible_menus_open.custom && !loading_parent.calendar_tags && filtered_custom_tags && filtered_custom_tags.length > 0" 22425 v-for="tag in filtered_custom_tags" 22426 :key="tag.id" 22427 > 22428 <div 22429 v-if="tag" 22430 @click="clicked_calendar_tag(tag)" 22431 class="calendar-tag-item flexWrapLeftAlign align-items-center tinyPaddingTop relative pointer" 22432 style="display: flex;" 22433 > 22434 <label class="calendar-tag-item flexWrapLeftAlign align-items-center" style="display: flex; align-items: center; flex: 1;"> 22435 <div 22436 class="custom-checkbox" 22437 :style="{ 22438 backgroundColor: ui_tag_ids_custom && ui_tag_ids_custom[tag.id] 22439 ? (tag.get('hex_value') || '#5774f5') 22440 : '', 22441 border: '2px solid ' + (tag.get('hex_value') || '#5774f5') 22442 }" 22443 > 22444 <i class="fa-regular fa-check" 22445 v-if="ui_tag_ids_custom && ui_tag_ids_custom[tag.id]" 22446 style="color: white; position: absolute; top: 2px; left: 2px; font-size: 12px;" 22447 ></i> 22448 </div> 22449 <span class="calendar-tag-name" style="flex: 1 1 auto; word-break: break-word;"> 22450 {{ tag.get('name') }} 22451 </span> 22452 </label> 22453 <!-- Tag context menu trigger --> 22454 <div 22455 class="calendar-tag-remove-icon" 22456 style="margin-left: 4px; flex-shrink: 0; padding-left: 4px; padding-right: 4px; z-index: 3;" 22457 @click.stop="toggleTagMenu(tag, $event)" 22458 > 22459 <i class="fa-regular fa-ellipsis-vertical"></i> 22460 </div> 22461 <!-- Tag context menu dropdown --> 22462 <div 22463 v-if="open_menu_for_tag_id === tag.id" 22464 class="tag-context-menu" 22465 :class="{'tag-context-menu--above': isLastTag(tag)}" 22466 @click.stop 22467 > 22468 <button 22469 type="button" 22470 class="tag-context-menu-item" 22471 @click.stop="closeTagMenu(); isolate_calendar_tag(tag)" 22472 > 22473 Display this only 22474 </button> 22475 <button 22476 v-if="can_edit_tags" 22477 type="button" 22478 class="tag-context-menu-item tag-context-menu-item-danger" 22479 @click.stop="closeTagMenu(); clicked_remove_tag(tag)" 22480 > 22481 Remove tag 22482 </button> 22483 </div> 22484 </div> 22485 </template> 22486 </div> 22487 </div> 22488 </div> 22489 </div> 22490 <!-- MOBILE VERSION --> 22491 <!-- Teleport the modal to body --> 22492 <teleport to="#v-overlays"> 22493 <div v-if="show_calendar_tags" class="showOnMobile"> 22494 <transition name="slide-in-right">
22495 <aside class="mobile-filter-overlay scrollable" role="dialog" aria-modal="true" aria-labelledby="calendar-filters-title"> 22496 <div class="calendar-tag-picker marginBottom"> 22497 <div class="relative flexNoWrapLeftAlign" style="align-items: baseline;"> 22498 <div 22499 style="position: sticky;top: 0;background-color: #fff;z-index: 10;" 22500 class="lightBorderBottom smallPaddingTop smallPaddingBottom bigPaddingLeftRight" 22501 > 22502 <div 22503 v-if="can_edit_tags" 22504 class="tinyPaddingBottom" 22505 > 22506 <icon-button 22507 label="Add event" 22508 icon_class="fa-regular fa-plus" 22509 @clicked="add_event()" 22510 style="max-height: fit-content;" 22511 ></icon-button> 22512 </div> 22513 <div 22514 class="card-header-button-v3 pointer noWrap centeredText relative" 22515 > 22516 {{ v_model_view_preference }} 22517 <i class="fa-light fa-chevron-down" style="padding-left: 3px;"></i> 22518 <select 22519 v-model="v_model_view_preference" 22520 @change="changeViewPreference($event)" 22521 class="pointer" 22522 style="position:absolute;left:0px;top:0px;width:100%;height:100%;opacity:0;" 22523 > 22524 <option value="Month">Month</option> 22525 <option value="Week">Week</option> 22526 <option value="Day">Day</option> 22527 </select> 22528 </div> 22529 <h3 id="calendar-filters-title" class="flexWrapLeftAlign align-items-center smallMarginTop" style="padding-top: 0;"> 22530 Calendars 22531 <div class="flexGrowOne"></div> 22532 <!-- <i class="fa-regular fa-xmark pointer" @click="$emit('close')" style="margin-right: 10px;"></i> --> 22533 </h3> 22534 </div> 22535 <div class="calendar-tag-picker-input bigPaddingLeftRight scrollable paddingBottom"> 22536 <div v-if="default_calendar_tags && default_calendar_tags.title" class="smallMarginTop"> 22537 <h4 class="smallBlockLetters leftText tinyMarginBottom pointer" @click="collapsible_menus_open.default_primary = !collapsible_menus_open.default_primary"> 22538 {{ default_calendar_tags.title }} 22539 <i 22540 class="fa fa-chevron-down rotate-chevron" 22541 :class="{ down: collapsible_menus_open.default_primary }" 22542 style="margin-left: 6px;" 22543 ></i> 22544 </h4> 22545 <div v-if="collapsible_menus_open.default_primary && loading_parent.filter" class="coming-soon">xxx</div> 22546 <div v-if="collapsible_menus_open.default_primary && !loading_parent.filter" class="calendar-filter-search-wrap tinyMarginBottom"> 22547 <div 22548 class="calendar-filter-search-container relative" 22549 :class="{ 'active': filter_search_default_focused }" 22550 > 22551 <i class="fa-light fa-magnifying-glass calendar-filter-search-icon" style="z-index: 10;"></i> 22552 <input 22553 class="calendar-filter-search-input" 22554 :placeholder="default_search_placeholder" 22555 v-model="filter_search_default" 22556 @focus="filter_search_default_focused = true" 22557 @blur="filter_search_default_focused = false" 22558 autocomplete="off" 22559 autocorrect="off" 22560 autocapitalize="none" 22561 spellcheck="false" 22562 > 22563 </div> 22564 </div> 22565 <div class="flexWrapLeftAlign align-items-center"> 22566 <p 22567 v-if="collapsible_menus_open.default_primary" 22568 @click="select_default_tags()" 22569 class="smallBlockLetters tinyMarginBottom" 22570 style="margin-right: 12px; font-size: 12px;" 22571 > 22572 <a> 22573 <i class="fa-regular fa-check" style="margin-right: 4px;"></i>{{ default_select_all_label }} 22574 </a> 22575 </p> 22576 <p 22577 v-if="collapsible_menus_open.default_primary && (has_default_search ? true : has_any_default_filters_selected)" 22578 @click="clear_default_tags()" 22579 class="smallBlockLetters tinyMarginBottom" 22580 style="margin-right: 12px; font-size: 12px;" 22581 > 22582 <a> 22583 <i class="fa-regular fa-xmark" style="margin-right: 4px;"></i>{{ default_clear_label }} 22584 </a> 22585 </p> 22586 </div> 22587 <template 22588 v-if="collapsible_menus_open.default_primary && filtered_default_options?.length > 0" 22589 v-for="tag in filtered_default_options" 22590 :key="tag.id" 22591 > 22592 <div 22593 @click="clicked_default_tag(tag)" 22594 class="calendar-tag-item flexWrapLeftAlign align-items-center relative pointer" 22595 style="display: flex; padding-top: 16px;" 22596 > 22597 <label class="calendar-tag-item flexWrapLeftAlign align-items-center" style="display: flex; align-items: center; flex: 1;"> 22598 <div 22599 class="custom-checkbox" 22600 :style="{ 22601 backgroundColor: ui_tag_ids_default && ui_tag_ids_default[tag.id] 22602 ? (tag.color || '#5774f5') 22603 : '', 22604 border: '2px solid ' + (tag.color || '#5774f5') 22605 }" 22606 > 22607 <i class="fa-regular fa-check" 22608 style="color: white; position: absolute; top: 2px; left: 2px; font-size: 12px;" 22609 ></i> 22610 </div>
22611 <span class="calendar-tag-name" style="flex: 1 1 auto; word-break: break-word;"> 22612 {{ tag.label }} 22613 </span> 22614 </label> 22615 <!-- Default tag context menu trigger (mobile) --> 22616 <div 22617 class="calendar-tag-more mobile-version" 22618 style="margin-left: 4px; flex-shrink: 0; padding-left: 4px; padding-right: 4px; z-index: 3;" 22619 @click.stop="toggleTagMenu(tag, $event)" 22620 > 22621 <i class="fa-regular fa-ellipsis-vertical"></i> 22622 </div> 22623 <!-- Default tag context menu dropdown (only isolate option) --> 22624 <div 22625 v-if="open_menu_for_tag_id === tag.id" 22626 class="tag-context-menu" 22627 @click.stop 22628 > 22629 <button 22630 type="button" 22631 class="tag-context-menu-item" 22632 @click.stop="closeTagMenu(); isolate_default_tag(tag)" 22633 > 22634 Display this only 22635 </button> 22636 </div> 22637 </div> 22638 </template> 22639 </div> 22640 <!-- Secondary default group (mobile) --> 22641 <div v-if="default_calendar_tags_secondary && default_calendar_tags_secondary.title" class="marginTop"> 22642 <h4 class="smallBlockLetters leftText tinyMarginBottom pointer" @click="collapsible_menus_open.default_secondary = !collapsible_menus_open.default_secondary"> 22643 {{ default_calendar_tags_secondary.title }} 22644 <i class="fa fa-chevron-down rotate-chevron" :class="{ down: collapsible_menus_open.default_secondary }" style="margin-left: 6px;"></i> 22645 </h4> 22646 <div v-if="collapsible_menus_open.default_secondary" class="flexWrapLeftAlign align-items-center tinyMarginBottom"> 22647 <p @click="select_default_tags_secondary()" class="smallBlockLetters tinyMarginBottom" style="margin-right: 12px; font-size: 12px;"><a><i class="fa-regular fa-check" style="margin-right: 4px;"></i>Select all</a></p> 22648 <p v-if="has_any_default_secondary_filters_selected" @click="clear_default_tags_secondary()" class="smallBlockLetters tinyMarginBottom" style="margin-right: 12px; font-size: 12px;"><a><i class="fa-regular fa-xmark" style="margin-right: 4px;"></i>Clear</a></p> 22649 </div> 22650 <template v-if="collapsible_menus_open.default_secondary && filtered_default_secondary_options?.length > 0" v-for="tag in filtered_default_secondary_options" :key="tag.id"> 22651 <div @click="clicked_default_tag_secondary(tag)" class="calendar-tag-item flexWrapLeftAlign align-items-center relative pointer" style="display: flex; padding-top: 16px;"> 22652 <label class="calendar-tag-item flexWrapLeftAlign align-items-center" style="display: flex; align-items: center; flex: 1;"> 22653 <div class="custom-checkbox" :style="{ backgroundColor: ui_tag_ids_default_secondary && ui_tag_ids_default_secondary[tag.id] ? (tag.color || '#5774f5') : '', border: '2px solid ' + (tag.color || '#5774f5') }"> 22654 <i class="fa-regular fa-check" style="color: white; position: absolute; top: 2px; left: 2px; font-size: 12px;"></i> 22655 </div>
22656 <span class="calendar-tag-name" style="flex: 1 1 auto; word-break: break-word;">{{ tag.label }}</span> 22657 </label> 22658 <div class="calendar-tag-more mobile-version" style="margin-left: 4px; flex-shrink: 0; padding-left: 4px; padding-right: 4px; z-index: 3;" @click.stop="toggleTagMenu(tag, $event)"> 22659 <i class="fa-regular fa-ellipsis-vertical"></i> 22660 </div> 22661 <div v-if="open_menu_for_tag_id === tag.id" class="tag-context-menu" @click.stop> 22662 <button type="button" class="tag-context-menu-item" @click.stop="closeTagMenu(); isolate_default_tag_secondary(tag)">Display this only</button> 22663 </div> 22664 </div> 22665 </template> 22666 </div> 22667 <!-- Other custom calendars (event tags) --> 22668 <div v-if="has_other_calendar_filters" class="marginTop"> 22669 <h4 class="smallBlockLetters leftText tinyMarginBottom pointer noWrap" @click="collapsible_menus_open.custom = !collapsible_menus_open.custom"> 22670 {{ calendar_tag_label }} 22671 <i 22672 v-if="can_edit_tags" 22673 class="fa-regular fa-plus pointer" 22674 style="margin-left: 6px; margin-right: 3px;" 22675 @click.stop="clicked_add_calendar_tag()" 22676 ></i> 22677 <i 22678 class="fa fa-chevron-down rotate-chevron" 22679 :class="{ down: collapsible_menus_open.custom }" 22680 style="margin-left: 6px;" 22681 ></i> 22682 </h4> 22683 <div v-if="collapsible_menus_open.custom && loading_parent.calendar_tags" class="coming-soon">xxx</div> 22684 <div v-if="collapsible_menus_open.custom && !loading_parent.calendar_tags" class="calendar-filter-search-wrap tinyMarginBottom"> 22685 <div 22686 class="calendar-filter-search-container relative" 22687 :class="{ 'active': filter_search_custom_focused }" 22688 > 22689 <i class="fa-light fa-magnifying-glass calendar-filter-search-icon" style="z-index: 10;"></i> 22690 <input 22691 class="calendar-filter-search-input" 22692 :placeholder="'Search tags'" 22693 v-model="filter_search_custom" 22694 @focus="filter_search_custom_focused = true" 22695 @blur="filter_search_custom_focused = false" 22696 autocomplete="off" 22697 autocorrect="off" 22698 autocapitalize="none" 22699 spellcheck="false" 22700 > 22701 </div> 22702 </div> 22703 <div v-if="collapsible_menus_open.custom && !loading_parent.calendar_tags" class="flexWrapLeftAlign align-items-center tinyMarginBottom"> 22704 <p @click="select_custom_tags()" class="smallBlockLetters tinyMarginBottom" style="margin-right: 12px; font-size: 12px;"> 22705 <a><i class="fa-regular fa-check" style="margin-right: 4px;"></i>{{ custom_select_all_label }}</a> 22706 </p> 22707 <p 22708 v-if="has_custom_search || has_any_custom_filters_selected" 22709 @click="clear_custom_tags()" 22710 class="smallBlockLetters tinyMarginBottom" 22711 style="margin-right: 12px; font-size: 12px;" 22712 > 22713 <a><i class="fa-regular fa-xmark" style="margin-right: 4px;"></i>{{ custom_clear_label }}</a> 22714 </p> 22715 </div> 22716 22717 <!-- default (no tags) option --> 22718 <div 22719 v-if="show_catch_all_custom_tags_option && collapsible_menus_open.custom && show_no_tags_in_custom" 22720 @click="clicked_catch_all_custom_tag()" 22721 class="calendar-tag-item flexWrapLeftAlign align-items-center tinyPaddingTop relative pointer" 22722 style="display: flex;" 22723 > 22724 <label class="calendar-tag-item flexWrapLeftAlign align-items-center" style="display: flex; align-items: center; flex: 1;"> 22725 <div 22726 class="custom-checkbox" 22727 :style="{ 22728 backgroundColor: ui_catch_all_custom_tag_selected 22729 ? '#5774f5' 22730 : '', 22731 border: '2px solid #5774f5' 22732 }" 22733 > 22734 <i class="fa-regular fa-check" 22735 v-if="ui_catch_all_custom_tag_selected" 22736 style="color: white; position: absolute; top: 2px; left: 2px; font-size: 12px;" 22737 ></i> 22738 </div>
22739 <span class="calendar-tag-name" style="flex: 1 1 auto; word-break: break-word;"> 22740 No tags 22741 </span> 22742 </label> 22743 <!-- Tag context menu trigger --> 22744 <div 22745 class="calendar-tag-catch-all mobile-version" 22746 style="margin-left: 4px; flex-shrink: 0; padding-left: 4px; padding-right: 4px; z-index: 3;" 22747 @click.stop="toggleTagMenu({id: 'catch_all_custom'}, $event)" 22748 > 22749 <i class="fa-regular fa-ellipsis-vertical"></i> 22750 </div> 22751 <!-- Tag context menu dropdown --> 22752 <div 22753 v-if="open_menu_for_tag_id === 'catch_all_custom'" 22754 class="tag-context-menu" 22755 @click.stop 22756 > 22757 <button 22758 type="button" 22759 class="tag-context-menu-item" 22760 @click.stop="closeTagMenu(); isolate_catch_all_custom()" 22761 > 22762 Display this only 22763 </button> 22764 </div> 22765 </div> 22766 <!-- end default (no tags) option --> 22767 22768 <template 22769 v-if="collapsible_menus_open.custom && !loading_parent.calendar_tags && filtered_custom_tags && filtered_custom_tags.length > 0" 22770 v-for="tag in filtered_custom_tags" 22771 :key="tag.id" 22772 > 22773 <div v-if="tag" @click="clicked_calendar_tag(tag)" class="calendar-tag-item flexWrapLeftAlign align-items-center relative pointer" style="display: flex; padding-top: 16px"> 22774 <label class="calendar-tag-item flexWrapLeftAlign align-items-center" style="display: flex; align-items: center; flex: 1;"> 22775 <div 22776 class="custom-checkbox" 22777 :style="{ 22778 backgroundColor: ui_tag_ids_custom && ui_tag_ids_custom[tag.id] 22779 ? (tag.get('hex_value') || '#5774f5') 22780 : '', 22781 border: '2px solid ' + (tag.get('hex_value') || '#5774f5') 22782 }" 22783 > 22784 <i class="fa-regular fa-check" 22785 v-if="ui_tag_ids_custom && ui_tag_ids_custom[tag.id]" 22786 style="color: white; position: absolute; top: 2px; left: 2px; font-size: 12px;" 22787 ></i> 22788 </div>
22789 <span class="calendar-tag-name" style="flex: 1 1 auto; word-break: break-word;"> 22790 {{ tag.get('name') }} 22791 </span> 22792 </label> 22793 <!-- Tag context menu trigger (mobile)--> 22794 <div 22795 class="calendar-tag-more mobile-version" 22796 style="margin-left: 4px; flex-shrink: 0; padding-left: 4px; padding-right: 4px; z-index: 3;" 22797 @click.stop="toggleTagMenu(tag, $event)" 22798 > 22799 <i class="fa-regular fa-ellipsis-vertical"></i> 22800 </div> 22801 <!-- Tag context menu dropdown --> 22802 <div 22803 v-if="open_menu_for_tag_id === tag.id" 22804 class="tag-context-menu" 22805 :class="{'tag-context-menu--above': isLastTag(tag)}" 22806 @click.stop 22807 > 22808 <button 22809 type="button" 22810 class="tag-context-menu-item" 22811 @click.stop="closeTagMenu(); isolate_calendar_tag(tag)" 22812 > 22813 Display this only 22814 </button> 22815 <button 22816 v-if="can_edit_tags" 22817 type="button" 22818 class="tag-context-menu-item tag-context-menu-item-danger" 22819 @click.stop="closeTagMenu(); clicked_remove_tag(tag)" 22820 > 22821 Remove tag 22822 </button> 22823 </div> 22824 </div> 22825 </template> 22826 22827 </div> 22828 </div> 22829 </div> 22830 </div> 22831 </aside> 22832 </transition> 22833 22834 <div v-if="show_calendar_tags" class="overlay-backdrop showOnMobile" @click="$emit('close')"></div> 22835 </div> 22836 </teleport> 22837</script>
22837 22838
22839<script> 22840 app_content.component("booking-calendar-filters", { 22841 template: "#template-booking-calendar-filters", 22842 data(){ 22843 return { 22844 collapsible_menus_open: { 22845 default_primary: true, 22846 default_secondary: true, 22847 custom: true, 22848 }, 22849 v_model_view_preference: this.view_preference || 'Month', 22850 open_menu_for_tag_id: null, 22851 filter_search_default: '', 22852 filter_search_custom: '', 22853 filter_search_default_secondary: '', 22854 filter_search_default_focused: false, 22855 filter_search_custom_focused: false, 22856 filter_search_default_secondary_focused: false, 22857 } 22858 }, 22859 props: { 22860 show_calendar_tags: Boolean, 22861 default_calendar_tags: Object, 22862 calendar_tags: Array, 22863 selected_tags: Object, 22864 catch_all_custom_tag_selected: Boolean, 22865 ui_tag_ids_default: Object, 22866 ui_tag_ids_custom: Array, 22867 ui_catch_all_custom_tag_selected: Boolean, 22868 show_catch_all_custom_tags_option: { 22869 type: Boolean, 22870 default: false, 22871 }, 22872 default_calendar_tags_secondary: Object, 22873 ui_tag_ids_default_secondary: Object, 22874 collapsible_menus_open: Object, 22875 loading_parent: Object, 22876 calendar_type: String, 22877 view_only: Boolean, 22878 view_preference: String, 22879 }, 22880 mounted(){ 22881 this.v_model_view_preference = this.view_preference || 'Month'; 22882 document.addEventListener('click', this.handleClickOutside); 22883 }, 22884 beforeDestroy(){ 22885 document.removeEventListener('click', this.handleClickOutside); 22886 }, 22887 methods: { 22888 add_event(){ 22889 this.$emit('add_event'); 22890 }, 22891 isLastTag(tag){ 22892 if (!this.calendar_tags || this.calendar_tags.length === 0 || !tag) { 22893 return false; 22894 } 22895 const len = this.calendar_tags.length; 22896 const last = this.calendar_tags[len - 1]; 22897 const secondLast = this.calendar_tags[len - 2]; 22898 return (last && last.id === tag.id) || (secondLast && secondLast.id === tag.id); 22899 }, 22900 toggleTagMenu(tag, event){ 22901 if (event && event.stopPropagation) { 22902 event.stopPropagation(); 22903 } 22904 // Toggle the menu for this tag; close if it's already open 22905 this.open_menu_for_tag_id = this.open_menu_for_tag_id === tag.id ? null : tag.id; 22906 }, 22907 closeTagMenu(){ 22908 this.open_menu_for_tag_id = null; 22909 }, 22910 handleClickOutside(){ 22911 // Close any open menu when clicking outside 22912 if (this.open_menu_for_tag_id !== null) { 22913 this.open_menu_for_tag_id = null; 22914 } 22915 }, 22916 clicked_default_tag(tag) { 22917 this.$emit('clicked_default_tag', tag); 22918 }, 22919 clicked_catch_all_custom_tag(){ 22920 this.$emit('clicked_catch_all_custom_tag'); 22921 }, 22922 clicked_calendar_tag(tag) { 22923 this.$emit('clicked_calendar_tag', tag); 22924 }, 22925 clicked_add_calendar_tag(){ 22926 this.$emit('clicked_add_calendar_tag'); 22927 }, 22928 clicked_remove_tag(tag) { 22929 this.$emit('clicked_remove_tag', tag); 22930 }, 22931 changeViewPreference(event){ 22932 this.$emit('change_view_preference', event ); 22933 }, 22934 change_show_calendar_tags(){ 22935 this.$emit('change_show_calendar_tags'); 22936 }, 22937 isolate_default_tag(tag){ 22938 this.$emit('isolate_default_tag', tag); 22939 }, 22940 clicked_default_tag_secondary(tag) { 22941 this.$emit('clicked_default_tag_secondary', tag); 22942 }, 22943 select_default_tags_secondary() { 22944 var q = (this.filter_search_default_secondary || '').trim(); 22945 if (q && this.filtered_default_secondary_options && this.filtered_default_secondary_options.length) { 22946 this.$emit('select_visible_default_tags_secondary', this.filtered_default_secondary_options.map((t)=>{ return t.id; })); 22947 } else { 22948 this.$emit('select_all_default_tags_secondary'); 22949 } 22950 }, 22951 clear_default_tags_secondary() { 22952 var q = (this.filter_search_default_secondary || '').trim(); 22953 if (q && this.filtered_default_secondary_options && this.filtered_default_secondary_options.length) { 22954 this.$emit('clear_visible_default_tags_secondary', this.filtered_default_secondary_options.map((t)=>{ return t.id; })); 22955 } else { 22956 this.$emit('clear_all_default_tags_secondary'); 22957 } 22958 }, 22959 clear_visible_default_tags_secondary() { 22960 var ids = (this.filtered_default_secondary_options || []).map((t)=>{ return t.id; }); 22961 this.$emit('clear_visible_default_tags_secondary', ids); 22962 }, 22963 isolate_default_tag_secondary(tag) { 22964 this.$emit('isolate_default_tag_secondary', tag); 22965 }, 22966 isolate_calendar_tag(tag){ 22967 this.$emit('isolate_calendar_tag', tag); 22968 }, 22969 select_all_custom_tags(){ 22970 this.$emit('select_all_custom_tags'); 22971 }, 22972 select_default_tags(){ 22973 var q = (this.filter_search_default || '').trim(); 22974 if (q && this.filtered_default_options && this.filtered_default_options.length) { 22975 var visibleIds = this.filtered_default_options.map((t)=>{ return t.id; }); 22976 this.$emit('select_visible_default_tags', visibleIds); 22977 } else { 22978 this.$emit('select_all_default_tags'); 22979 } 22980 }, 22981 clear_default_tags(){ 22982 var q = (this.filter_search_default || '').trim(); 22983 if (q && this.filtered_default_options && this.filtered_default_options.length) { 22984 var visibleIds = this.filtered_default_options.map((t)=>{ return t.id; }); 22985 this.$emit('clear_visible_default_tags', visibleIds); 22986 } else { 22987 this.$emit('clear_all_default_tags'); 22988 } 22989 }, 22990 select_custom_tags(){ 22991 var q = (this.filter_search_custom || '').trim(); 22992 if (q) { 22993 var visibleIds = (this.filtered_custom_tags || []).map((t)=>{ return t.id; }); 22994 if (this.show_no_tags_in_custom) { visibleIds.push('catch_all_custom'); } 22995 this.$emit('select_visible_custom_tags', visibleIds); 22996 } else { 22997 this.$emit('select_all_custom_tags'); 22998 } 22999 }, 23000 clear_custom_tags(){ 23001 var q = (this.filter_search_custom || '').trim(); 23002 if (q) { 23003 var visibleIds = (this.filtered_custom_tags || []).map((t)=>{ return t.id; }); 23004 if (this.show_no_tags_in_custom) { visibleIds.push('catch_all_custom'); } 23005 this.$emit('clear_visible_custom_tags', visibleIds); 23006 } else { 23007 this.$emit('clear_all_custom_tags'); 23008 } 23009 }, 23010 select_all_default_tags(){ 23011 this.$emit('select_all_default_tags'); 23012 }, 23013 clear_all_default_tags(){ 23014 this.$emit('clear_all_default_tags'); 23015 }, 23016 isolate_catch_all_custom(){ 23017 this.$emit('isolate_catch_all_custom'); 23018 } 23019 }, 23020 computed: { 23021 show_other_filters(){ 23022 // member facing calendars only show if we have tags 23023 if(this.calendar_type && (this.calendar_type == 'member_portal' || this.calendar_type == 'web_component' )){ 23024 //only show if we have tags
23025 return this.has_other_calendar_filters; 23026 } 23027 //admin calendars always show 23028 //...since they need to be able to add tags 23029 return true; 23030 }, 23031 has_other_calendar_filters(){ 23032 if(!this.calendar_tags || this.calendar_tags.length == 0){ 23033 return false; 23034 } 23035 return true; 23036 }, 23037 can_edit_tags(){ 23038 if(this.view_only){ 23039 return false; 23040 } 23041 if(this.calendar_type && ( this.calendar_type == 'member_portal' || this.calendar_type == 'web_component' || this.calendar_type == 'events_primary' )){ 23042 //member facing calendars 23043 // also the primary events calendar since it shows ALL event tags 23044 return false; 23045 } 23046 return true; 23047 }, 23048 calendar_tag_label(){ 23049 if(this.calendar_type && (this.calendar_type == 'member_portal' || this.calendar_type == 'web_component' || this.calendar_type == 'events_primary' )){ 23050 return "Event tags"; 23051 } 23052 return "Other calendars"; 23053 }, 23054 default_search_placeholder(){ 23055 var title = this.default_calendar_tags?.title || 'event types'; 23056 if(title.toLowerCase().trim() === 'type'){ 23057 return 'Search event types'; 23058 }else{ 23059 return 'Search ' + title.toLowerCase(); 23060 } 23061 }, 23062 has_any_default_filters_selected(){ 23063 var defaultSelected = (this.ui_tag_ids_default && typeof this.ui_tag_ids_default === 'object') ? Object.keys(this.ui_tag_ids_default).length : 0; 23064 return defaultSelected > 0; 23065 }, 23066 has_any_custom_filters_selected(){ 23067 var customSelected = (this.ui_tag_ids_custom && typeof this.ui_tag_ids_custom === 'object') ? Object.keys(this.ui_tag_ids_custom).length : 0; 23068 if (customSelected > 0) { return true; } 23069 if (this.ui_catch_all_custom_tag_selected) { return true; } 23070 return false; 23071 }, 23072 has_default_search(){ 23073 return (this.filter_search_default || '').trim().length > 0; 23074 }, 23075 has_custom_search(){ 23076 return (this.filter_search_custom || '').trim().length > 0; 23077 }, 23078 filtered_default_options(){ 23079 var options = this.default_calendar_tags?.options || []; 23080 var q = (this.filter_search_default || '').trim().toLowerCase(); 23081 if (!q) { return options; } 23082 if (typeof fuzzysort !== 'undefined') { 23083 var matches = fuzzysort.go(q, options, { keys: ['label'], threshold: -1000, limit: 100, allowTypo: false }); 23084 return matches ? matches.map((m)=>{ return m.obj; }) : []; 23085 } 23086 return options.filter((opt)=>{ return (opt.label || '').toLowerCase().indexOf(q) !== -1; }); 23087 }, 23088 filtered_default_secondary_options(){ 23089 var options = this.default_calendar_tags_secondary?.options || []; 23090 var q = (this.filter_search_default_secondary || '').trim().toLowerCase(); 23091 if (!q) { return options; } 23092 if (typeof fuzzysort !== 'undefined') { 23093 var matches = fuzzysort.go(q, options, { keys: ['label'], threshold: -1000, limit: 100, allowTypo: false }); 23094 return matches ? matches.map((m)=>{ return m.obj; }) : []; 23095 } 23096 return options.filter((opt)=>{ return (opt.label || '').toLowerCase().indexOf(q) !== -1; }); 23097 }, 23098 has_any_default_secondary_filters_selected(){ 23099 var o = this.ui_tag_ids_default_secondary && typeof this.ui_tag_ids_default_secondary === 'object' ? this.ui_tag_ids_default_secondary : {}; 23100 return Object.keys(o).filter((k)=>{ return o[k]; }).length > 0; 23101 }, 23102 default_select_all_label(){ 23103 if (!this.has_default_search || !this.filtered_default_options) { return 'Select all'; } 23104 var n = this.filtered_default_options.length; 23105 return n ? 'Select all (' + n + ')' : 'Select all'; 23106 }, 23107 default_clear_label(){ 23108 if (!this.has_default_search || !this.filtered_default_options) { return 'Clear'; } 23109 var n = this.filtered_default_options.length; 23110 return n ? 'Clear (' + n + ')' : 'Clear'; 23111 }, 23112 _custom_searchables(){ 23113 var list = []; 23114 if (this.calendar_tags && this.calendar_tags.length) {
23115 this.calendar_tags.forEach((tag)=>{ 23116 list.push({ name: (tag.get ? tag.get('name') : (tag.name || '') || ''), tag: tag }); 23117 }); 23118 } 23119 if (this.show_catch_all_custom_tags_option) { 23120 list.push({ name: 'No tags', tag: null }); 23121 } 23122 return list; 23123 }, 23124 filtered_custom_tags(){ 23125 var searchables = this._custom_searchables; 23126 var q = (this.filter_search_custom || '').trim().toLowerCase(); 23127 if (!q) { 23128 return this.calendar_tags || []; 23129 } 23130 if (typeof fuzzysort !== 'undefined') { 23131 var matches = fuzzysort.go(q, searchables, { keys: ['name'], threshold: -1000, limit: 100, allowTypo: false }); 23132 if (!matches || !matches.length) { return []; } 23133 return matches.map((m)=>{ return m.obj.tag; }).filter(Boolean); 23134 } 23135 return (this.calendar_tags || []).filter((tag)=>{ 23136 var name = (tag.get ? tag.get('name') : (tag.name || '')) || '' || ''; 23137 return name.toLowerCase().indexOf(q) !== -1; 23138 }); 23139 }, 23140 filtered_custom_display_count(){ 23141 var n = (this.filtered_custom_tags || []).length; 23142 if (this.show_no_tags_in_custom) { n += 1; } 23143 return n; 23144 }, 23145 custom_select_all_label(){ 23146 if (!this.has_custom_search) { return 'Select all'; } 23147 var n = this.filtered_custom_display_count; 23148 return n ? 'Select all (' + n + ')' : 'Select all'; 23149 }, 23150 custom_clear_label(){ 23151 if (!this.has_custom_search) { return 'Clear'; } 23152 var n = this.filtered_custom_display_count; 23153 return n ? 'Clear (' + n + ')' : 'Clear'; 23154 }, 23155 show_no_tags_in_custom(){ 23156 if (!this.show_catch_all_custom_tags_option) { return false; } 23157 var q = (this.filter_search_custom || '').trim().toLowerCase(); 23158 if (!q) { return true; } 23159 if (typeof fuzzysort !== 'undefined') { 23160 var matches = fuzzysort.go(q, [{ name: 'No tags', tag: null }], { keys: ['name'], threshold: -1000, allowTypo: false }); 23161 return matches && matches.length > 0; 23162 } 23163 return 'no tags'.indexOf(q) !== -1 || q === 'no'; 23164 }, 23165 }, 23166 watch: { 23167 view_preference(newVal) { 23168 this.v_model_view_preference = newVal; 23169 23170 } 23171 } 23172 23173 23174 }); 23175</script>
23175 23176 23177<style scoped> 23178 /* Rotating chevrons */ 23179 .rotate-chevron.down { 23180 transform: rotate(180deg); 23181 } 23182 23183 .calendar-filter-search-wrap { 23184 margin-top: 6px; 23185 } 23186 .calendar-filter-search-container { 23187 display: flex; 23188 align-items: center; 23189 border-radius: 5px; 23190 border: 1px solid #e5eaec; 23191 overflow: hidden; 23192 margin-bottom: 2px; 23193 } 23194 .calendar-filter-search-container.active { 23195 border: 1px solid #4a8be6; 23196 } 23197 .calendar-filter-search-input { 23198 flex: 1; 23199 padding: 8px 8px 8px 30px; 23200 border: none; 23201 outline: none; 23202 } 23203 .calendar-filter-search-icon { 23204 font-size: 12px; 23205 position: absolute; 23206 left: 10px; 23207 top: 50%; 23208 transform: translateY(-50%); 23209 color: #52596f; 23210 } 23211 23212 .calendar-tag-picker.flexible-height-version{ 23213 max-height: 100vh; 23214 } 23215 23216 .custom-checkbox { 23217 width: 18px; 23218 height: 18px; 23219 display: inline-block; 23220 position: relative; 23221 align-items: center; 23222 border-radius: 4px; 23223 margin-right: 8px; 23224 box-sizing: border-box; 23225 vertical-align: middle; 23226 cursor: pointer; 23227 } 23228 23229 /* Improved calendar tag item for wrapping and left alignment */ 23230 .calendar-tag-item { 23231 /* align-items: flex-start !important; */ 23232 flex-direction: row; 23233 flex-wrap: wrap; 23234 white-space: normal; 23235 text-align: left; 23236 line-height: 1.4; 23237 } 23238 23239 .calendar-tag-name { 23240 display: inline-block; 23241 word-break: break-word; 23242 max-width: 80%; 23243 } 23244 23245 .calendar-tag-item:hover .calendar-tag-remove-icon { 23246 visibility: visible; 23247 } 23248 23249 .calendar-tag-remove-icon { 23250 visibility: hidden; 23251 cursor: pointer; 23252 color:#788097; 23253 } 23254 23255 /* MOBILE */ 23256 .mobile-filter-overlay{ 23257 position: fixed; 23258 top: 0; 23259 right: 0; 23260 height: 100vh; 23261 width: 65vw; 23262 min-width: 250px; 23263 max-width: 480px; 23264 background: #fff; 23265 z-index: 1002; 23266 box-shadow: 0 0 20px rgba(0,0,0,0.2); 23267 display: flex; 23268 flex-direction: column; 23269 border-radius: 5px 0 0 5px; 23270 } 23271 .overlay-backdrop{ 23272 position: fixed; 23273 inset: 0; 23274 background: rgba(0,0,0,0.4); 23275 z-index: 1001; 23276 } 23277 /* Slide-in-from-right transition */ 23278 .slide-in-right-enter-active, .slide-in-right-leave-active{ 23279 transition: transform 200ms ease-out; 23280 } 23281 .slide-in-right-enter-from, .slide-in-right-leave-to{ 23282 transform: translateX(100%); 23283 } 23284 23285 .tag-context-menu{ 23286 position: absolute; 23287 top: 100%; 23288 right: 0; 23289 margin-top: 4px; 23290 background: #fff; 23291 border: 1px solid #e0e4ee; 23292 border-radius: 6px; 23293 box-shadow: 0 4px 12px rgba(0,0,0,0.08); 23294 min-width: 160px; 23295 z-index: 10; 23296 padding: 4px 0; 23297 } 23298 23299 .tag-context-menu--above{ 23300 top: auto; 23301 bottom: 100%; 23302 margin-top: 0; 23303 margin-bottom: -5px; 23304 } 23305 23306 .tag-context-menu-item{ 23307 width: 100%; 23308 padding: 6px 10px; 23309 background: transparent; 23310 border: none; 23311 text-align: left; 23312 font-size: 16px; 23313 cursor: pointer; 23314 color: #333; 23315 white-space: nowrap; 23316 } 23317 23318 .tag-context-menu-item:hover{ 23319 background: #f3f5fb; 23320 } 23321 23322 .tag-context-menu-item-danger{ 23323 color: #c0392b; 23324 } 23325</style> 23326
23326<script type="text/html" id="template-booking-calendar-cell"> 23327 <div 23328 class="calendar-cell grid-cell pointer booking-calendar" 23329 :class="{'notCurrentMonth': ! is_current_month, 'today': is_today}" 23330 @click="add_event" 23331 > 23332 <div class="day-info flexWrap"> 23333 <div @click.stop="zoom_in_to_date"> 23334 <div class="cell-day calendarObj">{{ day }}</div> 23335 <div class="day-name">{{ dayOfWeek }}</div> 23336 </div> 23337 </div> 23338 <div class="events"> 23339 <!-- render content for each slot --> 23340 <template 23341 v-for="(slot, index) in slots" 23342 :key="index" 23343 > 23344 <!-- ok, we have an event for this slot--> 23345 <template v-if="slot.event"> 23346 <div 23347 class="event" 23348 :class="[{'all_day': slot.event.isAllDay}, {'partial-day': ! slot.event.isAllDay}]" 23349 @click.stop="clicked_event( slot.event )" 23350 :style="return_multi_day_event_style( slot.event )" 23351 > 23352 <div 23353 class="event-title-v2 flexNoWrap align-items-center easeSuperFast" 23354 style="font-size:12.5px;" 23355 > 23356 <template 23357 v-if="! slot.event.isAllDay" 23358 > 23359 <!-- dot --> 23360 <i 23361 style="padding-right:6px;padding-left:2px;font-size:8px;" 23362 class="fa-solid fa-circle hideOnMobile" 23363 :style="{color: slot.event.color}" 23364 ></i> 23365 <!-- start time --> 23366 <span v-if="! slot.event.isAllDay" class="event-time-v2 hideOnMobile">{{ formatEventTime( slot.event.ts ) }}</span> 23367 </template> 23368 <!-- event title --> 23369 {{ slot.event.title }} 23370 </div> 23371 </div> 23372 </template> 23373 <template v-else-if="should_hold_slot(slot, index)"> 23374 <!-- Leave it blank if this slot is used later in the week --> 23375 <div 23376 class="event placeholder" 23377 > 23378 <!-- add a placeholder if there's an event in this slot but it doesn't start on this day --> 23379 <div 23380 class="event-title-v2 flexNoWrap align-items-center easeSuperFast" 23381 style="font-size:12.5px; visibility: hidden;" 23382 > </div> 23383 <!-- adds a hidden, non-breaking space --> 23384 </div> 23385 </template> 23386 23387 <!-- no event for this slot --> 23388 <template v-else> 23389 <div 23390 class="event partial-day" 23391 ></div> 23392 </template> 23393 23394 </template> 23395 23396 <!-- "+ x more" Label for Overflow --> 23397 <div 23398 v-if="overflowCount > 0" 23399 class="more-events-label" 23400 @click.stop="clicked_show_more_events" 23401 > 23402 + {{ overflowCount }} more 23403 </div> 23404 </div> 23405 </div> 23406</script>
23406 23407 23408<style scoped> 23409 23410 .event-time-v2{ 23411 font-size: 11px; 23412 padding-right: 4px;; 23413 } 23414 23415 .more-events-label { 23416 font-size: 12px; 23417 cursor: pointer; 23418 padding-left: 6px; 23419 padding-right: 6px; 23420 text-align: left; 23421 padding-top: 2px; 23422 padding-bottom: 2px; 23423 cursor: pointer; 23424 border-radius: 5px; 23425 } 23426 23427 .event-title-v2{ 23428 font-size: 12.5px; 23429 white-space: nowrap; 23430 padding-top: 2px; 23431 padding-bottom: 2px;; 23432 padding-left: 6px; 23433 padding-right: 6px; 23434 cursor: pointer; 23435 border-radius: 5px; 23436 } 23437 23438 .more-events-label:hover, 23439 .partial-day .event-title-v2:hover{ 23440 background-color: #e4ebf1; 23441 } 23442 .booking-calendar .event.partial-day { 23443 /* color: #fff; */ 23444 border-radius: 8px; 23445 } 23446 23447 .cell-day.calendarObj{ 23448 min-width: 22px; 23449 max-width: 22px; 23450 min-height: 22px; 23451 max-height: 22px; 23452 margin: 6px !important; 23453 font-size: 12px; 23454 } 23455 .grid-cell.today .cell-day{ 23456 background-color: var(--custom-mp-accent, #4a56f8); 23457 color: rgb(255, 255, 255); 23458 } 23459 23460 23461 /* Event List Styling */ 23462 .events { 23463 display: flex; 23464 flex-direction: column; 23465 gap: 4px; /* Space between events */ 23466 } 23467 23468 .event{ 23469 font-size: 12px; 23470 font-weight: 400; 23471 overflow: hidden; 23472 white-space: nowrap; 23473 text-align: left; 23474 } 23475 23476 /* Individual Event Styling */ 23477 .event.all_day { 23478 background-color: var(--custom-mp-accent, #4a56f8); 23479 color: #fff; 23480 border-radius: 8px; 23481 width: calc(100% - 4px); 23482 } 23483 23484</style> 23485
23486<script> 23487 23488 /* ----- COMPONENT ----- */ 23489 app_content.component("booking-calendar-cell", { 23490 template: "#template-booking-calendar-cell", 23491 data() { 23492 return {} 23493 }, 23494 props: { 23495 date: Object, 23496 day: Number, 23497 day_of_week: String, 23498 date_components: Object, 23499 events: Array, 23500 slots: Array, 23501 timezone: String, 23502 is_current_month: Boolean, 23503 days_array: Array, 23504 calendar_type: String 23505 }, 23506 mounted(){ 23507 }, 23508 methods: { 23509 add_event(){ 23510 var date_components = return_date_components_v2({ 23511 date: this.date, 23512 timezone: this.timezone 23513 }) 23514 this.$emit("add_event", date_components); 23515 }, 23516 zoom_in_to_date(){ 23517 this.$emit("zoom_in_to_date", this.date); 23518 }, 23519 formatEventTime(timestamp) { 23520 const time = moment.tz(timestamp, this.timezone); 23521 if (time.minute() === 0) { 23522 return time.format("ha"); 23523 } 23524 return time.format("h:mma"); 23525 }, 23526 is_multi_day_event( event ){ 23527 return moment.tz(event.ts, this.timezone).isBefore(moment.tz(event.end_ts, this.timezone), 'day'); 23528 }, 23529 // Updated to fix last day rendering & timezone issues for multi-day events 23530 return_multi_day_event_style(event) { 23531 const cellDate = moment.tz(this.date, this.timezone).startOf('day'); 23532 const eventStart = moment.tz(event.ts, this.timezone).startOf('day'); 23533 const eventEnd = moment.tz(event.end_ts, this.timezone).startOf('day'); 23534 23535 // Skip styling if the event doesn't span this day 23536 if (cellDate.isBefore(eventStart) || cellDate.isAfter(eventEnd)) { 23537 return { display: 'none' }; 23538 } 23539 23540 const isFirstDay = cellDate.isSame(eventStart, 'day'); 23541 const isStartOfWeek = cellDate.day() === 0; 23542 const textColor = this.adaptiveTextColorFromColors(event.bgColors); 23543 23544 if (isFirstDay || isStartOfWeek) { 23545 const remainingDays = eventEnd.diff(cellDate, 'days') + 1; 23546 const span = Math.min(remainingDays, 7 - cellDate.day()); 23547 23548 return { 23549 width: `calc(${span * 100}% - 4px)`, 23550 zIndex: 1, 23551 color: textColor, 23552 background: event.bgColors?.length 23553 ? `repeating-linear-gradient( 23554 -45deg, 23555 ${event.bgColors.map((color, i) => `${color} ${i * 20}px, ${color} ${(i + 1) * 20}px`).join(",")} 23556 )` 23557 : event.bgColor || "#4a56f8" 23558 }; 23559 } 23560 23561 return { 23562 width: 'calc(100% - 4px)', 23563 background: 'transparent', 23564 visibility: 'hidden' 23565 }; 23566 }, 23567 remaining_days_in_event(event) { 23568 const cellDate = moment.tz(this.date, this.timezone).startOf("day"); 23569 const eventEnd = moment.tz(event.end_ts, this.timezone).startOf("day"); 23570 if (cellDate.isAfter(eventEnd)) { 23571 return 0; 23572 } 23573 return eventEnd.diff(cellDate, "days") + 1; 23574 }, 23575 return_number_of_days_for_event( event ){ 23576 let start = moment.tz(event.ts, this.timezone); 23577 let end = moment.tz(event.end_ts, this.timezone); 23578 //continue 23579 return end.diff(start, 'days') + 1; 23580 }, 23581 is_first_day_of_event(event) { 23582 // Compare the event start date with the cell's date 23583 const eventStart = moment.tz(event.ts, this.timezone).startOf("day"); 23584 const cellDate = moment.tz(this.date, this.timezone).startOf("day"); 23585 return eventStart.isSame(cellDate, "day"); 23586 }, 23587 clicked_event(event) { 23588 // Handle event click 23589 this.$emit("clicked_event", event); 23590 }, 23591 clicked_show_more_events() { 23592 // Handle "+ x more" click (e.g., show a modal or expand view
23592) 23593 this.$emit("clicked_show_more_events", this.date, this.day_of_week, this.events); 23594 }, 23595 should_hold_slot(slot, index) { 23596 const usedSlots = this.return_slots_in_use_for_week(slot, index); 23597 return usedSlots.includes(index); 23598 }, 23599 return_slots_in_use_for_week(slot, index) { 23600 const startOfWeek = moment.tz(this.date, this.timezone).startOf("week"); 23601 const endOfWeek = moment.tz(this.date, this.timezone).endOf("week"); 23602 23603 const slotsUsed = new Set(); 23604 23605 this.days_array.forEach(day => { 23606 const dayMoment = moment.tz(day.date, this.timezone).startOf("day"); 23607 23608 // Only consider days in the current week 23609 if (dayMoment.isBetween(startOfWeek, endOfWeek, null, '[]')) { 23610 if (Array.isArray(day.slots)) { 23611 day.slots.forEach(slotObj => { 23612 if (slotObj.event) { 23613 slotsUsed.add(slotObj.slot); 23614 } 23615 }); 23616 } 23617 } 23618 }); 23619 return Array.from(slotsUsed); 23620 }, 23621 // --- text color helpers --- 23622 computeLuminance(hex) { 23623 if (!hex) return 1; // treat as light 23624 const m = String(hex).trim(); 23625 let r, g, b; 23626 if (/^#?[0-9a-fA-F]{3}$/.test(m)) { 23627 const s = m.replace('#',''); 23628 r = parseInt(s[0] + s[0], 16); 23629 g = parseInt(s[1] + s[1], 16); 23630 b = parseInt(s[2] + s[2], 16); 23631 } else { 23632 const s = m.replace('#','').slice(0,6); 23633 r = parseInt(s.substr(0,2), 16); 23634 g = parseInt(s.substr(2,2), 16); 23635 b = parseInt(s.substr(4,2), 16); 23636 } 23637 const toLin = (c) => { 23638 c /= 255; 23639 return c <= 0.03928 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4); 23640 }; 23641 return 0.2126*toLin(r) + 0.7152*toLin(g) + 0.0722*toLin(b); // 0=dark, 1=light 23642 }, 23643 adaptiveTextColorFromColors(colors, lightText = '#ffffff', darkText = '#2c2e30') { 23644 const arr = (colors && colors.length) ? colors : []; 23645 if (!arr.length) { 23646 const defaultBg = '#4a56f8'; 23647 return this.computeLuminance(defaultBg) < 0.5 ? lightText : darkText; 23648 } 23649 let darkCount = 0; 23650 for (let i = 0; i < arr.length; i++) { 23651 const L = this.computeLuminance(arr[i]); 23652 if (!isNaN(L) && L < 0.5) darkCount++; 23653 } 23654 const mostlyDark = darkCount >= Math.ceil(arr.length / 2); 23655 return mostlyDark ? lightText : darkText; 23656 }, 23657 }, 23658 computed:{ 23659 is_today(){ 23660 return moment.tz(new Date(), this.timezone).isSame(moment.tz(this.date, this.timezone), 'day'); 23661 }, 23662 overflowCount() { 23663 // Calculate the number of hidden events 23664 let visible_events = 0; 23665 this.slots.forEach(slot => { 23666 if( slot.event ){ 23667 visible_events ++; 23668 } 23669 }); 23670 return this.events.length - visible_events; 23671 }, 23672 days_until_end_of_week(){ 23673 let start = moment.tz(this.date, this.timezone); 23674 let days_until_end_of_week = 7 - start.day(); 23675 return days_until_end_of_week; 23676 }, 23677 is_first_day_of_week(){ 23678 return moment.tz(this.date, this.timezone).day() === 0; 23679 }, 23680 }, 23681 }) 23682</script>
23682
23682<script type="text/html" id="template-booking-calendar-day-column"> 23683 23684 <div class="flexGrowOne"> 23685 <!-- Time Slots Section --> 23686 <div class="time-slots"> 23687 <!-- all day --> 23688 <div 23689 v-if="view_preference == 'Day'" 23690 class="time-slot" 23691 style="height:auto;" 23692 > 23693 <div class="time-label">All day</div> 23694 <div 23695 class="time-content" 23696 style="display: flex;flex-direction: column;gap: 4px; padding: 8px;" 23697 > 23698 <!-- Filter and position events for this hour --> 23699 23700 <div v-for="(event, index) in all_day_events" :key="index" class="event all_day"> 23701 <div 23702 class="event-title-v2 flexNoWrap align-items-center easeSuperFast" 23703 :style="all_day_event_style(event)" 23704 @click="clicked_event(event)" 23705 > 23706 {{ event.title }} 23707 </div> 23708 </div> 23709 23710 </div> 23711 </div> 23712 <!-- hours --> 23713 <div 23714 v-for="hour in 24" 23715 :key="hour" 23716 class="time-slot pointer" 23717 @click="add_event( hour - 1 )" 23718 > 23719 <div 23720 class="time-label" 23721 v-if="view_preference == 'Day'" 23722 >{{ formatHour( hour - 1 ) }}</div> 23723 <div class="time-content"> 23724 <!-- Filter and position events for this hour --> 23725 <div 23726 v-for="(event, index) in filterEventsForHour(hour - 1)" 23727 :key="index" 23728 class="event time-slot-event" 23729 :style="calculateEventStyle(event, index)" 23730 @click.stop="clicked_event( event )" 23731 > 23732 <div class="event-title-v2" style="white-space: initial;">{{ event.title }}</div> 23733 </div> 23734 </div> 23735 </div> 23736 </div> 23737 </div> 23738 23739</script>
23739 23740 23741<style scoped> 23742 23743 .event.time-slot-event { 23744 position: absolute; 23745 left: 5px; /* Align slightly inside the slot */ 23746 right: 5px; 23747 color: white; 23748 border-radius: 4px; 23749 font-size: 12px; 23750 font-weight: 500; 23751 overflow: hidden; 23752 white-space: nowrap; 23753 text-overflow: ellipsis; 23754 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); 23755 z-index: 1; 23756 padding-top: 0px; 23757 padding-bottom: 0px; 23758 padding-left: 8px; 23759 padding-right: 8px; 23760 cursor: pointer; 23761 background-color: #5774f5; /* Primary color */ 23762 } 23763 23764</style> 23765
23766<script> 23767 23768 /* ----- COMPONENT ----- */ 23769 app_content.component("booking-calendar-day-column", { 23770 template: "#template-booking-calendar-day-column", 23771 data() { 23772 return {} 23773 }, 23774 props: { 23775 date_components: Object, 23776 events: Array, 23777 timezone: String, 23778 view_preference: String 23779 }, 23780 mounted(){ 23781 }, 23782 methods: { 23783 add_event( hour ){ 23784 let date_components = {... this.date_components}; 23785 date_components.hour = hour; 23786 date_components.date = this.date_components.day; 23787 this.$emit('add_event', date_components); 23788 }, 23789 clicked_event( event ){ 23790 this.$emit('clicked_event', event); 23791 }, 23792 formatHour(hour) { 23793 const baseDate = moment().startOf('day').hour(hour); 23794 return baseDate.format("h:mm A"); 23795 }, 23796 filterEventsForHour(hour) { 23797 const filtered = this.events.filter((event) => { 23798 if (event.isAllDay) return false; 23799 const eventStart = moment(event.ts).tz(this.timezone); 23800 return eventStart.hour() === hour; 23801 }); 23802 const count = filtered.length; 23803 return filtered.map(event => ({ 23804 ...event, 23805 countForHour: count 23806 })); 23807 }, 23808 all_day_event_style(event){ 23809 return { 23810 "background": event.bgColors && event.bgColors.length > 0 23811 ? `repeating-linear-gradient( 23812 -45deg, 23813 ${event.bgColors.map((color, i) => `${color} ${i * 20}px, ${color} ${(i + 1) * 20}px`).join(",")} 23814 )` 23815 : event.bgColor || "", 23816 }; 23817 }, 23818 calculateEventStyle(event, i) { 23819 const eventStart = moment( event.ts ).tz(this.timezone); 23820 const eventEnd = moment(event.end_ts).tz(this.timezone); 23821 const count = event.countForHour; 23822 23823 // Calculate start and end as a percentage of the hour 23824 const startMinutes = eventStart.minutes(); 23825 const endMinutes = eventEnd.minutes(); 23826 const duration = eventEnd.diff(eventStart, "minutes"); 23827 23828 return { 23829 top: `${(startMinutes / 60) * 100}%`, // Start position 23830 height: `calc(${(duration / 60) * 100}% - 3px)`, // Subtracts 3px for padding 23831 width: `calc(${(1/count) * 100}% - 3px)`, // Subtracts 3px for padding 23832 'margin-left': `calc(${(i/count) * 100}%)`, 23833 'padding-left': '0px', 23834 'padding-right': '0px', 23835 "background": event.bgColors && event.bgColors.length > 0 23836 ? `repeating-linear-gradient( 23837 -45deg, 23838 ${event.bgColors.map((color, i) => `${color} ${i * 20}px, ${color} ${(i + 1) * 20}px`).join(",")} 23839 )` 23840 : event.bgColor || "", 23841 }; 23842 }, 23843 }, 23844 computed:{ 23845 all_day_events(){ 23846 let all_day = this.events.filter( event => event.isAllDay === true ); 23847 return all_day; 23848 }, 23849 }, 23850 }) 23851</script>
23851 23852 23853 23854 23855 23856 23857 23858 <!-- native tab bar --> 23859 <!DOCTYPE html> 23860<html lang="en"> 23861
23861<script type="text/html" id="template-bottom-sheet-overlay"> 23862 23863 <div 23864 class="v-overlay scrollable hugePaddingBottom active" 23865 @touchstart.self="touched_overlay()" 23866 @mousedown.self="touched_overlay()" 23867 @click.self="clicked_overlay()" 23868 :class="[{'noPointer': prevent_close}, {'bottom-sheet-blur': blur_background}]" 23869 > 23870 <div 23871 class="standardOverlayCard centeredBlock bottom-sheet" 23872 :class="[ 23873 {'short': height == 'short' || is_short}, 23874 {'narrow': narrow}, 23875 {'noMaxWidth': full_width} 23876 ]" 23877 > 23878 23879 <!-- generic header content --> 23880 <div class="bottom-sheet-overlay-header"> 23881 23882 <div 23883 v-if="! prevent_close" 23884 class="relative" 23885 > 23886 <div 23887 class="overlay-header-handle-wrap centeredBlock" 23888 @click.stop="toggle_height()" 23889 > 23890 <div class="overlay-header-handle"></div> 23891 </div> 23892 <i 23893 class="overlay-x-button fa-regular fa-xmark pointer" 23894 @click.stop="dismiss_overlay()" 23895 ></i> 23896 </div> 23897 23898 <!-- header content injected here --> 23899 <slot name="header"></slot> 23900 23901 </div> 23902 23903 <!-- body content injected here --> 23904 <slot name="body"></slot> 23905 23906 <!-- footer content injected here --> 23907 <slot name="footer"></slot> 23908 23909 </div> 23910 </div> 23911 23912</script>
23912 23913 23914<style> 23915 23916 .standardOverlayCard.bottom-sheet.narrow{ 23917 max-width: 600px; 23918 } 23919 23920 .standardOverlayCard.bottom-sheet.medium{ 23921 max-width: 800px; 23922 } 23923 23924 .standardOverlayCard.bottom-sheet{ 23925 position: fixed; 23926 bottom: 0px; 23927 top: 50px; 23928 left: 0px; 23929 right: 0px; 23930 z-index: 1001; 23931 background-color: #fff; 23932 box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); 23933 transition: bottom 0.3s cubic-bezier(0.25, 0.1, 0.25, 1); 23934 border-top-left-radius: 10px; 23935 border-top-right-radius: 10px; 23936 padding-bottom: 10px; 23937 padding-top: 10px; 23938 max-width: 1100px; 23939 margin-top: 0px; 23940 border-bottom-left-radius: 0px; 23941 border-bottom-right-radius: 0px; 23942 } 23943 23944 .standardOverlayCard.bottom-sheet.short{ 23945 top: 50%; 23946 } 23947 23948 @media (max-height: 600px) { 23949 23950 .standardOverlayCard.bottom-sheet.short{ 23951 top: 150px; 23952 } 23953 23954 } 23955 23956 .standardOverlayCard.bottom-sheet h3{ 23957 padding-top:0px; 23958 } 23959 23960 .standardOverlayCard.bottom-sheet{ 23961 display: flex; 23962 flex-direction: column; 23963 padding-bottom:0px; 23964 padding-top: 0px; 23965 } 23966 23967 .standardOverlayCard.bottom-sheet .bottom-sheet-overlay-card-header 23968 { 23969 flex: 0 0 auto; /* Fixed height, doesn't grow or shrink */ 23970 } 23971 23972 .standardOverlayCard.bottom-sheet .standardCardFooter{ 23973 position: absolute; 23974 bottom: 0px; 23975 padding-bottom: var(--cs-safe-area-bottom, 10px); 23976 left: 0px; 23977 right: 0px; 23978 background-color: #fff; 23979 border-top: 1px solid #e4ebf1; 23980 z-index: 10; 23981 } 23982 23983 .standardOverlayCard.bottom-sheet .standardCardBody{ 23984 flex: 1 1 auto; /* Flexible height, can grow and shrink */ 23985 padding-bottom: 90px !important; 23986 } 23987 23988 .overlay-header-handle-wrap{ 23989 padding: 10px; 23990 cursor: pointer; 23991 max-width: 120px; 23992 } 23993 .overlay-header-handle{ 23994 width: 70px; 23995 height: 3px; 23996 border-radius: 10px; 23997 background-color: #acb3ba; 23998 margin-left: auto; 23999 margin-right: auto; 24000 margin-top: 8px; 24001 margin-bottom: 8px; 24002 } 24003 .overlay-x-button{ 24004 position: absolute; 24005 right: 14px; 24006 color: #535e6c; 24007 font-size: 20px; 24008 top: 12px; 24009 } 24010</style> 24011
24012<script> 24013 24014 //define the component 24015 app_overlays.component("bottom-sheet-overlay", { 24016 template: "#template-bottom-sheet-overlay", 24017 data() { 24018 return { 24019 height: "tall" 24020 } 24021 }, 24022 props: { 24023 blur_background: { 24024 type: Boolean, 24025 default: false 24026 }, 24027 narrow: { 24028 type: Boolean, 24029 default: false 24030 }, 24031 prevent_close: { 24032 type: Boolean, 24033 default: false 24034 }, 24035 full_width: { 24036 type: Boolean, 24037 default: false 24038 }, 24039 is_short: { 24040 type: Boolean, 24041 default: false 24042 } 24043 }, 24044 mounted(){ 24045 const universalEditOverlay = document.getElementById('overlay_universal-edit'); 24046 if (universalEditOverlay) { 24047 universalEditOverlay.style.zIndex = "1000000"; 24048 } 24049 const membershipValidationOverlay = document.getElementById('overlay_verifyMembership'); 24050 if (membershipValidationOverlay) { 24051 membershipValidationOverlay.style.zIndex = "1000000"; 24052 } 24053 }, 24054 beforeUnmount() { 24055 //reset the z-index 24056 const universalEditOverlay = document.getElementById('overlay_universal-edit'); 24057 if (universalEditOverlay) { 24058 universalEditOverlay.style.zIndex = ""; // Revert to default 24059 } 24060 const membershipValidationOverlay = document.getElementById('overlay_verifyMembership'); 24061 if (membershipValidationOverlay) { 24062 membershipValidationOverlay.style.zIndex = ""; // Revert to default 24063 } 24064 }, 24065 methods: { 24066 dismiss_overlay(){ 24067 this.$emit('dismiss_overlay'); 24068 }, 24069 toggle_height(){ 24070 if( this.height === "tall" ){ 24071 this.height = "short"; 24072 }else{ 24073 this.height = "tall"; 24074 } 24075 }, 24076 clicked_overlay(){ 24077 if( this.prevent_close ){ 24078 return; 24079 } 24080 if (should_close_overlay(this.last_touch)) { 24081 this.dismiss_overlay(); 24082 } 24083 }, 24084 touched_overlay(){ 24085 this.last_touch = new Date().getTime(); 24086 }, 24087 }, 24088 }); 24089 24090 24091</script>
24091 24092 24093<style> 24094 .bottom-sheet-blur{ 24095 backdrop-filter: blur(6px); 24096 } 24097 24098 @keyframes bottom-sheet-fade-in { 24099 from { opacity: 0; } 24100 to { opacity: 1; } 24101 } 24102</style>
24102<script type="text/html" id="template-show-push-notification"> 24103 24104 <bottom-sheet-overlay 24105 @dismiss_overlay="dismiss_overlay()" 24106 > 24107 24108 <!-- header content --> 24109 <template #header> 24110 <div class="flexNoWrap"> 24111 <h3 24112 class="flexGrowOne" 24113 style="padding-bottom:0px;" 24114 > 24115 <p>{{ title }}</p> 24116 <template v-if="has_date"> 24117 <p 24118 class="card-header-subtext pointer" 24119 >{{ date_string }}</p> 24120 </template> 24121 </h3> 24122 </div> 24123 </template> 24124 24125 <!-- body content --> 24126 <template #body> 24127 <div 24128 class="standardCardBody fff scrollable" 24129 > 24130 <p class="p-1" style="margin: 0px 8px;">{{ notification.get('data').alert }}</p> 24131 </div> 24132 </template> 24133 24134 <!-- footer content --> 24135 <template 24136 #footer 24137 > 24138 <div 24139 class="standardCardFooter flexNoWrap" 24140 > 24141 <div class="flexGrowOne"></div> 24142 <button class="marketing-header-button blue easeSuperFast flexWrapLeftAlign align-items-center" @click="dismiss_overlay()" >Done</button> 24143 </div> 24144 </template> 24145 24146 </bottom-sheet-overlay> 24147 24148</script>
24148 24149
24150<script> 24151 24152 function show_push_notification( params ){ 24153 store.components.show_push_notification.notification = params.notification; 24154 store.overlays.show_push_notification = true; 24155 } 24156 24157 //add a value to our global store, specific to this component 24158 store.components["show_push_notification"] = { 24159 notification: null 24160 } 24161 24162 //define the component 24163 app_overlays.component("show-push-notification", { 24164 template: "#template-show-push-notification", 24165 data() { 24166 return store.components["show_push_notification"] 24167 }, 24168 methods: { 24169 dismiss_overlay(){ 24170 store.overlays.show_push_notification = false; 24171 } 24172 }, 24173 computed: { 24174 title(){ 24175 let title = this.notification.get('data').title; 24176 if (title && title.length > 0){ 24177 return title; 24178 } 24179 return "Notification"; 24180 }, 24181 has_date(){ 24182 return this.notification.createdAt !== undefined; 24183 }, 24184 date_string(){ 24185 const createdAt = this.notification.createdAt; 24186 24187 if (! createdAt){ 24188 return createdAt; 24189 } 24190 24191 if ( is_utc_date_today( createdAt ) ){ 24192 return format_utc_date_v2({ 24193 "utcDate": createdAt, 24194 "formatString": "[Today at] h:mm A" 24195 }); 24196 } else { 24197 return format_utc_date_v2({ 24198 "utcDate": createdAt, 24199 "formatString": "ddd M/D h:mm A" 24200 }); 24201 } 24202 } 24203 } 24204 }); 24205 24206</script>
24206
24206<script type="text/html" id="template-native-tab-bar"> 24207 24208 <!-- first, make sure we're in the react native member app --> 24209 <template v-if="is_member_portal || is_react_native_member_app || is_admin_preview"> 24210 24211 <!-- ââ Floating glass tab bar âââââââââââ --> 24212 <div 24213 v-if="show_tab_bar && use_floating_tab_bar" 24214 class="mp-float-tab-bar" 24215 :class="{ 24216 'mp-float-tab-bar--preview': is_admin_preview, 24217 'mp-float-tab-bar--settings-preview': settings_preview_mode 24218 }" 24219 > 24220 <!-- Single sliding pill â moves between tabs via CSS transition --> 24221 <div class="mp-float-tab-pill" :style="float_pill_style"></div> 24222 24223 <template v-if="visible_tabs"> 24224 <div 24225 v-for="tab in visible_tabs" 24226 :key="tab.key" 24227 class="mp-float-tab-item" 24228 :class="{ 'mp-float-tab-item--active': tab_is_active(tab) }" 24229 @touchstart.prevent="tapped_tab_bar_item(tab)" 24230 @click="tapped_tab_bar_item(tab)" 24231 > 24232 <template v-if="show_member_details_avatar(tab)"> 24233 <span 24234 class="mp-float-tab-avatar" 24235 :class="{ 'mp-float-tab-avatar--active': tab_is_active(tab) }" 24236 :style="{ backgroundImage: 'url(' + member_tab_avatar_url + ')' }" 24237 ></span> 24238 </template> 24239 <template v-else> 24240 <i class="mp-float-tab-icon" :class="float_icon_class(tab, tab_is_active(tab))"></i> 24241 </template> 24242 </div> 24243 <!-- More item --> 24244 <div 24245 class="mp-float-tab-item" 24246 :class="{ 'mp-float-tab-item--active': more_is_active }" 24247 @touchstart.prevent="tapped_more()" 24248 @click="tapped_more()" 24249 > 24250 <i :class="(more_is_active ? 'fa-solid' : 'fa-regular') + ' fa-ellipsis mp-float-tab-icon'"></i> 24251 </div> 24252 </template> 24253 </div> 24254 24255 <!-- Classic tab bar âââââââââââââââ --> 24256 <div 24257 v-else-if="show_tab_bar" 24258 class="native-tab-bar-outer flexNoWrap" 24259 :style="[{ 24260 backgroundColor: tab_bar_colors.background || 'rgba(249, 251, 251, 1)' 24261 }, { 24262 backdropFilter: tab_bar_colors.blur ? 'blur(' + tab_bar_colors.blur + ')' : 'none' 24263 }]" 24264 :class="{ 'noBorder': tab_bar_colors.background, 'preview': is_admin_preview }" 24265 > 24266 <template v-if="visible_tabs"> 24267 <div 24268 v-for="tab in visible_tabs" 24269 :key="tab.key" 24270 class="native-tab-bar-item" 24271 :class="{ active : tab_is_active( tab ) }" 24272 :style=" tab_is_active( tab ) ? { color: tab_bar_colors.active || '#4a56f8' } : { color: tab_bar_colors.text || '#697684' } " 24273 @touchstart.prevent="tapped_tab_bar_item( tab )" 24274 @click="tapped_tab_bar_item( tab )" 24275 > 24276 <div class="centeredText centeredBlock"> 24277 <p> 24278 <template v-if="show_member_details_avatar(tab)"> 24279 <span 24280 class="native-tab-bar-avatar" 24281 :style="{ backgroundImage: 'url(' + member_tab_avatar_url + ')' }" 24282 ></span> 24283 </template> 24284 <template v-else> 24285 <i class="native-tab-bar-icon" :class="tab.icon_class"></i> 24286 </template> 24287 </p> 24288 <p class="native-tab-bar-label">{{ tab.label }}</p> 24289 </div> 24290 </div> 24291 <div 24292 class="native-tab-bar-item" 24293 @touchstart.prevent="tapped_more()" 24294 @click="tapped_more()" 24295 :class="{ 'active': more_is_active }" 24296 :style=" more_is_active ? { color: tab_bar_colors.active || '#4a56f8' } : { color: tab_bar_colors.text || '#697684' } " 24297 > 24298 <div class="centeredText centeredBlock"> 24299 <p><i class="fa-regular fa-ellipsis native-tab-bar-icon"></i></p> 24300 <p class="native-tab-bar-label">More</p> 24301 </div> 24302 </div> 24303 </template> 24304 </div> 24305 24306 <!-- compact more-section tab bar (floating) --> 24307 <div 24308 v-else-if="show_more_section_tab_bar && use_floating_tab_bar" 24309 class="mp-float-tab-bar" 24310 > 24311 <div class="mp-float-tab-pill" :style="compact_float_pill_style"></div> 24312 <div 24313 class="mp-float-tab-item"
24314 @touchstart.prevent="tapped_back_immediate()" 24315 @click="tapped_back_immediate()" 24316 > 24317 <i class="fa-regular fa-chevron-left mp-float-tab-icon"></i> 24318 </div> 24319 <div 24320 class="mp-float-tab-item" 24321 @touchstart.prevent="tapped_home()" 24322 @click="tapped_home()" 24323 > 24324 <i class="mp-float-tab-icon" :class="compact_home_icon_class"></i> 24325 </div> 24326 <div 24327 class="mp-float-tab-item mp-float-tab-item--active" 24328 @touchstart.prevent="tapped_tab_bar_item(compact_selected_tab)" 24329 @click="tapped_tab_bar_item(compact_selected_tab)" 24330 > 24331 <template v-if="show_member_details_avatar(compact_selected_tab)"> 24332 <span 24333 class="mp-float-tab-avatar mp-float-tab-avatar--active" 24334 :style="{ backgroundImage: 'url(' + member_tab_avatar_url + ')' }" 24335 ></span> 24336 </template> 24337 <template v-else> 24338 <i class="mp-float-tab-icon" :class="compact_selected_tab_icon_class"></i> 24339 </template> 24340 </div> 24341 <div 24342 class="mp-float-tab-item" 24343 @touchstart.prevent="tapped_more()" 24344 @click="tapped_more()" 24345 > 24346 <i class="fa-regular fa-ellipsis mp-float-tab-icon"></i> 24347 </div> 24348 </div> 24349 24350 <!-- compact more-section tab bar (classic) --> 24351 <div 24352 v-else-if="show_more_section_tab_bar" 24353 class="native-tab-bar-outer flexNoWrap" 24354 :style="[{ 24355 backgroundColor: tab_bar_colors.background || 'rgba(249, 251, 251, 1)' 24356 }, { 24357 backdropFilter: tab_bar_colors.blur ? 'blur(' + tab_bar_colors.blur + ')' : 'none' 24358 }]" 24359 :class="{ 'noBorder': tab_bar_colors.background, 'preview': is_admin_preview }" 24360 > 24361 <div 24362 class="native-tab-bar-item" 24363 :style="{ color: tab_bar_colors.text || '#697684' }" 24364 @touchstart.prevent="tapped_back_immediate()" 24365 @click="tapped_back_immediate()" 24366 > 24367 <div class="centeredText centeredBlock"> 24368 <p><i class="fa-regular fa-chevron-left native-tab-bar-icon"></i></p> 24369 <p class="native-tab-bar-label">Back</p> 24370 </div> 24371 </div> 24372 <div 24373 class="native-tab-bar-item" 24374 :style="{ color: tab_bar_colors.text || '#697684' }" 24375 @touchstart.prevent="tapped_home()" 24376 @click="tapped_home()" 24377 > 24378 <div class="centeredText centeredBlock"> 24379 <p><i class="native-tab-bar-icon" :class="compact_home_icon_class"></i></p> 24380 <p class="native-tab-bar-label">{{ compact_home_tab_label }}</p> 24381 </div> 24382 </div> 24383 <div 24384 class="native-tab-bar-item active" 24385 :style="{ color: tab_bar_colors.active || '#4a56f8' }" 24386 @touchstart.prevent="tapped_tab_bar_item(compact_selected_tab)" 24387 @click="tapped_tab_bar_item(compact_selected_tab)" 24388 > 24389 <div class="centeredText centeredBlock"> 24390 <p> 24391 <template v-if="show_member_details_avatar(compact_selected_tab)"> 24392 <span 24393 class="native-tab-bar-avatar" 24394 :style="{ backgroundImage: 'url(' + member_tab_avatar_url + ')' }" 24395 ></span> 24396 </template> 24397 <template v-else> 24398 <i class="native-tab-bar-icon" :class="compact_selected_tab_icon_class"></i> 24399 </template> 24400 </p> 24401 <p class="native-tab-bar-label">{{ compact_selected_tab.label }}</p> 24402 </div> 24403 </div> 24404 <div 24405 class="native-tab-bar-item" 24406 :style="{ color: tab_bar_colors.text || '#697684' }" 24407 @touchstart.prevent="tapped_more()" 24408 @click="tapped_more()" 24409 > 24410 <div class="centeredText centeredBlock"> 24411 <p><i class="fa-regular fa-ellipsis native-tab-bar-icon"></i></p> 24412 <p class="native-tab-bar-label">More</p> 24413 </div> 24414 </div> 24415 </div> 24416 24417 <!-- back button (global) --> 24418 <div v-else-if="show_back_button"> 24419 24420 <!-- overlay --> 24421 <div 24422 class="native-tab-bar-back-button-overlay pointer" 24423 @touchstart.self.prevent="back_button_expanded = false" 24424 @click.self="back_button_expanded = false" 24425 :class="{ 'active': back_button_expanded }" 24426 ></div> 24427 24428 <!-- home button --> 24429 <div 24430 class="native-tab-bar-floating-home-button boxShadowBottom moreBlur pointer easeFast" 24431 @click="tapped_home()" 24432 :class="{ 'active': back_button_expanded }" 24433 > 24434 <div class="centeredText centeredBlock"> 24435 <p><i class="fa-solid fa-house native-tab-bar-icon" style="font-size:20px;"></i><span v-if="back_button_expanded" style="padding-left:6px;font-size:18px;">Home</span></p> 24436 </div> 24437 </div> 24438 24439 <!-- back button --> 24440 <div 24441 class="native-tab-bar-floating-back-button boxShadowBottom moreBlur pointer easeFast" 24442 @click="tapped_back()" 24443 :class="{ 'expanded': back_button_expanded }" 24444 > 24445 <div class="centeredText centeredBlock"> 24446 <p><i class="fa-regular fa-chevron-left native-tab-bar-icon" style="font-size:20px;"></i><span v-if="back_button_expanded" style="padding-left:6px;font-size:18px;">Back</span></p> 24447 </div> 24448 </div> 24449 24450 </div> 24451 24452 </template> 24453 24454</script>
24454 24455 24456 24457<style> 24458 24459 .native-tab-bar-outer{ 24460 position: fixed; 24461 bottom: 0px; 24462 padding-bottom: max(0px, calc(var(--cs-safe-area-bottom, 0px) - 10px)); 24463 left: 0px; 24464 right: 0px; 24465 background-color: rgba(249, 251, 251, 1); 24466 border-top: 1px solid #e4ebf1; 24467 z-index: 1000; 24468 transition: 0.15s cubic-bezier(0.25, 0.1, 0.25, 1); 24469 } 24470 24471 .native-tab-bar-padding-bottom { 24472 padding-bottom: var(--native-tab-bar-padding-bottom, 0px); 24473 } 24474 24475 /* In admin preview mode this is just a sample, so keep z-index low and let real UI layers sit above it. */ 24476 .native-tab-bar-outer.preview{ 24477 position: relative; 24478 z-index: 1; 24479 } 24480 24481 .native-tab-bar-item{ 24482 padding: 16px 11px; 24483 display: flex; 24484 flex: 1 1 0; /* flex-grow: 1; flex-shrink: 1; flex-basis: 0; */ 24485 color: #697684; 24486 cursor: pointer; 24487 } 24488 .native-tab-bar-item.active{ 24489 color: #4a56f8; 24490 } 24491 .native-tab-bar-label{ 24492 font-size: 12px; 24493 padding-top: 4px; 24494 white-space: nowrap; 24495 } 24496 .native-tab-bar-icon{ 24497 font-size: 22px; 24498 } 24499 .native-tab-bar-avatar{ 24500 width: 23px; 24501 height: 23px; 24502 border-radius: 100%; 24503 background-size: cover; 24504 background-position: center center; 24505 background-repeat: no-repeat; 24506 display: inline-block; 24507 vertical-align: middle; 24508 } 24509 24510 /*------ back button -------*/ 24511 24512 .native-tab-bar-back-button-overlay{ 24513 position: fixed; 24514 top: 0; 24515 left: 0; 24516 width: 100%; 24517 height: 100%; 24518 /* 24519 background-color: rgba(111,117,122,0.5); 24520 */ 24521 background-color: rgba(249, 251, 251, 0.8); 24522 opacity: 0; 24523 visibility: hidden; 24524 transition: opacity 0.15s ease; 24525 z-index: 999; 24526 } 24527 24528 .native-tab-bar-back-button-overlay.active{ 24529 opacity: 1; 24530 visibility: visible; 24531 } 24532 24533 .native-tab-bar-floating-back-button{ 24534 position: fixed; 24535 bottom: 22px; 24536 left: 22px; 24537 right: auto; 24538 z-index: 1000; 24539 transition: 0.15s cubic-bezier(0.25, 0.1, 0.25, 1); 24540 border-radius: 100%; 24541 width: 50px; 24542 height: 50px; 24543 background-color: rgba(255, 255, 255, 0.28); 24544 backdrop-filter: blur(15px); 24545 -webkit-backdrop-filter: blur(15px); 24546 line-height: 50px; 24547 font-size: 16px; 24548 white-space: nowrap; 24549 min-width: 50px; 24550 } 24551 24552 .native-tab-bar-floating-back-button.expanded{ 24553 border-radius: 25px; 24554 padding-left: 16px; 24555 padding-right: 16px; 24556 width: auto; 24557 } 24558 24559 /*------ home button -------*/ 24560 24561 .native-tab-bar-floating-home-button.active{ 24562 border-radius: 25px; 24563 padding-left: 16px; 24564 padding-right: 16px; 24565 width: auto; 24566 visibility: visible; 24567 opacity: 1; 24568 animation: expand 0.15s cubic-bezier(0.25, 0.1, 0.25, 1) forwards; 24569 background-color: #4a56f8; 24570 color: #fff; 24571 } 24572 24573 .native-tab-bar-floating-home-button{ 24574 position: fixed; 24575 bottom: 82px; 24576 left: 22px; 24577 right: auto; 24578 visibility: hidden; 24579 opacity: 0; 24580 background-color: rgba(249, 251, 251, 1); 24581 /* border-top: 1px solid #e4ebf1; */ 24582 z-index: 1000; 24583 transition: 0.15s cubic-bezier(0.25, 0.1, 0.25, 1); 24584 border-radius: 20px; 24585 width: auto; 24586 height: 50px; 24587 background-color: #fff; 24588 line-height: 50px; 24589 font-size: 16px; 24590 white-space: nowrap; 24591 24592 /* Initial state for animation */ 24593 transform: scale(0); 24594 animation: none; 24595 } 24596 24597 /* Keyframes for the expansion effect */ 24598 @keyframes expand { 24599 from { 24600 transform: scale(0); 24601 opacity: 0; 24602 } 24603 to { 24604 transform: scale(1); 24605 opacity: 1; 24606 } 24607 } 24608 24609 /* ââ Floating glass tab bar (club XVgOrNASDY only) âââââââââââââââââââ */ 24610 24611 .mp-float-tab-bar { 24612 position: fixed; 24613 /* sit above the safe-area bottom edge with a small gap */ 24614 bottom: max(12px, var(--cs-safe-area-bottom, 12px)); 24615 /* center the pill horizontally; max-width keeps it compact on tablets */ 24616 left: 0; 24617 right: 0; 24618 margin-left: auto; 24619 margin-right: auto; 24620 max-width: 360px; 24621 width: calc(100% - 48px); 24622 /* light frosted glass pill */ 24623 border-radius: 30px; 24624 background: rgba(255, 255, 255, 0.28); 24625 backdrop-filter: blur(15px); 24626 -webkit-backdrop-filter: blur(15px); 24627 border: 1px solid rgba(0, 0, 0, 0.06); 24628 box-shadow: 0 4px 28px rgba(0, 0, 0, 0.12); 24629 /* layout */ 24630 display: flex; 24631 align-items: center; 24632 z-index: 1000; 24633 overflow: hidden; 24634 } 24635 24636 .mp-float-tab-bar--preview { 24637 position: relative; 24638 bottom: auto; 24639 margin: 12px auto; 24640 /* In preview mode we do not want the production-level z-index to overlap the left menu peek. */ 24641 z-index: 1; 24642 } 24643 24644 .mp-float-tab-bar--settings-preview { 24645 margin: 0; 24646 } 24647 24648 .mp-float-tab-item { 24649 flex: 1 1 0; 24650 display: flex; 24651 align-items: center; 24652 justify-content: center; 24653 position: relative; 24654 padding: 18px 0; 24655 cursor: pointer; 24656 /* tap highlight off */ 24657 -webkit-tap-highlight-color: transparent; 24658 } 24659 24660 /* Single sliding active pill â left and width are driven by float_pill_style computed */ 24661 .mp-float-tab-pill { 24662 position: absolute; 24663 top: 7px; 24664 bottom: 7px; 24665 /* left + width set inline; transition slides the pill between tabs */ 24666 border-radius: 22px; 24667 background: rgba(0, 0, 0, 0.08); 24668 pointer-events: none; 24669 transition: left 0.28s cubic-bezier(0.25, 0.1, 0.25, 1), 24670 width 0.28s cubic-bezier(0.25, 0.1, 0.25, 1); 24671 } 24672 24673 .mp-float-tab-icon { 24674 font-size: 22px; 24675 color: rgba(0, 0, 0, 0.7); 24676 position: relative; /* above the pill */ 24677 z-index: 1; 24678 } 24679 24680 .mp-float-tab-avatar { 24681 width: 26px;
24682 height: 26px; 24683 border-radius: 50%; 24684 background-size: cover; 24685 background-position: center; 24686 background-repeat: no-repeat; 24687 display: inline-block; 24688 position: relative; 24689 z-index: 1; 24690 opacity: 1; 24691 box-shadow: 0 0 0 2px transparent; 24692 transition: box-shadow 0.2s ease; 24693 } 24694 24695 .mp-float-tab-avatar--active { 24696 box-shadow: 0 0 0 2px #fff; 24697 } 24698 24699</style> 24700
24701<script> 24702 24703 let native_tab_bar_config = { 24704 template: "#template-native-tab-bar", 24705 data() { 24706 return { 24707 back_button_expanded: false 24708 } 24709 }, 24710 props: { 24711 primary_view: String, 24712 tabs: Array, 24713 hide_back_button: Boolean, 24714 is_admin_preview: { 24715 type: Boolean, 24716 default: false 24717 }, 24718 preview_colors: { 24719 type: Object, 24720 default: null 24721 }, 24722 preview_tab_bar_style: { 24723 type: String, 24724 default: null 24725 }, 24726 member_portal_config: { 24727 type: Object, 24728 default: null 24729 }, 24730 settings_preview_mode: { 24731 type: Boolean, 24732 default: false 24733 }, 24734 is_member_portal: { 24735 type: Boolean, 24736 default: false 24737 } 24738 }, 24739 mounted(){ 24740 console.log("Mounted native tab bar"); 24741 }, 24742 methods: { 24743 tab_is_active( tab ){ 24744 if( this.primary_view ){ 24745 return tab.key === this.primary_view; 24746 } 24747 return false; 24748 }, 24749 tapped_home(){ 24750 if(this.is_admin_preview){ 24751 return; 24752 } 24753 if( this.is_member_portal ){ 24754 this.back_button_expanded = false; 24755 updateURL('home'); 24756 displayViewForUrlPath(); 24757 }else{ 24758 window.location.href = '/account/home'; 24759 } 24760 }, 24761 tapped_more(){ 24762 if(this.is_admin_preview){ 24763 return; 24764 } 24765 toggle_left_menu(); 24766 }, 24767 tapped_tab_bar_item( tab ){ 24768 if(this.is_admin_preview){ 24769 return; 24770 } 24771 if( tab && tab.type === 'custom' && tab.link ){ 24772 window.location.href = tab.link; 24773 return; 24774 } 24775 updateURL( tab.key ); 24776 displayViewForUrlPath(); 24777 }, 24778 tapped_back(){ 24779 if(this.is_admin_preview){ 24780 return; 24781 } 24782 if( this.back_button_expanded ){ 24783 this.back_button_expanded = false; 24784 window.history.back(); 24785 } else { 24786 this.back_button_expanded = true; 24787 } 24788 }, 24789 tapped_back_immediate(){ 24790 if(this.is_admin_preview){ 24791 return; 24792 } 24793 window.history.back(); 24794 }, 24795 float_icon_class( tab, is_active ){ 24796 if (!tab || !tab.icon_class) { 24797 return ''; 24798 } 24799 if (is_active) { 24800 return tab.icon_class.replace('fa-regular', 'fa-solid'); 24801 } 24802 return tab.icon_class; 24803 }, 24804 show_member_details_avatar( tab ){ 24805 if( ! tab ){ 24806 return false; 24807 } 24808 //only show if its the account tab AND we have an avatar url 24809 return tab.key === "settings" && !!this.member_tab_avatar_url; 24810 }, 24811 }, 24812 computed: { 24813 member_tab_avatar_url(){ 24814 const current_user = Parse.User.current(); 24815 if( ! current_user ){ 24816 return null; 24817 } 24818 const member_object = current_user.get('memberObject'); 24819 if( member_object && member_object.get('profPicURL') ){ 24820 return member_object.get('profPicURL'); 24821 } 24822 if( current_user.get('profPicURL') ){ 24823 return current_user.get('profPicURL'); 24824 } 24825 return null; 24826 }, 24827 tab_bar_colors(){ 24828 if( this.preview_colors ){ 24829 return this.preview_colors; 24830 } 24831 //this is only supported in newer versions of the app (where we have the native_app_props set so we can calculate padding-bottom on the safe area) 24832 if( ! store.native_app_props || ! store.native_app_props.safeAreaInsets ){ 24833 if(!this.is_admin_preview){ 24834 return {}; 24835 } 24836 } 24837 //ok, it's a newer version of the app! 24838 if( store.clubObject && store.clubObject.get('web_settings').get('tab_bar_colors') ){ 24839 //validate the config 24840 let tab_bar_colors = store.clubObject.get('web_settings').get('tab_bar_colors'); 24841 if( 24842 tab_bar_colors.background && 24843 tab_bar_colors.text && 24844 tab_bar_colors.active 24845 ){ 24846 return tab_bar_colors; 24847 } 24848 } 24849 return {}; 24850 }, 24851 //we only show it when we're on one of the relevant primary views (contained in the tab bar) 24852 //.. otherwise we should display the back button bar 24853 tab_bar_keys(){ 24854 24855 /* 24856 var keys = []; 24857 if( this.visible_tabs ){
24858 this.visible_tabs.forEach(tab => { 24859 keys.push( tab.key ); 24860 }); 24861 } 24862 return keys; 24863 */ 24864 24865 let keys = []; 24866 if( this.tabs ){ 24867 this.tabs.forEach(tab => { 24868 keys.push( tab.key ); 24869 }); 24870 } 24871 return keys; 24872 24873 }, 24874 visible_tab_keys(){ 24875 let keys = []; 24876 if( this.visible_tabs ){ 24877 this.visible_tabs.forEach(tab => { 24878 keys.push( tab.key ); 24879 }); 24880 } 24881 return keys; 24882 }, 24883 more_is_active(){ 24884 if( ! this.tab_bar_keys.includes( this.primary_view ) ){ 24885 return false; 24886 } 24887 //it's active if not in the visible tabs 24888 if( this.visible_tab_keys.includes( this.primary_view ) ){ 24889 return false; 24890 } 24891 return true; 24892 }, 24893 show_more_section_tab_bar(){ 24894 if( ! this.is_member_portal ){ 24895 return false; 24896 } 24897 if( this.primary_view === 'booking' && this.is_inside_booking_flow ){ 24898 return false; 24899 } 24900 if( ! this.tab_bar_keys.includes( this.primary_view ) ){ 24901 return false; 24902 } 24903 if( this.visible_tab_keys.includes( this.primary_view ) ){ 24904 return false; 24905 } 24906 return true; 24907 }, 24908 show_tab_bar(){ 24909 //1) hide the tab bar if a member has drilled into one ofthe in-portal booking flows (golf, courts, simulators, 24910 if( this.primary_view === 'booking' && this.is_inside_booking_flow ){ 24911 return false; 24912 } 24913 //2) see if it's a visible tab, else don't show 24914 if( this.visible_tab_keys.includes( this.primary_view ) ){ 24915 return true; 24916 } 24917 return false; 24918 24919 24920 /* 24921 // hide the tab bar once a member has drilled into one of 24922 // the in-portal booking flows (golf, courts, simulators, 24923 // group-classes, dining, staff-services, lodging). The hub 24924 // landing page itself has secondary_view == 'default', so 24925 // we keep the tab bar visible there. 24926 if( this.primary_view === 'booking' && this.is_inside_booking_flow ){ 24927 return false; 24928 } 24929 for (let t = 0; t < this.tab_bar_keys.length; t++) { 24930 if( this.tab_bar_keys[t] === this.primary_view ){ 24931 return true 24932 } 24933 } 24934 return false; 24935 */ 24936 }, 24937 is_inside_booking_flow(){ 24938 let secondary = store.secondary_view; 24939 if( ! secondary || secondary === 'default' ){ 24940 return false; 24941 } 24942 return true; 24943 }, 24944 visible_tabs(){ 24945 if( this.tabs ){ 24946 return this.tabs.slice(0,4) 24947 } 24948 return []; 24949 }, 24950 show_back_button(){ 24951 if( this.hide_back_button ){ 24952 return false; 24953 } 24954 if( this.show_more_section_tab_bar ){ 24955 return false; 24956 } 24957 //if we're on the home screen, show the back button 24958 if( ! this.show_tab_bar && window.history.length >= 1 ){ 24959 return true; 24960 } 24961 return false; 24962 }, 24963 is_react_native_member_app(){ 24964 return store.is_react_native_member_app; 24965 }, 24966 use_floating_tab_bar(){ 24967 // In the admin preview, respect the dropdown selection immediately. 24968 if( this.is_admin_preview ){ 24969 return this.preview_tab_bar_style === 'modern'; 24970 } 24971 //end preview case 24972 if( this.member_portal_config ){ 24973 return this.member_portal_config.tab_bar_style === 'modern'; 24974 } 24975 return false;
24976 }, 24977 float_active_tab_index(){ 24978 if (!this.visible_tabs) { 24979 return 0; 24980 } 24981 for (let idx = 0; idx < this.visible_tabs.length; idx++) { 24982 if (this.tab_is_active(this.visible_tabs[idx])) { 24983 return idx; 24984 } 24985 } 24986 // "More" is the last slot 24987 return this.visible_tabs.length; 24988 }, 24989 float_pill_style(){ 24990 let total = this.visible_tabs ? this.visible_tabs.length + 1 : 5; 24991 let idx = this.float_active_tab_index; 24992 // Each slot is (100% / total) wide. The pill is inset 8px on each side. 24993 return { 24994 left: 'calc(' + idx + ' * (100% / ' + total + ') + 8px)', 24995 width: 'calc(100% / ' + total + ' - 16px)', 24996 }; 24997 }, 24998 compact_float_pill_style(){ 24999 // compact more-section bar is always 4 slots and selected tab is slot index 2 25000 let total = 4; 25001 let selected_tab_index = 2; 25002 return { 25003 left: 'calc(' + selected_tab_index + ' * (100% / ' + total + ') + 8px)', 25004 width: 'calc(100% / ' + total + ' - 16px)', 25005 }; 25006 }, 25007 compact_home_icon_class(){ 25008 let home_tab = this.tabs.find(tab => tab.key === 'home'); 25009 if( home_tab && home_tab.icon_class ){ 25010 return home_tab.icon_class; 25011 } 25012 return 'fa-regular fa-house'; 25013 }, 25014 compact_home_tab_label(){ 25015 let home_tab = this.tabs.find(tab => tab.key === 'home'); 25016 if( home_tab && home_tab.label ){ 25017 return home_tab.label; 25018 } 25019 return 'Home'; 25020 }, 25021 compact_selected_tab(){ 25022 let current_tab = this.tabs.find(tab => tab.key === this.primary_view); 25023 if( current_tab ){ 25024 return current_tab; 25025 } 25026 return { 25027 icon_class: 'fa-regular fa-circle', 25028 label: 'Tab', 25029 }; 25030 }, 25031 compact_selected_tab_icon_class(){ 25032 if( this.use_floating_tab_bar ){ 25033 return this.float_icon_class(this.compact_selected_tab, true); 25034 } 25035 if( this.compact_selected_tab && this.compact_selected_tab.icon_class ){ 25036 return this.compact_selected_tab.icon_class; 25037 } 25038 return 'fa-regular fa-circle'; 25039 }, 25040 }, 25041 } 25042 app_content.component("native-tab-bar-component", native_tab_bar_config ); 25043 app_overlays.component("native-tab-bar-component", native_tab_bar_config ); 25044 25045</script>
25045</html> 25046
25046<script> 25047 //after we've included all the relevant views, mount the vue apps 25048 app_content.mount('#v-content'); 25049 app_overlays.mount('#v-overlays'); 25050 25051 </script>
25051 25052 25053 25054 25055 25056</html>
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.