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-5de77e9587-20261005t210552"; 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: Montserrat !important; 241 } 242 243 .wb-content h1{ 244 font-size: 64px; 245 font-weight: normal; 246 font-family: Montserrat; 247 } 248 249 .custom-font-wrap h1{ 250 font-family: Montserrat; 251 } 252 253 .wb-content h2 { 254 font-size: 42px; 255 font-weight: normal; 256 font-family: Montserrat; 257 } 258 259 .custom-font-wrap h2{ 260 font-family: Montserrat; 261 } 262 263 .wb-content h3{ 264 font-size: 28px; 265 font-weight: normal; 266 font-family: Montserrat; 267 } 268 269 .wb-content select{ 270 font-size: 28px; 271 font-weight: normal; 272 font-family: Montserrat; 273 } 274 275 .custom-font-wrap h3{ 276 font-family: Montserrat; 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: Montserrat; 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: #90a2bc; 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: 144, 162, 188 !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: 144, 162, 188 !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: #90a2bc; 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: 144, 162, 188; 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: 144, 162, 188; } 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: 144, 162, 188; } 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: #90a2bc; 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: #90a2bc; 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: 144, 162, 188; 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 <link rel="shortcut icon" href="https://d282wvk2qi4wzk.cloudfront.net/q4qrmUsrDP_burgee_1669240759761"> 520 <link rel="icon" sizes="16x16 32x32 64x64" href="https://d282wvk2qi4wzk.cloudfront.net/q4qrmUsrDP_burgee_1669240759761"> 521 522 <!-- touch icon (mobile web) --> 523
524 <!-- web app title (mobile safari) --> 525 526 <meta name="apple-mobile-web-app-title" content="Dillon Yacht Club"> 527 528 <!-- app_manifest (android) --> 529 530 531 532 533 534 535 536 <title>Home - Dillon Yacht Club</title> 537 538 539 <link rel="canonical" href="https://dillonyachtclub.com/"> 540 541 <!-- fb open graph tags --> 542 <meta property="fb:app_id" content="314788812688609" /> 543 544 <meta property="og:image" content="https://d282wvk2qi4wzk.cloudfront.net/q4qrmUsrDP_burgee_1677808999381"> 545 546 547 548 <!-- Google Analytics for clubs --> 549 550 <!-- END google analytics for clubs --> 551 552 <!-- FB pixel for clubs --> 553 554 <!-- FB pixel for clubs --> 555 556 557 558 <!-- vue date picker --> 559 <!-- see: https://vue3datepicker.com/ --> 560
560<script src="/public/assets/js/vue-date-picker-8.5.1.js"></script>
560 561 <link rel="stylesheet" href="/public/assets/css/vue-date-picker.css"> 562 563 <!-- Member portal splash video: warm the CDN connection and start fetching the splash 564 video during initial HTML parse, before Vue mounts the <video> element. This avoids 565 the multi-second gap (especially in the native app webview) where only the poster 566 image shows while the JS bundle loads, the club loads, and Vue renders. --> 567 568 569</head> 570 <body 571 class='wb-content hosted-page' 572 data-club-id='q4qrmUsrDP' 573 data-club-name='Dillon Yacht Club' 574 data-timezone='America/Denver' 575 > 576 577 578 <div class="globalMessageInsert flexNoWrap easeFast hideForPrinting"> 579 <div class="global-message-icon-loading"> 580 <div class="global-message-icon-spinner"></div> 581 </div> 582 <p> 583 <i class="fa-solid fa-square-check global-message-icon-good"></i> 584 <i class="fa-solid fa-square-xmark global-message-icon-bad"></i> 585 <span></span> 586 </p> 587 </div> 588 589 590 <!-- start standard content --> 591 <div id="v-content" class="hidden relative leftText relative easeFast paddingTopHeader"> 592 <!-- header --> 593 594 <custom-page-header 595 v-if="! is_react_native_member_app" 596 ></custom-page-header> 597 598 <!-- page components --> 599 600 601 602 603 604 <!-- check the config --> 605 606 607 608<!-- clean them first, to weed out null values --> 609 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<!-- now generate markup --> 683 684 <hero-banner-component 685 :client_id="'dfeIDjSygNL_1670867171'" 686 :formatting="'{"section_height":"16","section_width":"70","align_items":"center","background_size":"cover","overlay_opacity":"0"}'" 687 :class_string="' color_scheme_dark orientation-left number_of_columns-3 cs-first-page-section caption_style-show_on_hover'" 688 :style_string="'min-height:16vh;align-items:center;'" 689 :element_style_string="'max-width:70%;'" 690 :component_type="'hero-banner'" 691 :elements_string="'%5B%7B%22type%22%3A%22h1-center%22%2C%22content%22%3A%22%3Ch1%20style%3D%5C%22text-align%3A%20center%3B%5C%22%3EWelcome%20to%26nbsp%3B%3C%2Fh1%3E%3Ch1%20style%3D%5C%22text-align%3A%20center%3B%5C%22%3EDillon%20Yacht%20Club%3C%2Fh1%3E%22%7D%5D'" 692 :images_string="'[]'" 693 ></hero-banner-component> 694 695 696 697 698 699 <!-- check the config --> 700 701 702 703<!-- clean them first, to weed out null values --> 704 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<!-- now generate markup --> 768 769 <div class="cs-text background flexWrap relative color_scheme_white orientation-left number_of_columns-3 caption_style-show_on_hover" style="min-height:50vh;align-items:center;"> 770 771 <div class="element-wrapper flexNoWrap flexWrapOnMobile" class="'element-wrapper_' + text" style="max-width:100%;"> 772 773 <text-component 774 :number_of_columns="'3'" 775 :column_prop="'%5B%7B%22type%22%3A%22h2-center%22%2C%22content%22%3A%22%3Ch3%20style%3D%5C%22text-align%3Acenter%5C%22%3E%3Cspan%20class%3D%5C%22cs-bold%5C%22%3E%3Ca%20href%3D%5C%22%2Fmembership%5C%22%20target%3D%5C%22_blank%5C%22%20class%3D%5C%22cs-link-ZrL3uWvPxf1_1670900503%5C%22%3EMembership%3C%2Fa%3E%3C%2Fspan%3E%3Cbr%20%2F%3E%3C%2Fh3%3E%3Cdiv%3E%3Cbr%20%2F%3E%3C%2Fdiv%3E%3Cdiv%20style%3D%5C%22text-align%3Acenter%5C%22%3E%3Cimg%20src%3D%5C%22https%3A%2F%2Fd282wvk2qi4wzk.cloudfront.net%2FbHrhhPQHkbya_upload_inline_q4qrmUsrDP_zddPOfQCSU_1778108316795%5C%22%20%2F%3E%3C%2Fdiv%3E%22%2C%22client_id%22%3A%22WWsa9qbHH6FShtO0%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A1%7D%2C%7B%22type%22%3A%22h3-center%22%2C%22content%22%3A%22%3Cp%20style%3D%5C%22text-align%3Acenter%5C%22%3E%C2%A0%3C%2Fp%3E%3Cp%20style%3D%5C%22text-align%3Acenter%5C%22%3EPlaying%20on%20or%20by%20the%20water%E2%80%99s%20edge%20at%20Lake%20Dillon%20rejuvenates%20the%20spirit%2C%20restores%20the%20soul%20and%20rebuilds%20families%20and%20friendships.%20Come%20explore%20Lake%20Dillon%2C%20the%20beaches%20and%20secret%20spots%20that%20can%20only%20accessed%20by%20water%2521%C2%A0%C2
775%A0%3C%2Fp%3E%3Cp%20style%3D%5C%22text-align%3Acenter%5C%22%3E%3Cbr%20%2F%3E%3C%2Fp%3E%22%2C%22client_id%22%3A%22xPktZ76GrawtGA32%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%5D'" 776 :column_index="'0'" 777 :section_element_animation="''" 778 ></text-component> 779 780 <text-component 781 :number_of_columns="'3'" 782 :column_prop="'%5B%7B%22type%22%3A%22h2-center%22%2C%22content%22%3A%22%3Ch3%20style%3D%5C%22text-align%3Acenter%5C%22%3E%3Cspan%20class%3D%5C%22cs-bold%5C%22%3E%3Ca%20href%3D%5C%22https%3A%2F%2Fdillonopen.com%2F%5C%22%20target%3D%5C%22_blank%5C%22%20class%3D%5C%22cs-link-KYe70RGTC9Q_1670900520%5C%22%3EDillon%20Open%20Regatta%3C%2Fa%3E%3C%2Fspan%3E%3C%2Fh3%3E%3Cdiv%3E%3Cbr%20%2F%3E%3C%2Fdiv%3E%3Cdiv%20style%3D%5C%22text-align%3Acenter%5C%22%3E%3Cimg%20src%3D%5C%22https%3A%2F%2Fd282wvk2qi4wzk.cloudfront.net%2FuQQAcqOugvcI_upload_inline_q4qrmUsrDP_zddPOfQCSU_1778108338596%5C%22%20%2F%3E%3C%2Fdiv%3E%3Cdiv%20style%3D%5C%22text-align%3Acenter%5C%22%3E%3Cbr%20%2F%3E%3C%2Fdiv%3E%22%2C%22client_id%22%3A%22F3G45nbwb49pFiKt%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%3Cp%20style%3D%5C%22text-align%3Acenter%5C%22%3E%C2%A0The%20Dillon%20Yacht%20Club%20extends%20a%20warm%20invitation%20to%20all%20sailors%20interested%20in%20participating%20in%20our%20annual%20Dillon%20Open%20Regatta%2C%20the%20world%E2%80%99s%20highest%20regatta%20at%209%2C017%20ft%20above%20sea%20level.%20Racing%20will%20be%20held%20on%20beautiful%20Lake%20Dillon%20where%20competitors%20will%20enjoy%20sunny%20days%20and%20spectacular%20views%20of%20the%20snow-capped%20Rocky%20Mountains.%C2%A0%3C%2Fp%3E%3Cp%20style%3D%5C%22text-align%3Acenter%5C%22%3E%3Cbr%20%2F%3E%3C%2Fp%3E%22%2C%22client_id%22%3A%22r2Y1vKojEmMozeyd%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%3E%3Cimg%20class%3D%5C%22cs-inline-image%5C%22%20src%3D%5C%22https%3A%2F%2Fd282wvk2qi4wzk.cloudfront.net%2FJiGOroATJtKx_upload_inline_q4qrmUsrDP_zddPOfQCSU_1778108045862%5C%22%20%2F%3E%3C%2Fp%3E%22%2C%22client_id%22%3A%22d7sPiqgRhdgOYJk9%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%3EJoin%20the%20Dillon%20Yacht%20Club%20WhatsApp%20community%20today%20for%20your%20source%20of%20racing%20announcements%2C%20fleet%20chats%2C%20and%20all%20other%20sources%20of%20member%20communication.%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%3E%3Cspan%20class%3D%5C%22cs-bold%5C%22%20style%3D%5C%22background-color%3Argb%2528254%2C%20251%2C%20251%2529%3Bcolor%3Argb%2528234%2C%2083%2C%2083%2529%5C%22%3ETo%20join%20the%20WhatsApp%20community%2C%20select%20the%20groups%20you%20wish%20to%20access.%20Then%20send%20a%20direct%20message%20on%20WhatsApp%20to%20Cooper%20Robinson%20at%20%2528303%2529265-1855.%20Cooper%20Robinson%20can%20then%20approve%20your%20group%20join%20requests.%20We%20appreciate%20your%20patience%20in%20the%20approval%20process%20to%20help%20restrict%20potential%20spam%20accounts%20from%20joining%20our%20communication%20channels.%3C%2Fspan%3E%3C%2Fp%3E%3Cp%20style%3D%5C%22text-align%3Acenter%5C%22%3E%3Cspan%20class%3D%5C%22cs-bold%5C%22%20style%3D%5C%22background-color%3Argb%2528254%2C%20251%2C%20251%2529%3Bcolor%3Argb%2528234%2C%2083%2C%2083%2529%5C%22%3E%3Cbr%20%2F%3E%3C%2Fspan%3E%3C%2Fp%3E%3Cp%20style%3D%5C%22text-align%3Acenter%5C%22%3ELink%20to%20join%20the%20WhatsApp%20community%3A%3C%2Fp%3E%3Cp%20style%3D%5C%22text-align%3Acenter%5C%22%3E%3Ca%20href%3D%5C%22https%3A%2F%2Fchat.whatsapp.com%2FH0JDPgJOkGcD8qjox21JBP%5C%22%20target%3D%5C%22_blank%5C%22%20class%3D%5C%22cs-link-9ccSWYqHIpB_1778107381%5C%22%20style%3D%5C%22font-family%3ALato%3Bbackground-color%3Argb%2528255%2C%20255%2C%20255%2529%5C%22%3Ehttps%3A%2F%2Fchat.whatsapp.com%2FH0JDPgJOkGcD8qjox21JBP%3C%2Fa%3E%3C%2Fp%3E%3Cp%20style%3D%5C%22text-align%3Acenter%5C%22%3E%3Cbr%20%2F%3E%3C%2Fp%3E%22%2C%22client_id%22%3A%22JkeKUslFTStYfYTW%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%5D'" 783 :column_index="'1'" 784 :section_element_animation="''" 785 ></text-component> 786 787 <text-component 788 :number_of_columns="'3'" 789 :column_prop="'%5B%7B%22type%22%3A%22h2-center%22%2C%22content%22%3A%22%3Ch3%20style%3D%5C%22text-align%3Acenter%5C%22%3E%3Cspan%20class%3D%5C%22cs-bold%5C%22%3E%3Ca%20href%3D%5C%22https%3A%2F%2Fwww.dillonjuniorsailing.com%2F%5C%22%20target%3D%5C%22_blank%5C%22%20class%3D%5C%22cs-link-alxfnImHVE7_1670900537%5C%22%3EJunior%2
7890Sailing%20Program%3C%2Fa%3E%3C%2Fspan%3E%3C%2Fh3%3E%3Cdiv%3E%3Cbr%20%2F%3E%3C%2Fdiv%3E%3Cdiv%20style%3D%5C%22text-align%3Acenter%5C%22%3E%3Cimg%20src%3D%5C%22https%3A%2F%2Fwww.dillonjuniorsailing.com%2Fuploads%2F1%2F7%2F3%2F8%2F17381411%2Feditor%2Flogo-gold_2.png%3F1525407852%5C%22%20alt%3D%5C%22DILLON%20JUNIOR%20SAILING%20-%20Summer%20Sailing%20Programs%5C%22%20%2F%3E%3C%2Fdiv%3E%22%2C%22client_id%22%3A%22gUzkjTHNUNHbGnHG%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%3E%C2%A0DYCJSP%20has%20created%20a%20history%20of%20excellent%20summer%20sailing%20programs%20and%20is%20a%20great%20way%20to%20spend%20a%20Colorado%20summer%20outside%20learning%20a%20lifelong%20skill%2C%20sailing.%20We%20focus%20on%203%20things%3A%20safety%2C%20fun%2C%20%26amp%3B%20sailing%20skills.%20Our%20classes%20are%20for%20kids%20ages%208%20to%2018%20of%20all%20skill%20levels.%C2%A0%3C%2Fp%3E%3Cp%20style%3D%5C%22text-align%3Acenter%5C%22%3E%3Cbr%20%2F%3E%3C%2Fp%3E%22%2C%22client_id%22%3A%225lUvt5FQTpseymIX%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%5D'" 790 :column_index="'2'" 791 :section_element_animation="''" 792 ></text-component> 793 794 </div> 795 796 </div> 797 798 799 800 801 802 <!-- check the config --> 803 804 805 806<!-- clean them first, to weed out null values --> 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 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886<!-- now generate markup --> 887 888 <image-grid-component 889 :client_id="'ZMrYGt2sKCR_1670867478'" 890 :class_string="' color_scheme_white orientation-left number_of_columns-3 caption_style-show_on_hover'" 891 :image_grid_layout="'grid'" 892 :images_per_row="'4'" 893 :caption_size="'24'" 894 :subtext_size="'24'" 895 :link_label_size="'24'" 896 :text_position="'center'" 897 :caption_font_weight="'400'" 898 :image_grid_opacity="'40'" 899 :image_grid_border_radius="'0'" 900 :formatting="'{"caption_style":"show_on_hover","overlay_opacity":"40","caption_align":"center"}'" 901 :images_string="'[{\"id\":\"Nq1LolhpIx0_1670867700\",\"url\":\"https://d282wvk2qi4wzk.cloudfront.net/ZMrYGt2sKCR_1670867478_grid_image_1670867698545\"},{\"id\":\"PHBISSysB2U_1670867712\",\"url\":\"https://d282wvk2qi4wzk.cloudfront.net/1670867711163_2649\"},{\"id\":\"M7lSVOwa9LJ_1671591695\",\"url\":\"https://d282wvk2qi4wzk.cloudfront.net/1671591694648_600\"},{\"id\":\"ElXjM2crcq2_1671594131\",\"url\":\"https://d282wvk2qi4wzk.cloudfront.net/1671594130801_3347\"},{\"id\":\"XyjfnUAHBhG_1671594560\",\"url\":\"https://d282wvk2qi4wzk.cloudfront.net/1671594560108_6717\"},{\"id\":\"jK08KT2SJIQ_1671594720\",\"url\":\"https://d282wvk2qi4wzk.cloudfront.net/1671594719366_6551\"},{\"id\":\"0XclhyCPMHm_1671596578\",\"url\":\"https://d282wvk2qi4wzk.cloudfront.net/1671596578388_2795\"},{\"id\":\"sHT9R8dX7vX_1671596726\",\"url\":\"https://d282wvk2qi4wzk.cloudfront.net/1671596726047_4372\"}]'" 902 ></image-grid-component> 903 904 905 906 907 908 <!-- footer --> 909 910 911 912<!-- check the config --> 913 914 915<div class="cs-footer relative color_scheme_white" style="" > 916 <div class=""> 917 <div class="cs-footer-flex-wrap-row"> 918 <div class="cs-footer-logo-wrap background"> 919 920 921 922 <div class="cs-burgee-wrap relative" style="height:70px;width:120px;"> 923 <div class="cs-burgee" style="background-image:url(https://d282wvk2qi4wzk.cloudfront.net/q4qrmUsrDP_burgee_1669240759761)"></div> 924 <a class="absoluteA" href="/"></a> 925 </div> 926 927 </div> 928 <div> 929 <div class="flexWrapLeftAlign"> 930 <div class="cs-footer-link-wrap flexGrowOne noPaddingBottom"> 931 932 933 934 935 936 <!-- component --> 937 <div class="footer-link-section flexGrowOne"> 938 939 940 <p class="footer-sublink-section-header smallMarginTop">MAILING ADDRESS</p> 941 942 <!-- handle sub-components --> 943 944 945 <div class="cs-footer-sublink-outer easeFast"> 946 947 948 949 950 951 <div class="cs-footer-sublink-wrap relative"> 952 953 954 955 <p class="cs-footer-sublink-label">PO Box 4308, Dillon, CO 80435</p> 956 957 958 </div> 959 960 961 962 </div> 963 964 </div> 965 966 967 968 969 970 971 972 <!-- component --> 973 <div class="footer-link-section flexGrowOne"> 974 975 976 <p class="footer-sublink-section-header smallMarginTop"> 977 FACEBOOK 978 <a class="absoluteA" href="https://www.facebook.com/dillonyachtclub/"></a> 979 </p> 980 981 <!-- handle sub-components --> 982 983 984 </div> 985 986 987 988 <!-- social icons --> 989 990 </div> 991 </div> 992 <div class="cs-footer-link-wrap noPaddingTop"> 993 <div class="powered_by_wrap relative"> 994 <div class="flexNoWrap align-items-center"> 995 <p class="powered_by"><i class="fa-solid fa-shield-check" style="padding-right:3px;"></i> Powered by</p> 996 <p class="modern pointer powered_by_text relative" style="font-weight:bold;">Clubspot <a class="absoluteA" href="https://theclubspot.com"></a></p> 997 </div> 998 999 <p style="display:flex;font-family:roboto !important;" class="cs-footer-sublink-label roboto copyright_text noPointer">© 2026 Dillon Yacht Club, All rights reserved.</p> 1000 </div> 1001 </div> 1002 </div> 1003 </div> 1004 </div> 1005</div> 1006 1007 <!-- react native tab bar --> 1008 <native-tab-bar-component></native-tab-bar-component> 1009 1010 </div> 1011 <div id="v-overlays" class="hidden"> 1012 <!-- common --> 1013 <template v-if="overlays.show_push_notification"> 1014 <show-push-notification></show-push-notification> 1015 </template> 1016 <template v-if="overlays.overflow_overlay"> 1017 <overflow-overlay></overflow-overlay> 1018 </template> 1019 1020 <template v-if="overlays.view_all_events"> 1021 <view-all-events-overlay></view-all-events-overlay> 1022 </template> 1023 1024 <template v-if="overlays.view_photo_carousel"> 1025 <view-photo-carousel></view-photo-carousel> 1026 </template> 1027 <template v-if="overlays.show_event_details_in_member_cal"> 1028 <show-event-details-in-member-cal-overlay></show-event-details-in-member-cal-overlay> 1029 </template> 1030 1031 </div> 1032 <div id="overlay_membership-directory-popup" class="standardOverlay scrollable hugePaddingBottom"> 1033 <div class="standardOverlayCard centeredBlock eightyThenNinety overflowHidden relative"> 1034 <div class="xButton" onclick="fadeOut('#overlay_membership-directory-popup');"></div> 1035 <div class="profile-background overflowHidden"> 1036 <div class="profile-background-inner hundop" style="height:100%;"></div> 1037 </div> 1038 <div class="profile-picture color_scheme_accent_dark background capitalize" onclick="openDirectoryProfileImage(this)"></div> 1039 <!-- enlarged image overlay --> 1040 <div id="directory-image-lightbox" class="directory-image-lightbox" onclick="closeDirectoryProfileImage()"> 1041 <div class="directory-image-lightbox-inner"> 1042 <img class="directory-image-lightbox-img" src="" alt="Profile photo"> 1043 </div> 1044 </div>
1045 <div class="paddingAllAround smallMarginBottom"> 1046 1047 <p class="insert insert_name product-card-label"></p> 1048 1049 <div class="section-wrap tinyMarginTop tinyMarginBottom"> 1050 <p class="flexGrowOne inline-value" data-db-key="street"></p> 1051 <p class="flexGrowOne inline-value" data-db-key="city"></p> 1052 <p class="flexGrowOne inline-value" data-db-key="state"></p> 1053 <p class="flexGrowOne inline-value" data-db-key="zip"></p> 1054 </div> 1055 1056 <div class="section-wrap tinyMarginTop tinyMarginBottom"> 1057 <h4 class="smallBlockLetters leftText smallMarginTop">Contact</h4> 1058 <p class="flexGrowOne inline-value tinyPaddingTop" data-db-key="email"></p> 1059 <p class="flexGrowOne inline-value tinyPaddingTop" data-db-key="mobile"></p> 1060 </div> 1061 1062 <div class="section-wrap tinyMarginTop tinyMarginBottom"> 1063 <h4 class="smallBlockLetters leftText smallMarginTop">Secondary address</h4> 1064 <p class="flexGrowOne inline-value dark tinyPaddingTop" data-db-key="street_secondary"></p> 1065 <p class="flexGrowOne inline-value dark" data-db-key="city_secondary"></p> 1066 <p class="flexGrowOne inline-value dark" data-db-key="state_secondary"></p> 1067 <p class="flexGrowOne inline-value dark" data-db-key="zip_secondary"></p> 1068 </div> 1069 1070 <div class="section-wrap section-wrap_custom_fields tinyMarginTop tinyMarginBottom"> 1071 1072 </div> 1073 1074 <!-- boat fields --> 1075 <div class="section-wrap tinyMarginTop tinyMarginBottom"> 1076 <p class="flexGrowOne inline-value" data-db-key="boatName"></p> 1077 <p class="flexGrowOne inline-value" data-db-key="sailNumber" data-prepend="Sail #:"></p> 1078 <p class="flexGrowOne inline-value" data-db-key="hullNumber" data-prepend="Hull #:"></p> 1079 <p class="flexGrowOne inline-value" data-db-key="class" data-prepend="Class: "></p> 1080 <p class="flexGrowOne inline-value" data-db-key="registration" data-prepend="Registration #:"></p> 1081 </div> 1082 1083 <div class="section-wrap section-wrap_documents hidden tinyMarginTop tinyMarginBottom"> 1084 <h4 class="smallBlockLetters leftText smallMarginTop">Documents</h4> 1085 <!-- documents inserted here --> 1086 1087 </div> 1088 1089 <div class="section-wrap section-wrap_family tinyMarginTop tinyMarginBottom"> 1090 <h4 class="smallBlockLetters leftText smallMarginTop version_member">Family</h4> 1091 <h4 class="smallBlockLetters leftText smallMarginTop version_boat hidden">Related Members</h4> 1092 <!-- family members inserted here --> 1093 1094 </div> 1095 1096 <div class="section-wrap section-wrap_boats hidden tinyMarginTop tinyMarginBottom"> 1097 <h4 class="smallBlockLetters leftText smallMarginTop version_member">Boats</h4> 1098 <h4 class="smallBlockLetters leftText smallMarginTop version_boat hidden">Related boats</h4> 1099 <!-- boats inserted here --> 1100 1101 </div> 1102 1103 </div> 1104 </div> 1105</div> <div id="overlay_event-description-popup" class="standardOverlay scrollable hugePaddingBottom" style="display:none;opacity:0;"> 1106 <div class="standardOverlayCard centeredBlock eightyThenNinety" style="max-width:850px;"> 1107 <h3></h3> 1108 <div class="standardCardBody noPadding fff"> 1109 <div class="centeredBlock eightyThenNinety bigPaddingBottom marginTop"> 1110 <p class="inline-label smallerMarginBottom">Date</p> 1111 <p class="metric marginBottom leftText tinyMarginTop metric_start_date" style="margin-bottom:20px;"></p> 1112 <p class="inline-label smallerMarginBottom">Time</p> 1113 <p class="metric marginBottom leftText tinyMarginTop metric_start_date_time" style="margin-bottom:20px;"></p> 1114 <p class="inline-label smallerMarginBottom">Event Details</p> 1115 <div class="marginBottom leftText tinyMarginTop metric_description" style="line-height:1.5;"></div> 1116 </div> 1117 </div> 1118 <div class="standardCardFooter flexNoWrap"> 1119 <div class="flexGrowOne"></div> 1120 <button class="cardFooterButton easeFast" onclick="fadeOut('#overlay_event-description-popup');">Done</button> 1121 </div> 1122 </div> 1123</div> 1124 </body> 1125
1125<script> 1126 1127 //add the timezone to the global store 1128 store.timezone = $('body').data('timezone'); 1129 1130 //add to global store 1131 store.eventBus = ref({ 1132 listeners: {}, 1133 $on(event, callback) { 1134 if (!this.listeners[event]) { 1135 this.listeners[event] = []; 1136 } 1137 this.listeners[event].push(callback); 1138 }, 1139 $emit(event, ...args) { 1140 if (this.listeners[event]) { 1141 for (const callback of this.listeners[event]) { 1142 callback(...args); 1143 } 1144 } 1145 }, 1146 $off(event, callback) { 1147 if (!this.listeners[event]) { 1148 return; 1149 } 1150 if (!callback) { 1151 delete this.listeners[event]; 1152 return; 1153 } 1154 this.listeners[event] = this.listeners[event].filter(listener => listener !== callback); 1155 }, 1156 }); 1157 1158 //create the app 1159 var app_content = createApp({ 1160 data() { 1161 return store 1162 }, 1163 mounted() { 1164 console.log("mounted vue app: main content"); 1165 $('#v-content').removeClass('hidden'); 1166 var clubId = $('body').data('clubId'); 1167 if(clubId) { 1168 var query = new Parse.Query('clubs'); 1169 query.include('web_settings'); 1170 return query.get(clubId).then((foundClub) => { 1171 store.clubObject = foundClub; 1172 }) 1173 } 1174 }, 1175 methods: { 1176 1177 }, 1178 computed: { 1179 is_react_native_member_app(){ 1180 return store?.is_react_native_member_app || false; 1181 } 1182 } 1183 }); 1184 1185 //create the app 1186 var app_overlays = createApp({ 1187 data() { 1188 return store 1189 }, 1190 mounted() { 1191 console.log("mounted vue app: overlays"); 1192 $('#v-overlays').removeClass('hidden'); 1193 } 1194 }); 1195 1196 </script>
1196 1197 1198 <!-- define/register vue components --> 1199
1199<script type="text/html" id="template-custom-page-header"> 1200 1201 1202 <!-- globals --> 1203 1204 1205 1206 <!-- check the config --> 1207 1208 1209 1210 1211 1212 1213 1214 1215 <!-- color scheme --> 1216 1217 1218 1219 1220 1221 <!-- opacity --> 1222 1223 1224 1225 <!-- font-size --> 1226 1227 1228 1229 1230 <!-- font-weight --> 1231 1232 1233 1234 1235 <!-- header style (eg stacked vs inline vs centered) --> 1236 1237 1238 1239 1240 <!-- header position (eg relative vs absolute) --> 1241 1242 1243 1244 1245 <!-- sub-menu style (eg simple vs animated slide-down) --> 1246 1247 1248 1249 1250 1251 1252 1253 <!-- header background image, eg. for inverness yc --> 1254 1255 1256 <!-- burgee url --> 1257 1258 1259 <!-- ok, start header markup --> 1260 <div 1261 ref="header" 1262 class="cs-header easeFast absolute-header color_scheme_white cs-font-size-16 header-400 header-style_stacked header-position_relative" 1263 style="" 1264 1265 1266 > 1267 1268 <div class="cs-header-background background" style="opacity: 100%;"></div> 1269 1270 <div class="innerHeader align-items-middle flexNoWrap"> 1271 <div class="hamburgerWrap marketingMenu"> 1272 <div style="position: fixed; height: 1px; width: 1px; opacity: .01;"></div> 1273 <div class="hamburger hideOnDesktop"> 1274 1275 <div id="headerLineTop" class="ease"></div> 1276 <div id="headerLineMiddle" class="ease"></div> 1277 <div id="headerLineBottom" class="ease"></div> 1278 1279 </div> 1280 1281 <p class="burger-menu-text hideOnDesktop hideOnMobile">Menu</p> 1282 1283 </div> 1284 1285 1286 1287 1288 1289 <div class="cs-burgee-wrap hideOnMobile" style="height:118px;width:129px;"> 1290 <div class="cs-burgee" style="background-image:url(https://d282wvk2qi4wzk.cloudfront.net/q4qrmUsrDP_burgee_1677808999381);background-size:contain;"></div> 1291 <a href="/" class="absoluteA"></a> 1292 </div> 1293 1294 <div class="cs-burgee-wrap-mobile"> 1295 <div class="cs-burgee" style="background-image:url(https://d282wvk2qi4wzk.cloudfront.net/q4qrmUsrDP_burgee_1677808999381);background-size:contain;"></div> 1296 <a :href="burgee_link" class="absoluteA"></a> 1297 </div> 1298 1299 1300 1301 <div class="cs-desktop-menu-wrap flexGrowOne flexWrapRightAlign hideOnMobile header-style_stacked"> 1302 1303 1304 1305 1306 1307 1308 <div class="cs-header-link-wrap header-style_stacked"> 1309 <p class="button_link_p" style="">HOME</p> 1310 <!-- link --> 1311 1312 1313 <a class="absoluteA" href="/"></a> 1314 1315 <!-- handle sub-components --> 1316 1317 1318 </div> 1319 1320 1321 1322 1323 1324 <div class="cs-header-link-wrap header-style_stacked"> 1325 <p class="button_link_p" style="">OUR CLUB</p> 1326 <!-- link --> 1327 1328 1329 <!-- handle sub-components --> 1330 1331 1332 <div class="cs-header-sublink-outer easeFast"> 1333 1334 1335 1336 1337 1338 1339 <div class="cs-header-sublink-wrap relative" style="--sm-index:0;"> 1340 <p class="cs-header-sublink-label">Club History</p> 1341 1342 1343 1344 <a class="absoluteA" href="/club-history"></a> 1345 1346 </div> 1347 1348 1349 1350 1351 1352 1353 1354 1355 <div class="cs-header-sublink-wrap relative" style="--sm-index:1;"> 1356 <p class="cs-header-sublink-label">Our Board of Directors</p> 1357 1358 1359 1360 <a class="absoluteA" href="/board-of-directors"></a> 1361 1362 </div> 1363 1364 1365 1366 1367 1368 1369 1370 1371 <div class="cs-header-sublink-wrap relative" style="--sm-index:2;"> 1372 <p class="cs-header-sublink-label">Commodores</p> 1373 1374 1375 1376 <a class="absoluteA" href="/commodores"></a> 1377 1378 </div> 1379 1380 1381 1382 1383 1384 1385 1386 1387 <div class="cs-header-sublink-wrap relative" style="--sm-index:3;"> 1388 <p class="cs-header-sublink-label">Membership</p> 1389 1390 1391 1392 <a class="absoluteA" href="/membership"></a> 1393 1394 </div> 1395 1396 1397 1398 1399 1400 1401 1402 1403 <div class="cs-header-sublink-wrap relative" style="--sm-index:4;"> 1404 <p class="cs-header-sublink-label">Photo Gallery</p> 1405 1406 1407 1408 <a class="absoluteA" href="/photo-gallery"></a> 1409 1410 </div> 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 <div class="cs-header-sublink-wrap relative" style="--sm-index:6;"> 1427 <p class="cs-header-sublink-label">Map of Lake Dillon</p> 1428 1429 1430 1431 <a class="absoluteA" href="/map-of-lake-dillon"></a> 1432 1433 </div> 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 </div> 1445 1446 </div> 1447 1448 1449 1450 1451 1452 <div class="cs-header-link-wrap header-style_stacked"> 1453 <p class="button_link_p" style="">RACING</p> 1454 <!-- link --> 1455 1456 1457 <!-- handle sub-components --> 1458 1459 1460 <div class="cs-header-sublink-outer easeFast"> 1461 1462 1463 1464 1465 1466 1467 <div class="cs-header-sublink-wrap relative" style="--sm-index:0;"> 1468 <p class="cs-header-sublink-label">Fleets</p> 1469 1470 1471 1472 <a class="absoluteA" href="/fleets"></a> 1473 1474 </div> 1475 1476 1477 1478 1479 1480 1481 1482 1483 <div class="cs-header-sublink-wrap relative" style="--sm-index:1;"> 1484 <p class="cs-header-sublink-label">Racing Events</p> 1485 1486 1487 1488 <a class="absoluteA" href="/racing-events"></a> 1489 1490 </div> 1491 1492 1493 1494 1495 1496 1497 1498 1499 <div class="cs-header-sublink-wrap relative" style="--sm-index:2;"> 1500 <p class="cs-header-sublink-label">Dillon Open</p> 1501 1502 1503 1504 <a class="absoluteA" href="https://dillonopen.com/"></a> 1505 1506 </div> 1507 1508 1509 1510 1511 1512 1513 1514 1515 <div class="cs-header-sublink-wrap relative" style="--sm-index:3;"> 1516 <p class="cs-header-sublink-label">Race Committee</p> 1517 1518 1519 1520 <a class="absoluteA" href="/race-committee"></a> 1521 1522 </div> 1523 1524 1525 1526 1527 1528 1529 1530 1531 <div class="cs-header-sublink-wrap relative" style="--sm-index:4;"> 1532 <p class="cs-header-sublink-label">Crew Match</p> 1533 1534 1535 1536 <a class="absoluteA" href="/crew-match"></a> 1537 1538 </div> 1539 1540 1541 1542 1543 1544 1545 1546 1547
1547 <div class="cs-header-sublink-wrap relative" style="--sm-index:5;"> 1548 <p class="cs-header-sublink-label">DYC Dillon Marina Slip Wait List Policy</p> 1549 1550 1551 1552 <a class="absoluteA" href="/dyc-dillon-marina-slip-waitlist-policy-(public)"></a> 1553 1554 </div> 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 </div> 1566 1567 </div> 1568 1569 1570 1571 1572 1573 <div class="cs-header-link-wrap header-style_stacked"> 1574 <p class="button_link_p" style="">CALENDAR</p> 1575 <!-- link --> 1576 1577 1578 <a class="absoluteA" href="/calendar"></a> 1579 1580 <!-- handle sub-components --> 1581 1582 1583 </div> 1584 1585 1586 1587 1588 1589 <div class="cs-header-link-wrap header-style_stacked"> 1590 <p class="button_link_p" style="">JUNIOR SAILING</p> 1591 <!-- link --> 1592 1593 1594 <a class="absoluteA" href="https://www.dillonjuniorsailing.com/"></a> 1595 1596 <!-- handle sub-components --> 1597 1598 1599 </div> 1600 1601 1602 1603 1604 1605 <div class="cs-header-link-wrap header-style_stacked"> 1606 <p class="button_link_p" style="">CONTACT US</p> 1607 <!-- link --> 1608 1609 1610 <a class="absoluteA" href="/contact-us"></a> 1611 1612 <!-- handle sub-components --> 1613 1614 1615 </div> 1616 1617 1618 1619 1620 1621 <div class="cs-header-link-wrap link-style-button header-style_stacked"> 1622 <p class="button_link_p" style="background-color:#2856a1 !important; color:#ffffff !important;">MEMBERS</p> 1623 <!-- link --> 1624 1625 1626 <a class="absoluteA" href="/account/billing"></a> 1627 1628 <!-- handle sub-components --> 1629 1630 1631 <div class="cs-header-sublink-outer easeFast"> 1632 1633 1634 1635 1636 1637 1638 <div class="cs-header-sublink-wrap relative" style="--sm-index:0;"> 1639 <p class="cs-header-sublink-label">Directory</p> 1640 1641 1642 1643 <a class="absoluteA" href="/directory"></a> 1644 1645 </div> 1646 1647 1648 1649 1650 1651 1652 1653 1654 <div class="cs-header-sublink-wrap relative" style="--sm-index:1;"> 1655 <p class="cs-header-sublink-label">DYC By Laws</p> 1656 1657 1658 1659 <a class="absoluteA" href="/dyc-by-laws-2018"></a> 1660 1661 </div> 1662 1663 1664 1665 1666 1667 1668 1669 1670 <div class="cs-header-sublink-wrap relative" style="--sm-index:2;"> 1671 <p class="cs-header-sublink-label">DYC Wait List Policy</p> 1672 1673 1674 1675 <a class="absoluteA" href="/dyc-dillon-marina-wait-list-and-policy"></a> 1676 1677 </div> 1678 1679 1680 1681 </div> 1682 1683 </div> 1684 1685 1686 1687 </div> 1688 </div> 1689 </div> 1690 1691 <!-- now the mobile menu --> 1692 <div class="mobileMenuOverlay hideOnDesktop ease"></div> 1693 <div class="mobileMenu hideOnDesktop easeFast scrollable"> 1694 <div class="innerMenu"> 1695 <div class="mobile-link-wrap"> 1696 1697 1698 1699 <div class="cs-burgee-wrap" style="width:175px;height:60px;float:none;margin-top:-40px;margin-bottom:15px;"> 1700 <div class="cs-burgee" style="background-position:left;background-image:url(https://d282wvk2qi4wzk.cloudfront.net/q4qrmUsrDP_burgee_1677808999381);background-size:contain;margin-bottom:15px;"></div> 1701 <a :href="burgee_link" class="absoluteA"></a> 1702 </div> 1703 1704 1705 1706 <div class="mobile-sublink-outer easeFast"> 1707 1708 1709 <div class="header-sublink-wrap relative"> 1710 <p class="header-sublink-label">My Account</p> 1711 <p class="header-sublink-explain">Click below to view your account</p> 1712 <button class="wb-button-primary easeFast boxShadowBottom smallMarginTop"> 1713 View my account 1714 <a class="absoluteA" href="/account"></a> 1715 </button> 1716 </div> 1717 1718 1719 1720 1721 1722 1723 1724 <!-- component --> 1725 1726 1727 <p class="header-sublink-section-header smallMarginTop"> 1728 HOME 1729 <a class="absoluteA" href="/"></a> 1730 </p> 1731 1732 <!-- handle sub-components --> 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 <!-- component --> 1744 1745 1746 <p class="header-sublink-section-header smallMarginTop">OUR CLUB</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 Club History 1762 <a class="absoluteA" href="/club-history"></a> 1763 </p> 1764 1765 </div> 1766 1767 1768 1769 1770 1771 1772 1773 1774 <div class="cs-header-sublink-wrap relative"> 1775 1776 1777 <p class="cs-header-sublink-label"> 1778 Our Board of Directors 1779 <a class="absoluteA" href="/board-of-directors"></a> 1780 </p> 1781 1782 </div> 1783 1784 1785 1786 1787 1788 1789 1790 1791 <div class="cs-header-sublink-wrap relative"> 1792 1793 1794 <p class="cs-header-sublink-label"> 1795 Commodores 1796 <a class="absoluteA" href="/commodores"></a> 1797 </p> 1798 1799 </div> 1800 1801 1802 1803 1804 1805 1806 1807 1808 <div class="cs-header-sublink-wrap relative"> 1809 1810 1811 <p class="cs-header-sublink-label"> 1812 Membership 1813 <a class="absoluteA" href="/membership"></a> 1814 </p> 1815 1816 </div> 1817 1818 1819 1820 1821 1822 1823 1824 1825 <div class="cs-header-sublink-wrap relative"> 1826 1827 1828 <p class="cs-header-sublink-label"> 1829 Photo Gallery 1830 <a class="absoluteA" href="/photo-gallery"></a> 1831 </p> 1832 1833 </div> 1834 1835 1836 1837 1838 1839 1840 1841 1842 1843 1844 1845 1846 1847 1848 1849 <div class="cs-header-sublink-wrap relative"> 1850 1851 1852 <p class="cs-header-sublink-label"> 1853 Map of Lake Dillon 1854 <a class="absoluteA" href="/map-of-lake-dillon"></a> 1855 </p> 1856 1857 </div> 1858 1859 1860 1861 1862 1863 1864 1865 1866 1867 1868 1869 1870
1870 1871 1872 1873 1874 1875 1876 1877 <!-- component --> 1878 1879 1880 <p class="header-sublink-section-header smallMarginTop">RACING</p> 1881 1882 <!-- handle sub-components --> 1883 1884 1885 1886 1887 1888 1889 1890 1891 <div class="cs-header-sublink-wrap relative"> 1892 1893 1894 <p class="cs-header-sublink-label"> 1895 Fleets 1896 <a class="absoluteA" href="/fleets"></a> 1897 </p> 1898 1899 </div> 1900 1901 1902 1903 1904 1905 1906 1907 1908 <div class="cs-header-sublink-wrap relative"> 1909 1910 1911 <p class="cs-header-sublink-label"> 1912 Racing Events 1913 <a class="absoluteA" href="/racing-events"></a> 1914 </p> 1915 1916 </div> 1917 1918 1919 1920 1921 1922 1923 1924 1925 <div class="cs-header-sublink-wrap relative"> 1926 1927 1928 <p class="cs-header-sublink-label"> 1929 Dillon Open 1930 <a class="absoluteA" href="https://dillonopen.com/"></a> 1931 </p> 1932 1933 </div> 1934 1935 1936 1937 1938 1939 1940 1941 1942 <div class="cs-header-sublink-wrap relative"> 1943 1944 1945 <p class="cs-header-sublink-label"> 1946 Race Committee 1947 <a class="absoluteA" href="/race-committee"></a> 1948 </p> 1949 1950 </div> 1951 1952 1953 1954 1955 1956 1957 1958 1959 <div class="cs-header-sublink-wrap relative"> 1960 1961 1962 <p class="cs-header-sublink-label"> 1963 Crew Match 1964 <a class="absoluteA" href="/crew-match"></a> 1965 </p> 1966 1967 </div> 1968 1969 1970 1971 1972 1973 1974 1975 1976 <div class="cs-header-sublink-wrap relative"> 1977 1978 1979 <p class="cs-header-sublink-label"> 1980 DYC Dillon Marina Slip Wait List Policy 1981 <a class="absoluteA" href="/dyc-dillon-marina-slip-waitlist-policy-(public)"></a> 1982 </p> 1983 1984 </div> 1985 1986 1987 1988 1989 1990 1991 1992 1993 1994 1995 1996 1997 1998 1999 2000 2001 2002 2003 2004 <!-- component --> 2005 2006 2007 <p class="header-sublink-section-header smallMarginTop"> 2008 CALENDAR 2009 <a class="absoluteA" href="/calendar"></a> 2010 </p> 2011 2012 <!-- handle sub-components --> 2013 2014 2015 2016 2017 2018 2019 2020 2021 2022 2023 <!-- component --> 2024 2025 2026 <p class="header-sublink-section-header smallMarginTop"> 2027 JUNIOR SAILING 2028 <a class="absoluteA" href="https://www.dillonjuniorsailing.com/"></a> 2029 </p> 2030 2031 <!-- handle sub-components --> 2032 2033 2034 2035 2036 2037 2038 2039 2040 2041 2042 <!-- component --> 2043 2044 2045 <p class="header-sublink-section-header smallMarginTop"> 2046 CONTACT US 2047 <a class="absoluteA" href="/contact-us"></a> 2048 </p> 2049 2050 <!-- handle sub-components --> 2051 2052 2053 2054 2055 2056 2057 2058 2059 2060 2061 <!-- component --> 2062 2063 2064 <p class="header-sublink-section-header smallMarginTop"> 2065 MEMBERS 2066 <a class="absoluteA" href="/account/billing"></a> 2067 </p> 2068 2069 <!-- handle sub-components --> 2070 2071 2072 2073 2074 2075 2076 2077 2078 <div class="cs-header-sublink-wrap relative"> 2079 2080 2081 <p class="cs-header-sublink-label"> 2082 Directory 2083 <a class="absoluteA" href="/directory"></a> 2084 </p> 2085 2086 </div> 2087 2088 2089 2090 2091 2092 2093 2094 2095 <div class="cs-header-sublink-wrap relative"> 2096 2097 2098 <p class="cs-header-sublink-label"> 2099 DYC By Laws 2100 <a class="absoluteA" href="/dyc-by-laws-2018"></a> 2101 </p> 2102 2103 </div> 2104 2105 2106 2107 2108 2109 2110 2111 2112 <div class="cs-header-sublink-wrap relative"> 2113 2114 2115 <p class="cs-header-sublink-label"> 2116 DYC Wait List Policy 2117 <a class="absoluteA" href="/dyc-dillon-marina-wait-list-and-policy"></a> 2118 </p> 2119 2120 </div> 2121 2122 2123 2124 2125 2126 2127 2128 </div> 2129 </div> 2130 </div> 2131 </div> 2132 2133</script>
2133 2134 2135 2136
2137<script> 2138 2139 //define the component 2140 app_content.component("custom-page-header", { 2141 template: "#template-custom-page-header", 2142 data() { 2143 return {} 2144 }, 2145 props: {}, 2146 mounted(){ 2147 this.sync_submenu_edge_alignment(); 2148 window.addEventListener("resize", this.schedule_submenu_edge_alignment, { passive: true }); 2149 if( !this.$refs.header ){ 2150 return; 2151 } 2152 // Belt-and-suspenders: re-check right before a panel opens so a 2153 // stale measurement (from before nav content settled) can't leave 2154 // it hanging off-screen. 2155 this.$refs.header.addEventListener("mouseover", this.handle_submenu_hover_check); 2156 this.$refs.header.addEventListener("focusin", this.handle_submenu_hover_check); 2157 let sticky_header = this.$refs.header.classList.contains("cs-header-behavior_always_visible"); 2158 if( !sticky_header ){ 2159 return; 2160 } 2161 window.addEventListener("scroll", this.handle_header_scroll, { passive: true }); 2162 this.$nextTick(() => { 2163 window.requestAnimationFrame(() => { 2164 this.handle_header_scroll(); 2165 }); 2166 }); 2167 }, 2168 beforeUnmount(){ 2169 window.removeEventListener("scroll", this.handle_header_scroll); 2170 window.removeEventListener("resize", this.schedule_submenu_edge_alignment); 2171 window.clearTimeout(this._submenu_edge_alignment_timeout); 2172 if( this.$refs.header ){ 2173 this.$refs.header.removeEventListener("mouseover", this.handle_submenu_hover_check); 2174 this.$refs.header.removeEventListener("focusin", this.handle_submenu_hover_check); 2175 } 2176 }, 2177 methods: { 2178 // Left-aligned animated sub-menus can run past the right edge of the 2179 // viewport for nav items near the end of the menu - flip those to 2180 // hang from the right instead so they stay on screen. 2181 schedule_submenu_edge_alignment(){ 2182 window.clearTimeout(this._submenu_edge_alignment_timeout); 2183 this._submenu_edge_alignment_timeout = window.setTimeout(() => { 2184 this.sync_submenu_edge_alignment(); 2185 }, 150); 2186 }, 2187 handle_submenu_hover_check(event){ 2188 if( event.target.closest && event.target.closest(".cs-header-link-wrap") ){ 2189 this.sync_submenu_edge_alignment(); 2190 } 2191 }, 2192 sync_submenu_edge_alignment(){ 2193 if( !this.$refs.header ){ 2194 return; 2195 } 2196 let is_animated = this.$refs.header.classList.contains("cs-header-submenu-style_animated_buttons") 2197 || this.$refs.header.classList.contains("cs-header-submenu-style_animated_flat"); 2198 if( !is_animated ){ 2199 return; 2200 } 2201 let panels = this.$refs.header.querySelectorAll(".cs-header-sublink-outer"); 2202 let viewport_width = window.innerWidth || document.documentElement.clientWidth || 0; 2203 panels.forEach((panel) => { 2204 panel.classList.remove("cs-header-sublink-outer_align-right"); 2205 if( panel.getBoundingClientRect().right > viewport_width - 8 ){ 2206 panel.classList.add("cs-header-sublink-outer_align-right"); 2207 } 2208 }); 2209 }, 2210 handle_header_scroll(){ 2211 if( !this.$refs.header ){ 2212 return; 2213 } 2214 let scroll_y = window.scrollY || window.pageYOffset || 0; 2215 let header_rect = this.$refs.header.getBoundingClientRect(); 2216 let first_component = document.querySelector("#v-content .cs-first-page-section") 2217 || document.querySelector("#v-content .cs-custom-code-wrap.first-page-section"); 2218 let first_component_rect = first_component 2219 ? first_component.getBoundingClientRect() 2220 : null; 2221 let viewport_height = window.innerHeight || document.documentElement.clientHeight || 0; 2222 let transition_mode = this.$refs.header.getAttribute("data-header-transition-mode") 2223 || 'phased'; 2224 let was_past_handoff = !!this._header_past_handoff;
2225 let past_full_handoff = !!( 2226 first_component_rect 2227 && first_component_rect.height > 0 2228 && first_component_rect.bottom <= ( 2229 header_rect.bottom + ( was_past_handoff ? 8 : -8 ) 2230 ) 2231 ) || ( 2232 viewport_height > 0 2233 && scroll_y >= viewport_height + ( was_past_handoff ? -8 : 8 ) 2234 ); 2235 let immediate_threshold = Math.max(1, Math.round(header_rect.height / 2)); 2236 let header_past_handoff = transition_mode === 'immediate' 2237 ? scroll_y >= immediate_threshold 2238 : scroll_y > 1 && past_full_handoff; 2239 this._header_past_handoff = header_past_handoff; 2240 let early_offset = Math.max(8, Math.round(header_rect.height * 0.2)); 2241 let early_default_gradient = scroll_y >= early_offset 2242 && !header_past_handoff 2243 && transition_mode === 'phased' 2244 && this.$refs.header.classList.contains("cs-header-background-on-scroll"); 2245 this.apply_header_background_for_scroll(header_past_handoff); 2246 if( early_default_gradient ){ 2247 this.sync_header_background_rgb(); 2248 } 2249 this.$refs.header.classList.remove("cs-header-following-section-gradient"); 2250 this.$refs.header.classList.toggle( 2251 "cs-header-early-default-gradient", 2252 early_default_gradient 2253 ); 2254 }, 2255 apply_header_background_for_scroll(is_scrolled){ 2256 if( !this.$refs.header ){ 2257 return; 2258 } 2259 let top_raw = this.$refs.header.getAttribute("data-header-bg-top"); 2260 let scrolled_raw = this.$refs.header.getAttribute("data-header-bg-scrolled"); 2261 if( !top_raw ){ 2262 return; 2263 } 2264 let top = {}; 2265 let scrolled = {}; 2266 try { 2267 top = JSON.parse(top_raw) || {}; 2268 scrolled = JSON.parse(scrolled_raw || top_raw) || top; 2269 } catch (error) { 2270 return; 2271 } 2272 let active = is_scrolled ? scrolled : top; 2273 let previous_text_color = this.$refs.header.className.match(/color_scheme_[^\s]+/); 2274 if( previous_text_color ){ 2275 this.$refs.header.classList.remove(previous_text_color[0]); 2276 } 2277 this.$refs.header.classList.add( 2278 "color_scheme_" + ( active.color || 'white' ) 2279 ); 2280 let previous_mode = this.$refs.header.className.match(/cs-header-background-mode-[^\s]+/); 2281 if( previous_mode ){ 2282 this.$refs.header.classList.remove(previous_mode[0]); 2283 } 2284 let previous_blur = this.$refs.header.className.match(/cs-header-blur-(light|medium|strong)/); 2285 if( previous_blur ){ 2286 this.$refs.header.classList.remove(previous_blur[0]); 2287 } 2288 let previous_color = this.$refs.header.className.match(/cs-header-background-color-[^\s]+/); 2289 if( previous_color ){ 2290 this.$refs.header.classList.remove(previous_color[0]); 2291 } 2292 this.$refs.header.classList.add("cs-header-background-mode-" + ( active.mode || 'none' )); 2293 this.$refs.header.classList.add( 2294 "cs-header-background-color-" + ( active.color || 'white' ) 2295 ); 2296 if( active.mode === 'blur_tint' ){ 2297 this.$refs.header.classList.add("cs-header-blur-" + ( active.blur_amount || 'medium' )); 2298 } 2299 if( active.opacity !== undefined ){ 2300 this.$refs.header.style.setProperty( 2301 '--cs-header-background-opacity', 2302 String(active.opacity / 100) 2303 ); 2304 } 2305 this.$refs.header.classList.toggle( 2306 "cs-header-top-visible", 2307 !is_scrolled && active.mode !== 'none' 2308 ); 2309 this.$refs.header.classList.toggle( 2310 "cs-header-blur-active", 2311 is_scrolled && active.mode !== 'none' 2312 ); 2313 let header_visible = !( is_scrolled && active.mode === 'none' ); 2314 this.$refs.header.style.opacity = header_visible ? '' : '0'; 2315 this.$refs.header.style.visibility = header_visible ? '' : 'hidden'; 2316 this.$refs.header.style.pointerEvents = header_visible ? '' : 'none'; 2317 },
2318 // Sample the real sticky fill (incl. club custom CSS) so the interim 2319 // gradient never falls back to overlay navy / a mismatched default. 2320 sync_header_background_rgb(){ 2321 if( !this.$refs.header ){ 2322 return; 2323 } 2324 let scrolled_raw = this.$refs.header.getAttribute("data-header-bg-scrolled"); 2325 if( scrolled_raw ){ 2326 try { 2327 let scrolled = JSON.parse(scrolled_raw) || {}; 2328 let previous_color = this.$refs.header.className.match(/cs-header-background-color-[^\s]+/); 2329 if( previous_color ){ 2330 this.$refs.header.classList.remove(previous_color[0]); 2331 } 2332 this.$refs.header.classList.add( 2333 "cs-header-background-color-" + ( scrolled.color || 'white' ) 2334 ); 2335 if( scrolled.opacity !== undefined ){ 2336 this.$refs.header.style.setProperty( 2337 '--cs-header-background-opacity', 2338 String(scrolled.opacity / 100) 2339 ); 2340 } 2341 } catch (error) { 2342 return; 2343 } 2344 } 2345 let bg = this.$refs.header.querySelector('.cs-header-background'); 2346 if( !bg ){ 2347 return; 2348 } 2349 let color = window.getComputedStyle(bg).backgroundColor || ''; 2350 let match = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([0-9.]+))?/.exec(color); 2351 if( !match ){ 2352 return; 2353 } 2354 let alpha = match[4] === undefined ? 1 : Number(match[4]); 2355 if( !( alpha > 0 ) ){ 2356 return; 2357 } 2358 this.$refs.header.style.setProperty( 2359 '--cs-header-background-rgb', 2360 match[1] + ', ' + match[2] + ', ' + match[3] 2361 ); 2362 }, 2363 sync_section_gradient_height(section_height_px){ 2364 if( !this.$refs.section_gradient || !this.$refs.header ){ 2365 return; 2366 } 2367 let transfer_raw = this.$refs.header.getAttribute("data-header-top-fade-transfer"); 2368 if( !transfer_raw ){ 2369 return; 2370 } 2371 let transfer = null; 2372 try { 2373 transfer = JSON.parse(transfer_raw); 2374 } catch (error) { 2375 return; 2376 } 2377 if( !transfer || transfer.kind === 'legacy_fade' ){ 2378 return; 2379 } 2380 if( !section_height_px || section_height_px <= 0 ){ 2381 return; 2382 } 2383 this.$refs.section_gradient.style.height = 2384 ( section_height_px * ( transfer.height_percent / 100 ) ) + "px"; 2385 }, 2386 }, 2387 computed: { 2388 burgee_link(){ 2389 //starting june 18 2026 we don't show this custom page header component in the member app at all.. 2390 /* 2391 if( this.is_react_native_member_app ){ 2392 return "/account" 2393 } 2394 */ 2395 return "/" 2396 }, 2397 current_user(){ 2398 return Parse.User.current() 2399 }, 2400 }, 2401 }); 2402 2403 2404</script>
2404
2404<script type="text/html" id="template-image-grid"> 2405 <template v-if="!image_grid_layout || image_grid_layout !== 'carousel'"> 2406 <div 2407 class="image-grid-wrap centeredBlock" 2408 :class="'image-grid-wrap_' + client_id + ' images_per_row_' + images_per_row + ' ' + class_string" 2409 > 2410 <!-- draggable start for image-grid-image --> 2411 <div 2412 v-if="!show_first_row_skeleton && images?.length > 0" 2413 class="image-grid-insert flexWrapLeftAlign" 2414 :class=" is_admin ? 'draggable-container_image_grid_' + component.id : '' " 2415 > 2416 <template v-for="(image, index) in images" :key="image?.id"> 2417 <div 2418 v-if="image && image.url" 2419 class="image-grid-image relative" 2420 :class="'image-grid-image_' + image?.id" 2421 :id="image?.id" 2422 > 2423 <!-- video --> 2424 <div 2425 v-if="image.is_video" 2426 class="image-grid-image-inner" 2427 :class="{ 'image-grid-fade-media': !is_admin, 'is-loaded': image_has_loaded(image) }" 2428 style="align-items:center;display:flex;" 2429 :style="{'border-radius': image_grid_border_radius + 'px' }" 2430 > 2431 <video class="cs-background-video" autoplay playsinline loop muted :src="image.url" style="display:block;"></video> 2432 <div v-if="is_admin" class="image-grid-drag-handle-wrap draggable-handle"> 2433 <i class="fa-regular fa-grip-dots image-grid-drag-handle"></i> 2434 </div> 2435 </div> 2436 <!-- image --> 2437 <div 2438 v-else 2439 class="image-grid-image-inner" 2440 :class="{ 'image-grid-fade-media': !is_admin, 'is-loaded': image_has_loaded(image) }" 2441 :style="{'background-image': 'url(' + image.url + ')', 'border-radius': image_grid_border_radius + 'px' }" 2442 > 2443 <div v-if="is_admin" class="image-grid-drag-handle-wrap draggable-handle"> 2444 <i class="fa-regular fa-grip-dots image-grid-drag-handle"></i> 2445 </div> 2446 </div> 2447 <!-- overlay --> 2448 <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)"> 2449 <div class="image-toolbar-wrapper flexNoWrap centeredBlock" @click.stop> 2450 <div 2451 class="image-toolbar-option easeFast" 2452 data-option="change" 2453 style="border-right:1px solid #fff;" 2454 @click.stop="change_image(image)" 2455 >Change</div> 2456 <div 2457 class="image-toolbar-option easeFast" 2458 data-option="update" 2459 style="border-right:1px solid #fff;" 2460 @click.stop="prepThenDisplayImageSettings(image)" 2461 >Update</div> 2462 <div 2463 class="image-toolbar-option easeFast" 2464 data-option="remove" 2465 @click.stop="tapped_remove_image(image)" 2466 ><i class="fa-regular fa-trash" style="font-size:14px;"></i></div> 2467 </div> 2468 <div class="image-text-wrapper" :style="{ alignItems: text_position === 'start' ? 'flex-start' : text_position === 'center' ? 'center' : 'flex-end' }"> 2469 <h2 2470 v-if="image.caption" 2471 class="image-grid-image-caption" 2472 :style="{ 'font-size': caption_size + 'px', 'font-weight': caption_font_weight }" 2473 >{{ image.caption }}</h2> 2474 <p 2475 v-if="image.subtext" 2476 class="image-grid-image-caption marginTopFour" 2477 :style="{ 'font-size': subtext_size + 'px', 'font-weight': caption_font_weight }" 2478 >{{ image.subtext }}</p> 2479 <p 2480 v-if="image.link_label" 2481 class="image-grid-image-caption marginTopFour" 2482 style="text-decoration: underline;" 2483 :style="{ 'font-size': link_label_size + 'px', 'font-weight': caption_font_weight }" 2484 >{{ image.link_label }}</p> 2485 </div> 2486 </div> 2487 <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' }"> 2488 <div class="image-text-wrapper" :style="{ alignItems: text_position === 'start' ? 'flex-start' : text_position === 'center' ? 'center' : 'flex-end' }"> 2489 <h2 2490 v-if="image.caption" 2491 class="image-grid-image-caption" 2492 :style="{ 'font-size': caption_size + 'px', 'font-weight': caption_font_weight }" 2493 >{{ image.caption }}</h2> 2494 <p 2495 v-if="image.subtext" 2496 class="image-grid-image-caption marginTopFour" 2497 :style="{ 'font-size': subtext_size + 'px', 'font-weight': caption_font_weight }" 2498 >{{ image.subtext }}</p> 2499 <p 2500 v-if="image.link_label" 2501 class="image-grid-image-caption marginTopFour" 2502 style="text-decoration: underline;" 2503 :style="{ 'font-size': link_label_size + 'px', 'font-weight': caption_font_weight }" 2504 >{{ image.link_label }}</p> 2505 <a 2506 v-if="return_link_href(image.link_to)" 2507 class="absoluteA" 2508 :href="return_link_href(image.link_to)" 2509 rel="noopener noreferrer" 2510 ></a> 2511 </div> 2512 </div> 2513 </div> 2514 <div 2515 v-else-if="is_admin && image && image.placeholder" 2516 class="image-grid-image relative cs-image-slot-placeholder-host" 2517 :class="'image-grid-image_' + image?.id" 2518 :id="image?.id" 2519 @click.stop="fill_image_placeholder(image)" 2520 > 2521 <div class="image-grid-image-inner cs-image-slot-placeholder" :style="{ 'border-radius': image_grid_border_radius + 'px' }">
2522 <span class="cs-image-slot-placeholder-icon"><i class="fa-regular fa-image"></i></span> 2523 <span class="cs-image-slot-placeholder-label">Add {{ image.label || 'image' }}</span> 2524 </div> 2525 </div> 2526 </template> 2527 <!-- upload image **for admin --> 2528 <template v-if="is_admin"> 2529 <div class="image-grid-image image-grid-image_upload non-draggable"> 2530 <div class="image-grid-image-inner"> 2531 <div class="uploadImageZone relative pointer"> 2532 <input 2533 @change="imageUploadChange($event)" 2534 class="image-grid-upload-input absoluteInput" 2535 type="file" accept="image/*,video/*" 2536 > 2537 <p class="centeredBlock" style="font-family:roboto !important;">+ Upload image/video</p> 2538 <div class="uploadImageOverlay"> 2539 <div class="uploadImageSpinner centeredBlock"></div> 2540 </div> 2541 </div> 2542 </div> 2543 </div> 2544 </template> 2545 </div> 2546 <!-- draggable end for image-grid-image --> 2547 <div v-if="!show_first_row_skeleton && images?.length === 0 && is_admin" class="image-grid-insert flexWrapLeftAlign"> 2548 <div class="image-grid-image image-grid-image_upload"> 2549 <div class="image-grid-image-inner"> 2550 <div class="uploadImageZone relative pointer"> 2551 <input 2552 @change="imageUploadChange($event)" 2553 class="image-grid-upload-input absoluteInput" 2554 type="file" accept="image/*,video/*" 2555 > 2556 <p class="centeredBlock" style="font-family:roboto !important;">+ Upload image/video</p> 2557 <div class="uploadImageOverlay"> 2558 <div class="uploadImageSpinner centeredBlock"></div> 2559 </div> 2560 </div> 2561 </div> 2562 </div> 2563 </div> 2564 <div v-if="show_first_row_skeleton" class="image-grid-insert flexWrapLeftAlign"> 2565 <div 2566 v-for="n in skeleton_tile_count" 2567 :key="'image-grid-skeleton-' + n" 2568 class="image-grid-image relative" 2569 > 2570 <div 2571 class="image-grid-image-inner image-grid-skeleton-tile" 2572 :style="{ 'border-radius': image_grid_border_radius + 'px' }" 2573 ></div> 2574 </div> 2575 </div> 2576 </div> 2577 </template> 2578 <template v-else> 2579 2580 <div style="box-sizing: border-box; padding: 20px;" class="event-list-wrap centeredBlock centeredText" :class="'event-list-wrap_' + client_id"> 2581 <!-- <div 2582 class="flexNoWrap align-items-center event_list_carousel_default" 2583 style="padding: 0px 0px 15px 5px;" 2584 > 2585 <p>PLACEHOLDER</p> 2586 </div> --> 2587 <div 2588 class="image-grid-wrap centeredBlock" 2589 :class="'image-grid-wrap_' + client_id + class_string" 2590 style="padding: 20px;" 2591 > 2592 <div 2593 class="tableInsert event-list-card-wrapper" 2594 style="overflow: scroll !important; scrollbar-width: 5px !important; flex-wrap: nowrap;" 2595 :style="{ 'gap': formatting_obj.card_spacing ? Number(formatting_obj.card_spacing) + 'px' : '25px' }" 2596 > 2597 <template v-if="show_first_row_skeleton"> 2598 <div 2599 v-for="n in skeleton_tile_count" 2600 :key="'image-grid-carousel-skeleton-' + n" 2601 class="event-list-card noPadding noMargin" 2602 :class="{ 2603 'small-card-image-grid': formatting_obj.card_size == 'small', 2604 'medium-card-image-grid': !formatting_obj.card_size || formatting_obj.card_size == 'medium', 2605 'large-card-image-grid': formatting_obj.card_size == 'large', 2606 }" 2607 :style="{'border-radius': image_grid_border_radius + 'px' ||'0px' }" 2608 > 2609 <div class="image-grid-image relative"> 2610 <div 2611 class="image-grid-event-image image-grid-skeleton-tile" 2612 :style="{'border-radius': image_grid_border_radius + 'px' }" 2613 ></div> 2614 </div> 2615 <div 2616 v-if="formatting_obj.caption_align === 'under'" 2617 class="image-grid-card-content background" 2618 :style="{'border-radius': image_grid_border_radius + 'px' ||'0px' }" 2619 > 2620 <div class="image-grid-skeleton-caption image-grid-skeleton-tile"></div> 2621 </div> 2622 </div> 2623 </template> 2624 <template 2625 v-else-if="images && images.length > 0" 2626 v-for="(image, index) in images" 2627 :key="image?.id" 2628 > 2629 <div 2630 v-if="image && image.url" 2631 class="event-list-card noPadding noMargin" 2632 :class="{ 2633 'small-card-image-grid': formatting_obj.card_size == 'small', 2634 'medium-card-image-grid': !formatting_obj.card_size || formatting_obj.card_size == 'medium', 2635 'large-card-image-grid': formatting_obj.card_size == 'large', 2636 'box-shadow-light': formatting_obj.box_shadow == 'light', 2637 'box-shadow-dark': formatting_obj.box_shadow == 'dar
2637k', 2638 'pointer': !is_admin, 2639 }" 2640 :style="{'border-radius': image_grid_border_radius + 'px' ||'0px' }" 2641 @click.stop="clicked_carousel_image(image, $event)" 2642 > 2643 <div 2644 v-if="image && image.url" 2645 class="image-grid-image relative" 2646 :class="'image-grid-image_' + image?.id" 2647 :id="image?.id" 2648 > 2649 <!-- video --> 2650 <div 2651 v-if="image.is_video" 2652 class="image-grid-event-image" 2653 :class="{ 'image-grid-fade-media': !is_admin, 'is-loaded': image_has_loaded(image) }" 2654 style="align-items:center;display:flex;" 2655 :style="{'border-radius': image_grid_border_radius + 'px' }" 2656 > 2657 <video class="cs-background-video" autoplay playsinline loop muted :src="image.url" style="display:block;"></video> 2658 </div> 2659 <!-- image --> 2660 <div 2661 v-else 2662 class="image-grid-event-image" 2663 :class="{ 'image-grid-fade-media': !is_admin, 'is-loaded': image_has_loaded(image) }" 2664 :style="{'background-image': 'url(' + image.url + ')', 'border-radius': image_grid_border_radius + 'px' }" 2665 ></div> 2666 <!-- overlay --> 2667 <div 2668 @click.stop="change_image(image)" 2669 v-if="is_admin" 2670 class="image-grid-image-overlay flexWrap easeFast" 2671 style="left:0px;right:0px;bottom:0px;top:0px" 2672 :style="{ 'background-color': returnRGBAStringForImageGridImageOverlayOpacity( image_grid_opacity ), 'border-radius': image_grid_border_radius + 'px' }" 2673 > 2674 <div class="image-toolbar-wrapper flexNoWrap centeredBlock" @click.stop> 2675 <div 2676 class="image-toolbar-option easeFast" 2677 data-option="change" 2678 style="border-right:1px solid #fff;" 2679 @click.stop="change_image(image)" 2680 >Change</div> 2681 <div 2682 class="image-toolbar-option easeFast" 2683 data-option="update" 2684 style="border-right:1px solid #fff;" 2685 @click.stop="prepThenDisplayImageSettings(image)" 2686 >Update</div> 2687 <div 2688 class="image-toolbar-option easeFast" 2689 data-option="remove" 2690 @click.stop="tapped_remove_image(image)" 2691 ><i class="fa-regular fa-trash" style="font-size:14px;"></i></div> 2692 </div> 2693 <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' }"> 2694 <h2 2695 v-if="image.caption" 2696 class="image-grid-image-caption" 2697 :style="{ 'font-size': caption_size + 'px', 'font-weight': caption_font_weight }" 2698 >{{ image.caption }}</h2> 2699 <p 2700 v-if="image.subtext" 2701 class="image-grid-image-caption marginTopFour" 2702 :style="{ 'font-size': subtext_size + 'px', 'font-weight': caption_font_weight }" 2703 >{{ image.subtext }}</p> 2704 <p 2705 v-if="image.link_label" 2706 class="image-grid-image-caption marginTopFour" 2707 style="text-decoration: underline;" 2708 :style="{ 'font-size': link_label_size + 'px', 'font-weight': caption_font_weight }" 2709 >{{ image.link_label }}</p> 2710 </div> 2711 </div> 2712 <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)"> 2713 <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' }"> 2714 <h2 2715 v-if="image.caption" 2716 class="image-grid-image-caption" 2717 :style="{ 'font-size': caption_size + 'px', 'font-weight': caption_font_weight }" 2718 >{{ image.caption }}</h2> 2719 <p 2720 v-if="image.subtext" 2721 class="image-grid-image-caption marginTopFour" 2722 :style="{ 'font-size': subtext_size + 'px', 'font-weight': caption_font_weight }" 2723 >{{ image.subtext }}</p> 2724 <p 2725 v-if="image.link_label" 2726 class="image-grid-image-caption marginTopFour" 2727 style="text-decoration: underline;" 2728 :style="{ 'font-size': link_label_size + 'px', 'font-weight': caption_font_weight }" 2729 >{{ image.link_label }}</p> 2730 </div> 2731 <a 2732 v-if="return_link_href(image.link_to)" 2733 class="absoluteA" 2734 :href="return_link_href(image.link_to)" 2735 rel="noopener noreferrer" 2736 ></a> 2737 </div> 2738 </div> 2739 <div 2740 v-if="formatting_obj.caption_align === 'under'" 2741 class="image-grid-card-content background" 2742 :style="{'border-radius': image_grid_border_radius + 'px' ||'0px' }" 2743 > 2744 <div class="image-text-wrapper" :style="{ alignItems: text_position === 'start' ? 'flex-start' : text_position === 'center' ? 'center' : 'flex-end' }"> 2745 <h2 2746 v-if="image.caption" 2747 class="image-grid-image-caption visible" 2748 :style="{ 'font-size': caption_size + 'px', 'font-weight': caption_font_weight }" 2749 >{{ image.caption }}</h2> 2750 <p 2751 v-if="image.subtext" 2752 class="image-grid-image-caption visible marginTopFour" 2753 :style="{ 'font-size': subtext_size + 'px', 'font-weight': caption_font_weight }" 2754 >{{ image.subtext }}</p> 2755 <p 2756 v-if="image.link_label" 2757 class="image-grid-image-caption visible marginTopFour" 2758 style="text-decoration: underline;" 2759 :style="{ 'font-size': link_label_size + 'px', 'font-weight': caption_font_weight }" 2760 > 2761 <a 2762 v-if="return_link_href(image.link_to)" 2763 :href="return_link_href(image.link_to)" 2764 rel="noopener noreferrer" 2765 >{{ image.link_label }}</a> 2766 <template v-else>{{ image.link_label }}</template> 2767 </p> 2768 </div> 2769 </div> 2770 </div> 2771 <div 2772 v-else-if="is_admin && image && image.placeholder" 2773 class="event-list-card noPadding noMargin cs-image-slot-placeholder-host pointer" 2774 :class="{ 2775 'small-card-image-grid': formatting_obj.card_size == 'small', 2776 'medium-card-image-grid': !formatting_obj.card_size || formatting_obj.card_size == 'medium', 2777 'large-card-image-grid': formatting_obj.card_size == 'large', 2778 }" 2779 :style="{'border-radius': image_grid_border_radius + 'px' ||'0px' }" 2780 @click.stop="fill_image_placeholder(image)" 2781 > 2782 <div class="image-grid-image relative"> 2783 <div class="image-grid-event-image cs-image-slot-placeholder">
2784 <span class="cs-image-slot-placeholder-icon"><i class="fa-regular fa-image"></i></span> 2785 <span class="cs-image-slot-placeholder-label">Add {{ image.label || 'image' }}</span> 2786 </div> 2787 </div> 2788 </div> 2789 </template> 2790 <!-- upload image **for admin --> 2791 <template v-if="is_admin"> 2792 <div 2793 class="event-list-card pointer" 2794 :class="{ 2795 'small-card-image-grid': formatting_obj.card_size == 'small', 2796 'medium-card-image-grid': !formatting_obj.card_size || formatting_obj.card_size == 'medium', 2797 'large-card-image-grid': formatting_obj.card_size == 'large', 2798 }" 2799 > 2800 <div class="image-grid-image relative upload-image-grid-image-zone"> 2801 <div class="image-grid-event-image"></div> 2802 <input 2803 @change="imageUploadChange($event)" 2804 class="image-grid-upload-input absoluteInput" 2805 type="file" accept="image/*,video/*" 2806 > 2807 <p class="centeredBlock" style="font-family:roboto !important;">+ Upload image/video</p> 2808 <!-- <div class="uploadImageOverlay"> 2809 <div class="uploadImageSpinner centeredBlock"></div> 2810 </div> --> 2811 </div> 2812 <div 2813 v-if="formatting_obj.caption_align === 'under'" 2814 class="image-grid-card-content" 2815 :style="{'border-radius': image_grid_border_radius + 'px' ||'0px' }" 2816 > 2817 <div class="image-text-wrapper" :style="{ alignItems: text_position === 'start' ? 'flex-start' : text_position === 'center' ? 'center' : 'flex-end' }"> 2818 <!-- PLACEHOLDER --> 2819 </div> 2820 </div> 2821 </div> 2822 </template> 2823 </div> 2824 </div> 2825 </div> 2826 </template> 2827</script>
2827 2828 2829<style> 2830 .cs-image-slot-placeholder-host { 2831 cursor: pointer; 2832 } 2833 .cs-image-slot-placeholder { 2834 display: flex; 2835 flex-direction: column; 2836 align-items: center; 2837 justify-content: center; 2838 gap: 8px; 2839 width: 100%; 2840 height: 100%; 2841 min-height: 140px; 2842 background: #f1f4f8; 2843 border: 1.5px dashed #b7c1d1; 2844 color: #5f6f8d; 2845 text-align: center; 2846 box-sizing: border-box; 2847 } 2848 .cs-image-slot-placeholder-icon { 2849 font-size: 22px; 2850 } 2851 .cs-image-slot-placeholder-label { 2852 font-family: roboto !important; 2853 font-size: 14px; 2854 font-weight: 600; 2855 } 2856 .image-grid-image.dragging{ 2857 overflow: hidden; 2858 border-radius: 10px; 2859 } 2860 .image-grid-image.dragging .image-grid-image-inner { 2861 transition: all 0.35s ease-in-out; 2862 border: 1.2px solid #fff; 2863 border-radius: 10px; 2864 z-index: 1000; 2865 transform: scale(1.1); 2866 cursor: grab; 2867 cursor: move; 2868 } 2869 .image-grid-image.dragging .image-grid-drag-handle-wrap { 2870 display: none; 2871 position: relative; 2872 } 2873 .event-list-card { 2874 overflow: hidden; 2875 background: white; 2876 font-family: 'Inter', sans-serif; 2877 display: flex; 2878 flex-direction: column; 2879 flex: 0 0 auto; 2880 transition: transform 0.1s ease; 2881 } 2882 .event-list-card.small-card-image-grid { 2883 width: 190px; 2884 } 2885 .event-list-card.medium-card-image-grid { 2886 width: 230px; 2887 } 2888 .event-list-card.large-card-image-grid { 2889 width: 300px; 2890 } 2891 .event-list-card.box-shadow-light { 2892 box-shadow: 2px 4px 8px rgba(0, 0, 0, 0.2); 2893 } 2894 .event-list-card.box-shadow-dark { 2895 box-shadow: 6px 6px 16px rgba(0, 0, 0, 0.2); 2896 } 2897 .event-list-card .image-grid-image { 2898 width: 100%; 2899 flex-basis: auto; 2900 aspect-ratio: 1 / 1; 2901 } 2902 .event-list-card .image-grid-image:before { 2903 display: none; 2904 } 2905 .event-list-card .image-grid-event-image { 2906 display: flex; 2907 text-align: center; 2908 align-items: center; 2909 width: 100%; 2910 height: 100%; 2911 background-size: cover; 2912 background-repeat: no-repeat; 2913 background-position: center; 2914 color: #fff; 2915 position: absolute; 2916 pointer-events: none; 2917 } 2918 .event-list-card .image-grid-event-image * { 2919 pointer-events: none; 2920 } 2921 .event-list-card .image-grid-image-overlay { 2922 z-index: 1; 2923 } 2924 .event-list-card .image-grid-carousel-live-overlay { 2925 pointer-events: auto; 2926 cursor: pointer; 2927 } 2928 .image-grid-card-content { 2929 height: 100px; 2930 padding: 12px 5px; 2931 } 2932 .image-grid-skeleton-tile { 2933 position: relative; 2934 background-color: #e8edf1; 2935 overflow: hidden; 2936 margin-right: 0; 2937 } 2938 .image-grid-skeleton-tile::after { 2939 background: linear-gradient(40deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0)); 2940 display: block; 2941 content: ""; 2942 position: absolute; 2943 width: 100%; 2944 height: 100%; 2945 transform: translateX(-100%); 2946 top: 0; 2947 left: 0; 2948 animation: coming_soon 0.75s infinite; 2949 } 2950 .image-grid-skeleton-caption { 2951 height: 16px; 2952 width: 70%; 2953 border-radius: 4px; 2954 margin: 8px auto 0; 2955 } 2956 .image-grid-fade-media { 2957 opacity: 0; 2958 transition: opacity 0.4s ease; 2959 } 2960 .image-grid-fade-media.is-loaded { 2961 opacity: 1; 2962 } 2963 .upload-image-grid-image-zone{ 2964 color: #323d63; 2965 text-align: center; 2966 border-radius: 2px; 2967 background-color: #f1f5f9; 2968 position: relative; 2969 display: flex; 2970 align-items: center; 2971 } 2972</style> 2973
2974<script> 2975 2976 app_content.component("image-grid-component", { 2977 template: "#template-image-grid", 2978 data() { 2979 return { 2980 global: store, 2981 loading: false, 2982 first_row_ready: false, 2983 loaded_image_ids: {}, 2984 }; 2985 }, 2986 props: { 2987 client_id: String, 2988 class_string: String, 2989 image_grid_layout: String, 2990 images_per_row: [String, Number], 2991 caption_size: String, 2992 subtext_size: String, 2993 link_label_size: String, 2994 text_position: String, 2995 caption_font_weight: String, 2996 image_grid_opacity: String, 2997 images_string: String, 2998 image_grid_border_radius: String, 2999 // carousel 3000 formatting: String, 3001 // admin 3002 is_admin: { 3003 type: Boolean, 3004 default: false, 3005 }, 3006 data_source_type: String, 3007 images_arr: Array, 3008 component: Object, 3009 last_update: Number, 3010 }, 3011 mounted() { 3012 this.preload_first_row(); 3013 this.$nextTick(() => { 3014 if (this.is_admin && this.images_arr){ 3015 this.makeElementsSortable_image_grid(); 3016 } 3017 }) 3018 }, 3019 methods: { 3020 preload_first_row(){ 3021 if( this.is_admin ){ 3022 this.first_row_ready = true; 3023 return; 3024 } 3025 let list = this.images || []; 3026 let count = this.first_row_slot_count; 3027 let slice = []; 3028 for( let i = 0; i < list.length && slice.length < count; i++ ){ 3029 let entry = list[i]; 3030 if( entry && entry.url && !entry.placeholder && !entry.is_video ){ 3031 slice.push(entry); 3032 } 3033 } 3034 if( slice.length === 0 ){ 3035 this.first_row_ready = true; 3036 this.preload_remaining_images(); 3037 return; 3038 } 3039 let remaining = slice.length; 3040 let finish = () => { 3041 this.first_row_ready = true; 3042 this.preload_remaining_images(); 3043 }; 3044 setTimeout(finish, 2500); 3045 slice.forEach((entry) => { 3046 this.preload_image_entry(entry, () => { 3047 remaining -= 1; 3048 if( remaining <= 0 ){ 3049 finish(); 3050 } 3051 }); 3052 }); 3053 }, 3054 preload_remaining_images(){ 3055 let list = this.images || []; 3056 list.forEach((entry) => { 3057 this.preload_image_entry(entry); 3058 }); 3059 }, 3060 preload_image_entry( entry, on_done ){ 3061 if( !entry || !entry.id ){ 3062 if( on_done ){ 3063 on_done(); 3064 } 3065 return; 3066 } 3067 if( entry.is_video || !entry.url || entry.placeholder ){ 3068 this.mark_image_loaded(entry.id); 3069 if( on_done ){ 3070 on_done(); 3071 } 3072 return; 3073 } 3074 if( this.loaded_image_ids[entry.id] ){ 3075 if( on_done ){ 3076 on_done(); 3077 } 3078 return; 3079 } 3080 let img = new Image(); 3081 img.onload = () => { 3082 this.mark_image_loaded(entry.id); 3083 if( on_done ){ 3084 on_done(); 3085 } 3086 }; 3087 img.onerror = () => { 3088 this.mark_image_loaded(entry.id); 3089 if( on_done ){ 3090 on_done(); 3091 } 3092 }; 3093 img.src = entry.url; 3094 },
3095 mark_image_loaded( id ){ 3096 if( !id ){ 3097 return; 3098 } 3099 let next = Object.assign({}, this.loaded_image_ids); 3100 next[id] = true; 3101 this.loaded_image_ids = next; 3102 }, 3103 image_has_loaded( image ){ 3104 if( this.is_admin ){ 3105 return true; 3106 } 3107 if( !image || !image.id ){ 3108 return false; 3109 } 3110 if( image.is_video ){ 3111 return true; 3112 } 3113 return !!this.loaded_image_ids[image.id]; 3114 }, 3115 makeElementsSortable_image_grid(){ 3116 //now create the new instance 3117 const containerSelector = `.draggable-container_image_grid_${this.component.id}`; 3118 const containers = document.querySelectorAll(containerSelector); 3119 if (containers?.length === 0) { 3120 return false; 3121 } 3122 const sortable = new Draggable.Sortable(containers, { 3123 draggable: '.image-grid-image:not(.non-draggable)', 3124 handle: '.draggable-handle', 3125 }); 3126 //now register listeners 3127 sortable.on('sortable:start', event => { 3128 var target = event.data.dragEvent.source; 3129 target.classList.add('dragging'); 3130 }); 3131 sortable.on('sortable:stop', event => { 3132 var target = event.data.dragEvent.source; 3133 target.classList.remove('dragging'); 3134 //now handle ui updates inside a timeout due to a draggable library issue (https://github.com/Shopify/draggable/issues/92) 3135 setTimeout(() => { 3136 this.handle_sortable_stop( event ); 3137 }, 0) 3138 }); 3139 }, 3140 handle_sortable_stop( event ){ 3141 let targetId = event.data.dragEvent.source.id; 3142 let dropElement = document.getElementById(targetId); 3143 var reordered_ids = Array.from(event.newContainer.children).map(event => event.id); 3144 reordered_ids.pop(); //pop off last value which is an empty string placeholder for image upload 3145 var reordered_images = []; 3146 reordered_ids.forEach(image_id => { 3147 //find the image 3148 var this_image = null; 3149 this.images.forEach(image => { 3150 if( image.id == image_id ){ 3151 this_image = image 3152 } 3153 }); 3154 if( this_image ){ 3155 reordered_images.push( this_image ); 3156 } 3157 }); 3158 //save changes 3159 save_changes_for_component( this.component, 'images', reordered_images ); 3160 }, 3161 returnRGBAStringForImageGridImageOverlayOpacity( opacity ){ 3162 if( ! opacity && opacity !== 0 ){ 3163 opacity = 40; 3164 } 3165 return "rgba(8,8,19,"+ opacity / 100 +")"; 3166 }, 3167 clicked_carousel_image( image, event ){ 3168 if( this.is_admin ){ 3169 return; 3170 } 3171 if( event && event.target && event.target.closest && event.target.closest('a') ){ 3172 return; 3173 } 3174 // An image with a link navigates instead of opening the lightbox. 3175 let href = this.return_link_href( image && image.link_to ); 3176 if( href ){ 3177 window.location.href = href; 3178 return; 3179 } 3180 this.open_carousel_lightbox( image ); 3181 }, 3182 // link_to is saved raw from the image settings input, so: 3183 // - a bare domain needs a protocol or it resolves as a relative path 3184 // - anything that isn't a usable link returns '' so callers fall back 3185 // to the lightbox instead of navigating nowhere 3186 return_link_href( link_to ){ 3187 if( ! link_to ){ 3188 return ''; 3189 } 3190 let trimmed = String( link_to ).trim(); 3191 if( /^(https?:\/\/|mailto:|tel:)\S/i.test( trimmed ) ){ 3192 return trimmed; 3193 } 3194 if( /^\//.test( trimmed ) || /^#\S/.test( trimmed ) ){ 3195 return trimmed; 3196 } 3197 if( /^[a-z0-9][a-z0-9-]*(\.[a-z0-9-]+)+([\/?#]\S*)?$/i.test( trimmed ) ){ 3198 return 'https://' + trimmed; 3199 } 3200 return ''; 3201 }, 3202 lightbox_file_name( entry ){ 3203 let url = (entry && entry.url) || ''; 3204 let from_url = url.split('?')[0].split('/').pop(); 3205 if( entry && entry.is_video ){ 3206 if( from_url && from_url.indexOf('.') !== -1 ){ 3207 return from_url; 3208 } 3209 return 'video.mp4'; 3210 } 3211 return from_url || 'photo.jpg'; 3212 }, 3213 open_carousel_lightbox( image ){ 3214 if( !store.components || !store.components.view_photo_carousel ){ 3215 return; 3216 } 3217 let photos = (this.images || []).filter((entry) => { 3218 return entry && entry.url && !entry.placeholder; 3219 }); 3220 if( photos.length === 0 ){ 3221 return; 3222 } 3223 let current_index = photos.findIndex((entry) => { 3224 return image && entry.id === image.id; 3225 }); 3226 if( current_index < 0 ){ 3227 current_index = 0; 3228 } 3229 store.components.view_photo_carousel.json_photos = photos.map((entry) => { 3230 return { 3231 id: entry.id, 3232 url: entry.url, 3233 caption: entry.caption, 3234 file_name: this.lightbox_file_name(entry), 3235 }; 3236 }); 3237 store.components.view_photo_carousel.current_index = current_index; 3238 store.components.view_photo_carousel.json_config = { 3239 context: 'web_builder', 3240 icon_only_actions: true, 3241 allow_download: false, 3242 allow_share: true, 3243 show_caption: true, 3244 caption_size: this.caption_size || 16, 3245 caption_font_weight: this.caption_font_weight || 400, 3246 blur_background: false, 3247 }; 3248 store.overlays.view_photo_carousel = true; 3249 }, 3250 confirmed_remove_image( data ){ 3251 var image = data.image; 3252 var component = data.component; 3253 //find existing images 3254 var images = component.get('config_latest').images || []; 3255 //loop through to remove this one 3256 var new_images = []; 3257 for (var i = 0; i <= images?.length - 1; i++) { 3258 if( images[i].id !== image.id ){ 3259 new_images.push( images[i] ); 3260 } 3261 } 3262 //now save and update the preview 3263 save_changes_for_component( component, 'images', new_images ); 3264 store.overlays.are_you_sure = false;
3265 }, 3266 // Agent-placed empty slot. Open the shared image-slot picker; on a 3267 // pick, write the chosen/imported URL into this slot and clear the 3268 // placeholder flag, so it renders like any uploaded tile. 3269 fill_image_placeholder( image ){ 3270 if( typeof webbuilder_open_image_slot_picker !== 'function' ){ 3271 if( typeof displayMessage === 'function' ){ 3272 displayMessage('Image picker is not loaded. Refresh the page and try again.', true, 5000); 3273 } 3274 return; 3275 } 3276 let label = (image && image.label) ? image.label : 'image'; 3277 let slot_id = image && image.id; 3278 webbuilder_open_image_slot_picker({ 3279 label: label, 3280 mode: 'placeholder', 3281 on_pick: (url) => { 3282 if( !url || !slot_id ){ 3283 return; 3284 } 3285 let current = (this.component.get('config_latest') && this.component.get('config_latest').images) || []; 3286 let next = current.map((entry) => { 3287 if( !entry || entry.id !== slot_id ){ 3288 return entry; 3289 } 3290 let updated = Object.assign({}, entry, { url: url, is_video: false }); 3291 delete updated.placeholder; 3292 return updated; 3293 }); 3294 save_changes_for_component( this.component, 'images', next ); 3295 }, 3296 on_keep_empty: () => { 3297 if( !slot_id ){ 3298 return; 3299 } 3300 let current = (this.component.get('config_latest') && this.component.get('config_latest').images) || []; 3301 let next = current.filter((entry) => entry && entry.id !== slot_id); 3302 save_changes_for_component( this.component, 'images', next ); 3303 }, 3304 }); 3305 }, 3306 // Filled tile "Change" action â swap the image via the shared picker 3307 // (mirrors custom-code's attached-slot menu: pick/paste/copy-external/ 3308 // remove). Caption/link editing stays on the existing Update toolbar. 3309 change_image( image ){ 3310 if( typeof webbuilder_open_image_slot_picker !== 'function' ){ 3311 if( typeof displayMessage === 'function' ){ 3312 displayMessage('Image picker is not loaded. Refresh the page and try again.', true, 5000); 3313 } 3314 return; 3315 } 3316 let slot_id = image && image.id; 3317 if( !slot_id ){ 3318 return; 3319 } 3320 webbuilder_open_image_slot_picker({ 3321 label: 'image', 3322 mode: 'attached', 3323 current_url: (image && image.url) || '', 3324 on_pick: (url) => { 3325 if( !url ){ 3326 return; 3327 } 3328 let current = (this.component.get('config_latest') && this.component.get('config_latest').images) || []; 3329 let next = current.map((entry) => { 3330 if( !entry || entry.id !== slot_id ){ 3331 return entry; 3332 } 3333 return Object.assign({}, entry, { url: url, is_video: false }); 3334 }); 3335 save_changes_for_component( this.component, 'images', next ); 3336 }, 3337 on_remove: () => { 3338 this.confirmed_remove_image({ image: image, component: this.component }); 3339 }, 3340 }); 3341 }, 3342 tapped_remove_image( image ){ 3343 //ask for confirmation 3344 var params = {}; 3345 params["fn"] = this.confirmed_remove_image; 3346 params["title"] = "Remove image"; 3347 params["subtext"] = "Are you sure you want to permanently remove this tile from the image grid?"; 3348 params["button_label"] = "Remove"; 3349 params["data"] = { 3350 "image": image, 3351 "component": this.component 3352 }; 3353 are_you_sure( params ); 3354 return 3355 }, 3356 prepThenDisplayImageSettings(imageObject){ 3357 store.components.image_settings_overlay.componentObject = this.component; 3358 store.components.image_settings_overlay.imageObject = imageObject; 3359 store.components.image_settings_overlay.settings = { 3360 link_input: imageObject.link_to || '', 3361 caption_input: imageObject.caption || '', 3362 subtext_input: imageObject.subtext || '', 3363 link_label_input: imageObject.link_label || '', 3364 } 3365 store.components.image_settings_overlay.image_grid_props = { 3366 client_id: this.client_id, 3367 caption_size: this.caption_size, 3368 caption_font_weight: this.caption_font_weight, 3369 };
3370 this.global.overlays.image_settings_overlay = !this.global.overlays.image_settings_overlay; 3371 this.$nextTick(() => { 3372 this.updatePositionForImageSettings( imageObject ); 3373 }); 3374 }, 3375 updatePositionForImageSettings(imageObject){ 3376 var image_object = imageObject; 3377 if( ! image_object ){ 3378 return 3379 } 3380 var element = $(".image-grid-image_" + image_object.id); 3381 if( ! element ){ 3382 return 3383 } 3384 var offset = element.offset(); 3385 var top = offset.top; 3386 var popupHeight = $('.image-settings').outerHeight(true) || 0; 3387 var above_top = top - ( popupHeight + 10 ); 3388 var viewportTop = $(window).scrollTop(); 3389 var correct_top = above_top >= viewportTop + 10 ? above_top : top + 45; 3390 store.components.image_settings_overlay.position = { 3391 top: `${correct_top}px`, 3392 left: `${offset.left}px` 3393 }; 3394 }, 3395 imageUploadChange($event){ 3396 var file = event.target.files[0]; 3397 var componentObject = this.component; 3398 if(file && componentObject){ 3399 //update the ui 3400 $(this).parents('.uploadImageZone').addClass('loading'); 3401 //set attributes 3402 var type = 'image_grid'; 3403 var timestamp = Date.now(); 3404 //check the file type 3405 var is_video = this.returnIsVideo( file['name'] ) || false; 3406 if( is_video ){ 3407 //ok, it's a video 3408 //.. handle the video 3409 var file_name = componentObject.get('client_id')+"_grid_video_"+timestamp; 3410 return uploadFileToAWSV2( file, file_name ).then(( url )=>{ 3411 this.handleUploadedImageGridImage( url, componentObject, true ); 3412 }); 3413 }else{ 3414 //assume it's an image! 3415 var file_name = componentObject.get('client_id')+"_grid_image_"+timestamp; 3416 //handle the image 3417 loadCleanImage(file, this.handleCleanImage_image_grid, type, file_name, componentObject.get('client_id') ); 3418 } 3419 } 3420 //reset input value to detect change if same file is uploaded 3421 event.target.value = ''; 3422 }, 3423 returnIsVideo(filename) { 3424 var ext = this.getExtension(filename); 3425 switch (ext.toLowerCase()) { 3426 case 'm4v': 3427 case 'avi': 3428 case 'mpg': 3429 case 'mp4': 3430 case 'mp3': 3431 case 'mov': 3432 // etc 3433 return true; 3434 } 3435 return false; 3436 }, 3437 getExtension(filename) { 3438 var parts = filename.split('.'); 3439 return parts[parts?.length - 1]; 3440 }, 3441 completed_crop(params){ 3442 const { fileType, is_video, file_name, blob, componentObject } = params; 3443 //upload it 3444 return uploadFileToAWSV2( blob, file_name ).then(( url )=>{ 3445 this.handleUploadedImageGridImage( url, componentObject, is_video ); 3446 }); 3447 }, 3448 handleCleanImage_image_grid( canvas, type, file_name, componentID, image_index, is_video, fileType ){ 3449 $('body').data('can', canvas); 3450 let componentObject = this.component; 3451 if(!fileType) { 3452 fileType = 'image/jpeg' 3453 } 3454 var url = canvas.toDataURL(fileType); 3455 var params = {}; 3456 params["url"] = url; 3457 params["fn"] = this.completed_crop; 3458 params["componentID"] = componentID; 3459 params["fileType"] = fileType; 3460 params["is_video"] = is_video || false; 3461 params["file_name"] = file_name; 3462 params["componentObject"] = componentObject; 3463 launch_image_cropper( params ); 3464 }, 3465 handleUploadedImageGridImage(url, componentObject, is_video){ 3466 if( componentObject ){ 3467 //it's still in the dom 3468 //.. update it 3469 var images = componentObject.get('config_latest')?.images || []; 3470 images.push({ 3471 "id": generateClientID(), 3472 "url": url, 3473 "is_video": is_video 3474 }); 3475 save_changes_for_component( this.component, 'images', images ); 3476 //store.eventBus.$emit('saveChangesForComponent', this.component, option, value) 3477 //message 3478 if( is_video ){ 3479 displayMessage("Video uploaded. It may take a moment for your video to appear.", false, 5000); 3480 }else{ 3481 displayMessage("Image uploaded.", false, 3000); 3482 } 3483 } 3484 }, 3485 }, 3486 computed: { 3487 images(){ 3488 if (!this.is_admin && this.images_string){ 3489 try { 3490 const parsedObject = JSON.parse(this.images_string); 3491 // Drop unfilled agent placeholders on the public site â 3492 // they are an admin-only "Add image" affordance. 3493 return Array.isArray(parsedObject) 3494 ? parsedObject.filter((entry) => entry && entry.url && !entry.placeholder) 3495 : parsedObject; 3496 } catch (error) { 3497 console.error('Error parsing JSON: ', error) 3498 } 3499 } else if (this.is_admin && this.images_arr){ 3500 return this.images_arr || []; 3501 } 3502 }, 3503 formatting_obj(){ 3504 if (this.formatting){ 3505 return JSON.parse(this.formatting); 3506 } 3507 return {}; 3508 }, 3509 first_row_slot_count(){ 3510 if( this.image_grid_layout === 'carousel' ){ 3511 let size = this.formatting_obj.card_size; 3512 if( size === 'small' ){ 3513 return 5; 3514 } 3515 if( size === 'large' ){ 3516 return 3; 3517 } 3518 return 4; 3519 } 3520 let per_row = Number(this.images_per_row); 3521 if( !per_row || per_row < 1 ){ 3522 return 3; 3523 } 3524 return per_row; 3525 }, 3526 skeleton_tile_count(){ 3527 let images = this.images; 3528 let row = this.first_row_slot_count; 3529 if( images && images.length > 0 && images.length < row ){ 3530 return images.length; 3531 } 3532 return row; 3533 }, 3534 show_first_row_skeleton(){ 3535 if( this.is_admin ){ 3536 return false;
3537 } 3538 return !this.first_row_ready; 3539 }, 3540 }, 3541 }) 3542 3543</script>
3543
3543<script type="text/html" id="template-photo-gallery"> 3544 <div class="photo-gallery-wrap centeredBlock centeredText"> 3545 <div v-if="loading" class="photo-gallery"> 3546 <div 3547 v-for="n in skeleton_tile_count" 3548 :key="'photo-gallery-skeleton-' + n" 3549 class="photo-container" 3550 :style="calculate_container_dimensions()" 3551 > 3552 <div class="photo-cover photo-gallery-skeleton-tile"></div> 3553 </div> 3554 </div> 3555 <!-- hosted site --> 3556 <div v-if="!loading && !is_admin"> 3557 <div 3558 v-if="photo_album_id && json_photos" 3559 class="photo-gallery" 3560 > 3561 <div 3562 v-for="(photo, index) in json_photos" 3563 class="photo-container" 3564 :id="photo.id" 3565 @click="clicked_gallery_photo( index )" 3566 :style="calculate_container_dimensions()" 3567 > 3568 <video v-if="photo && returnIsVideo(photo.file_name)" class="video-cover paddingTop" autoplay muted loop playsinline> 3569 <source :src="photo.url" type="video/mp4"> 3570 </video> 3571 <div 3572 v-else-if="photo && photo.url" 3573 class="photo-cover" 3574 :class="{ 'photo-cover-fade': !is_admin, 'is-loaded': photo_has_loaded(photo) }" 3575 :style="'background-image:url('+encodeURI(photo.url)+')'" 3576 ></div> 3577 <!-- overlay --> 3578 <div class="photo-overlay"></div> 3579 </div> 3580 </div> 3581 </div> 3582 <!-- admin --> 3583 <div v-if="!loading && is_admin"> 3584 <div 3585 v-if="!photo_album_id" 3586 @click="link_photo_album()" 3587 class="noObjectsMSG centeredBlock centeredText" 3588 style="padding: 5em;" 3589 > 3590 <p style="cursor: pointer; font-family: 'Roboto', 'Arial' !important;"><i class="fa-light fa-images smallMarginRight"></i>Click to link a photo album</p> 3591 </div> 3592 <div 3593 v-else-if="photo_album_id && json_photos" 3594 class="photo-gallery" 3595 > 3596 <div 3597 v-for="(photo, index) in json_photos" 3598 class="photo-container" 3599 :id="photo.id" 3600 @click="clicked_gallery_photo( index )" 3601 :style="calculate_container_dimensions()" 3602 > 3603 <video v-if="photo && returnIsVideo(photo.file_name)" class="video-cover paddingTop" autoplay muted loop playsinline> 3604 <source :src="photo.url" type="video/mp4"> 3605 </video> 3606 <div 3607 v-else-if="photo && photo.url" 3608 class="photo-cover" 3609 :class="{ 'photo-cover-fade': !is_admin, 'is-loaded': photo_has_loaded(photo) }" 3610 :style="'background-image:url('+encodeURI(photo.url)+')'" 3611 ></div> 3612 <!-- overlay --> 3613 <div class="photo-overlay"></div> 3614 </div> 3615 </div> 3616 </div> 3617 </div> 3618</script>
3618 3619 3620<style> 3621 .photo-gallery-wrap{ 3622 height: fit-content; 3623 width: 100%; 3624 } 3625 .photo-gallery{ 3626 display: flex; 3627 flex-wrap: wrap; 3628 align-items: center; 3629 justify-content: flex-start; /* center for mobile */ 3630 } 3631 .photo-container { 3632 position: relative; 3633 box-sizing: border-box; 3634 margin: 0.3em; 3635 text-align: center; 3636 } 3637 .photo-container .video-cover { 3638 width: 100%; 3639 object-fit: cover; 3640 } 3641 .photo-container > .photo-cover { 3642 height: 100%; 3643 width: inherit; 3644 display: block; 3645 overflow: hidden; 3646 margin: 0 auto; 3647 background-size: cover; 3648 background-position: center; 3649 background-repeat: no-repeat; 3650 opacity: 1; 3651 } 3652 .photo-container > .photo-cover.photo-cover-fade { 3653 opacity: 0; 3654 transition: opacity 0.4s ease; 3655 } 3656 .photo-container > .photo-cover.photo-cover-fade.is-loaded { 3657 opacity: 1; 3658 } 3659 .photo-overlay{ 3660 position: absolute; 3661 display: flex; 3662 align-items: center; 3663 justify-content: center; 3664 top: 0; 3665 left: 0; 3666 height: 100%; 3667 width: 100%; 3668 opacity: 0; 3669 background-color: rgba(8,8,19,0.3); 3670 transition: opacity 0.2s ease-out; 3671 } 3672 .photo-container:hover .photo-overlay { 3673 cursor: pointer; 3674 opacity: 0.8 !important; 3675 } 3676 .photo-gallery-skeleton-tile { 3677 position: relative; 3678 background-color: #e8edf1; 3679 overflow: hidden; 3680 margin-right: 0; 3681 height: 100%; 3682 width: 100%; 3683 border-radius: 2px; 3684 } 3685 .photo-gallery-skeleton-tile::after { 3686 background: linear-gradient(40deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0)); 3687 display: block; 3688 content: ""; 3689 position: absolute; 3690 width: 100%; 3691 height: 100%; 3692 transform: translateX(-100%); 3693 top: 0; 3694 left: 0; 3695 animation: coming_soon 0.75s infinite; 3696 } 3697</style> 3698
3699<script> 3700 3701 app_content.component("photo-gallery-component", { 3702 template: "#template-photo-gallery", 3703 data() { 3704 return { 3705 global: store, 3706 loading: true, 3707 json_photos: [], 3708 loaded_photo_ids: {}, 3709 }; 3710 }, 3711 props: { 3712 client_id: String, 3713 is_admin: Boolean, 3714 component: Object, 3715 last_update: Number, 3716 photo_album: [String, Object], 3717 show_caption: Boolean, 3718 caption_size: Number, 3719 caption_font_weight: Number, 3720 photos_per_row: Number, 3721 }, 3722 mounted() { 3723 if( !this.photo_album_id ){ 3724 this.loading = false; 3725 return; 3726 } 3727 this.load_photo_albums( this.client_id ); 3728 }, 3729 methods: { 3730 calculate_container_dimensions(){ 3731 const percentage = 100 / this.photos_per_row; 3732 const isMobile = window.innerWidth <= 600; 3733 if ( isMobile ){ 3734 return { 3735 flex: `1 1 calc(${percentage}% - 0.6em)`, 3736 maxWidth: `calc(${percentage}% - 0.6em)`, 3737 height: `calc(${100}px * (${percentage} / 100) * 2.5)`, 3738 }; 3739 } 3740 return { 3741 flex: `1 1 calc(${percentage}% - 0.6em)`, 3742 maxWidth: `calc(${percentage}% - 0.6em)`, 3743 height: `calc(${500}px * (${percentage} / 100) * 2.5)`, 3744 }; 3745 }, 3746 load_photo_albums( client_id, overrides = {} ){ 3747 this.loading = true; 3748 var promise = Parse.Promise.as(); 3749 var thisPromise = promise.then(() => { 3750 var params = {}; 3751 params["client_id"] = client_id; 3752 params["photo_album_id"] = this.photo_album_id; 3753 params["overrides"] = overrides; 3754 params["request_url"] = window.location.href; 3755 return Parse.Cloud.run('retrieve_data_for_component', params); 3756 }).then(retrieved_photos => { 3757 this.json_photos = retrieved_photos; 3758 this.preload_gallery_photos(retrieved_photos); 3759 return Parse.Promise.as(); 3760 }).then(() => { 3761 this.loading = false; 3762 }, error => { 3763 checkParseError(error); 3764 this.loading = false; 3765 }); 3766 return thisPromise; 3767 }, 3768 clicked_gallery_photo( index ){ 3769 store.components.view_photo_carousel.json_photos = this.json_photos; 3770 store.components.view_photo_carousel.current_index = index; 3771 store.components.view_photo_carousel.json_config = { 3772 context: 'web_builder', 3773 icon_only_actions: true, 3774 allow_download: false, 3775 allow_share: true, 3776 show_caption: this.show_caption || false, 3777 caption_size: this.caption_size || 16, 3778 caption_font_weight: this.caption_font_weight || 400, 3779 blur_background: false, 3780 }; 3781 store.overlays.view_photo_carousel = true; 3782 }, 3783 link_photo_album(){ 3784 store.components.link_photo_album.component = this.component; 3785 store.components.link_photo_album.last_update = this.last_update; 3786 store.overlays.link_photo_album = true; 3787 }, 3788 returnIsVideo(filename) { 3789 var ext = this.getExtension(filename); 3790 switch (ext.toLowerCase()) { 3791 case 'm4v': 3792 case 'avi': 3793 case 'mpg': 3794 case 'mp4': 3795 case 'mp3': 3796 case 'mov': 3797 // etc 3798 return true; 3799 } 3800 return false;
3801 }, 3802 getExtension(filename) { 3803 var parts = filename.split('.'); 3804 return parts[parts.length - 1]; 3805 }, 3806 preload_gallery_photos( photos ){ 3807 if( !photos || !photos.length ){ 3808 return; 3809 } 3810 photos.forEach((photo) => { 3811 if( !photo || !photo.id ){ 3812 return; 3813 } 3814 if( this.returnIsVideo(photo.file_name) || !photo.url ){ 3815 this.mark_photo_loaded(photo.id); 3816 return; 3817 } 3818 let img = new Image(); 3819 img.onload = () => { 3820 this.mark_photo_loaded(photo.id); 3821 }; 3822 img.onerror = () => { 3823 this.mark_photo_loaded(photo.id); 3824 }; 3825 img.src = photo.url; 3826 }); 3827 }, 3828 mark_photo_loaded( id ){ 3829 if( !id ){ 3830 return; 3831 } 3832 let next = Object.assign({}, this.loaded_photo_ids); 3833 next[id] = true; 3834 this.loaded_photo_ids = next; 3835 }, 3836 photo_has_loaded( photo ){ 3837 if( this.is_admin ){ 3838 return true; 3839 } 3840 if( !photo || !photo.id ){ 3841 return false; 3842 } 3843 if( this.returnIsVideo(photo.file_name) ){ 3844 return true; 3845 } 3846 return !!this.loaded_photo_ids[photo.id]; 3847 }, 3848 }, 3849 computed: { 3850 photo_album_obj(){ 3851 if (this.photo_album && typeof this.photo_album === 'string'){ 3852 return JSON.parse(unescape(decodeURIComponent(this.photo_album))); 3853 } else if (this.photo_album){ 3854 return this.photo_album; 3855 } 3856 return; 3857 }, 3858 photo_album_id(){ 3859 let album = this.photo_album_obj; 3860 if( !album ){ 3861 return null; 3862 } 3863 return album.id || null; 3864 }, 3865 skeleton_tile_count(){ 3866 let per_row = Number(this.photos_per_row); 3867 if( !per_row || per_row < 1 ){ 3868 return 4; 3869 } 3870 return per_row; 3871 }, 3872 photo_album_name(){ 3873 return this.photo_album_obj.name || null; 3874 }, 3875 }, 3876 watch: { 3877 last_update(newVal, oldVal){ 3878 if (newVal !== oldVal){ 3879 this.load_photo_albums( this.client_id ); 3880 } 3881 } 3882 } 3883 }) 3884 3885</script>
3885
3885<script type="text/html" id="template-membership-calendar"> 3886 <!-- calendar component --> 3887 <div v-if="current_date"class="lightBorderTop hundop"> 3888 <calendar-booking-component 3889 ref="bookingCalendar" 3890 :is_admin="false" 3891 :rerender_key="rerender_key" 3892 :calendar_type="'web_component'" 3893 :default_calendar_tags="formatted_event_types" 3894 :selected_tags="selected_tags" 3895 :events="filtered_events" 3896 :view_preference="view_preference" 3897 :current_date="current_date" 3898 :calendar_tags="calendar_tags" 3899 :timezone="timezone" 3900 :height="'initial'" 3901 :filter_logic="filter_logic" 3902 :loading_parent="loading" 3903 :ui_tag_ids_default="ui_tag_ids_default" 3904 :ui_tag_ids_custom="ui_tag_ids_custom" 3905 :disable_filtering="!allow_user_filtering" 3906 @change_date="change_date" 3907 @change_view_preference="change_view_preference" 3908 @clicked_default_tag="clicked_default_tag" 3909 @clicked_calendar_tag="clicked_calendar_tag" 3910 @clicked_event="clicked_event" 3911 @clicked_show_more_events="clicked_show_more_events" 3912 @zoom_in_to_date="zoom_in_to_date" 3913 @add_event="clicked_day_cell" 3914 @isolate_default_tag="isolate_default_tag" 3915 @select_all_default_tags="select_all_default_tags" 3916 @clear_all_default_tags="clear_all_default_tags" 3917 @select_visible_default_tags="select_visible_default_tags" 3918 @clear_visible_default_tags="clear_visible_default_tags" 3919 @isolate_calendar_tag="isolate_calendar_tag" 3920 @select_all_custom_tags="select_all_custom_tags" 3921 @clear_all_custom_tags="clear_all_custom_tags" 3922 @select_visible_custom_tags="select_visible_custom_tags" 3923 @clear_visible_custom_tags="clear_visible_custom_tags" 3924 @unselect_all_custom_tags="unselect_all_custom_tags" 3925 /> 3926 </div> 3927</script>
3927 3928
3929<script> 3930 /* ----- STORE ----- */ 3931 store.components['membership_cal'] = { 3932 clubId: null, 3933 config_obj: {}, 3934 // new 3935 rerender_key: 0, 3936 current_date: null, 3937 view_preference: "Month", 3938 events: [], 3939 loading: { 3940 calendar: false, 3941 calendar_tags: false, 3942 ui_refresh: false, 3943 }, 3944 ui_tag_ids_default: {}, //used to track which tags are selected in the UI for default tags 3945 ui_tag_ids_custom: {}, 3946 selected_tags: { 3947 default: [], 3948 custom: [], 3949 }, 3950 calendar_tags: [], 3951 }; 3952 /* ----- COMPONENT ----- */ 3953 let membership_calendar_config = { 3954 template: '#template-membership-calendar', 3955 data() { 3956 return store.components['membership_cal']; 3957 }, 3958 props: { 3959 config: String, 3960 client_id: String, 3961 class_string: String, 3962 style_string: String, 3963 //admin 3964 component: Object, 3965 last_update: Number, 3966 tz: String, 3967 }, 3968 mounted() { 3969 if (this.config){ 3970 try { 3971 let raw_config = this.config; 3972 try { 3973 raw_config = decodeURIComponent(raw_config); 3974 } catch (decode_error) { 3975 // Already plain JSON from older markup. 3976 } 3977 this.config_obj = JSON.parse(raw_config); 3978 } catch (parse_error) { 3979 this.config_obj = {}; 3980 } 3981 } 3982 this.clubId = $('body').data('club-id'); 3983 // this.load_membership_cal(this.client_id); 3984 // Use component prop to determine if admin flow 3985 this.is_admin = !!this.component; 3986 // this.checkThenHandleEventTagsForCalendar(this.client_id); 3987 // //eventBus for overflow-overlay 3988 // store.eventBus.$on('clickSchedule_membership_cal', (event) => { 3989 // this.clickSchedule(event) 3990 // }) 3991 3992 //******* NEW CALENDAR BELOW *******// 3993 if(!this.current_date){ 3994 this.current_date = moment.tz( this.timezone ); 3995 } 3996 this.load_calendar(); 3997 this.set_tags(); 3998 // this.handle_filter_options(); 3999 this.load_event_tags(); 4000 this.get_timezone(); 4001 }, 4002 methods: { 4003 get_timezone(){ 4004 var promise = Parse.Promise.as().then(() => { 4005 var query = new Parse.Query("clubs"); 4006 query.select("timezone"); 4007 return query.get(this.clubId); 4008 }).then((clubObj) => { 4009 if(clubObj){ 4010 store.clubObject = clubObj; 4011 } 4012 return Parse.Promise.as(); 4013 }).catch((error) => { 4014 checkParseError(error); 4015 displayMessage("An error occurred while loading the club timezone: " + error.message, true, 5000); 4016 }); 4017 }, 4018 load_calendar(){ 4019 this.loading.calendar = true; 4020 var promise = Parse.Promise.as(); 4021 var events = []; 4022 var camps = []; 4023 var regattas = []; 4024 var group_classes = []; 4025 var recurring_events = []; 4026 var thisPromise = promise.then(() => { 4027 var promises = []; 4028 //if admin configured filter_tag_ids, we need to load all event types 4029 //so we can match events that have those tags regardless of type 4030 var should_load_all_types = this.filter_tag_ids && this.filter_tag_ids.length > 0; 4031 //social events 4032 var primary_promise = promise.then(()=>{ 4033 //see if we want these event types 4034 if( ! this.event_types.includes('socials') && ! should_load_all_types ){ 4035 //not retrieving social events 4036 return Promise.resolve({ 4037 formatted_events: [], 4038 recurring_events: [], 4039 }); 4040 } 4041 var params = {}; 4042 var { startTimestamp, endTimestamp } = this.getCalendarRange(); 4043 params["club_id"] = $('body').data('club-id'); 4044 params["start_timestamp"] = startTimestamp; 4045 params["end_timestamp"] = endTimestamp; 4046 params["flow"] = "member_portal"; 4047 params["is_admin"] = false;
4048 return Parse.Cloud.run('retrieve_social_events_for_calendar_v2', params); 4049 }).then((retrievedEvents) => { 4050 events = retrievedEvents.formatted_events || []; 4051 recurring_events = retrievedEvents.recurring_events || []; 4052 return Parse.Promise.as(); 4053 }); 4054 promises.push(primary_promise); 4055 // load camps 4056 var camps_promise = promise.then(()=>{ 4057 //see if we want these event types 4058 if( ! this.event_types.includes('camps') && ! should_load_all_types ){ 4059 //not retrieving camps 4060 return Promise.resolve([]); 4061 } 4062 var params = {}; 4063 var { startTimestamp, endTimestamp } = this.getCalendarRange(); 4064 params["club_id"] = $('body').data('club-id'); 4065 params["start_timestamp"] = startTimestamp; 4066 params["end_timestamp"] = endTimestamp; 4067 params["is_admin"] = false; 4068 return Parse.Cloud.run('retrieve_camps_for_calendar_v2', params); 4069 }).then((retrievedCamps) => { 4070 camps = retrievedCamps; 4071 return Parse.Promise.as(); 4072 }); 4073 promises.push(camps_promise); 4074 // load regattas 4075 var regattas_promise = promise.then(()=>{ 4076 //see if we want these event types 4077 if( this.hide_regattas ){ 4078 return Promise.resolve([]); 4079 } 4080 if( ! this.event_types.includes('regattas') && ! should_load_all_types ){ 4081 //not retrieving regattas 4082 return Promise.resolve([]); 4083 } 4084 var params = {}; 4085 var { startTimestamp, endTimestamp } = this.getCalendarRange(); 4086 params["club_id"] = $('body').data('club-id'); 4087 params["start_timestamp"] = startTimestamp; 4088 params["end_timestamp"] = endTimestamp; 4089 params["flow"] = "member_portal"; 4090 return Parse.Cloud.run('retrieve_regattas_for_calendar_v2', params); 4091 }).then((retrievedRegattas) => { 4092 regattas = retrievedRegattas; 4093 return Parse.Promise.as(); 4094 }); 4095 promises.push(regattas_promise); 4096 // load group classes 4097 var group_classes_promise = promise.then(()=>{ 4098 if( ! this.event_types.includes('group_classes') && ! should_load_all_types ){ 4099 return Promise.resolve([]); 4100 } 4101 var params = {}; 4102 var { startTimestamp, endTimestamp } = this.getCalendarRange(); 4103 params["club_id"] = $('body').data('club-id'); 4104 params["start_timestamp"] = startTimestamp; 4105 params["end_timestamp"] = endTimestamp; 4106 params["is_admin"] = false; 4107 return Parse.Cloud.run('retrieve_group_classes_for_calendar_v2', params); 4108 }).then((retrievedGroupClasses) => { 4109 group_classes = retrievedGroupClasses || []; 4110 return Parse.Promise.as(); 4111 }); 4112 promises.push(group_classes_promise); 4113 //return them in parallel 4114 return Promise.all(promises); 4115 }).then(() => { 4116 this.events = []; 4117 this.events = [ ...events, ...camps, ...regattas, ...group_classes, ...recurring_events ]; 4118 this.loading.calendar = false;
4119 }).catch((error) => { 4120 checkParseError(error); 4121 displayMessage("An error occurred while loading the calendar: " + error.message, true, 5000); 4122 this.loading.calendar = false; 4123 this.events = []; 4124 }); 4125 return thisPromise; 4126 }, 4127 load_event_tags(){ 4128 this.loading.calendar_tags = true; 4129 var promise = Parse.Promise.as(); 4130 var thisPromise = promise.then(() => { 4131 var query = new Parse.Query("event_tags"); 4132 query.equalTo("clubObject", { 4133 __type: "Pointer", 4134 objectId: $('body').data('club-id'), 4135 className: "clubs" 4136 }); 4137 query.equalTo("archived", false); 4138 query.notEqualTo("internal_only", true); 4139 query.limit( 1000 ); 4140 query.ascending("name"); 4141 return query.find(); 4142 }).then((retrievedTags) => { 4143 //if admin restricted to specific tags, only expose those in the user filter UI 4144 if( this.filter_tag_ids && this.filter_tag_ids.length > 0 ){ 4145 retrievedTags = retrievedTags.filter(tag => this.filter_tag_ids.indexOf(tag.id) !== -1); 4146 } 4147 this.calendar_tags = retrievedTags; 4148 //now set all of them to selected 4149 this.ui_tag_ids_custom = {};
4150 this.calendar_tags.forEach((tag) => { 4151 this.ui_tag_ids_custom[tag.id] = true; 4152 }); 4153 this.selected_tags.custom = this.calendar_tags.map(t => t.id); 4154 this.loading.calendar_tags = false; 4155 }).catch((error) => { 4156 checkParseError(error); 4157 displayMessage("An error occurred while loading calendar tags: " +error.message, true, 5000); 4158 this.loading.calendar_tags = false; 4159 this.calendar_tags = []; 4160 this.loading.calendar = false; 4161 }); 4162 return thisPromise; 4163 }, 4164 set_tags(){ 4165 //select all event types by default 4166 //options: "camp", "regatta", "social", "calendar_event" 4167 this.selected_tags.default = []; 4168 this.selected_tags.custom = []; 4169 let selected = []; 4170 //check what event types are available 4171 let event_types = this.event_types || []; 4172 if( event_types.indexOf('camps') !== -1 ) { 4173 selected.push('camp'); 4174 } 4175 if( event_types.indexOf('regattas') !== -1 ) { 4176 selected.push('regatta'); 4177 } 4178 if( event_types.indexOf('socials') !== -1 ) { 4179 selected.push('social'); 4180 selected.push('calendar_event'); 4181 } 4182 if( event_types.indexOf('group_classes') !== -1 ) { 4183 selected.push('group_class'); 4184 } 4185 this.selected_tags.default = selected; 4186 //and update the ui tags 4187 this.ui_tag_ids_default = {}; 4188 this.selected_tags.default.forEach((id) => { 4189 this.ui_tag_ids_default[id] = true; 4190 }); 4191 }, 4192 getCalendarRange() { 4193 let startTimestamp, endTimestamp; 4194 if (this.view_preference === "Month") { 4195 // Start and end of the previous and next months 4196 startTimestamp = moment(this.current_date).startOf("month").subtract(1, "month").valueOf(); 4197 endTimestamp = moment(this.current_date).endOf("month").add(1, "month").valueOf(); 4198 } else if (this.view_preference === "Week") { 4199 // Start and end of the previous and next 2 weeks 4200 startTimestamp = moment(this.current_date).startOf("isoWeek").subtract(2, "week").valueOf(); 4201 endTimestamp = moment(this.current_date).endOf("isoWeek").add(2, "week").valueOf(); 4202 } else if (this.view_preference === "Day") { 4203 // Start and end of the previous and next 2 weeks 4204 //.. important to do a wide enough range so that multi-day events are included 4205 startTimestamp = moment(this.current_date).startOf("isoWeek").subtract(2, "week").valueOf(); 4206 endTimestamp = moment(this.current_date).endOf("isoWeek").add(2, "week").valueOf(); 4207 } 4208 4209 return { startTimestamp, endTimestamp }; 4210 }, 4211 change_date( new_date ){ 4212 this.current_date = new_date; 4213 this.load_calendar(); 4214 }, 4215 change_view_preference( new_value ){ 4216 this.view_preference = new_value; 4217 this.load_calendar(); 4218 }, 4219 clicked_day_cell(date_components){ 4220 // if its month view, and we are on mobile (width < 720), go to day view 4221 if(this.view_preference === "Month" && window.innerWidth < 720){ 4222 //set the current date to the clicked date 4223 var clicked_date = moment.tz({ 4224 year: date_components.year, 4225 month: date_components.month, 4226 date: date_components.date, 4227 }, this.timezone); 4228 this.zoom_in_to_date( clicked_date ); 4229 } 4230 }, 4231 zoom_in_to_date( date ){ 4232 this.change_view_preference( "Day" ); 4233 this.current_date = date; 4234 }, 4235 clicked_default_tag(tag){ 4236 this.loading.ui_refresh = true; 4237 //update the ui 4238 if(this.ui_tag_ids_default[tag.id] === undefined){ 4239 //if the tag is not in the ui_tag_ids_default, add it 4240 this.ui_tag_ids_default[tag.id] = true; 4241 }else{ 4242 this.ui_tag_ids_default[tag.id] = !this.ui_tag_ids_default[tag.id]; 4243 } 4244 requestAnimationFrame(() => { 4245 // second RAF helps ensure weâre past layout/paint on Chrome/Safari 4246 requestAnimationFrame(() => { 4247 const list = this.selected_tags.default; 4248 const idx = list.indexOf(tag.id); 4249 // create a new array to avoid deep reactive churn 4250 this.selected_tags.default = idx !== -1 4251 ? list.filter(t => t !== tag.id) 4252 : [...list, tag.id]; 4253 this.loading.ui_refresh = false;
4254 }); 4255 }); 4256 }, 4257 clicked_calendar_tag(tag){ 4258 this.loading.ui_refresh = true; 4259 //update the ui 4260 if(this.ui_tag_ids_custom[tag.id] === undefined){ 4261 //if the tag is not in the ui_tag_ids_custom, add it 4262 this.ui_tag_ids_custom[tag.id] = true; 4263 }else{ 4264 this.ui_tag_ids_custom[tag.id] = !this.ui_tag_ids_custom[tag.id]; 4265 } 4266 requestAnimationFrame(() => { 4267 // second RAF helps ensure weâre past layout/paint on Chrome/Safari 4268 requestAnimationFrame(() => { 4269 const list = this.selected_tags.custom; 4270 const idx = list.indexOf(tag.id); 4271 // create a new array to avoid deep reactive churn 4272 this.selected_tags.custom = idx !== -1 4273 ? list.filter(t => t !== tag.id) 4274 : [...list, tag.id]; 4275 this.loading.ui_refresh = false; 4276 }); 4277 }); 4278 }, 4279 isolate_default_tag(tag){ 4280 //set the ui check boxes 4281 this.ui_tag_ids_default = {}; 4282 this.ui_tag_ids_default[tag.id] = true; 4283 this.ui_tags_ 4284 //set the selected tags to just this one 4285 this.selected_tags.default = [tag.id]; 4286 }, 4287 select_all_default_tags(){ 4288 //set the ui check boxes 4289 this.ui_tag_ids_default = {}; 4290 var event_types = ["camp", "regatta", "social", "banquet_event", "calendar_event", "group_class"]; 4291 if(store.clubObject.get('hide_regattas')){ 4292 event_types = event_types.filter(type => type !== 'regatta'); 4293 } 4294 event_types.forEach((type) => { 4295 this.ui_tag_ids_default[type] = true; 4296 }); 4297 //set the selected tags to all default tags 4298 this.selected_tags.default = event_types; 4299 }, 4300 clear_all_default_tags(){ 4301 this.ui_tag_ids_default = {}; 4302 this.selected_tags.default = []; 4303 }, 4304 select_visible_default_tags(visibleIds){ 4305 if (!visibleIds || !visibleIds.length) return; 4306 var current = this.selected_tags.default || []; 4307 var next = current.slice(); 4308 visibleIds.forEach(function(id){ if (next.indexOf(id) === -1) next.push(id); }); 4309 this.selected_tags.default = next; 4310 next.forEach(function(id){ this.ui_tag_ids_default[id] = true; }.bind(this)); 4311 }, 4312 clear_visible_default_tags(visibleIds){ 4313 if (!visibleIds || !visibleIds.length) return; 4314 var set = {}; 4315 visibleIds.forEach(function(id){ set[id] = true; }); 4316 var current = this.selected_tags.default || []; 4317 this.selected_tags.default = current.filter(function(id){ return !set[id]; }); 4318 this.ui_tag_ids_default = {}; 4319 (this.selected_tags.default || []).forEach(function(id){ this.ui_tag_ids_default[id] = true; }.bind(this)); 4320 }, 4321 isolate_calendar_tag(tag){ 4322 //set the ui check boxes 4323 this.ui_tag_ids_custom = {}; 4324 this.ui_tag_ids_custom[tag.id] = true; 4325 //set the selected tags to just this one 4326 this.selected_tags.custom = [tag.id]; 4327 }, 4328 select_all_custom_tags(){ 4329 //set the ui check boxes 4330 this.ui_tag_ids_custom = {};
4331 this.calendar_tags.forEach((tag) => { 4332 this.ui_tag_ids_custom[tag.id] = true; 4333 }); 4334 //set the selected tags to all custom tags 4335 this.selected_tags.custom = this.calendar_tags.map((tag) => tag.id); 4336 }, 4337 unselect_all_custom_tags(){ 4338 //used for the "catch all" option 4339 //set the ui check boxes 4340 this.ui_tag_ids_custom = {}; 4341 //set the selected tags to no custom tags 4342 this.selected_tags.custom = []; 4343 }, 4344 clear_all_custom_tags(){ 4345 this.ui_tag_ids_custom = {}; 4346 this.selected_tags.custom = []; 4347 }, 4348 select_visible_custom_tags(visibleIds){ 4349 if (!visibleIds || !visibleIds.length) return; 4350 var tagIds = visibleIds.filter(function(id){ return id !== 'catch_all_custom'; }); 4351 var current = this.selected_tags.custom || []; 4352 var next = current.slice(); 4353 tagIds.forEach(function(id){ if (next.indexOf(id) === -1) next.push(id); }); 4354 this.selected_tags.custom = next; 4355 next.forEach(function(id){ this.ui_tag_ids_custom[id] = true; }.bind(this)); 4356 }, 4357 clear_visible_custom_tags(visibleIds){ 4358 if (!visibleIds || !visibleIds.length) return; 4359 var set = {}; 4360 visibleIds.forEach(function(id){ set[id] = true; }); 4361 var current = this.selected_tags.custom || []; 4362 this.selected_tags.custom = current.filter(function(id){ return !set[id]; }); 4363 this.ui_tag_ids_custom = {}; 4364 (this.selected_tags.custom || []).forEach(function(id){ this.ui_tag_ids_custom[id] = true; }.bind(this)); 4365 }, 4366 clicked_event(event){ 4367 var is_recurring_event = event.is_recurring; 4368 // open detail view 4369 if(is_recurring_event) { 4370 // if(event.external_event_url) { 4371 // //its external, just pop open the window 4372 // return window.open(event.external_event_url); 4373 // } 4374 // var params = { 4375 // event_json: event, 4376 // }; 4377 show_event_details_in_member_cal({ event_json: event }); 4378 } else if(event) { 4379 if(event.regatta_subevent) { 4380 //go to microsite 4381 var regatta_id = event.parent_regatta_id; 4382 window.open(`/regatta/${ regatta_id }`); 4383 } else if(event.type == 'regatta'){ 4384 if(event.external_regatta_url){ 4385 window.open(event.external_regatta_url); 4386 } else { 4387 window.open(`/regatta/${ event.id }`); 4388 } 4389 } else if(event.type == 'camp'){ 4390 //go to registration 4391 window.open(`/register/camp/${ event.id }/class`); 4392 } else if(event.type === 'group_class'){ 4393 var timestamp = event.ts; 4394 var dateComponents = return_date_components_v2({ 4395 timezone: this.timezone, 4396 date: new Date(timestamp) 4397 }); 4398 var dateString = dateComponents.year + '-' + (dateComponents.month + 1) + '-' + dateComponents.date; 4399 let event_json = { 4400 ...event, 4401 external_event_url: '/group-classes?date_string=' + dateString, 4402 }; 4403 let params = { 4404 event_json: event_json, 4405 }; 4406 show_event_details_in_member_cal(params); 4407 } else { 4408 // if(event.external_event_url){ 4409 // window.open(event.external_event_url); 4410 // } else 4411 if(event.type === 'social') { 4412 //not calendar only ... go to the event page 4413 window.open(`/event/${ event.id }`); 4414 } else { 4415 //calendar event 4416 var params = { 4417 event_json: event, 4418 }; 4419 show_event_details_in_member_cal( params ); 4420 } 4421 } 4422 } 4423 }, 4424 }, 4425 computed: { 4426 event_types(){ 4427 if(this.is_admin) { 4428 return this.component?.get('config_latest').event_types || []; 4429 } else { 4430 return this.config_obj.event_types || []; 4431 } 4432 }, 4433 filter_tag_ids(){ 4434 if(this.is_admin) { 4435 return this.component?.get('config_latest').filter_tag_ids || []; 4436 } else { 4437 return this.config_obj.filter_tag_ids || []; 4438 } 4439 }, 4440 allow_user_filtering(){ 4441 //defaults to true for legacy components where the field is not set 4442 var raw; 4443 if(this.is_admin) { 4444 raw = this.component?.get('config_latest').allow_user_filtering; 4445 } else { 4446 raw = this.config_obj.allow_user_filtering; 4447 } 4448 return raw !== false; 4449 }, 4450 filter_logic(){ 4451 //when admin restricts the calendar to a set of event tags, an event 4452 //should be visible if it matches the type OR has a matching tag (per 4453 //admin filter rules), so we use OR logic on the user-facing filter 4454 //to avoid hiding events that admin intends to show. 4455 if( this.filter_tag_ids && this.filter_tag_ids.length > 0 ){ 4456 return 'or'; 4457 } 4458 return 'and'; 4459 }, 4460 event_type_setting_for_event(){ 4461 //map the actual event.type strings to the toggle keys used in event_types config 4462 return { 4463 'camp': 'camps', 4464 'regatta': 'regattas', 4465 'social': 'socials', 4466 'calendar_event': 'socials', 4467 'group_class': 'group_classes', 4468 }; 4469 }, 4470 filtered_events(){ 4471 var event_types = this.event_types || []; 4472 var filter_tag_ids = this.filter_tag_ids || []; 4473 //if neither types nor tags are configured, fall back to whatever was loaded 4474 if( event_types.length === 0 && filter_tag_ids.length === 0 ){ 4475 return this.events; 4476 } 4477 var type_map = this.event_type_setting_for_event; 4478 return this.events.filter(event => { 4479 var setting_key = type_map[event.type]; 4480 var type_in_selected = setting_key && event_types.indexOf(setting_key) !== -1; 4481 if( filter_tag_ids.length === 0 ){ 4482 //no tag filter configured: only show events whose type is selected 4483 return type_in_selected; 4484 } 4485 //tags are configured: an event is shown if its type is selected 4486 //(regardless of tags applied) OR it has any of the selected tags 4487 if( type_in_selected ){ 4488 return true; 4489 } 4490 var event_tag_ids = event.event_tag_ids || []; 4491 return event_tag_ids.some(id => filter_tag_ids.indexOf(id) !== -1); 4492 }); 4493 }, 4494 formatted_event_types(){ 4495 this.rerender_key; //depend on this to force recompute when it changes 4496 var event_types = this.event_types || []; 4497 // Updated vibrant, distinct colors of similar intensity to #853fc6 4498 var options = []; 4499 var camp_option = { 4500 key: "camp", 4501 label: "Camps", 4502 id: "camp", 4503 color: "#236651" 4504 }; 4505 var social_option = { 4506 key: "social", 4507 label: "Social events", 4508 id: "social", 4509 color: "#ffa69e" 4510 }; 4511 var calendar_event_option = { 4512 key: "calendar_event", 4513 label: "Other", 4514 id: "calendar_event",
4515 color: "#e1e7ea" 4516 }; 4517 if( event_types.includes('camps') ) { 4518 options.push(camp_option); 4519 } 4520 //respect hide regattas on club object 4521 if( event_types.includes('regattas') && ! this.hide_regattas ) { 4522 var regatta_option = { 4523 key: "regatta", 4524 label: "Regattas", 4525 id: "regatta", 4526 color: "#87d4f9" 4527 }; 4528 options.push(regatta_option); 4529 } 4530 if( event_types.includes('socials') ) { 4531 options.push(social_option); 4532 options.push(calendar_event_option); 4533 } 4534 if( event_types.includes('group_classes') ) { 4535 options.push({ 4536 key: "group_class", 4537 label: "Group classes", 4538 id: "group_class", 4539 color: "#5c7c99" 4540 }); 4541 } 4542 return { 4543 title: "Type", 4544 options: options 4545 }; 4546 }, 4547 hide_regattas(){ 4548 return store.clubObject && store.clubObject.get('hide_regattas'); 4549 }, 4550 timezone(){ 4551 if(this.tz){ 4552 return this.tz; 4553 } 4554 return store.clubObject ? store.clubObject.get('timezone') : moment.tz.guess(); 4555 } 4556 4557 }, 4558 watch: { 4559 last_update(newVal, oldVal){ 4560 if (newVal !== oldVal){ 4561 if (this.config){ 4562 try { 4563 let raw_config = this.config; 4564 try { 4565 raw_config = decodeURIComponent(raw_config); 4566 } catch (decode_error) { 4567 // Already plain JSON from older markup. 4568 } 4569 this.config_obj = JSON.parse(raw_config); 4570 } catch (parse_error) { 4571 this.config_obj = {}; 4572 } 4573 } 4574 this.rerender_key = generateClientID(); //force update of event types included 4575 this.clubId = $('body').data('club-id'); 4576 this.load_calendar(); 4577 //re-filter the user-facing tag list in case admin changed filter_tag_ids 4578 this.load_event_tags(); 4579 this.is_admin = !!this.component; 4580 } 4581 } 4582 }, 4583 } 4584 app_content.component('membership-calendar-component', membership_calendar_config); 4585 app_overlays.component('membership-calendar-component', membership_calendar_config); 4586</script>
4586
4586<script type="text/html" id="template-membership-directory"> 4587 <div class="cs-background-image relative" :class="class_string" :style="style_string"> 4588 <template v-if="formatting && formatting.overlay_opacity"> 4589 <div class="cs-image-overlay" :style="{ 'opacity': formatting.overlay_opacity.toString() }"></div> 4590 </template> 4591 <div class="element-wrapper" :class="'element-wrapper_' + component_type" :style="element_style_string"> 4592 <div class="membership-directory-wrap centeredBlock centeredText" :data-client-id="client_id"> 4593 <div class="ease flexNoWrap show_when_loaded"> 4594 <div class="inputWrap searchBarWrap flexGrowOne" style="min-width:100%;"> 4595 <input class="searchBar easeFast" placeholder="Search members..." v-model="search"> 4596 </div> 4597 <div class="flexGrowOne"></div> 4598 </div> 4599 <div v-if="!loading && member_tags?.length >= 1" class="scrollable posScrollbar lightBorderTop noPaddingBottom"> 4600 <div class="flexNoWrap insert_member_tags" style="padding:15px;"> 4601 <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> 4602 <template v-for="tag in member_tags" :key="tag.id"> 4603 <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> 4604 </template> 4605 </div> 4606 </div> 4607 <!-- <div class="show_when_loaded table_markup_insert"> --> 4608 <div class="lightBorderTop scrollable fff noPadding"> 4609 <table v-if="!loading && filtered_members?.length >= 1 && configurable_columns.length >= 2" class="tableInsert hundop"> 4610 <tbody> 4611 <tr class="tableHeaderRow" :class="{ 'ascending' : sortObject.order == 'ascending' }"> 4612 <template v-for="column in configurable_columns"> 4613 <th v-if="!Array.isArray(column.headerName) && !column.headerClasses.includes('sortable')" :class="column.headerClasses" :style="column.headerStyle">{{ column.headerName }}</th> 4614 <th v-if="!Array.isArray(column.headerName) && column.headerClasses.includes('sortable')" 4615 :style="column.headerStyle" 4616 :data-db-key="column['data-db-key']" 4617 @click="clicked_sort(column['data-db-key'])" 4618 :class="[column.headerClasses, { 'sorted': sortObject.key === column['data-db-key'] }]" 4619 > 4620 {{ column.headerName }} 4621 <div class="sort-arrow"></div> 4622 </th> 4623 <template v-for="headerName in column.headerName"> 4624 <th v-if="Array.isArray(column.headerName)" :class="column.headerClasses">{{ headerName }}</th> 4625 </template> 4626 </template> 4627 </tr> 4628 <!-- member rows injected here --> 4629 <template v-for="member in filtered_members" :key="member.id"> 4630 <tr 4631 v-if="check_archive_status( member )" 4632 class="tableRow pointer" 4633 :class="`member-row_${member.id || member.objectId}`" 4634 @click="clicked_member_row( member )" 4635 > 4636 <!-- photo column --> 4637 <td v-if="check_configurable_classes('column_photo')" class="lessPadding column_photo first-cell" style="padding-right:0px;"> 4638 <div v-if="member && member.get('profPicURL')" class="smallIcon noMargin noBorder" :style="{ backgroundImage: 'url(' + member.get('profPicURL') + ')' }"></div> 4639 <div v-else 4640 class="smallIcon noMargin noBorder flexWrap align-items-center" 4641 style="padding-right:0px; 4642 background-color: #f1f5fa; 4643 text-align: center; 4644 font-size: 16px;" 4645 > 4646 <i class="fa-regular fa-user"></i> 4647 </div> 4648 </td> 4649 <!-- firstName column --> 4650 <td v-if="check_configurable_classes('firstName')"> 4651 <p v-if="(member && member.get('firstName')) && !member.get('nickname')" class="capitalize">{{ member.get('firstName') }}</p> 4652 <p v-if="(member && member.get('firstName')) && member.get('nickname')" class="capitalize">{{ member.get('firstName') }} ({{ member.get('nickname') }})</p> 4653 <p v-if="member && !member.get('firstName')" class="incomplete">None</p> 4654 </td> 4655 <!-- lastName column --> 4656 <td v-if="check_configurable_classes('lastName')"> 4657 <p v-if="(member && member.get('lastName'))" class="capitalize">{{ member.get('lastName') }}</p> 4658 <p v-else class="incomplete">None</p> 4659 </td> 4660 <!-- member number column --> 4661 <td v-if="check_configurable_classes('column_member_number')" class="column_member_number"> 4662 <p v-if="(member && member.get('membershipObject')) && member.get('membershipObject').get('memberNumber')" class="uppercase">{{ member.get('membershipObject').get('memberNumber') }}</p> 4663 <p v-else class="incomplete">None</p> 4664 </td> 4665 <!-- email column --> 4666 <td v-if="check_configurable_classes('column_email')" class="column_email"> 4667 <p v-if="member && member.get('email')"><a @click.stop :href="'mailto:' + member.get('email')">{{ member.get('email')}}</a></p> 4668 <p v-else class="incomplete">None</p> 4669 </td> 4670 <!-- mobile column --> 4671 <td v-if="check_configurable_classes('column_mobile')" class="column_mobile"> 4672 <p v-if="member && member.get('mobile')"><a @click.stop :href="'tel:' + member.get('mobile')">{{ member.get('mobile')}}</a></p> 4673 <p v-else class="incomplete">None</p> 4674 </td> 4675 <!-- home phone column --> 4676 <td v-if="check_configurable_classes('column_phone_home')" class="column_phone_home"> 4677 <p v-if="member && member.get('phone_home')"><a @click.stop :href="'tel:' + member.get('phone_home')">{{ member.get('phone_home')}}</a></p> 4678 <p v-else class="incomplete">None</p> 4679 </td> 4680 <!-- member tags column --> 4681 <td v-if="check_configurable_classes('column_member_tags')" class="column_member_tags"> 4682 <div class="flexWrapLeftAlign" v-html="return_member_tags( member ) || ''"></div> 4683 </td> 4684 <!-- ghin number column --> 4685 <td v-if="check_configurable_classes('column_ghin_number')" class="column_ghin_number"> 4686 <p v-if="member && member.get('ghin_number')">{{ member.get('ghin_number') }}</p> 4687 <p v-else class="incomplete">None</p> 4688 </td> 4689 <!-- address ( street, city, state, zip) columns --> 4690 <template v-if="check_configurable_classes('column_address')"> 4691 <td class="column_address"> 4692 <p v-if="member && member.get('street')">{{ member.get('street') }}</p> 4693 <p v-else class="incomplete">None</p> 4694 </td> 4695 <td class="column_address"> 4696 <p v-if="member && member.get('city')">{{ member.get('city') }}</p> 4697 <p v-else class="incomplete">None</p> 4698 </td> 4699 <td class="column_address"> 4700 <p v-if="member && member.get('state')">{{ member.get('state') }}</p> 4701 <p v-else class="incomplete">None</p> 4702 </td> 4703 <td class="column_address"> 4704 <p v-if="member && member.get('zip')">{{ member.get('zip') }}</p> 4705 <p v-else class="incomplete">None</p> 4706 </td> 4707 </template> 4708 <!-- secondary address ( street, city, state, zip ) columns --> 4709 <template v-if="check_configurable_classes('column_address_secondary')"> 4710 <td class="column_address_secondary"> 4711 <p v-if="member && member.get('street_secondary')">{{ member.get('street_secondary') }}</p> 4712 <p v-else class="incomplete">None</p> 4713 </td> 4714 <td class="column_address_secondary"> 4715 <p v-if="member && member.get('city_secondary')">{{ member.get('city_secondary') }}</p> 4716 <p v-else class="incomplete">None</p> 4717 </td> 4718 <td class="column_address_secondary"> 4719 <p v-if="member && member.get('state_secondary')">{{ member.get('state_secondary') }}</p> 4720 <p v-else class="incomplete">None</p> 4721 </td> 4722 <td class="column_address_secondary"> 4723 <p v-if="member && member.get('zip_secondary')">{{ member.get('zip_secondary') }}</p> 4724 <p v-else class="incomplete">None</p> 4725 </td> 4726 </template> 4727 <!-- start date column --> 4728 <td v-if="check_configurable_classes('column_start_date')" class="column_start_date"> 4729 <p v-if="(member && member.get('membershipObject')) && member.get('membershipObject').get('startDate')">{{ format_utc_date( member.get('membershipObject').get('startDate') ) }}</p> 4730 <p v-else class="incomplete">None</p> 4731 </td> 4732 <!-- end date column --> 4733 <td v-if="check_configurable_classes('column_end_date')" class="column_end_date"> 4734 <p v-if="(member && member.get('membershipObject')) && member.get('membershipObject').get('endDate')">{{ format_utc_date( member.get('membershipObject').get('endDate')) }}</p> 4735 <p v-else class="incomplete">None</p> 4736 </td>
4737 <!-- renewal column --> 4738 <td v-if="check_configurable_classes('column_renewed')" class="column_renewed"> 4739 <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> 4740 <p v-else class="incomplete">None</p> 4741 </td> 4742 <!-- category column --> 4743 <td v-if="check_configurable_classes('column_category')" class="column_category"> 4744 <p v-if="(member && member.get('membershipObject')) && member.get('membershipObject').get('tierObject')">{{ member.get('membershipObject').get('tierObject').get('name') }}</p> 4745 <p v-else class="incomplete">None</p> 4746 </td> 4747 <!-- plan column --> 4748 <td v-if="check_configurable_classes('column_plan')" class="column_plan"> 4749 <p v-if="(member && member.get('membershipObject')) && member.get('membershipObject').get('planObject')">{{ member.get('membershipObject').get('planObject').get('name') }}</p> 4750 <p v-else class="incomplete">None</p> 4751 </td> 4752 </tr> 4753 </template> 4754 </tbody> 4755 </table> 4756 </div> 4757 <!-- </div> --> 4758 <div v-if="loading" class="cardBodySpinnerWrap relative active"> 4759 <div class="cardBodySpinner centeredBlock"></div> 4760 </div> 4761 <p v-if="!loading && filtered_members.length == 0" class="noObjectsMSG withPadding">{{ return_no_results_msg() }}</p> 4762 </div> 4763 </div> 4764 </div> 4765</script>
4765 4766
4767<script> 4768 4769 function prepThenDisplayMembershipDirectoryPopup( memberObject ){ 4770 //clear stuff 4771 $('#overlay_membership-directory-popup .insert').text(''); 4772 //name 4773 var name = ''; 4774 //check type 4775 if( memberObject.className == "boats" ){ 4776 //actually a boat - we re-use the same overlay 4777 $('#overlay_membership-directory-popup .version_boat').removeClass('hidden'); 4778 $('#overlay_membership-directory-popup .version_member').addClass('hidden'); 4779 name = returnNameStringForBoat( memberObject ); 4780 //handle documents 4781 $('.section-wrap_documents').find('.inline-value').remove(); 4782 // var documents = $('.membership-directory-wrap').data('documents'); 4783 var documents = store.components.membership_directory.documents; 4784 if( documents ){ 4785 for (var i = 0; i <= documents.length - 1; i++) { 4786 if( documents[i].get('boatObject') ){ 4787 if( documents[i].get('boatObject').id == memberObject.objectId ){ 4788 var this_name = documents[i].get('name'); 4789 if( this_name && this_name.length >= 1 ){ 4790 $('#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>'); 4791 } 4792 } 4793 } 4794 } 4795 } 4796 }else{ 4797 //ok, it's a member 4798 $('#overlay_membership-directory-popup .version_boat').addClass('hidden'); 4799 $('#overlay_membership-directory-popup .version_member').removeClass('hidden'); 4800 //name 4801 if( memberObject.get('firstName') ){ 4802 name += memberObject.get('firstName'); 4803 if( memberObject.get('nickname') ){ 4804 name += ' ('+ memberObject.get('nickname') + ')'; 4805 } 4806 } 4807 if( memberObject.get('lastName') ){ 4808 if( name.length >= 1 ){ 4809 name += ' '; 4810 } 4811 name += memberObject.get('lastName'); 4812 } 4813 } 4814 $('#overlay_membership-directory-popup .insert_name').text(name); 4815 //prof pic 4816 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 4817 if( profPicURL ){ 4818 $('#overlay_membership-directory-popup .profile-picture').text(""); 4819 $('#overlay_membership-directory-popup .profile-picture').css('background-image', 'url('+profPicURL+')' ); 4820 $('#overlay_membership-directory-popup .profile-background-inner').css('background-image', 'url('+profPicURL+')' ); 4821 }else{ 4822 $('#overlay_membership-directory-popup .profile-picture').css('background-image', '' ); 4823 $('#overlay_membership-directory-popup .profile-background-inner').css('background-image', ''); 4824 if( name.length >= 1 ){ 4825 var initial = name.substr(0,1).toLowerCase(); 4826 $('#overlay_membership-directory-popup .profile-picture').text( initial ); 4827 } 4828 } 4829 //plug in data 4830 $('#overlay_membership-directory-popup .inline-value').each(function(){ 4831 var key = $(this).data('db-key'); 4832 if( key ){ 4833 var value = sanitize_string(memberObject.get(key)); 4834 if( value ){ 4835 if( key.toLowerCase().indexOf('email') >= 0 ){ 4836 //it's an email 4837 $(this).html( '<a href="mailto:'+ value +'">'+ value +'</a>' ); 4838 $(this).removeClass( 'hidden' ); 4839 }else if( key.toLowerCase().includes('mobile') || key.toLowerCase().includes('phone') ){ 4840 //it's a mobile number 4841 $(this).html( '<a href="tel:'+ returnCleanMobileNumber( value ) +'">'+ formatMobileNumber( value ) +'</a>' ); 4842 $(this).removeClass( 'hidden' ); 4843 }else if( $(this).data('prepend') ){ 4844 $(this).text( $(this).data('prepend') + " " + value ); 4845 $(this).removeClass( 'hidden' ); 4846 }else{ 4847 //not an email, mobile number, or special boat field 4848 $(this).text( value ); 4849 $(this).removeClass( 'hidden' ); 4850 } 4851 }else{ 4852 $(this).text( "" ).addClass('hidden'); 4853 $(this).data( 'date', null ); 4854 } 4855 } 4856 }); 4857 //custom fields 4858 $('.section-wrap_custom_fields').html(''); 4859 // var custom_fields = $('.membership-directory-wrap').data('custom_fields'); 4860 var custom_fields = store.components.membership_directory.custom_fields; 4861 if( custom_fields ){ 4862 //plug them in 4863 for (var i = 0; i <= custom_fields.length - 1; i++) { 4864 var response = returnResponseForCustomField( memberObject, custom_fields[i].id ); 4865 if( response ){ 4866 $('.section-wrap_custom_fields').append('<h4 class="smallBlockLetters leftText smallMarginTop">'+ custom_fields[i].get('name') +'</h4>'); 4867 $('.section-wrap_custom_fields').append('<p class="inline-value dark tinyPaddingTop">'+ response +'</p>'); 4868 } 4869 } 4870 } 4871 //family 4872 $('.section-wrap_family').find('.inline-value').remove(); 4873 var membershipObject = memberObject.get('membershipObject'); 4874 var membersArray = membershipObject.get('membersArray'); 4875 var family_members = []; 4876 if( membersArray ){ 4877 for (var i = 0; i <= membersArray.length - 1; i++) { 4878 if( membersArray[i].id != memberObject.id ){ 4879 var this_member = returnObjectByID( membersArray[i].id, store.components.membership_directory.allMembers ); 4880 if( this_member ){ 4881 family_members.push( this_member ); 4882 } 4883 } 4884 } 4885 } 4886 for (var i = 0; i <= family_members.length - 1; i++) { 4887 var this_name = ''; 4888 if( family_members[i].get('firstName') ){ 4889 this_name = family_members[i].get('firstName'); 4890 if( family_members[i].get('lastName') ){ 4891 this_name += ' '; 4892 this_name += family_members[i].get('lastName'); 4893 } 4894 } 4895 if( this_name.length >= 1 ){ 4896 $('#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>'); 4897 } 4898 } 4899 //boats 4900 $('.section-wrap_boats').find('.inline-value').remove(); 4901 var boats_map = store.components.membership_directory.boats_map; 4902 if( boats_map ){ 4903 var these_boats = boats_map[ membershipObject.id ]; 4904 if( these_boats ){ 4905 for (var i = 0; i <= these_boats.length - 1; i++) { 4906 var this_name = returnNameStringForBoat( these_boats[i] ); 4907 if( this_name.length >= 1 ){ 4908 $('#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>'); 4909 } 4910 } 4911 } 4912 } 4913 //continue 4914 $('#overlay_membership-directory-popup .section-wrap').each(function(){ 4915 if( $(this).find('.inline-value').not( $('.hidden') ).length >= 1 ){ 4916 $(this).removeClass('hidden'); 4917 }else{ 4918 $(this).addClass('hidden'); 4919 } 4920 }); 4921 //fade it in 4922 fadeIn('#overlay_membership-directory-popup'); 4923 } 4924 4925 function openDirectoryProfileImage( el ){ 4926 var bgImage = $(el).css('background-image'); 4927 if( !bgImage || bgImage === 'none' ) return; 4928 var url = bgImage.replace(/^url\(["']?/, '').replace(/["']?\)$/, ''); 4929 if( !url ) return; 4930 $('#directory-image-lightbox .directory-image-lightbox-img').attr('src', url); 4931 $('#directory-image-lightbox').addClass('active'); 4932 } 4933 4934 function closeDirectoryProfileImage(){ 4935 $('#directory-image-lightbox').removeClass('active'); 4936 $('#directory-image-lightbox .directory-image-lightbox-img').attr('src', ''); 4937 } 4938 4939 function returnNameStringForBoat( boatObject ){ 4940 var this_name = ''; 4941 if( boatObject.get('boatName') ){ 4942 this_name += boatObject.get('boatName'); 4943 } 4944 if( boatObject.get('sailNumber') ){ 4945 if( this_name.length >= 1 ){ 4946 this_name += " - "; 4947 } 4948 this_name += boatObject.get('sailNumber'); 4949 } 4950 if( boatObject.get('class') ){ 4951 if( this_name.length >= 1 ){ 4952 this_name += " - "; 4953 } 4954 this_name += boatObject.get('class'); 4955 } 4956 if( boatObject.get('hullNumber') ){ 4957 if( this_name.length >= 1 ){ 4958 this_name += " - "; 4959 } 4960 this_name += boatObject.get('hullNumber'); 4961 } 4962 return this_name; 4963 } 4964 4965 function returnResponseForCustomField( member, custom_field_id ){ 4966 //find the response in custom_field_responses where .get('member') == ,ember and .get('custom_field').id matches custom_field_id 4967 var all_responses = store.components.membership_directory.custom_field_responses; 4968 if(!member || !custom_field_id){ 4969 return null; 4970 } 4971 var matching_response = all_responses?.find(resp => { 4972 // Match member (Parse.Object, so check .id) 4973 const member_matches = resp.get('member')?.id === member.id; 4974 4975 // Match custom field (resp.get('custom_field') is also a Parse.Object) 4976 const field_matches = resp.get('custom_field')?.id === custom_field_id; 4977 4978 return member_matches && field_matches; 4979 }); 4980 if(matching_response){ 4981 if( matching_response.get('field_type') == 'text' || matching_response.get('field_type') == 'textarea' || matching_response.get('field_type') == 'number' ){ 4982 return matching_response.get('response') 4983 }else if (matching_response.get('field_type') == 'select'){ 4984 var selected_options = matching_response.get('selected_options'); 4985 var displayString = selected_options.map(opt => opt.option_name).join(', ');
4986 return displayString; 4987 }else if( matching_response.get('field_type') == 'radio' ){ 4988 return matching_response.get('selected_options').map(option => option.option_name) 4989 }else if( matching_response.get('field_type') == 'file_upload' ){ 4990 return matching_response.get('field_url') || null 4991 } 4992 } 4993 4994 return null; 4995 } 4996 4997 /* ----- STORE ----- */ 4998 store.components["membership_directory"] = { 4999 clubId: null, 5000 clubIds: ['8pB9LjQKoE', '2oWU1chg3P'], //for mantoloking and cambridge yc 5001 allMembers: [], 5002 member_tags: [], 5003 json: [], 5004 boats_map: [], 5005 custom_fields: [], 5006 custom_field_responses: [], 5007 documents: [], //for scira 5008 selectedTag: 'all', 5009 // configurable_columns: [], 5010 count: 0, 5011 search: '', 5012 loading: true, 5013 sortObject: { 5014 order: 'ascending', 5015 key: 'lastName' 5016 }, 5017 }; 5018 5019 /* ----- COMPONENT ----- */ 5020 app_content.component("membership-directory-component", { 5021 template: "#template-membership-directory", 5022 data() { 5023 return store.components['membership_directory'] 5024 }, 5025 props: { 5026 formatting: Object, 5027 client_id: String, 5028 class_string: String, 5029 style_string: String, 5030 element_style_string: String, 5031 component_type: String, 5032 //admin 5033 last_update: Number, 5034 }, 5035 mounted() { 5036 this.clubId = $('body').data('club-id'); 5037 this.load_membership_directory(); 5038 // this.fillColumnClasses(); 5039 }, 5040 methods: { 5041 sortByFirstName_ascending( a, b ){ 5042 var aFirstName = ''; 5043 if( a.get('firstName') ){ 5044 aFirstName += a.get('firstName').trim().toLowerCase(); 5045 } 5046 var bFirstName = ''; 5047 if( b.get('firstName') ){ 5048 bFirstName += b.get('firstName').trim().toLowerCase(); 5049 } 5050 if(aFirstName < bFirstName) { return -1; } 5051 if(aFirstName > bFirstName) { return 1; } 5052 //they had the same name 5053 return 0; 5054 }, 5055 sortByFirstName_descending( a, b ){ 5056 var aFirstName = ''; 5057 if( a.get('firstName') ){ 5058 aFirstName += a.get('firstName').trim().toLowerCase(); 5059 } 5060 var bFirstName = ''; 5061 if( b.get('firstName') ){ 5062 bFirstName += b.get('firstName').trim().toLowerCase(); 5063 } 5064 if(aFirstName > bFirstName) { return -1; } 5065 if(aFirstName < bFirstName) { return 1; } 5066 return 0; 5067 }, 5068 sortByLastName_ascending( a, b ){ 5069 var aLastName = ''; 5070 if( a.get('lastName') ){ 5071 aLastName += a.get('lastName').trim().toLowerCase(); 5072 } 5073 var bLastName = ''; 5074 if( b.get('lastName') ){ 5075 bLastName += b.get('lastName').trim().toLowerCase(); 5076 } 5077 if(aLastName < bLastName) { return -1; } 5078 if(aLastName > bLastName) { return 1; } 5079 return 0; 5080 }, 5081 sortByLastName_descending( a, b ){ 5082 var aLastName = ''; 5083 if( a.get('lastName') ){ 5084 aLastName += a.get('lastName').trim().toLowerCase(); 5085 } 5086 var bLastName = ''; 5087 if( b.get('lastName') ){ 5088 bLastName += b.get('lastName').trim().toLowerCase(); 5089 } 5090 if(aLastName > bLastName) { return -1; } 5091 if(aLastName < bLastName) { return 1; } 5092 return 0; 5093 }, 5094 sortByMemberNumber_ascending( a, b ){ 5095 var aMemberNumber = ''; 5096 if( a.get('membershipObject') && a.get('membershipObject').get('memberNumber') ){ 5097 aMemberNumber += a.get('membershipObject').get('memberNumber').trim().toLowerCase(); 5098 } 5099 var bMemberNumber = ''; 5100 if( b.get('membershipObject') && b.get('membershipObject').get('memberNumber') ){ 5101 bMemberNumber += b.get('membershipObject').get('memberNumber').trim().toLowerCase(); 5102 } 5103 if(aMemberNumber < bMemberNumber) { return -1; } 5104 if(aMemberNumber > bMemberNumber) { return 1; } 5105 return 0; 5106 }, 5107 sortByMemberNumber_descending( a, b ){ 5108 var aMemberNumber = ''; 5109 if( a.get('membershipObject') && a.get('membershipObject').get('memberNumber') ){ 5110 aMemberNumber += a.get('membershipObject').get('memberNumber').trim().toLowerCase(); 5111 } 5112 var bMemberNumber = ''; 5113 if( b.get('membershipObject') && b.get('membershipObject').get('memberNumber') ){ 5114 bMemberNumber += b.get('membershipObject').get('memberNumber').trim().toLowerCase(); 5115 } 5116 if(aMemberNumber > bMemberNumber) { return -1; } 5117 if(aMemberNumber < bMemberNumber) { return 1; } 5118 return 0; 5119 }, 5120 sortByStartDate_ascending( a, b ){ 5121 var aStartDate; 5122 if( a.get('membershipObject') && a.get('membershipObject').get('startDate') ){ 5123 aStartDate = a.get('membershipObject').get('startDate'); 5124 } 5125 var bStartDate; 5126 if( b.get('membershipObject') && b.get('membershipObject').get('startDate') ){
5127 bStartDate = b.get('membershipObject').get('startDate'); 5128 } 5129 if(aStartDate < bStartDate) { return -1; } 5130 if(aStartDate > bStartDate) { return 1; } 5131 return 0; 5132 }, 5133 sortByStartDate_descending( a, b ){ 5134 var aStartDate; 5135 if( a.get('membershipObject') && a.get('membershipObject').get('startDate') ){ 5136 aStartDate = a.get('membershipObject').get('startDate'); 5137 } 5138 var bStartDate; 5139 if( b.get('membershipObject') && b.get('membershipObject').get('startDate') ){ 5140 bStartDate = b.get('membershipObject').get('startDate'); 5141 } 5142 if(aStartDate > bStartDate) { return -1; } 5143 if(aStartDate < bStartDate) { return 1; } 5144 return 0; 5145 }, 5146 sortByRenewed_ascending( a, b ){ 5147 var aRenewed; 5148 if( a.get('membershipObject') && a.get('membershipObject').get('renewed') ){ 5149 aRenewed = a.get('membershipObject').get('renewed'); 5150 }else if( a.get('membershipObject') && a.get('membershipObject').get('startDate') ){ 5151 aRenewed = a.get('membershipObject').get('startDate'); 5152 } 5153 var bRenewed; 5154 if( b.get('membershipObject') && b.get('membershipObject').get('renewed') ){ 5155 bRenewed = b.get('membershipObject').get('renewed'); 5156 }else if( b.get('membershipObject') && b.get('membershipObject').get('startDate') ){ 5157 bRenewed = b.get('membershipObject').get('startDate'); 5158 } 5159 if(aRenewed < bRenewed) { return -1; } 5160 if(aRenewed > bRenewed) { return 1; } 5161 return 0; 5162 }, 5163 sortByRenewed_descending( a, b ){ 5164 var aRenewed; 5165 if( a.get('membershipObject') && a.get('membershipObject').get('renewed') ){ 5166 aRenewed = a.get('membershipObject').get('renewed'); 5167 }else if( a.get('membershipObject') && a.get('membershipObject').get('startDate') ){ 5168 aRenewed = a.get('membershipObject').get('startDate'); 5169 } 5170 var bRenewed; 5171 if( b.get('membershipObject') && b.get('membershipObject').get('renewed') ){ 5172 bRenewed = b.get('membershipObject').get('renewed'); 5173 }else if( b.get('membershipObject') && b.get('membershipObject').get('startDate') ){ 5174 bRenewed = b.get('membershipObject').get('startDate'); 5175 } 5176 if(aRenewed > bRenewed) { return -1; } 5177 if(aRenewed < bRenewed) { return 1; } 5178 return 0; 5179 }, 5180 sortByEndDate_ascending( a, b ){ 5181 var aEndDate; 5182 if( a.get('membershipObject') && a.get('membershipObject').get('endDate') ){ 5183 aEndDate = a.get('membershipObject').get('endDate'); 5184 } 5185 var bEndDate; 5186 if( b.get('membershipObject') && b.get('membershipObject').get('endDate') ){ 5187 bEndDate = b.get('membershipObject').get('endDate'); 5188 } 5189 if(aEndDate < bEndDate) { return -1; } 5190 if(aEndDate > bEndDate) { return 1; } 5191 return 0; 5192 }, 5193 sortByEndDate_descending( a, b ){ 5194 var aEndDate; 5195 if( a.get('membershipObject') && a.get('membershipObject').get('endDate') ){ 5196 aEndDate = a.get('membershipObject').get('endDate'); 5197 } 5198 var bEndDate; 5199 if( b.get('membershipObject') && b.get('membershipObject').get('endDate') ){ 5200 bEndDate = b.get('membershipObject').get('endDate'); 5201 } 5202 if(aEndDate > bEndDate) { return -1; } 5203 if(aEndDate < bEndDate) { return 1; } 5204 return 0; 5205 }, 5206 load_membership_directory(){ 5207 //update the ui 5208 store.components.membership_directory.loading = true; 5209 var promise = Parse.Promise.as(); 5210 var thisPromise = promise.then(() => { 5211 //get new data 5212 var params = {}; 5213 params["client_id"] = $('.membership-directory-wrap').data('client-id'); 5214 params["request_url"] = window.location.href; 5215 return Parse.Cloud.run('retrieve_data_for_component', params); 5216 }).then(result => { 5217 //vue 5218 store.components.membership_directory.member_tags = result.member_tags; 5219 store.components.membership_directory.loading = false; 5220 var json = []; 5221 var retrieved_members = result.members; 5222 for (var i = 0; i <= retrieved_members.length - 1; i++) { 5223 var boats_string = ""; 5224 var number = ""; 5225 if( retrieved_members[i].get('membershipObject') ){ 5226 number = retrieved_members[i].get('membershipObject').get('memberNumber'); 5227 } 5228 var boats_map = result.boats_map; 5229 if( boats_map && retrieved_members[i].get('membershipObject') ){ 5230 var these_boats = boats_map[ retrieved_members[i].get('membershipObject').id ]; 5231 if( these_boats ){ 5232 for (var b = these_boats.length - 1; b >= 0; b--) { 5233 if( boats_string.length >= 1 ){ 5234 boats_string += " ";
5235 } 5236 boats_string += returnNameStringForBoat( these_boats[b] ); 5237 } 5238 } 5239 } 5240 retrieved_members[i].boats_string = boats_string 5241 json.push({ 5242 "objectId": retrieved_members[i].id, 5243 "name": retrieved_members[i].get('firstName') + " " + retrieved_members[i].get('lastName'), 5244 "boats_string": boats_string, 5245 "number": number 5246 }) 5247 } 5248 //refactor for search 5249 store.components.membership_directory.allMembers = retrieved_members; 5250 //vue 5251 store.components.membership_directory.json = json; 5252 store.components.membership_directory.boats_map = result.boats_map; 5253 store.components.membership_directory.custom_fields = result.custom_fields; 5254 store.components.membership_directory.custom_field_responses = result.custom_field_responses; 5255 store.components.membership_directory.documents = result.documents; //for scira 5256 }, function(error){ 5257 checkParseError( error ); 5258 }); 5259 return thisPromise; 5260 }, 5261 /* ----- common methods ----- */ 5262 clicked_sort( key ){ 5263 if( this.sortObject.key == key ){ 5264 //already sorting by this one - flip it 5265 if( this.sortObject.order == "descending" ){ 5266 this.sortObject.order = "ascending"; 5267 }else{ 5268 this.sortObject.order = "descending"; 5269 } 5270 }else{ 5271 //not sorting by this yet 5272 this.sortObject.key = key; 5273 this.sortObject.order = "descending"; 5274 } 5275 }, 5276 /* ----- table methods ----- */ 5277 handleTagSelection( tag_id ){ 5278 this.selectedTag = tag_id; 5279 }, 5280 fillColumnClasses(){ 5281 // let configureColumns = { 5282 // 'show_photo': { 5283 // order: 1, 5284 // headerClasses: 'column_photo lessPadding', 5285 // headerStyle: 'padding-left:10px;0px', 5286 // headerName: null, 5287 // }, 5288 // 'show_member_number': { 5289 // order: 4, 5290 // headerClasses: 'column_member_number sortable', 5291 // 'data-db-key': 'memberNumber', 5292 // headerName: 'Number' 5293 // }, 5294 // 'show_email': { 5295 // order: 5, 5296 // headerClasses: 'column_email', 5297 // headerName: 'Email' 5298 // }, 5299 // 'show_mobile': { 5300 // order: 6, 5301 // headerClasses: 'column_mobile', 5302 // headerName: 'Mobile' 5303 // }, 5304 // 'show_phone_home': { 5305 // order: 7, 5306 // headerClasses: 'column_phone_home', 5307 // headerName: 'Home Phone' 5308 // }, 5309 // 'show_member_tags': { 5310 // order: 8, 5311 // headerClasses: 'column_member_tags', 5312 // headerName: 'Tags' 5313 // }, 5314 // 'show_ghin_number': { 5315 // order: 9, 5316 // headerClasses: 'column_ghin_number', 5317 // headerName: 'GHIN Number' 5318 // }, 5319 // 'show_address': { 5320 // order: 10, 5321 // headerClasses: 'column_address', 5322 // headerName: ['Street', 'City', 'State', 'Postal Code'] 5323 // }, 5324 // 'show_address_secondary': { 5325 // order: 11, 5326 // headerClasses: 'column_address_secondary', 5327 // headerName: ['Street (Secondary)', 'City (Secondary)', 'State (Secondary)', 'Postal Code (Secondary)'] 5328 // }, 5329 // 'show_start_date': { 5330 // order: 12, 5331 // headerClasses: 'sortable column_start_date', 5332 // 'data-db-key': 'startDate', 5333 // headerName: 'Start Date' 5334 // }, 5335 // 'show_end_date': { 5336 // order: 13, 5337 // headerClasses: 'sortable column_end_date', 5338 // 'data-db-key': 'endDate', 5339 // headerName: 'End Date' 5340 // }, 5341 // 'show_renewed': { 5342 // order: 14, 5343 // headerClasses: 'sortable column_renewed', 5344 // 'data-db-key': 'renewed', 5345 // headerName: 'Renewed' 5346 // }, 5347 // 'show_category': { 5348 // order: 15, 5349 // headerClasses: 'column_category', 5350 // headerName: 'Category' 5351 // }, 5352 // 'show_plan': { 5353 // order: 16, 5354 // headerClasses: 'column_plan', 5355 // headerName: 'Plan' 5356 // }, 5357 // } 5358 // let classStringArr = this.class_string.split(' '); 5359 // this.configurable_columns = classStringArr.reduce((acc, currentClass) => { 5360 // if (configureColumns[currentClass] !== undefined){ 5361 // acc.push(configureColumns[currentClass]) 5362 // } 5363 // return acc; 5364 // }, [ 5365 // { 5366 // order: 2, 5367 // headerClasses: 'sortable firstName', 5368 // 'data-db-key': 'firstName', 5369 // required: true, 5370 // headerName: 'First Name' 5371 // }, 5372 // { 5373 // order: 3, 5374 // headerClasses: 'sortable lastName', 5375 // 'data-db-key': 'lastName', 5376 // required: true, 5377 // headerName: 'Last Name' 5378 // }, 5379 // ]).sort((a, b) => { 5380 // return a.order - b.order; 5381 // }) 5382 }, 5383 check_archive_status( memberObject ){ 5384 var membershipObject = memberObject.get('membershipObject'); 5385 if(membershipObject && ! membershipObject.get('archived') && membershipObject.get('status') != 'canceled' ){ 5386 return true; 5387 } 5388 return false;
5389 }, 5390 clicked_member_row( memberObject ){ 5391 $('#overlay_membership-directory-popup').data('members', this.allMembers); 5392 $('#overlay_membership-directory-popup').data('boats_map', this.boats_map); 5393 prepThenDisplayMembershipDirectoryPopup( memberObject ); 5394 }, 5395 check_configurable_classes( className ){ 5396 for (let i = 0; i < this.configurable_columns.length; i++){ 5397 if (this.configurable_columns[i].headerClasses.includes(className)){ 5398 return true; 5399 break; 5400 } 5401 } 5402 return false; 5403 }, 5404 return_member_tags( memberObject ){ 5405 var member_tags = memberObject.get('member_tags') || []; 5406 var tag_string = ""; 5407 var tagsSituation = ''; 5408 if( member_tags.length >= 1 ){ 5409 for (var i = 0; i <= member_tags.length - 1; i++) { 5410 if( member_tags[i].className == "member_tags" ){ 5411 if( ! member_tags[i].get('archived') && ! member_tags[i].get('hidden') ){ 5412 if( tag_string.length >= 1 ){ 5413 tag_string += ','; 5414 } 5415 tag_string += member_tags[i].id; 5416 if( member_tags[i].get('hex_value') ){ 5417 //custom color 5418 tagsSituation += '<p class="table-row-tag" style="background-color:#'+ member_tags[i].get('hex_value') +';">' + member_tags[i].get('name') + '</p>'; 5419 }else{ 5420 //default color 5421 tagsSituation += '<p class="table-row-tag grey">' + member_tags[i].get('name') + '</p>'; 5422 } 5423 } 5424 } 5425 } 5426 } 5427 if( tagsSituation.length >= 1 ){ 5428 return tagsSituation; 5429 }else{ 5430 return '<p class="incomplete">None</p>' 5431 } 5432 }, 5433 format_utc_date( memberObject ){ 5434 return formatUTCDate( memberObject, true, false, true ) 5435 }, 5436 return_no_results_msg(){ 5437 if (this.clubIds.includes(this.clubId)){ 5438 return 'Search a member to get started.' //for mantoloking, cambridge yc, northport yc 5439 } else { 5440 return 'No members found.'; 5441 } 5442 }, 5443 sortMembershipDirectory( members ){ 5444 var sortObject = store.components.membership_directory.sortObject; 5445 if( ! sortObject ){ 5446 sortObject = {} 5447 } 5448 if( ! sortObject.key ){ 5449 sortObject.key = "startDate"; 5450 } 5451 if( ! sortObject.order ){ 5452 sortObject.order = "descending" 5453 } 5454 if( sortObject.key == "firstName" ){ 5455 if( sortObject.order == "ascending" ){ 5456 members = members.sort( this.sortByFirstName_ascending ); 5457 }else{ 5458 members = members.sort( this.sortByFirstName_descending ); 5459 } 5460 }else if( sortObject.key == "lastName" ){ 5461 if( sortObject.order == "ascending" ){ 5462 members = members.sort( this.sortByLastName_ascending ); 5463 }else{ 5464 members = members.sort( this.sortByLastName_descending ); 5465 } 5466 }else if( sortObject.key == "memberNumber" ){ 5467 if( sortObject.order == "ascending" ){ 5468 members = members.sort( this.sortByMemberNumber_ascending ); 5469 }else{ 5470 members = members.sort( this.sortByMemberNumber_descending ); 5471 } 5472 }else if( sortObject.key == "startDate" ){ 5473 if( sortObject.order == "ascending" ){ 5474 members = members.sort( this.sortByStartDate_ascending ); 5475 }else{ 5476 members = members.sort( this.sortByStartDate_descending ); 5477 } 5478 }else if( sortObject.key == "endDate" ){ 5479 if( sortObject.order == "ascending" ){ 5480 members = members.sort( this.sortByEndDate_ascending ); 5481 }else{ 5482 members = members.sort( this.sortByEndDate_descending ); 5483 } 5484 }else if( sortObject.key == "renewed" ){ 5485 if( sortObject.order == "ascending" ){ 5486 members = members.sort( this.sortByRenewed_ascending ); 5487 }else{ 5488 members = members.sort( this.sortByRenewed_de
5488scending ); 5489 } 5490 } 5491 return members; 5492 } 5493 }, 5494 computed: { 5495 members_json(){ 5496 let json = []; 5497 this.allMembers.forEach(member => { 5498 let this_search = ''; 5499 if( member.get('firstName') ){ 5500 this_search += ' ' + member.get('firstName').toLowerCase(); 5501 } 5502 if( member.get('lastName') ){ 5503 this_search += ' ' + member.get('lastName').toLowerCase(); 5504 } 5505 if( member['boats_string'] ){ 5506 this_search += ' ' + member['boats_string'].toLowerCase(); 5507 } 5508 if( member?.get('membershipObject')?.get('memberNumber') ){ 5509 this_search += ' ' + member?.get('membershipObject')?.get('memberNumber').toLowerCase(); 5510 } 5511 json.push({ 5512 "memberObject": member, 5513 "search": this_search, 5514 }) 5515 }); 5516 return json; 5517 }, 5518 members_matching_search(){ 5519 let val = this.search.toLowerCase(); 5520 var keys = [ 'search' ]; 5521 //sort them 5522 var these_matches = fuzzysort.go(val, this.members_json, { 5523 threshold: -10000, // Don't return matches worse than this (higher is faster) 5524 limit: 20, // Don't return more results than this (lower is faster) 5525 allowTypo: false, // Allows a snigle transpoes (false is faster) 5526 key: null, // For when targets are objects (see its example usage) 5527 keys: keys, // For when targets are objects (see its example usage) 5528 scoreFn: null, // For use with `keys` (see its example usage) 5529 }); 5530 return these_matches.map(match => match.obj.memberObject); 5531 }, 5532 filtered_members(){ 5533 let val = this.search.toLowerCase(); 5534 let filteredMembers; 5535 if (this.clubIds.includes(this.clubId)){ 5536 filteredMembers = []; //for mantoloking, cambridge yc, northport yc 5537 } else { 5538 filteredMembers = this.allMembers; 5539 } 5540 // handle search results using all members 5541 if (this.search.length > 0){ 5542 //use the new method 5543 filteredMembers = this.members_matching_search; 5544 } 5545 // handle member tag selection using filtered search results 5546 if (this.selectedTag !== 'all'){ 5547 filteredMembers = filteredMembers.filter(member => { 5548 let memberTags = member.get('member_tags') || []; 5549 for (let i = 0; i < memberTags.length; i++){ 5550 if (memberTags[i].id === this.selectedTag){ 5551 return member; 5552 break; 5553 } 5554 } 5555 }) 5556 } 5557 if (this.clubIds.includes(this.clubId) && filteredMembers.length > 20){ 5558 return this.sortMembershipDirectory( filteredMembers.slice(0, 20) ); //for mantoloking, cambridge yc, northport yc 5559 } else { 5560 return this.sortMembershipDirectory( filteredMembers ); 5561 } 5562 }, 5563 configurable_columns(){ 5564 let configureColumns = { 5565 'show_photo': { 5566 order: 1, 5567 headerClasses: 'column_photo lessPadding', 5568 headerStyle: 'padding-left:10px;0px', 5569 headerName: null, 5570 }, 5571 'show_member_number': { 5572 order: 4, 5573 headerClasses: 'column_member_number sortable', 5574 'data-db-key': 'memberNumber', 5575 headerName: 'Number' 5576 }, 5577 'show_email': { 5578 order: 5, 5579 headerClasses: 'column_email', 5580 headerName: 'Email' 5581 }, 5582 'show_mobile': { 5583 order: 6, 5584 headerClasses: 'column_mobile', 5585 headerName: 'Mobile' 5586 }, 5587 'show_phone_home': { 5588 order: 7, 5589 headerClasses: 'column_phone_home', 5590 headerName: 'Home Phone' 5591 }, 5592 'show_member_tags': { 5593 order: 8, 5594 headerClasses: 'column_member_tags', 5595 headerName: 'Tags' 5596 }, 5597 'show_ghin_number': { 5598 order: 9, 5599 headerClasses: 'column_ghin_number', 5600 headerName: 'GHIN Number' 5601 }, 5602 'show_address': { 5603 order: 10, 5604 headerClasses: 'column_address', 5605 headerName: ['Street', 'City', 'State', 'Postal Code'] 5606 }, 5607 'show_address_secondary': { 5608 order: 11, 5609 headerClasses: 'column_address_secondary', 5610 headerName: ['Street (Secondary)', 'City (Secondary)', 'State (Secondary)', 'Postal Code (Secondary)'] 5611 }, 5612 'show_start_date': { 5613 order: 12, 5614 headerClasses: 'sortable column_start_date', 5615 'data-db-key': 'startDate', 5616 headerName: 'Start Date' 5617 }, 5618 'show_end_date': { 5619 order: 13, 5620 headerClasses: 'sortable column_end_date', 5621 'data-db-key': 'endDate', 5622 headerName: 'End Date' 5623 }, 5624 'show_renewed': { 5625 order: 14, 5626 headerClasses: 'sortable column_renewed', 5627 'data-db-key': 'renewed', 5628 headerName: 'Renewed' 5629 }, 5630 'show_category': { 5631 order: 15, 5632 headerClasses: 'column_category', 5633 headerName: 'Category' 5634 }, 5635 'show_plan': { 5636 order: 16, 5637 headerClasses: 'column_plan', 5638 headerName: 'Plan' 5639 }, 5640 } 5641 let classStringArr = this.class_string.split(' '); 5642 return classStringArr.reduce((acc, currentClass) => { 5643 if (configureColumns[currentClass] !== undefined){ 5644 acc.push(configureColumns[currentClass]) 5645 } 5646 return acc; 5647 }, [ 5648 { 5649 order: 2, 5650 headerClasses: 'sortable firstName', 5651 'data-db-key': 'firstName', 5652 required: true, 5653 headerName: 'First Name' 5654 }, 5655 { 5656 order: 3, 5657 headerClasses: 'sortable lastName', 5658 'data-db-key': 'lastName', 5659 required: true, 5660 headerName: 'Last Name' 5661 }, 5662 ]).sort((a, b) => { 5663 return a.order - b.order; 5664 }) 5665 }, 5666 } 5667 }) 5668</script>
5668
5668<script type="text/html" id="template-event-list-card"> 5669 <div 5670 @click.stop="tapped_event_list_event()" 5671 class="event-row overlay-event-row flexEventOne noBorderBottom" 5672 :class="'event-row_' + event.id" 5673 :data-event-id="event.id" 5674 > 5675 <div 5676 class="event-list-card" 5677 :class="{ 5678 'small-card': formatting.card_size == 'small', 5679 'large-card': formatting.card_size == 'large', 5680 'box-shadow-light': formatting.box_shadow == 'light', 5681 'box-shadow-dark': formatting.box_shadow == 'dark', 5682 }" 5683 :style="{ 'border-radius': formatting.border_radius + 'px' || '0px' }" 5684 > 5685 <div 5686 v-if="image_data.url" 5687 class="event-image" 5688 :class="{ 5689 'small-card': formatting.card_size == 'small', 5690 'large-card': formatting.card_size == 'large', 5691 'use-contain': image_data.crop === 'contain' 5692 }" 5693 :style="{ 5694 '--event-image-url': `url(${image_data.url})`, 5695 backgroundImage: 'var(--event-image-url)', 5696 backgroundSize: image_data.crop || 'cover' 5697 }" 5698 > 5699 </div> 5700 <div 5701 v-else 5702 class="event-image" 5703 :class="{ 5704 'small-card': formatting.card_size == 'small', 5705 'large-card': formatting.card_size == 'large' 5706 }" 5707 > 5708 <p class="no-background">{{ event.get('name').charAt(0).toUpperCase() }}</p> 5709 </div> 5710 5711 <div 5712 class="card-content" 5713 :style="{ 'border-radius': formatting.border_radius + 'px' || '0px' }" 5714 > 5715 <p class="event-type">{{ event_type }}</p> 5716 <p 5717 class="event-title" 5718 :style="event_title_style" 5719 >{{ event_title }}</p> 5720 <div class="event-date-inline"> 5721 <i class="fa-regular fa-calendar" style="font-size: 0.8em;"></i> 5722 <span>{{ short_month }} {{ days }}</span> 5723 </div> 5724 <div v-if="show_time" class="event-time"> 5725 <i class="fa-regular fa-clock" style="font-size: 0.8em;"></i> 5726 <span>{{ event_time }}</span> 5727 </div> 5728 <button 5729 @click.stop 5730 v-if="button_label" 5731 class="event-button exclude-from-custom-button-styles relative background" 5732 v-html="button_label" 5733 ></button> 5734 </div> 5735 </div> 5736 </div> 5737</script>
5737 5738 5739<style> 5740 .event-list-card { 5741 width: 190px; 5742 overflow: hidden; 5743 background: white; 5744 font-family: 'Inter', sans-serif; 5745 display: flex; 5746 flex-direction: column; 5747 transition: transform 0.1s ease, background-color 0.15s ease; 5748 } 5749 .event-list-card.small-card { 5750 width: 190px; 5751 } 5752 .event-list-card.large-card { 5753 width: 230px; 5754 } 5755 .event-list-card.box-shadow-light { 5756 box-shadow: 2px 4px 8px rgba(0, 0, 0, 0.2); 5757 } 5758 .event-list-card.box-shadow-dark { 5759 box-shadow: 6px 6px 16px rgba(0, 0, 0, 0.2); 5760 } 5761 .event-list-card .event-image { 5762 display: flex; 5763 text-align: center; 5764 align-items: center; 5765 width: 100%; 5766 height: 170px; 5767 /* background-size now controlled via inline style / CSS class */ 5768 background-repeat: no-repeat; 5769 background-position: center; 5770 background-color: #77818f; 5771 color: #fff; 5772 position: relative; 5773 overflow: hidden; /* hide blurred overflow */ 5774 } 5775 /* When the image uses "contain", add a blurred cover background behind it */ 5776 .event-list-card .event-image.use-contain::before { 5777 content: ''; 5778 position: absolute; 5779 inset: 0; 5780 background-image: var(--event-image-url); 5781 background-size: cover; 5782 background-position: center; 5783 filter: blur(12px); 5784 transform: scale(1.1); /* avoid sharp edges from blur */ 5785 z-index: 0; 5786 pointer-events: none; 5787 } 5788 /* Foreground, non-blurred "contain" image layer */ 5789 .event-list-card .event-image.use-contain::after { 5790 content: ''; 5791 position: absolute; 5792 inset: 0; 5793 background-image: var(--event-image-url); 5794 background-size: contain; 5795 background-position: center; 5796 background-repeat: no-repeat; 5797 z-index: 1; 5798 pointer-events: none; 5799 } 5800 /* Ensure foreground content (initials) sits above the image layers */ 5801 .event-list-card .event-image .no-background { 5802 position: relative; 5803 z-index: 2; 5804 } 5805 .event-list-card .event-image.small-card{ 5806 height: 170px; 5807 } 5808 .event-list-card .event-image.large-card{ 5809 height: 220px; 5810 } 5811 .event-list-card .event-image .no-background { 5812 width: 100%; 5813 font-weight: 500; 5814 font-size: 5em; 5815 } 5816 .card-content { 5817 height: 140px; 5818 padding: 14px 12px; 5819 background-color: #f9f9f9; 5820 display: flex; 5821 flex-direction: column; 5822 transition: background-color 0.15s ease; 5823 } 5824 .event-row:hover .card-content { 5825 background-color: #f0f0f0; 5826 } 5827 .event-date-inline { 5828 display: flex; 5829 align-items: center; 5830 font-size: 14px; 5831 color: #000; 5832 margin-bottom: 2px; 5833 } 5834 .event-date-inline i { 5835 margin-right: 6px; 5836 } 5837 .event-list-card .event-type { 5838 font-size: 12px; 5839 text-transform: uppercase; 5840 color: #000; 5841 margin-bottom: 2px; 5842 } 5843 .event-list-card .event-title { 5844 font-size: 18px; 5845 text-transform: uppercase; 5846 margin-bottom: 8px; 5847 color: #000; 5848 font-family: serif; 5849 letter-spacing: 0.2px; 5850 } 5851 .event-list-card .event-time { 5852 display: flex; 5853 align-items: center; 5854 font-size: 14px; 5855 color: #000; 5856 margin-bottom: 12px; 5857 } 5858 .event-time i { 5859 margin-right: 6px; 5860 } 5861 .event-list-card .event-button { 5862 width: 100%; 5863 display: flex; 5864 align-items: center; 5865 justify-content: center; 5866 position: relative; 5867 z-index: 2; 5868 margin-top: auto; 5869 letter-spacing: 1px; 5870 line-height: 16px; 5871 cursor: pointer; 5872 font-size: 14px; 5873 font-weight: normal; 5874 padding: 6px 12px; 5875 text-transform: uppercase; 5876 transition: all 0.2s ease; 5877 border: 1.5px solid #000; 5878 border-radius: 2px; 5879 } 5880 .event-list-card .event-button:hover{ 5881 background-color: #000; 5882 color: white; 5883 } 5884 .res-callout{ 5885 display: flex; 5886 align-items: center; 5887 white-space: nowrap; 5888 height: fit-content; 5889 width: fit-content; 5890 padding: 3.5px; 5891 background-color: #fff; 5892 border: 1px solid #e4ebf1; 5893 border-radius: 5px; 5894 font-size: 10px; 5895 text-transform: uppercase; 5896 color: #5c6d7a; 5897 } 5898</style> 5899
5900<script> 5901 5902 var config = defineComponent({ 5903 template: "#template-event-list-card", 5904 data() { 5905 return {} 5906 }, 5907 props: { 5908 event_data: { 5909 type: Object, 5910 required: true, 5911 }, 5912 event_props: { 5913 type: Object, 5914 required: true, 5915 }, 5916 formatting: Object, 5917 }, 5918 methods: { 5919 tapped_event_list_event(){ 5920 this.$emit('tapped-event-list-event', this.event_data); 5921 }, 5922 }, 5923 computed: { 5924 short_month(){ 5925 if( this.start_date ){ 5926 return format_utc_date_v2({ 5927 "utcDate": this.start_date, 5928 "timezone": store.timezone, 5929 "formatString": "MMM" 5930 }); 5931 } 5932 return null; 5933 }, 5934 days(){ 5935 let string = ''; 5936 if( this.start_date ){ 5937 let start_components = return_date_components_v2({ 5938 "date": this.start_date, 5939 "timezone": store.timezone 5940 }); 5941 string += start_components.date; 5942 if( this.end_date && ! this.event.get('all_day') ){ 5943 let end_components = return_date_components_v2({ 5944 "date": this.end_date, 5945 "timezone": store.timezone 5946 }); 5947 if( start_components.date !== end_components.date ){ 5948 string += ' - ' + end_components.date; 5949 } 5950 } 5951 } 5952 return string; 5953 }, 5954 show_register_button(){ 5955 return this.event_props.show_register_button; 5956 }, 5957 show_time(){ 5958 return this.event_props.show_time; 5959 }, 5960 image_data(){ 5961 return this.event_props.image_data; 5962 }, 5963 start_date(){ 5964 return this.event_props.start_date; 5965 }, 5966 end_date(){ 5967 return this.event_props.end_date; 5968 }, 5969 event_title() { 5970 return this.event.get('name') || ''; 5971 }, 5972 event_title_style() { 5973 let style = {}; 5974 if( this.formatting.event_title_overflow === 'wrap' ){ 5975 style['white-space'] = 'break-spaces'; 5976 } 5977 if( this.formatting.event_title_font_size ){ 5978 style['font-size'] = this.formatting.event_title_font_size + 'px'; 5979 } 5980 return style; 5981 }, 5982 event_type(){ 5983 if( this.event.className == 'camps' ){ 5984 return 'camp'; 5985 }else if( this.event.className == 'regattas' ){ 5986 return 'regatta'; 5987 }else{ 5988 return 'event'; 5989 } 5990 }, 5991 button_label(){ 5992 let eventObject = this.event; 5993 let href_base = ''; 5994 let clubObject = store.clubObject; 5995 let web_settings = null; 5996 if( clubObject ){ 5997 web_settings = clubObject.get('web_settings'); 5998 } 5999 if( web_settings ){ 6000 if( web_settings.get('event_links') == "use_clubspot_regatta_links" && ( eventObject.className == "regattas" || eventObject.className == "subevents" ) ){ 6001 //force clubspot urls for regattas 6002 href_base += 'https://theclubspot.com'; 6003 } 6004 } 6005 var content; 6006 var show_register_button = this.show_register_button; 6007 if( eventObject.className == "regattas" || eventObject.className == "subevents" ){ 6008 if( eventObject.className == "subevents" ){ 6009 eventObject = eventObject.get('regattaObject'); 6010 } 6011 //check for external events 6012 if( eventObject.get('regatta_external') ){ 6013 //it's an external regatta - only render the external button if a URL is set; never fall back to register/results 6014 if( eventObject.get('external_regatta_url') ){ 6015 content = 'View event<a class="absoluteA" target="_blank" href="'+ eventObject.get('external_regatta_url') +'"></a>'; 6016 } 6017 }else{ 6018 //handle button status 6019 if( show_register_button ){ 6020 //register 6021 content = 'Register<a class="absoluteA" href="'+href_base+'/regatta/'+ eventObject.id +'"></a>'; 6022 }else{ 6023 //results 6024 content = 'View Results<a class="absoluteA" href="'+href_base+'/regatta/'+ eventObject.id +'/results"></a>'; 6025 } 6026 } 6027 }else if( eventObject.className == "events" ){ 6028 var registration_link; 6029 var details_link; 6030 if( eventObject.get('calendarOnly') ){ 6031 if( eventObject.get('external_event') ){ 6032 details_link = this.event_props.resolved_external_url || eventObject.get('external_event_url'); 6033 } 6034 }else{ 6035 if( eventObject.get('events_v2') ){ 6036 registration_link = '/event/' + eventObject.id; 6037 }else if( eventObject.get('external_event') && eventObject.get('external_register_link') ){ 6038 registration_link = eventObject.get('external_register_link'); 6039 }else if( eventObject.get('event_product') ){ 6040 if( eventObject.get('event_product').get('availability') && eventObject.get('event_product').get('availability').startDate ){ 6041 registration_link = "/reserve/" + eventObject.get('event_product').id + "?date=" + eventObject.get('event_product').get('availability').startDate; 6042 } 6043 } 6044 } 6045 if( registration_link && show_register_button ){ 6046 content = 'RSVP<a class="absoluteA" href="'+href_base+''+registration_link+'"></a>'; 6047 } else if( details_link && details_link.length > 0 ){ 6048 let button_label = eventObject.get('external_event_url_type') === 'dining' ? 'Reserve' : 'View details'; 6049 content = button_label + '<a class="absoluteA" target="_blank" href="'+ details_link +'"></a>'; 6050 } 6051 }else if( eventObject.className == "camps" ){ 6052 //handle button status 6053 if( show_register_button ){ 6054 //register 6055 content = 'Register<a href="'+href_base+'/register/camp/'+ eventObject.id +'/class" class="absoluteA"></a>'; 6056 } 6057 } 6058 return content; 6059 }, 6060 event(){ 6061 return this.event_data.event; 6062 }, 6063 clubObject(){ 6064 return store.clubObject; 6065 }, 6066 event_time() { 6067 if( this.show_time ){ 6068 return format_utc_date_v2({ 6069 "utcDate": this.start_date, 6070 "timezone": store.timezone, 6071 "formatString": "h:mm A" 6072 }); 6073 } 6074 return ""; 6075 }, 6076 } 6077 }) 6078 //define the component 6079 app_overlays.component("event-list-card-component", config); 6080 app_content.component("event-list-card-component", config); 6081 6082</script>
6082
6082<script type="text/html" id="template-event-list-event"> 6083 <div 6084 @click.stop="tapped_event_list_event()" 6085 class="event-row overlay-event-row flexEventOne" 6086 :class="'event-row_' + event.id" 6087 :data-event-id="event.id" 6088 > 6089 <!-- date and image wrap --> 6090 <div class="cs-signups-row-left flexNoWrapCenterAlign"> 6091 <div class="cs-signups-date-wrap relative" style="width: 55px;"> 6092 <div class="centeredBlock relative"> 6093 <div> 6094 <p class="cs-signups-date-month">{{ short_month }}</p> 6095 <p class="cs-signups-date-day">{{ days }}</p> 6096 </div> 6097 </div> 6098 </div> 6099 <div 6100 v-if="image_data.url" 6101 class="cs-signups-row-image flexWrap" 6102 :class="{ 'background-contain' : image_data.crop === 'contain', 'background-cover' : image_data.crop === 'cover'}" 6103 :style="{ backgroundImage: 'url(' + image_data.url + ')' }" 6104 > 6105 <div v-if="is_different_year" class="header-callout">{{ full_year }}</div> 6106 </div> 6107 <div 6108 v-else 6109 class="cs-signups-row-image flexWrap" 6110 > 6111 <div v-if="is_different_year" class="header-callout">{{ full_year }}</div> 6112 {{ event.get('name').charAt(0).toUpperCase() }} 6113 </div> 6114 </div> 6115 <!-- event info, links, and host wrap --> 6116 <div class="cs-signups-row-inner flexWrapLeftAlign ellipsis align-items-center"> 6117 <!-- event info --> 6118 <div class="cs-signups-name-wrap ellipsis textLeft"> 6119 <span class="flexWrap" style="display: flex; align-items: center; justify-content: flex-start; margin-bottom: 5px;"> 6120 <div class="cs-signups-name-0 textLeft" style="flex-grow: 0; margin-right: 8px;">{{ event.get('name') }}</div> 6121 </span> 6122 <div class="event-date-wrap-v2">{{ niceDate }}</div> 6123 <div class="event-date-wrap-v2 tiniestPaddingTop"> 6124 <p>{{ clubObject.get('name') }}</p> 6125 <p>{{ return_markup( event, 'event-host-2' )}}</p> 6126 </div> 6127 </div> 6128 <!-- event links --> 6129 <div @click.stop class="cs-signups-links-wrap flexGrowOne"> 6130 <div class="cs-signups-links-wrap-inner textLeft"> 6131 <div class="event-nav-1" v-html="return_markup( event, 'links' )"></div> 6132 <div class="event-nav-2" v-html="return_markup( event, 'button' )"></div> 6133 </div> 6134 </div> 6135 </div> 6136 </div> 6137</script>
6137 6138
6139<script> 6140 6141 var config = defineComponent({ 6142 template: "#template-event-list-event", 6143 data() { 6144 return {} 6145 }, 6146 props: { 6147 event_data: { 6148 type: Object, 6149 required: true, 6150 }, 6151 event_props: { 6152 type: Object, 6153 required: true, 6154 }, 6155 }, 6156 methods: { 6157 tapped_event_list_event(){ 6158 this.$emit('tapped-event-list-event', this.event_data); 6159 }, 6160 return_markup( eventObject, type ){ 6161 var href_base = ''; 6162 var clubObject = this.clubObject; 6163 var web_settings = clubObject.get('web_settings'); 6164 if( web_settings ){ 6165 if( web_settings.get('event_links') == "use_clubspot_regatta_links" && ( eventObject.className == "regattas" || eventObject.className == "subevents" ) ){ 6166 //force clubspot urls for regattas 6167 href_base += 'https://theclubspot.com'; 6168 } 6169 } 6170 // returns here 6171 if ( type === 'event-host-2' ){ 6172 var club_string = ''; 6173 var location_params = { 6174 eventObject: eventObject, 6175 hideCountry: true, 6176 hideZip: true, 6177 hideStreet: true, 6178 } 6179 var club_location_params = { 6180 eventObject: clubObject, 6181 hideCountry: true, 6182 hideZip: true, 6183 hideStreet: true, 6184 } 6185 if(formatLocation(location_params) ){ 6186 if( club_string.length >= 1 ){ 6187 club_string += ', '; 6188 } 6189 club_string += formatLocation(location_params); 6190 }else if( clubObject && formatLocation(club_location_params) ){ 6191 if( club_string.length >= 1 ){ 6192 club_string += ', '; 6193 } 6194 club_string += formatLocation(club_location_params); 6195 } 6196 return club_string; 6197 } else if ( type === 'links' ){ 6198 var content; 6199 if( eventObject.className == "regattas" || eventObject.className == "subevents" ){ 6200 //check for external events 6201 if( eventObject.get('regatta_external') ){ 6202 //it's an external regatta - only render the external link if a URL is set; never fall back to internal links 6203 if( eventObject.get('external_regatta_url') ){ 6204 content = '<p class="tiniestPaddingTop"><a target="_blank" href="'+ eventObject.get('external_regatta_url') +'">Event page</a></p>'; 6205 } 6206 }else{ 6207 if( eventObject.className == "subevents" ){ 6208 eventObject = eventObject.get('regattaObject'); 6209 } 6210 //event page 6211 content = '<p class="tiniestPaddingTop"><a href="'+href_base+'/regatta/'+ eventObject.id +'">Event page</a></p>'; 6212 //entries 6213 content += '<p class="tiniestPaddingTop"><a href="'+href_base+'/regatta/'+ eventObject.id +'/#entry-list">Entries</a></p>'; 6214 //race docs 6215 content += '<p class="tiniestPaddingTop"><a href="'+href_base+'/regatta/'+ eventObject.id +'/#notice-board">Notice board</a></p>'; 6216 } 6217 }else if( eventObject.className == "events" ){ 6218 //check for external events 6219 if( ! eventObject.get('calendarOnly') ){ 6220 if( eventObject.get('external_event') && eventObject.get('external_event_url') ){ 6221 //it's an external event 6222 content = '<p class="tiniestPaddingTop"><a target="_blank" href="'+ eventObject.get('external_event_url') +'">Event page</a></p>'; 6223 }else{ 6224 //event page 6225 content = '<p class="tiniestPaddingTop"><a href="'+href_base+'/event/'+ eventObject.id +'">Event page</a></p>'; 6226 } 6227 }else{ 6228 //calendar only event: use resolved URL per instance (recurring dining) or stored URL 6229 let external_url = this.event_props.resolved_external_url || eventObject.get('external_event_url'); 6230 if( 6231 eventObject.get('external_event') 6232 && eventObject.get('external_event_url_type') !== 'dining' 6233 && external_url 6234 ){ 6235 content = '<p class="tiniestPaddingTop"><a target="_blank" href="'+ external_url +'">View details</a></p>'; 6236 } 6237 } 6238 }else if( eventObject.className == "camps" ){ 6239 //it's a camp 6240 content = '<p class="tiniestPaddingTop"><a href="'+href_base+'/camp-registrations/'+ eventObject.id +'">Entry list</a></p>'; 6241 } 6242 return content; 6243 } else if ( type === 'button' ){ 6244 var content; 6245 var show_register_button = this.show_register_button; 6246 if( eventObject.className == "regattas" || eventObject.className == "subevents" ){ 6247 if( eventObject.className == "subevents" ){ 6248 eventObject = eventObject.get('regattaObject'); 6249 } 6250 //check for external events 6251 if( eventObject.get('regatta_external') ){ 6252 //it's an external regatta - only render the external button if a URL is set; never fall back to register/results 6253 if( eventObject.get('external_regatta_url') ){ 6254 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>'; 6255 } 6256 }else{ 6257 //handle button status 6258 if( show_register_button ){ 6259 //register 6260 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>'; 6261 }else{ 6262 //results 6263 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>'; 6264 } 6265 } 6266 }else if( eventObject.className == "events" ){ 6267 var registration_link; 6268 var details_link; 6269 if( eventObject.get('calendarOnly') ){ 6270 if( eventObject.get('external_event') ){ 6271 details_link = this.event_props.resolved_external_url || eventObject.get('external_event_url'); 6272 } 6273 }else{ 6274 if( eventObject.get('events_v2') ){ 6275 registration_link = '/event/' + eventObject.id; 6276 }else if( eventObject.get('external_event') && eventObject.get('external_register_link') ){ 6277 registration_link = eventObject.get('external_register_link'); 6278 } 6279 } 6280 if( registration_link && show_register_button ){ 6281 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>'; 6282 }else if( details_link ){ 6283 if( eventObject.get('external_event_url_type') === 'dining' ){ 6284 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>'; 6285 }else{ 6286 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>'; 6287 } 6288 } 6289 }else if( eventObject.className == "camps" ){ 6290 //handle button status 6291 if( show_register_button ){ 6292 //register 6293 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>'; 6294 } 6295 } 6296 return content; 6297 } 6298 }, 6299 }, 6300 computed: { 6301 show_register_button(){ 6302 return this.event_props.show_register_button; 6303 }, 6304 show_time(){ 6305 return this.event_props.show_time; 6306 }, 6307 image_data(){ 6308 return this.event_props.image_data; 6309 }, 6310 start_date(){ 6311 return this.event_props.start_date; 6312 }, 6313 end_date(){ 6314 return this.event_props.end_date; 6315 }, 6316 niceDate(){ 6317 if( this.start_date ){ 6318 if( this.show_time ){ 6319 //show the time 6320 return format_utc_date_v2({ 6321 "utcDate": this.start_date, 6322 "timezone": store.timezone, 6323 "formatString": "MMM D, YYYY, h:mm A" 6324 }); 6325 }else{ 6326 //show the date only 6327 return format_utc_date_v2({ 6328 "utcDate": this.start_date, 6329 "timezone": store.timezone, 6330 "formatString": "MMM D, YYYY" 6331 }); 6332 } 6333 } 6334 return null; 6335 }, 6336 full_year(){ 6337 if( this.start_date ){ 6338 return format_utc_date_v2({ 6339 "utcDate": this.start_date, 6340 "timezone": store.timezone, 6341 "formatString": "YYYY" 6342 }); 6343 } 6344 return null; 6345 }, 6346 days(){ 6347 let string = ''; 6348 if( this.start_date ){ 6349 let start_components = return_date_components_v2({ 6350 "date": this.start_date, 6351 "timezone": store.timezone 6352 });
6353 string += start_components.date; 6354 if( this.end_date && ! this.event.get('all_day') ){ 6355 let end_components = return_date_components_v2({ 6356 "date": this.end_date, 6357 "timezone": store.timezone 6358 }); 6359 if( start_components.date !== end_components.date ){ 6360 string += ' - ' + end_components.date; 6361 } 6362 } 6363 } 6364 return string; 6365 }, 6366 short_month(){ 6367 if( this.start_date ){ 6368 return format_utc_date_v2({ 6369 "utcDate": this.start_date, 6370 "timezone": store.timezone, 6371 "formatString": "MMM" 6372 }); 6373 } 6374 return null; 6375 }, 6376 is_different_year(){ 6377 if( this.start_date ){ 6378 let start_components = return_date_components_v2({ 6379 "date": this.start_date, 6380 "timezone": store.timezone 6381 }); 6382 let now_components = return_date_components_v2({ 6383 "date": new Date(), 6384 "timezone": store.timezone 6385 }); 6386 return start_components.year !== now_components.year; 6387 } 6388 return false; 6389 }, 6390 event(){ 6391 return this.event_data.event; 6392 }, 6393 clubObject(){ 6394 return store.clubObject; 6395 }, 6396 event_time() { 6397 if( this.show_time ){ 6398 return format_utc_date_v2({ 6399 "utcDate": this.start_date, 6400 "timezone": store.timezone, 6401 "formatString": "h:mm A" 6402 }); 6403 } 6404 return ""; 6405 }, 6406 }, 6407 }) 6408 //define the component 6409 app_overlays.component("event-list-event-component", config); 6410 app_content.component("event-list-event-component", config); 6411 6412</script>
6412 6413 6414<style> 6415 .res-callout{ 6416 display: flex; /* Add this to make the container a flexbox */ 6417 align-items: center; /* Center items vertically */ 6418 white-space: nowrap; /* Prevent wrapping */ 6419 height: fit-content; 6420 width: fit-content; 6421 padding: 3.5px; 6422 background-color: #fff; 6423 border: 1px solid #e4ebf1; 6424 border-radius: 5px; 6425 font-size: 10px; 6426 text-transform: uppercase; 6427 color: #5c6d7a; 6428 } 6429</style>
6429<script type="text/html" id="template-event-list"> 6430 <div class="cs-background-image relative" :class="class_string" :style="style_string"> 6431 <div class="element-wrapper" 6432 :class="'element-wrapper_' + component_type" 6433 :style="element_style_string" 6434 style="max-width:100% !important;min-width:90%;" 6435 > 6436 <!-- list view --> 6437 <template v-if="formatting_obj.layout === 'list' || !formatting_obj.layout"> 6438 <div class="event-list-wrap centeredBlock centeredText" :class="'event-list-wrap_' + client_id" :data-client_id="client_id"> 6439 <div 6440 class="flexNoWrap align-items-center" 6441 style="padding-bottom:5px;" 6442 > 6443 <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> 6444 <div 6445 class="view-all-events-wrapper tinyMarginLeft" 6446 @click="popViewAllEventsOverlay()" 6447 > 6448 <div :style="{ 'font-size': '14px !important', 'font-weight': '400' }">View all</div> 6449 <div class="smallDrilldownHeaderBackButton flip"></div> 6450 </div> 6451 </div> 6452 <div class="standardCardBody noBorderBottom fff noPadding overflowHidden"> 6453 <div v-if="!loading && sorted_event_data.length >= 1" class="tableInsert hundop flexWrapEvent"> 6454 <template v-for="(event_data, index) in sorted_event_data" :key="event_key(event_data)"> 6455 <p 6456 v-if="is_first_visible_event_of_month(event_data)" 6457 class="cs-section-header" 6458 >{{ month_string( event_data ) }}</p> 6459 <event-list-event-component 6460 :event_data="event_data" 6461 :event_props="event_props( event_data )" 6462 @tapped-event-list-event="tapped_event_list_event" 6463 ></event-list-event-component> 6464 </template> 6465 </div> 6466 <div v-if="loading" class="cardBodySpinnerWrap active relative"> 6467 <div class="cardBodySpinner centeredBlock"></div> 6468 </div> 6469 <no-objects-found 6470 v-if="!loading && sorted_event_data.length == 0" 6471 subtext="No events found" 6472 icon_class="fa-light fa-calendar-days" 6473 ></no-objects-found> 6474 </div> 6475 </div> 6476 </template> 6477 6478 <!-- carousel view --> 6479 <template v-if="formatting_obj.layout === 'carousel'"> 6480 <div class="event-list-wrap centeredBlock centeredText" :class="'event-list-wrap_' + client_id" :data-client_id="client_id"> 6481 <div 6482 class="flexNoWrap align-items-center event_list_carousel_default" 6483 style="padding: 0px 0px 15px 5px;" 6484 > 6485 <h3 6486 class="event_list_carousel_header" 6487 >{{ date_filter || 'upcoming' }} events</h3> 6488 </div> 6489 <span 6490 @mouseenter="start_auto_scroll('left')" 6491 @mouseleave="stop_auto_scroll" 6492 class="event-list-auto-scroll left" 6493 ></span> 6494 <div class="standardCardBody noBorderTop noBorderBottom fff noPadding"> 6495 <div 6496 ref="event_list_card_wrapper" 6497 v-if="!loading && sorted_event_data.length >= 1" 6498 class="tableInsert hundop event-list-card-wrapper" 6499 :style="{ gap: formatting_obj.card_spacing + 'px' || '25px' }" 6500 > 6501 <template v-for="(event_data, index) in sorted_event_data" :key="event_key(event_data)"> 6502 <event-list-card-component 6503 :event_data="event_data" 6504 :formatting="formatting_obj" 6505 :event_props="event_props( event_data )" 6506 @tapped-event-list-event="tapped_event_list_event" 6507 ></event-list-card-component> 6508 </template> 6509 </div> 6510 <div v-if="loading" class="cardBodySpinnerWrap active relative"> 6511 <div class="cardBodySpinner centeredBlock"></div> 6512 </div> 6513 <no-objects-found 6514 v-if="!loading && sorted_event_data.length == 0" 6515 subtext="No events found" 6516 icon_class="fa-light fa-calendar-days" 6517 ></no-objects-found> 6518 </div>
6519 <span 6520 @mouseenter="start_auto_scroll('right')" 6521 @mouseleave="stop_auto_scroll" 6522 class="event-list-auto-scroll right" 6523 ></span> 6524 </div> 6525 </template> 6526 </div> 6527 </div> 6528</script>
6528 6529 6530<style> 6531 .event_list_carousel_default { 6532 --event-list-carousel-font-family: 'Cormorant Garamond'; 6533 --event-list-carousel-font-size: 16px; 6534 --event-list-carousel-font-weight: 600; 6535 --event-list-carousel-text-transform: uppercase; 6536 --event-list-carousel-letter-spacing: 1.2px; 6537 --event-list-carousel-color: #000; 6538 } 6539 .event_list_carousel_header { 6540 /* font-family: var(--event-list-carousel-font-family) !important; */ 6541 font-size: var(--event-list-carousel-font-size) !important; 6542 font-weight: var(--event-list-carousel-font-weight) !important; 6543 text-transform: var(--event-list-carousel-text-transform) !important; 6544 letter-spacing: var(--event-list-carousel-letter-spacing) !important; 6545 color: var(--event-list-carousel-color) !important; 6546 } 6547 .event_list_carousel_header.select_filter { 6548 width: 100px !important; 6549 text-overflow: ellipsis !important; 6550 border: none !important; 6551 border-bottom: 0.5px solid black !important; 6552 appearance: none !important; 6553 -webkit-appearance: none !important; 6554 -moz-appearance: none !important; 6555 background: none !important; 6556 padding: 0px !important; 6557 box-shadow: none !important; 6558 display: inline-block !important; 6559 background-image: none !important; 6560 } 6561 .event-list-card-wrapper { 6562 position: relative; 6563 display: flex; 6564 justify-content: flex-start; 6565 align-items: center; 6566 padding: 10px; 6567 overflow: scroll !important; 6568 scrollbar-width: none !important; 6569 } 6570 .event-list-auto-scroll { 6571 position: absolute; 6572 top: 0; 6573 height: 100%; 6574 width: 20px; 6575 } 6576 .event-list-auto-scroll.left { 6577 left: 0; 6578 } 6579 .event-list-auto-scroll.right { 6580 right: 0; 6581 } 6582</style> 6583
6584<script> 6585 6586 app_content.component('event-list-component', { 6587 template: '#template-event-list', 6588 data() { 6589 return { 6590 event_data: [], 6591 loading: true, 6592 sortObject: { 6593 order: 'ascending', 6594 key: 'startDate' 6595 }, 6596 scroll_animation_frame: null, 6597 } 6598 }, 6599 props: { 6600 class_string: String, 6601 style_string: String, 6602 element_style_string: String, 6603 formatting: String, 6604 filter_types: String, 6605 filter_tag_ids: String, 6606 date_filter: { 6607 type: String, 6608 default: 'upcoming', 6609 }, 6610 component_type: String, 6611 client_id: String, 6612 //admin 6613 component: Object, 6614 last_update: Number, 6615 }, 6616 mounted() { 6617 if( this.date_filter == "past" ){ 6618 this.sortObject = { 6619 order: "descending", 6620 key: "startDate" 6621 }; 6622 } 6623 this.loadEventList(this.client_id); 6624 }, 6625 methods: { 6626 is_first_visible_event_of_month( event_data ){ 6627 return this.first_visible_events.includes( this.event_key(event_data) ); 6628 }, 6629 event_key( event_data ){ 6630 return event_data.event.id + '_' + event_data.ts; 6631 }, 6632 month_string( event_data ){ 6633 let start_date = new Date(event_data.ts); 6634 return format_utc_date_v2({ 6635 "utcDate": start_date, 6636 "timezone": store.timezone, 6637 "formatString": "MMMM YYYY" 6638 }); 6639 }, 6640 event_props( event_data ){ 6641 return { 6642 show_register_button: this.show_register_button( event_data ), 6643 show_time: this.show_time( event_data.event ), 6644 image_data: this.image_data( event_data.event ), 6645 start_date: this.start_date( event_data ), 6646 end_date: this.end_date( event_data ), 6647 resolved_external_url: this.resolve_external_url_for_instance( event_data ), 6648 }; 6649 }, 6650 resolve_external_url_for_instance( event_data ){ 6651 let event = event_data.event; 6652 if ( !event || !event.get('calendarOnly') ) { 6653 return null; 6654 } 6655 if ( event.get('external_event_url_type') === 'dining' ) { 6656 if ( event_data.external_event_url ) { 6657 return event_data.external_event_url; 6658 } 6659 let startDate = event.get('startDate'); 6660 let components = return_date_components_v2({ 6661 "date": startDate, 6662 "timezone": 'UTC' 6663 }); 6664 let date_string = components.year + '-' + ( components.month + 1 ) + '-' + components.date; 6665 let dining_url = '/dining?date_string=' + date_string; 6666 if ( event.get('startTime') ) { 6667 dining_url += '&time=' + event.get('startTime'); 6668 } 6669 return dining_url; 6670 } 6671 if ( event.get('external_event_url') ) { 6672 let url = event.get('external_event_url'); 6673 if ( url && !/^https?:\/\//i.test(url) ) { 6674 url = 'https://' + url; 6675 } 6676 return url; 6677 } 6678 return null; 6679 }, 6680 start_date( event_data ){ 6681 if( event_data.ts ){ 6682 return new Date(event_data.ts); 6683 } 6684 return null; 6685 }, 6686 end_date( event_data ){ 6687 if( event_data.end_ts ){ 6688 return new Date(event_data.end_ts); 6689 } 6690 return null; 6691 }, 6692 tapped_event_list_event( event_data ){ 6693 let event = event_data.event; 6694 if(event && event.className == 'events') { 6695 $('#overlay_event-description-popup h3').text(event.get('name')); 6696 let start_date = this.start_date( event_data ); 6697 let nice_date = format_utc_date_v2({ 6698 "utcDate": start_date, 6699 "timezone": store.timezone, 6700 "formatString": "MMM D, YYYY" 6701 }); 6702 $('#overlay_event-description-popup .metric_start_date').text( nice_date ); 6703 if( this.show_time( event ) ){ 6704 let event_time = format_utc_date_v2({ 6705 "utcDate": start_date, 6706 "timezone": store.timezone, 6707 "formatString": "h:mm A" 6708 }); 6709 $('#overlay_event-description-popup .metric_start_date_time').text( event_time ).removeClass('incomplete'); 6710 }else{ 6711 $('#overlay_event-description-popup .metric_start_date_time').text('Not provided').addClass('incomplete'); 6712 } 6713 if(event.get('description')) { 6714 $('#overlay_event-description-popup .metric_description').html(event.get('description')).removeClass('incomplete'); 6715 } else { 6716 $('#overlay_event-description-popup .metric_description').text('Not provided').addClass('incomplete'); 6717 } 6718 fadeIn('#overlay_event-description-popup'); 6719 } 6720 }, 6721 show_register_button( event_data ){ 6722 var now = new Date(); 6723 var ended = false;
6724 let eventObject = event_data.event; 6725 var registration_closed = eventObject.get('registration_closed'); 6726 var lastChanceDate = eventObject.get('lastChanceDate'); 6727 var startDate = new Date(event_data.ts); 6728 if( event_data.end_ts ){ 6729 var endDate = new Date(event_data.end_ts); 6730 } 6731 if( ! endDate && startDate ){ 6732 endDate = startDate; 6733 } 6734 var endDate_plus_one; 6735 if( endDate ){ 6736 endDate_plus_one = new Date( endDate.getTime() ); 6737 endDate_plus_one.setDate( endDate.getDate() + 1 ); 6738 } 6739 if( now > endDate_plus_one && endDate_plus_one ){ 6740 ended = true; 6741 } 6742 if( now > lastChanceDate && lastChanceDate ){ 6743 registration_closed = true; 6744 } 6745 if( ended || registration_closed || eventObject.get('calendarOnly') ){ 6746 return false; 6747 } else { 6748 return true; 6749 } 6750 }, 6751 show_time( event ){ 6752 if( 6753 event.get('calendarOnly') && event.get('all_day') 6754 ){ 6755 return false; 6756 }else if( 6757 event.className == "camps" || 6758 event.className == "regattas" || 6759 event.className == "subevents" 6760 ){ 6761 return false; 6762 } 6763 return true; 6764 }, 6765 image_data( event ){ 6766 if( event.className == "subevents" && event.get('regattaObject') ){ 6767 event = event.get('regattaObject'); 6768 } 6769 return { 6770 url: event.get('imageURL'), 6771 crop: event.get('imageCrop') 6772 } 6773 }, 6774 popViewAllEventsOverlay(){ 6775 view_all_events({ 6776 "class_string": this.class_string, 6777 "style_string": this.style_string, 6778 "element_style_string": this.element_style_string, 6779 "component_type": this.component_type, 6780 "client_id": this.client_id, 6781 "event_data": this.event_data, 6782 "props_generator": this.event_props, 6783 }); 6784 }, 6785 start_auto_scroll(direction) { 6786 const wrapper = this.$refs.event_list_card_wrapper; 6787 const amount = direction === 'right' ? 10 : -10; //scroll speed 6788 const scrollStep = () => { 6789 wrapper.scrollBy({ left: amount, behavior: 'auto' }); 6790 this.scroll_animation_frame = requestAnimationFrame(scrollStep); 6791 }; 6792 scrollStep(); 6793 }, 6794 stop_auto_scroll() { 6795 cancelAnimationFrame(this.scroll_animation_frame); 6796 this.scroll_animation_frame = null; 6797 }, 6798 loadEventList( client_id, overrides = {} ){ 6799 var promise = Parse.Promise.as(); 6800 var thisPromise = promise.then(() => { 6801 var params = {}; 6802 params["client_id"] = client_id; 6803 params["overrides"] = overrides; 6804 params["request_url"] = window.location.href; 6805 params["date_filter"] = this.date_filter; 6806 return Parse.Cloud.run('retrieve_data_for_component', params); 6807 }).then(event_data => { 6808 this.event_data = event_data; 6809 return Promise.resolve(); 6810 }).then(() => { 6811 this.loading = false; 6812 }, error => { 6813 checkParseError(error); 6814 this.loading = false; 6815 }); 6816 return thisPromise; 6817 }, 6818 sortEventList( event_data, sortObject ){ 6819 event_data = [... event_data]; 6820 if( ! sortObject ){ 6821 sortObject = {} 6822 } 6823 if( ! sortObject.key ){ 6824 sortObject.key = "startDate"; 6825 } 6826 if( ! sortObject.order ){ 6827 sortObject.order = "ascending" 6828 } 6829 if( sortObject.key == "name" ){ 6830 if( sortObject.order == "ascending" ){ 6831 return event_data.sort( this.sortByEventName_ascending ); 6832 }else{ 6833 return event_data.sort( this.sortByEventName_descending ); 6834 } 6835 }else if( sortObject.key == "startDate" ){ 6836 if( sortObject.order == "ascending" ){ 6837 return event_data.sort( this.sortByStartDate_event_ascending ); 6838 }else{ 6839 return event_data.sort( this.sortByStartDate_event_descending ); 6840 } 6841 }else if( sortObject.key == "endDate" ){ 6842 if( sortObject.order == "ascending" ){ 6843 return event_data.sort( this.sortByEndDate_event_ascending ); 6844 }else{ 6845 return event_data.sort( this.sortByEndDate_event_descending ); 6846 } 6847 } 6848 return event_data; 6849 }, 6850 sortByEventName_ascending( a, b ){ 6851 let event_a = a.event; 6852 let event_b = b.event; 6853 var aName = ''; 6854 if( event_a.get('name') ){ 6855 aName += event_a.get('name').trim().toLowerCase(); 6856 } 6857 var bName = ''; 6858 if( event_b.get('name') ){ 6859 bName += event_b.get('name').trim().toLowerCase(); 6860 } 6861 if(aName < bName) { return -1; } 6862 if(aName > bName) { return 1; } 6863 //they had the same name 6864 return 0; 6865 }, 6866 sortByEventName_descending( a, b ){ 6867 let event_a = a.event; 6868 let event_b = b.event; 6869 var aName = ''; 6870 if( event_a.get('name') ){ 6871 aName += event_a.get('name').trim().toLowerCase(); 6872 } 6873 var bName = ''; 6874 if( event_b.get('name') ){ 6875 bName += event_b.get('name').trim().toLowerCase(); 6876 } 6877 if(aName > bName) { return -1; } 6878 if(aName < bName) { return 1; } 6879 //they had the same name 6880 return 0; 6881 }, 6882 sortByStartDate_event_ascending( a, b ){ 6883 let a_ts = a.ts; 6884 let b_ts = b.ts; 6885 if(a_ts < b_ts) { return -1; } 6886 if(a_ts > b_ts) { return 1; } 6887 return 0; 6888 }, 6889 sortByStartDate_event_descending( a, b ){ 6890 let a_ts = a.ts; 6891 let b_ts = b.ts; 6892 if(a_ts > b_ts) { return -1; } 6893 if(a_ts < b_ts) { return 1; } 6894 return 0; 6895 }, 6896 sortByEndDate_event_ascending( a, b ){ 6897 let a_end_ts = a.end_ts; 6898 let b_end_ts = b.end_ts; 6899 if(a_end_ts < b_end_ts) { return -1; } 6900 if(a_end_ts > b_end_ts) { return 1; } 6901 return 0; 6902 }, 6903 sortByEndDate_event_descending( a, b ){ 6904 let a_end_ts = a.end_ts; 6905 let b_end_ts = b.end_ts; 6906 if(a_end_ts < b_end_ts) { return 1; } 6907 if(a_end_ts > b_end_ts) { return -1; } 6908 return 0; 6909 }, 6910 }, 6911 computed: { 6912 pre_filtered_event_data(){ 6913 //filter by date first 6914 let pre_filtered_event_data = [...this.event_data]; 6915 var timestamp = new Date().getTime(); 6916 if ( this.date_filter !== 'all' ){ 6917 pre_filtered_event_data = pre_filtered_event_data.filter(event_data => { 6918 let start = event_data.ts; 6919 let end = event_data.end_ts; 6920 if (this.date_filter === 'past'){ 6921 if( start < timestamp && ( end < timestamp || ! end || end == "undefined" ) ){ 6922 return true; 6923 } 6924 } else if (this.date_filter === 'upcoming'){ 6925 if ( start > timestamp || end > timestamp ){ 6926 return true; 6927 } 6928 // Single-day events: ts and end_ts are both midnight, so they get filtered out 6929 // when the event is today. Include if the event's date is today or later. 6930 if ( start === end || !end ){ 6931 let startOfEventDay = new Date(start); 6932 startOfEventDay.setUTCHours(0, 0, 0, 0); 6933 let startOfToday = new Date(); 6934 startOfToday.setUTCHours(0, 0, 0, 0); 6935 if ( startOfEventDay.getTime() >= startOfToday.getTime() ){ 6936 return true; 6937 } 6938 } 6939 } 6940 return false;
6941 }) 6942 } 6943 return pre_filtered_event_data; 6944 }, 6945 first_visible_events(){ 6946 let first_visible_events = []; 6947 let handled_keys = []; 6948 //loop through the event_data 6949 this.sorted_event_data.forEach(event_data => { 6950 let start_date = new Date(event_data.ts); 6951 let start_components = return_date_components_v2({ 6952 "date": start_date, 6953 "timezone": store.timezone 6954 }); 6955 let key = start_components.year + '_' + start_components.month; 6956 if( ! handled_keys.includes(key) ){ 6957 first_visible_events.push( this.event_key(event_data) ); 6958 handled_keys.push(key); 6959 } 6960 }); 6961 return first_visible_events; 6962 }, 6963 filtered_event_data(){ 6964 let filtered_event_data = this.pre_filtered_event_data; 6965 //filter by selected tag IDs (configured in admin settings panel) 6966 if( this.filter_tag_ids_arr && this.filter_tag_ids_arr.length > 0 ){ 6967 filtered_event_data = filtered_event_data.filter(event_data => { 6968 let event_tag_ids = event_data.event_tag_ids; 6969 return event_tag_ids && this.filter_tag_ids_arr.some(id => event_tag_ids.includes(id)); 6970 }); 6971 } 6972 //return them 6973 return filtered_event_data; 6974 }, 6975 sorted_event_data(){ 6976 let sorted = this.sortEventList( this.filtered_event_data, this.sortObject ); 6977 if( this.formatting_obj.layout === 'carousel' ){ 6978 return sorted.splice(0, 20); 6979 }else{ 6980 return sorted.splice(0, 5); 6981 } 6982 }, 6983 formatting_obj(){ 6984 if (this.formatting){ 6985 return JSON.parse(this.formatting); 6986 } 6987 return {}; 6988 }, 6989 filter_tag_ids_arr(){ 6990 if (this.filter_tag_ids){ 6991 return JSON.parse(this.filter_tag_ids); 6992 } 6993 return []; 6994 }, 6995 filter_types_arr(){ 6996 if (this.filter_types){ 6997 return JSON.parse(this.filter_types); 6998 } 6999 return []; 7000 }, 7001 }, 7002 watch: { 7003 last_update(newVal, oldVal){ 7004 if (newVal !== oldVal){ 7005 let event_types = this.component.get('config_latest')?.event_types || []; 7006 this.loadEventList( this.client_id, { event_types } ); 7007 } 7008 } 7009 }, 7010 }); 7011</script>
7011
7011<script type="text/html" id="template-iframe"> 7012 <div v-if="!loading && !is_admin" class="cs-iframe-wrap" :class="class_string" :style="style_string"> 7013 <iframe class="cs-iframe" :src="iframe_src"></iframe> 7014 </div> 7015 <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 + '%' }]"> 7016 <template v-if="iframe_src" > 7017 <iframe class="cs-iframe" sandbox="allow-forms allow-scripts" :src="iframe_src"></iframe> 7018 </template> 7019 <template v-if="!iframe_src"> 7020 <p class="noObjectsMSG centeredBlock centeredText">Click on edit to configure your iframe.</p> 7021 </template> 7022 </div> 7023</script>
7023 7024 7025<style> 7026 /* Draft-editor only floor (the admin wrap carries cs-min-section-width; 7027 the live wrap does not). Without it an iframe with no configured 7028 section_height â or an empty/unconfigured embed â collapses shorter 7029 than the component toolbar + inline "add section" button, so those 7030 controls overlap. A configured section_height (inline min-height) 7031 still wins since it's taller. */ 7032 .cs-iframe-wrap.cs-min-section-width { 7033 min-height: 130px; 7034 } 7035</style> 7036
7037<script> 7038 /* ----- COMPONENT ----- */ 7039 app_content.component('iframe-component', { 7040 template: '#template-iframe', 7041 data() { 7042 return { 7043 global: store, 7044 loading: true, 7045 is_admin: false, 7046 } 7047 }, 7048 props: { 7049 class_string: String, 7050 style_string: String, 7051 iframe_src: String, 7052 //admin 7053 component: Object, 7054 formatting_string: String, 7055 last_update: Number 7056 }, 7057 mounted() { 7058 this.is_admin = window.location.href.indexOf('website-builder') !== -1; 7059 this.loading = false; 7060 }, 7061 computed: { 7062 formatting_obj(){ 7063 if (this.formatting_string){ 7064 return JSON.parse(this.formatting_string); 7065 } 7066 return {}; 7067 }, 7068 }, 7069 }); 7070</script>
7070
7070<script type="text/html" id="template-camera-feed"> 7071 <div v-if="!loading && !is_admin" class="cs-iframe-wrap" :class="class_string" :style="style_string"> 7072 <div v-if="!errored" style="position:relative;padding:8px;width:100%;box-sizing:border-box;"> 7073 <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> 7074 <img v-show="image_loaded" :src="img_src" style="width:100%;height:100%;object-fit:contain;display:block;border-radius:12px;"> 7075 <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;"> 7076 <span style="width:7px;height:7px;border-radius:50%;background:#ff3b30;display:inline-block;"></span>LIVE 7077 </span> 7078 <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> 7079 </div> 7080 <p v-if="errored" class="noObjectsMSG centeredBlock centeredText">The live camera is temporarily unavailable.</p> 7081 </div> 7082 <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 + '%' }]"> 7083 <template v-if="has_camera"> 7084 <div style="position:relative;padding:8px;width:100%;box-sizing:border-box;"> 7085 <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> 7086 <img v-show="image_loaded" :src="img_src" style="width:100%;height:100%;object-fit:contain;display:block;border-radius:12px;"> 7087 <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;"> 7088 <span style="width:7px;height:7px;border-radius:50%;background:#ff3b30;display:inline-block;"></span>LIVE 7089 </span> 7090 <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> 7091 </div> 7092 </template> 7093 <template v-if="!has_camera"> 7094 <p class="noObjectsMSG centeredBlock centeredText">Click on edit to configure your camera.</p> 7095 </template> 7096 </div> 7097</script>
7097 7098
7099<script> 7100 app_content.component('camera-feed-component', { 7101 template: '#template-camera-feed', 7102 data() { 7103 return { 7104 global: store, 7105 loading: true, 7106 is_admin: false, 7107 img_src: '', 7108 errored: false, 7109 refresh_timer: null, 7110 loader: null, 7111 last_image_time: '', 7112 image_loaded: false, 7113 } 7114 }, 7115 props: { 7116 class_string: String, 7117 style_string: String, 7118 client_id: String, 7119 camera_feed_id: String, 7120 refresh_interval_ms: [Number, String], 7121 //admin 7122 component: Object, 7123 formatting_string: String, 7124 last_update: Number, 7125 }, 7126 mounted() { 7127 this.is_admin = window.location.href.indexOf('website-builder') !== -1; 7128 this.loading = false; 7129 if (this.has_camera) { 7130 this.start_refresh(); 7131 } 7132 }, 7133 beforeUnmount() { 7134 this.stop_refresh(); 7135 }, 7136 methods: { 7137 snapshot_base() { 7138 let base = `/camera-feed/${this.client_id}/snapshot.jpg`; 7139 //editor has no subdomain rewrite, so pass the club id explicitly 7140 if (this.is_admin && store.clubObject) { 7141 base += `?club_id=${store.clubObject.id}`; 7142 } 7143 return base; 7144 }, 7145 return_refresh_interval() { 7146 let interval = Number(this.refresh_interval_ms); 7147 if (!interval || isNaN(interval) || interval < 500) { 7148 interval = 2000; 7149 } 7150 return interval; 7151 }, 7152 return_next_snapshot_url() { 7153 let base = this.snapshot_base(); 7154 let separator = base.indexOf('?') === -1 ? '?' : '&'; 7155 return `${base}${separator}t=${Date.now()}`; 7156 }, 7157 start_refresh() { 7158 this.stop_refresh(); 7159 this.load_next_frame(); 7160 }, 7161 //fetch the next frame into an off-DOM Image so the visible <img> 7162 //is only swapped once bytes are fully loaded. we never reassign a 7163 //pending src, so the browser never cancels an in-flight request. 7164 load_next_frame() { 7165 let started = Date.now(); 7166 let url = this.return_next_snapshot_url(); 7167 let loader = new Image(); 7168 loader.onload = () => { 7169 //loaded bytes are in cache, so this swap paints instantly 7170 this.img_src = url; 7171 this.image_loaded = true; 7172 this.errored = false; 7173 this.last_image_time = this.format_time(new Date()); 7174 this.schedule_next_frame(started); 7175 }; 7176 loader.onerror = () => { 7177 this.errored = true; 7178 this.schedule_next_frame(started); 7179 }; 7180 loader.src = url; 7181 this.loader = loader; 7182 }, 7183 //only advance after the current frame finishes, subtracting elapsed 7184 //so cadence tracks the target interval (never firing early) 7185 schedule_next_frame(started) { 7186 let interval = this.return_refresh_interval(); 7187 let elapsed = Date.now() - started; 7188 let delay = Math.max(0, interval - elapsed); 7189 this.refresh_timer = setTimeout(() => { 7190 this.load_next_frame(); 7191 }, delay); 7192 }, 7193 stop_refresh() { 7194 if (this.refresh_timer) { 7195 clearTimeout(this.refresh_timer); 7196 this.refresh_timer = null; 7197 } 7198 if (this.loader) { 7199 this.loader.onload = null; 7200 this.loader.onerror = null; 7201 this.loader = null; 7202 } 7203 }, 7204 format_time(date) { 7205 let hours = date.getHours(); 7206 let minutes = date.getMinutes().toString().padStart(2, '0'); 7207 let seconds = date.getSeconds().toString().padStart(2, '0'); 7208 let period = hours >= 12 ? 'PM' : 'AM'; 7209 hours = hours % 12; 7210 if (hours === 0) { 7211 hours = 12; 7212 } 7213 return `${hours}:${minutes}:${seconds} ${period}`; 7214 }, 7215 }, 7216 computed: { 7217 has_camera() { 7218 if (!this.client_id) { 7219 return false;
7220 } 7221 if (this.camera_feed_id) { 7222 return true; 7223 } 7224 return false; 7225 }, 7226 formatting_obj() { 7227 if (this.formatting_string) { 7228 return JSON.parse(this.formatting_string); 7229 } 7230 return {}; 7231 }, 7232 }, 7233 watch: { 7234 camera_feed_id() { 7235 if (this.has_camera) { 7236 this.start_refresh(); 7237 } else { 7238 this.stop_refresh(); 7239 } 7240 }, 7241 refresh_interval_ms() { 7242 if (this.has_camera) { 7243 this.start_refresh(); 7244 } 7245 }, 7246 }, 7247 }); 7248</script>
7248 7249
7249<script type="text/html" id="template-blog-list"> 7250 <div class="relative" :class="class_string" :style="style_string"> 7251 <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}%` }]"> 7252 <div class="blog-list-wrap centeredBlock centeredText" :class="'blog-list-wrap_' + client_id" :data-client_id="client_id"> 7253 <div class="ease flexNoWrap show_when_loaded" style="justify-content: center; align-items: center;"> 7254 <!-- template type - list --> 7255 <template v-if="!formatting_obj.template || formatting_obj.template === 'list'"> 7256 <div class="inputWrap searchBarWrap flexGrowOne" style="min-width:100%;"> 7257 <input class="searchBar searchBar_blog-list easeFast" placeholder="Search posts..." v-model="search"> 7258 </div> 7259 <div class="flexGrowOne"></div> 7260 </template> 7261 <!-- template type - bulletin --> 7262 <template v-if="formatting_obj.template === 'bulletin'"> 7263 <div class="searchBarWrap_v2" style="width: 100%; 7264 display: flex; 7265 flex-direction: row; 7266 align-items: center; 7267 justify-content: center; 7268 margin-right: 8px; 7269 margin-left: 8px; 7270 margin-bottom: 10px;"> 7271 <input id="searchBar_blog-list_v2" type="text" placeholder="Search posts..." v-model="search" /> 7272 <button id="searchBar_blog-list_submit" type="submit"> 7273 <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> 7274 </button> 7275 </div> 7276 </template> 7277 </div> 7278 <div class="fff noPadding"> 7279 <!-- template type - list --> 7280 <template v-if="!formatting_obj.template || formatting_obj.template === 'list'"> 7281 <div v-if="!loading && truncated_posts.length >= 1" class="tableInsert hundop"> 7282 <template v-for="(post, index) in truncated_posts" :key="post.id"> 7283 <div 7284 :style="{ 'background-color': formatting_obj.background_color || '#fff', 'border-radius': '4px' }" 7285 class="blog-row flexWrapLeftAlign relative pointer blog-row-border" 7286 :class="'blog-row_' + post.id" 7287 :data-post-id="post.id" 7288 > 7289 <div v-if="post && post.get('thumbnail')" class="blog-post-thumbnail" :style="'background-image:url(' + post.get('thumbnail') + ')'"></div> 7290 <div v-if="post && !post.get('thumbnail')" class="blog-post-thumbnail default-image-icon"></div> 7291 <div class="blog-row-inner flexGrowOne"> 7292 <div class="blog-name-wrap-column"> 7293 <p 7294 class="blog-title" 7295 :style="{ 7296 'color': formatting_obj.title_color || '#000', 7297 'font-weight': formatting_obj.title_styling && formatting_obj.title_styling.bold ? '600 !important' : '400 !important', 7298 'font-style': formatting_obj.title_styling && formatting_obj.title_styling.italic ? 'italic' : 'normal', 7299 'text-decoration': formatting_obj.title_styling && formatting_obj.title_styling.underline ? 'underline' : 'none', 7300 }" 7301 >{{ post.get('title') }}</p> 7302 <p 7303 v-if="post && post.get('subtitle')" 7304 class="blog-subtitle" 7305 :style="{ 7306 'color': formatting_obj.subtitle_color || '#000', 7307 'font-weight': formatting_obj.subtitle_styling && formatting_obj.subtitle_styling.bold ? '600 !important' : '400 !important', 7308 'font-style': formatting_obj.subtitle_styling && formatting_obj.subtitle_styling.italic ? 'italic' : 'normal', 7309 'text-decoration': formatting_obj.subtitle_styling && formatting_obj.subtitle_styling.underline ? 'underline' : 'none', 7310 }" 7311 >{{ post.get('subtitle') }}</p> 7312 <p class="blog-date" :style="{ 'color': formatting_obj.date_color || '#000' }">{{ niceDate(post) }}</p> 7313 <p><a class="absoluteA" :href="'/blog-post/' + post.id" style="text-transform: none !important; border-bottom: none !important;"></a> 7314 <a 7315 :href="'/blog-post/' + post.id" 7316 :style="{ 7317 'font-variant': 'small-caps', 7318 'opacity': '0.9', 7319 'color': formatting_obj.date_color || '#000', 7320 'border-bottom': 'none !important', 7321 'text-decoration': 'underline !important', 7322 'text-decoration-color': (formatting_obj.date_color || '#000') + ' !important' 7323 }" 7324 >Read more</a> 7325 </p> 7326 </div> 7327 </div> 7328 <div v-if="post && post.get('pinned') === true"> 7329 <i class="fa-solid fa-thumbtack thumbtack-icon" style="height: 1em; transform: rotate(45deg);"></i> 7330 </div> 7331 </div> 7332 </template> 7333 <div 7334 v-if="has_more_posts" 7335 class="view_more_button flexNoWrapCenter align-items-center" 7336 @click="view_all = true" 7337 style="margin-bottom:0px;margin-left:0px;margin-right:0px;" 7338 > 7339 <div> 7340 <p>View more</p> 7341 </div> 7342 <i class="fa-regular fa-arrow-right easeFast"></i> 7343 </div> 7344 </div> 7345 </template> 7346 <!-- template type - bulletin --> 7347 <template v-if="formatting_obj.template === 'bulletin'"> 7348 <div 7349 v-if="!loading && truncated_posts.length >= 1" 7350 :class="class_string" 7351 > 7352 <div class="blog-list-container"> 7353 <section 7354 class="blog-list-cards-v2" 7355 > 7356 <div v-for="(post, index) in truncated_posts" :key="post.id"> 7357 <article 7358 :style="{ 'background-color': formatting_obj.background_color || '#fff' }" 7359 style="display: flex; flex-direction: column; align-items: center; justify-content: flex-start; position: relative;" 7360 class="blog_post_card_v2" 7361 @click="go_to_post( post )" 7362 > 7363 <div class="blog-list-image-container"> 7364 <img v-if="post && post.get('thumbnail')" class="blog_post_thumbnail" :src="post.get('thumbnail')"> 7365 <div v-if="post && !post.get('thumbnail')" style="display: flex; align-items: center; justify-content: center;"> 7366 <i class="fa-duotone fa-solid fa-newspaper" style="font-size: 10em;"></i> 7367 </div> 7368 </div> 7369 <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> 7370 <div class="card__content"> 7371 <div 7372 v-if="post && post.get('title')" 7373 class="card__title_v2" 7374 :style="{ 7375 'color': formatting_obj.title_color || '#000', 7376 'font-weight': formatting_obj.title_styling && formatting_obj.title_styling.bold ? '600 !important' : '400 !important', 7377 'font-style': formatting_obj.title_styling && formatting_obj.title_styling.italic ? 'italic' : 'normal', 7378 'text-decoration': formatting_obj.title_styling && formatting_obj.title_styling.underline ? 'underline' : 'none', 7379 }" 7380 > 7381 {{ post.get('title') }} 7382 </div> 7383 <div 7384 v-if="post && post.get('subtitle')" 7385 class="card__subtitle tinyMarginTop" 7386 :style="{ 7387 'color': formatting_obj.subtitle_color || '#000', 7388 'font-weight': formatting_obj.subtitle_styling && formatting_obj.subtitle_styling.bold ? '600 !important' : '400 !important', 7389 'font-style': formatting_obj.subtitle_styling && formatting_obj.subtitle_styling.italic ? 'italic' : 'normal', 7390 'text-decoration': formatting_obj.subtitle_styling && formatting_obj.subtitle_styling.underline ? 'underline' : 'none', 7391 }" 7392 > 7393 {{ post.get('subtitle') }} 7394 </div> 7395 <div class="card__date" :style="{ 'color': formatting_obj.date_color || '#000' }">{{ niceDate(post) }}</div> 7396 <div class="card__excerpt"> 7397 <a 7398 @click.stop 7399 :href="'/blog-post/' + post.id" 7400 :style="{ 7401 'font-variant': 'small-caps', 7402 'opacity': '0.9', 7403 'color': formatting_obj.date_color || '#000', 7404 'border-bottom': 'none !important', 7405 'text-decoration': 'underline !important', 7406 'text-decoration-color': (formatting_obj.date_color || '#000') + ' !important' 7407 }" 7408 > 7409 read more 7410 </a> 7411 </div> 7412 </div> 7413 </article> 7414 </div> 7415 </section> 7416 </div> 7417 <div 7418 v-if="has_more_posts" 7419 class="view_more_button flexNoWrapCenter align-items-center" 7420 :class="{'larchmont': is_larchmont || is_larchmont === 'true'}" 7421 @click="view_all = true" 7422 style="margin-bottom:0px;" 7423 > 7424 <div> 7425 <p>View more</p> 7426 </div> 7427 <i class="fa-regular fa-arrow-right easeFast"></i> 7428 </div> 7429 </div> 7430 </template> 7431 <div v-if="!loading && filtered_posts.length == 0" class="tableInsert hundop blog-list-contain"> 7432 <p class="noObjectsMSG withPadding textLeft blog-row flexWrapLeftAlign relative pointer">No posts found.</p> 7433 </div> 7434 </div> 7435 <div v-if="loading" class="cardBodySpinnerWrap active relative"> 7436 <div class="cardBodySpinner centeredBlock"></div> 7437 </div> 7438 </div> 7439 </div> 7440 </div> 7441</script>
7441 7442 7443<style> 7444 7445 /*------------------------ view more buttons --------------------- */ 7446 7447 .view_more_button.card-layout{ 7448 margin-top:0px; 7449 } 7450 .view_more_button{ 7451 color: #6a7482; 7452 margin: 8px; 7453 border: 1px solid #e5eaec; 7454 border-top: 1px solid #e5eaec; 7455 border-radius: 5px; 7456 cursor: pointer; 7457 font-size: 16px; 7458 padding: 16px; 7459 margin-top: 8px; 7460 } 7461 .view_more_button:hover{ 7462 color: #081333; 7463 background-color: #f8f9fb; 7464 } 7465 .view_more_button.larchmont{ 7466 color: #fff; 7467 margin: 8px; 7468 border: 1px solid #fff; 7469 border-top: 1px solid #fff; 7470 border-radius: 5px; 7471 cursor: pointer; 7472 font-size: 16px; 7473 padding: 16px; 7474 margin-top: 8px; 7475 } 7476 .view_more_button.larchmont:hover{ 7477 background-color: #002D62; 7478 color: #fff; 7479 } 7480 .view_more_button i{ 7481 padding-left: 8px; 7482 } 7483 .view_more_button:hover i{ 7484 color: #4a56f8; 7485 transform: translateX(4px); 7486 } 7487 7488 /* -- end view more button --*/ 7489 7490 section.blog-list-cards-v2 { 7491 margin: 0 auto; 7492 display: grid; 7493 grid-template-columns: repeat(3, 1fr); 7494 grid-auto-rows: auto; 7495 grid-gap: 16px; 7496 align-content: center; 7497 justify-content: center; 7498 padding: 8px; 7499 width: 100%; 7500 box-sizing: border-box; 7501 } 7502 .blog-list-image-container { 7503 width: 100%; 7504 height: 300px; 7505 display: flex; 7506 align-items: center; 7507 justify-content: center; 7508 overflow: hidden; 7509 border-top-left-radius: 6px; 7510 border-top-right-radius: 6px; 7511 border-bottom-left-radius: 0px; 7512 border-bottom-right-radius: 0px; 7513 background-color: #f2f2f2; 7514 } 7515 .blog_post_thumbnail { 7516 width: 100%; 7517 height: 100%; 7518 object-fit: cover; 7519 display: block; 7520 } 7521 .default_image_icon { 7522 background-image: url('https://s3-us-west-2.amazonaws.com/myclubspot/upload-photo-icon.jpg'); 7523 background-size: contain; 7524 background-position: center; 7525 background-repeat: no-repeat; 7526 width: 100%; 7527 height: 100%; 7528 } 7529 @media (max-width: 768px) { 7530 section.blog-list-cards-v2 { 7531 grid-template-columns: repeat(1, 1fr); 7532 } 7533 } 7534</style> 7535
7536<script> 7537 7538 store.components["blog_list"] = { 7539 allPosts: [], 7540 search: '', 7541 loading: true, 7542 view_all: false, 7543 post_limit: 6 //how many to show on initial load (before clicking "see more") 7544 }; 7545 7546 app_content.component("blog-list-component", { 7547 template: "#template-blog-list", 7548 data() { 7549 return store.components['blog_list'] 7550 }, 7551 props: { 7552 client_id: String, 7553 class_string: String, 7554 style_string: String, 7555 element_style_string: String, 7556 formatting: String, 7557 component_type: String, 7558 is_larchmont: Boolean, String, 7559 //admin 7560 component: Object, 7561 last_update: Number, 7562 }, 7563 mounted() { 7564 this.loadBlogList( this.client_id ) 7565 }, 7566 methods: { 7567 go_to_post( post ){ 7568 window.location.href = "blog-post/" + post.id; 7569 }, 7570 loadBlogList( client_id, overrides = {} ){ 7571 var promise = Parse.Promise.as(); 7572 var thisPromise = promise.then(() => { 7573 //get new data 7574 var params = {}; 7575 params["client_id"] = client_id; 7576 params["overrides"] = overrides; 7577 params["request_url"] = window.location.href; 7578 return Parse.Cloud.run('retrieve_data_for_component', params); 7579 }).then(retrieved_posts => { 7580 // sort with 'pinned' posts on top 7581 this.allPosts = retrieved_posts.sort((a, b) => (a.get('pinned') === b.get('pinned') ? 0 : a.get('pinned') ? -1 : 1)); 7582 this.loading = false; 7583 //and return 7584 return Parse.Promise.as(); 7585 }, error => { 7586 checkParseError( error ); 7587 }); 7588 return thisPromise; 7589 }, 7590 niceDate( postObject ){ 7591 return dayjs(postObject.get('post_date')).format('MMM DD, YYYY'); 7592 }, 7593 return_grid_column_css(){ 7594 if (window.innerWidth <= 990) { 7595 //for mobile 7596 return 'repeat(1, 256px)'; 7597 } else { 7598 return 'repeat(4, 256px)'; 7599 } 7600 }, 7601 }, 7602 computed: { 7603 has_more_posts(){ 7604 if( this.view_all ){ 7605 return false; 7606 } 7607 if( this.filtered_posts.length > this.post_limit ){ 7608 return true; 7609 } 7610 return false; 7611 }, 7612 truncated_posts(){ 7613 if( this.view_all ){ 7614 return this.filtered_posts; 7615 }else{ 7616 return this.filtered_posts.slice(0, this.post_limit); 7617 } 7618 }, 7619 filtered_posts(){ 7620 let val = this.search.toLowerCase(); 7621 let filteredPosts = this.allPosts; 7622 // handle search results using all posts 7623 if (this.search.length > 0){ 7624 filteredPosts = this.allPosts.filter(post => { 7625 let title = post.get('title').toLowerCase(); 7626 if ( title.includes(val) ){ 7627 return post; 7628 } 7629 }) 7630 } 7631 return filteredPosts; 7632 }, 7633 formatting_obj(){ 7634 if (this.formatting){ 7635 return JSON.parse(this.formatting); 7636 } 7637 return {}; 7638 }, 7639 } 7640 }) 7641</script>
7641
7641<script type="text/html" id="template-custom-code"> 7642 <template v-if="preview_element"> 7643 <div 7644 class="cs-custom-code-wrap" 7645 :class="[class_string, { 7646 'preview-mode': active_tab === 'preview', 7647 'first-page-section': is_first, 7648 'active-ai-thread-target': ai_panel_is_open_for_this_component, 7649 'custom-code-preview-blank': !custom_code_is_empty && custom_code_preview_is_blank, 7650 }]" 7651 :style="ai_loading_wrap_style" 7652 @mouseenter="activate_ai_builder_target_from_presence" 7653 > 7654 <!-- Tab bar --> 7655 <div class="custom-code-tab-bar"> 7656 <div 7657 class="custom-code-tab" 7658 :class="{ active: active_tab === 'edit' }" 7659 @click="active_tab = 'edit'" 7660 >Code</div> 7661 <div 7662 class="custom-code-tab" 7663 :class="{ active: active_tab === 'preview' }" 7664 @click="active_tab = 'preview'" 7665 >Preview</div> 7666 </div> 7667 <!-- Code pane --> 7668 <div v-show="active_tab === 'edit'" class="custom-code-pane"> 7669 <div ref="cm_editor" class="custom-code-editor"></div> 7670 </div> 7671 <!-- Preview pane --> 7672 <!-- Hide the welcome page during the pre-first-delta state. 7673 The loading pane below owns that window completely so 7674 the preview area does not show both the composer and 7675 "thinking" UI at the same time. --> 7676 <div 7677 v-show="active_tab === 'preview' && custom_code_is_empty && !has_ai_thread_for_component && !ai_streaming && !ai_streaming_pending && !ai_welcome_generating" 7678 class="custom-code-pane custom-code-preview-pane custom-code-welcome-pane" 7679 :class="{ 'is-welcome-drop-active': welcome_drop_active }" 7680 style="display: flex; align-items: center; justify-content: center; min-height: 420px; padding: 48px 20px; background: #fbfcff;" 7681 @dragenter="handle_welcome_dragenter" 7682 @dragover.prevent="handle_welcome_dragover" 7683 @dragleave="handle_welcome_dragleave" 7684 @drop.prevent="handle_welcome_drop" 7685 > 7686 <!-- Drop overlay (welcome composer) â same pattern as 7687 the chat panel's drop overlay; mounts only while 7688 a file drag is hovering this pane. --> 7689 <div 7690 v-if="welcome_file_upload_enabled && welcome_drop_active" 7691 aria-hidden="true" 7692 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;" 7693 > 7694 <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);"> 7695 <i class="fa-regular fa-image" style="font-size: 22px;"></i> 7696 <p class="noMargin">Drop images to attach</p> 7697 </div> 7698 </div> 7699 <div style="width: min(680px, 92%); margin: 0 auto; text-align: center;"> 7700 <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;"> 7701 <i class="fa-regular fa-wand-magic-sparkles" style="font-size: 22px;"></i> 7702 </div> 7703 <h3 class="noMargin" style="font-size: 24px; line-height: 32px; color: #081333; font-weight: 700;">Build a custom section</h3> 7704 <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> 7705 <div class="flexWrap" style="gap: 8px; justify-content: center; margin: 22px auto 14px auto;"> 7706 <button 7707 v-for="example in ai_prompt_examples" 7708 :key="example" 7709 type="button" 7710 class="ai-composer-pill pointer exclude-from-custom-button-styles" 7711 @click.stop.prevent="set_ai_prompt_example(example)" 7712 >{{ example }}</button> 7713 </div> 7714 <!-- Attached image thumbnails â read-only mirror of 7715 the chat panel's mcp_attachments. Files queued 7716 here flow into the chat panel's state via the 7717 global ai_mcp_panel_attach_files helper, so 7718 submit ships them alongside the prompt without 7719 this composer needing its own prep pipeline. --> 7720 <div 7721 v-if="welcome_file_upload_enabled && welcome_attachments && welcome_attachments.length" 7722 style="display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin-bottom: 10px;" 7723 > 7724 <div 7725 v-for="(att, ai) in welcome_attachments" 7726 :key="'welcome-att-' + att.id" 7727 :title="att.name" 7728 style="position: relative; width: 56px; height: 56px; border-radius: 8px; overflow: hidden; background: #eef0f4; border: 1px solid #d9dfeb; flex: 0 0 auto;" 7729 :style="att.status === 'error' ? 'border-color: #e
772907b6f;' : ''" 7730 > 7731 <img 7732 v-if="att.preview_url" 7733 :src="att.preview_url" 7734 :alt="att.name" 7735 style="width: 100%; height: 100%; object-fit: cover; display: block;" 7736 :style="att.status === 'pending' ? 'opacity: 0.55;' : ''" 7737 /> 7738 <div 7739 v-if="att.status === 'pending'" 7740 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;" 7741 aria-hidden="true" 7742 ></div> 7743 <button 7744 type="button" 7745 class="pointer exclude-from-custom-button-styles" 7746 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;" 7747 :title="'Remove ' + (att.name || 'image')" 7748 @click.stop.prevent="welcome_remove_attachment(ai)" 7749 > 7750 <i class="fa-regular fa-xmark"></i> 7751 </button> 7752 </div> 7753 </div> 7754 <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;"> 7755 <div style="flex: 1 1 auto; min-width: 0;"> 7756 <p class="noMargin" style="font-size: 12px; line-height: 16px; color: #4a56f8; font-weight: 700; margin-bottom: 8px !important;"> 7757 <i class="fa-regular fa-sparkles" style="padding-right: 4px;"></i>Design with AI 7758 </p> 7759 <textarea 7760 ref="ai_prompt_textarea" 7761 v-model="ai_prompt" 7762 rows="1" 7763 class="ai-prompt-textarea" 7764 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;" 7765 placeholder="Describe the section you want to build..." 7766 :disabled="ai_prompt_loading" 7767 @input="auto_resize_ai_prompt" 7768 @paste="handle_welcome_paste" 7769 @keydown.enter.exact.prevent="submit_ai_prompt" 7770 ></textarea> 7771 </div> 7772 <input 7773 v-if="welcome_file_upload_enabled" 7774 ref="ai_prompt_file_input" 7775 type="file" 7776 accept="image/*" 7777 multiple 7778 style="display: none;" 7779 @change="handle_welcome_file_input_change" 7780 /> 7781 <button 7782 v-if="welcome_file_upload_enabled" 7783 type="button" 7784 class="pointer exclude-from-custom-button-styles" 7785 :disabled="ai_prompt_loading" 7786 title="Attach image" 7787 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;" 7788 @click.stop.prevent="open_welcome_file_picker" 7789 > 7790 <i class="fa-regular fa-paperclip"></i> 7791 </button> 7792 <button 7793 type="button" 7794 class="ai-submit-button easeFast exclude-from-custom-button-styles" 7795 :class="{ 7796 inactive: !ai_prompt_can_submit, 7797 loading: ai_prompt_loading 7798 }" 7799 :disabled="!ai_prompt_can_submit" 7800 title="Send" 7801 @click.stop.prevent="submit_ai_prompt" 7802 > 7803 <i class="fa-regular fa-paper-plane"></i> 7804 </button> 7805 </div> 7806 </div> 7807 </div> 7808 <!-- Welcome "Generatingâ¦" state â shown after the blank welcome 7809 composer hands the prompt to the page agent, until the first 7810 result lands (content arrives) or the safety timer fires. --> 7811 <div 7812 v-show="active_tab === 'preview' && custom_code_is_empty && ai_welcome_generating" 7813 class="custom-code-pane custom-code-preview-pane custom-code-welcome-generating-pane" 7814 style="display: flex; align-items: center; justify-content: center; min-height: 420px; padding: 48px 20px; background: #fbfcff;"
7815 aria-live="polite" 7816 > 7817 <div style="display: flex; flex-direction: column; align-items: center; gap: 14px; text-align: center;"> 7818 <span class="custom-code-welcome-generating-spinner" aria-hidden="true"></span> 7819 <p class="noMargin" style="font-size: 15px; line-height: 22px; color: #4a56f8; font-weight: 600;">Generatingâ¦</p> 7820 </div> 7821 </div> 7822 <div 7823 v-show="active_tab === 'preview' && custom_code_is_empty && has_ai_thread_for_component && !ai_streaming && !ai_streaming_pending && !ai_welcome_generating" 7824 class="custom-code-pane custom-code-preview-pane custom-code-empty-state-pane" 7825 > 7826 <div class="custom-code-empty-state-copy"> 7827 <i class="fa-regular fa-code"></i> 7828 <span>No content</span> 7829 </div> 7830 </div> 7831 <!-- Preview strips scripts / non-visual tags, so a section that 7832 only has those would otherwise collapse to 0 height. --> 7833 <div 7834 v-show="active_tab === 'preview' && !custom_code_is_empty && custom_code_preview_is_blank && (!ai_streaming || ai_streaming_pending || !ai_generation_active)" 7835 class="custom-code-pane custom-code-preview-pane custom-code-empty-state-pane custom-code-nonvisual-pane" 7836 > 7837 <div class="custom-code-empty-state-copy"> 7838 <i class="fa-regular fa-code"></i> 7839 <span>This section has code that isn't visible in preview</span> 7840 </div> 7841 </div> 7842 <!-- Static preview pane: HTML is mounted into an open shadow 7843 root (see ensure_isolated_custom_code_host) so host theme 7844 CSS cannot restyle the section â same isolation as live. --> 7845 <div 7846 v-show="active_tab === 'preview' && !custom_code_is_empty && !custom_code_preview_is_blank && (!ai_streaming || ai_streaming_pending || !ai_generation_active)" 7847 ref="custom_code_preview" 7848 class="custom-code-pane custom-code-preview-pane" 7849 :style="ai_loading_pane_style" 7850 ></div> 7851 <!-- Pre-first-delta loading. Empty sections get a full pane; 7852 edits to existing content use the same large dots as an 7853 overlay so the current section remains visible while the 7854 updated HTML is being prepared. --> 7855 <div 7856 v-show="active_tab === 'preview' && ai_streaming_pending" 7857 class="custom-code-pane custom-code-preview-pane custom-code-loading-pane" 7858 :class="{ 'is-overlay': !custom_code_is_empty }" 7859 :style="ai_loading_pane_style" 7860 aria-live="polite" 7861 > 7862 <div class="custom-code-pending-dots" aria-label="Generating"> 7863 <span></span><span></span><span></span> 7864 </div> 7865 </div> 7866 <!-- Streaming preview pane: managed by flush_streaming_preview_pane 7867 during a live SSE codegen stream. We bypass v-html so a 7868 tiny morph diff can append-or-replace top-level children 7869 by index â keeps already-rendered sections stable as new 7870 ones stream in below them. Snapped back to the v-html 7871 pane on stream `done`. --> 7872 <div 7873 v-show="active_tab === 'preview' && ai_streaming && !ai_streaming_pending && ai_generation_active" 7874 ref="custom_code_stream_preview" 7875 class="custom-code-pane custom-code-preview-pane" 7876 :style="ai_loading_pane_style" 7877 ></div> 7878 7879 <!-- AI builder launcher â bottom-right of the component. 7880 Clicking sets this component as the active target for 7881 the shared right-side AI panel (defined in 7882 ai-mcp-panel.html) and expands it. The panel's webbuilder 7883 mode then reads `current_code` server-side from
7884 pageComponents.config_latest.custom_code and runs in a 7885 thread pinned to this component instance. --> 7886 <button 7887 type="button" 7888 class="custom-code-ai-launcher pointer exclude-from-custom-button-styles" 7889 :class="{ 'has-active-stream': ai_generation_active }" 7890 @mousedown.stop.prevent 7891 @mouseup.stop 7892 @touchstart.stop 7893 @click.stop.prevent="open_ai_builder" 7894 :title="ai_launcher_title" 7895 v-if="ai_launcher_visible" 7896 > 7897 <i class="fa-regular fa-sparkles"></i> 7898 <span class="custom-code-ai-launcher-label"> 7899 <template v-if="effective_ai_streaming_status === 'queued'">Queuedâ¦</template> 7900 <template v-else-if="ai_generation_active">Generatingâ¦</template> 7901 <template v-else>Edit with AI</template> 7902 </span> 7903 <span 7904 v-if="ai_generation_active" 7905 class="custom-code-ai-launcher-spinner" 7906 aria-hidden="true" 7907 ></span> 7908 </button> 7909 </div> 7910 </template> 7911 <template v-if="!preview_element"> 7912 <!-- Live host: same shadow isolation as builder preview. --> 7913 <span v-if="!loading" ref="custom_code_static" class="cs-custom-code-live-host"></span> 7914 </template> 7915</script>
7915 7916
7917<script> 7918 // Reset + inheritance for custom-code shadow roots. Host theme element 7919 // selectors (h1/a/details/â¦) cannot pierce the shadow; :host inherits 7920 // color/font from the light-DOM wrapper so club theme still flows in. 7921 var CS_CUSTOM_CODE_ISOLATION_CSS = [ 7922 ':host { display: block; width: 100%; color: inherit; font-family: inherit; font-size: inherit; line-height: inherit; }', 7923 '.cs-cc-root { display: block; width: 100%; color: inherit; font: inherit; box-sizing: border-box; }', 7924 '.cs-cc-root *, .cs-cc-root *::before, .cs-cc-root *::after { box-sizing: border-box; }', 7925 '.cs-cc-root summary { list-style: none; cursor: pointer; }', 7926 '.cs-cc-root summary::-webkit-details-marker, .cs-cc-root summary::marker { display: none; }', 7927 '.cs-cc-root img { max-width: 100%; }', 7928 ].join('\n'); 7929 7930 // Builder-only overlays live inside the shadow tree. Document CSS 7931 // cannot pierce it, so these rules must ship with the isolation sheet 7932 // or Add/Change chips render unstyled and get clipped by overflow. 7933 var CS_CUSTOM_CODE_PREVIEW_CHROME_CSS = [ 7934 '.cs-asset-placeholder-host, .cs-asset-attached-host { position: relative; }', 7935 '.cs-asset-background-placeholder-host:hover { outline: 2px dashed rgba(74, 86, 248, 0.55) !important; outline-offset: -4px !important; }', 7936 '.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; }', 7937 '.cs-asset-background-placeholder-chip:hover { background: #ffffff !important; border-color: rgba(74, 86, 248, 0.85) !important; }', 7938 '.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; }', 7939 '.cs-asset-placeholder-overlay:hover { background: rgba(74, 86, 248, 0.16) !important; border-color: rgba(74, 86, 248, 0.85) !important; }', 7940 '.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; }', 7941 '.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; }', 7942 '.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; }', 7943 '.cs-asset-change-button:hover, .cs-asset-change-button:focus { opacity: 1 !important; background: rgba(20, 24, 45, 0.95) !important; }', 7944 '.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; }', 7945 '.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; }', 7946 ].join('\n'); 7947 7948 function createDebounce() { 7949 let timeout = null; 7950 return function (fnc, delayMs) { 7951 clearTimeout(timeout); 7952 timeout = setTimeout(() => { 7953 fnc(); 7954 }, delayMs || 800); 7955 }; 7956 } 7957
7958 // âââ External image detection (shared) ââââââââââââââââââââââââââââââ 7959 // 7960 // Shared by the custom-code component and section-actions overlay. 7961 var WEBBUILDER_CLUBSPOT_ASSET_HOSTS = [ 7962 'd282wvk2qi4wzk.cloudfront.net', 7963 'myclubspot.s3-us-west-2.amazonaws.com', 7964 'myclubspot.s3.us-west-2.amazonaws.com', 7965 ]; 7966 7967 function webbuilder_is_external_image_url(url) { 7968 if (typeof url !== 'string' || !url.trim()) { 7969 return false; 7970 } 7971 let parsed; 7972 try { 7973 parsed = new URL(url.trim()); 7974 } catch (err) { 7975 return false; 7976 } 7977 if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { 7978 return false; 7979 } 7980 return WEBBUILDER_CLUBSPOT_ASSET_HOSTS.indexOf((parsed.hostname || '').toLowerCase()) === -1; 7981 } 7982 7983 // Parse an HTML string and return the unique external image URLs it 7984 // references â both <img src> and inline `background-image: url(...)`. 7985 function webbuilder_collect_external_image_urls(source_html) { 7986 let source = (typeof source_html === 'string') ? source_html : ''; 7987 if (!source.trim()) { 7988 return []; 7989 } 7990 let found = []; 7991 let seen = {}; 7992 let push_if_external = (raw_url) => { 7993 let candidate = (typeof raw_url === 'string') ? raw_url.trim() : ''; 7994 if (!candidate || seen[candidate]) { 7995 return; 7996 } 7997 if (!webbuilder_is_external_image_url(candidate)) { 7998 return; 7999 } 8000 seen[candidate] = true; 8001 found.push(candidate); 8002 }; 8003 let template = document.createElement('template'); 8004 template.innerHTML = source; 8005 let root = template.content; 8006 let imgs = Array.from(root.querySelectorAll('img[src]')); 8007 for (let img of imgs) { 8008 push_if_external(img.getAttribute('src')); 8009 } 8010 let styled = Array.from(root.querySelectorAll('[style*="background-image"]')); 8011 for (let el of styled) { 8012 let style = el.getAttribute('style') || ''; 8013 let bg_pattern = /background-image\s*:\s*url\(\s*(['"]?)([^'"\)]+)\1\s*\)/gi; 8014 let match; 8015 while ((match = bg_pattern.exec(style)) !== null) { 8016 push_if_external(match[2]); 8017 } 8018 } 8019 return found; 8020 } 8021 8022 // âââ Text editing âââââââââââââââââââââââââââââââââââââââââââââââââââ 8023 // 8024 // In PREVIEW mode we walk the rendered DOM looking for "text leaf" 8025 // elements (headings, paragraphs, buttons, links, list items, etc.) 8026 // and decorate each one with a click-to-edit affordance. Clicking 8027 // opens a small inline popover with a textarea; on save we mutate 8028 // the persisted custom_code source and persist via 8029 // save_changes_for_component, mirroring the placeholder fill flow. 8030 // 8031 // Unlike images, there's no "placeholder" concept for text â the AI 8032 // writes plausible draft copy directly into the section and admins 8033 // tweak it inline. Round-tripping (preview path â source path) 8034 // works because the preview's DOM is parsed from the same source 8035 // string we mutate, with the only structural divergence being 8036 //
8036<script> tags removed by sanitize_custom_code_html (which we 8037 // skip on the source side when walking). 8038 // 8039 // A "text leaf" is any element whose element children are all 8040 // inline tags (or none). That treats `<h1>Welcome <em>back</em></h1>` 8041 // as a single editable string â emphasis collapses on save, by 8042 // design. Admins can use the Code tab for mixed inline formatting. 8043 8044 var TEXT_INLINE_TAGS = new Set([ 8045 'a', 'abbr', 'b', 'bdi', 'bdo', 'br', 'cite', 'code', 'em', 'i', 8046 'kbd', 'mark', 'q', 's', 'samp', 'small', 'span', 'strong', 8047 'sub', 'sup', 'time', 'u', 'var', 'wbr', 8048 ]); 8049 8050 var TEXT_SKIP_TAGS = new Set([ 8051 'script', 'style', 'template', 'noscript', 'iframe', 'svg', 8052 'video', 'audio', 'canvas', 'img', 'picture', 'source', 'track', 8053 'input', 'textarea', 'select', 'option', 'optgroup', 'meta', 8054 'link', 'br', 'hr', 'col', 'param', 'embed', 'object', 8055 ]); 8056 8057 // Tags that never produce a layout box in the sanitized preview. 8058 var CUSTOM_CODE_PREVIEW_SKIP_TAGS = new Set([ 8059 'script', 'style', 'template', 'noscript', 'meta', 'link', 'head', 8060 ]); 8061 // Replaced / form controls that take space even without text. 8062 var CUSTOM_CODE_PREVIEW_VISUAL_TAGS = new Set([ 8063 'img', 'iframe', 'video', 'audio', 'canvas', 'svg', 'object', 'embed', 8064 'picture', 'input', 'textarea', 'select', 'hr', 'br', 8065 ]); 8066 8067 function custom_code_element_has_explicit_preview_box(element) { 8068 let style = element.getAttribute('style'); 8069 if (!style || typeof style !== 'string') { 8070 return false; 8071 } 8072 if (/background-image\s*:\s*url\s*\(/i.test(style)) { 8073 return true; 8074 } 8075 let height_match = style.match(/(?:^|;)\s*(?:min-)?height\s*:\s*([^;]+)/i); 8076 if (!height_match) { 8077 return false; 8078 } 8079 let value = height_match[1].trim().toLowerCase(); 8080 if (!value || value === '0' || value === '0px' || value === '0%' || value === 'auto') { 8081 return false; 8082 } 8083 return true; 8084 } 8085 8086 function custom_code_node_has_visible_preview_content(node) { 8087 if (!node) { 8088 return false; 8089 } 8090 if (node.nodeType === 8) { 8091 return false; 8092 } 8093 if (node.nodeType === 3) { 8094 return !!(node.nodeValue && node.nodeValue.trim()); 8095 } 8096 if (node.nodeType !== 1) { 8097 let children = node.childNodes || []; 8098 for (let i = 0; i < children.length; i++) { 8099 if (custom_code_node_has_visible_preview_content(children[i])) { 8100 return true; 8101 } 8102 } 8103 return false; 8104 } 8105 let tag = (node.tagName || '').toLowerCase(); 8106 if (CUSTOM_CODE_PREVIEW_SKIP_TAGS.has(tag)) { 8107 return false; 8108 } 8109 if (CUSTOM_CODE_PREVIEW_VISUAL_TAGS.has(tag)) { 8110 return true; 8111 } 8112 if (custom_code_element_has_explicit_preview_box(node)) { 8113 return true; 8114 } 8115 for (let i = 0; i < node.childNodes.length; i++) { 8116 if (custom_code_node_has_visible_preview_content(node.childNodes[i])) { 8117 return true; 8118 } 8119 } 8120 return false; 8121 } 8122 8123 function custom_code_html_has_visible_preview_content(html) { 8124 if (!html || typeof html !== 'string' || !html.trim()) { 8125 return false; 8126 } 8127 let template = document.createElement('template'); 8128 template.innerHTML = html; 8129 return custom_code_node_has_visible_preview_content(template.content); 8130 } 8131 8132 app_content.component('custom-code-component', { 8133 template: '#template-custom-code', 8134 data() { 8135 return { 8136 loading: true, 8137 debounce: createDebounce(), 8138 custom_code_value: '', 8139 active_tab: 'preview', 8140 // True while the section-actions "save external images" 8141 // batch is in flight, so duplicate overlay triggers no-op. 8142 importing_external_images: false, 8143 ai_prompt: '', 8144 ai_prompt_examples: [ 8145 'A modern hero section for our club homepage', 8146 'A membership benefits section with three cards', 8147 'An event promo section with image placeholders', 8148 ], 8149 ai_launcher_title: 'Edit with AI', 8150 // âââ Streaming state âââââââââââââââââââââââââââââââ 8151 // Flipped to true by begin_streaming_code() and back 8152 // to false by finish_streaming_code(). Controls the 8153 // template's preview-pane selection (v-html static vs. 8154 // morph-managed div) and the overlay visibility. 8155 ai_streaming: false, 8156 ai_streaming_status: null, // 'streaming' | 'queued' | 'aborted_local' | 'error' | 'done' 8157 // Snapshot of the editor contents at stream begin â 8158 // used by apply_generated_code as the return value 8159 // (Undo target) when the panel's done handler runs. 8160 ai_streaming_pre_code: null, 8161 // Accumulated code chunks. Always the full snapshot 8162 // of what the stream has emitted so far (chunks are 8163 // appended as they arrive). 8164 ai_streaming_buffer: '', 8165 // Local reactive tick for store.ai_streams snapshots. 8166 // Bumped by subscriber events; lets computeds that 8167 // read entry.code_buffer / entry.message_text 8168 // re-evaluate. Launcher status no longer depends on 8169 // this â that lives in store.codegen_request_state. 8170 ai_stream_event_tick: 0, 8171 ai_loading_pane_height: null, 8172 ai_loading_pane_height_release_timer: null, 8173 ai_streaming_child_min_heights: [], 8174 // Welcome composer drag-drop counter â increments on 8175 // dragenter, decrements on dragleave so the overlay 8176 // doesn't flicker on nested-element drag events. The 8177 // attachment LIST itself lives on the chat panel 8178 // (store.components.ai_mcp_panel.mcp_attachments) so 8179 // both composers stay in sync without a second prep 8180 // pipeline. 8181 welcome_drop_counter: 0, 8182 welcome_drop_active: false, 8183 // True between submitting from the blank welcome composer and 8184 // the page agent's first result landing. Swaps the welcome 8185 // composer for a "Generatingâ¦" pane. The codegen stream no 8186 // longer drives this flow (the page agent does), so this is a 8187 // local flag cleared when content arrives or on a safety timer. 8188 ai_welcome_generating: false, 8189 } 8190 }, 8191 props: { 8192 custom_code: String, 8193 //admin 8194 class_string: String, 8195 preview_element: { 8196 type: Boolean, 8197 default: false, 8198 }, 8199 is_first: { 8200 type: Boolean, 8201 default: false, 8202 }, 8203 component: { 8204 type: Object, 8205 default: null, 8206 }, 8207 first_section_top_spacer_px_override: { 8208 type: Number, 8209 default: null, 8210 }, 8211 last_update: Number, 8212 }, 8213 watch: { 8214 active_tab(val) { 8215 if (val !== 'preview') { 8216 this.close_text_edit_popover(); 8217 } 8218 if (val === 'edit' && this.cm_instance) { 8219 this.$nextTick(() => this.cm_instance.refresh()); 8220 } 8221 if (val === 'preview') { 8222 this.$nextTick(() => this.initialize_custom_code_interactions()); 8223 } 8224 this.publish_active_tab_to_store(); 8225 }, 8226 // First result from the page agent has landed â drop the welcome 8227 // "Generatingâ¦" pane so the new section renders. 8228 custom_code_value(val) { 8229 if (this.ai_welcome_generating && (val || '').trim()) { 8230 this.clear_welcome_generating_state(); 8231 } 8232 }, 8233 last_update() { 8234 this.close_text_edit_popover(); 8235 // Don't fight a LIVE stream â the streaming buffer is the 8236 // source of truth until the build settles. But only an actually 8237 // active stream (entry still streaming/queued) should block the 8238 // resync: a stale `ai_streaming` flag (entry terminal or gone, 8239 // e.g. an aborted/errored build) must NOT wedge the section on 8240 // old HTML, or the agent's authoritative __refresh re-pull can 8241 // never land. Otherwise sync the preview straight from the 8242 // component config â the live preview repaints off 8243 // custom_code_value even when the (hidden) editor isn't mounted. 8244 let entry = this.active_codegen_stream_entry; 8245 let stream_active = this.ai_streaming 8246 && entry 8247 && (entry.status === 'streaming' || entry.status === 'queued'); 8248 if (!stream_active) { 8249 if (this.ai_streaming) { 8250 // Stale flag â release the editor lock before syncing. 8251 this.force_clear_ai_loading_state(); 8252 } 8253 let config_latest = this.component.get('config_latest') || {}; 8254 let new_value = config_latest.custom_code || ''; 8255 if (this.cm_instance && new_value !== this.cm_instance.getValue()) { 8256 this.cm_instance.setValue(new_value); 8257 } 8258 this.custom_code_value = new_value; 8259 this.publish_current_code_to_store(); 8260 } 8261 this.$nextTick(() => this.initialize_custom_code_interactions()); 8262 }, 8263 // Re-measure the auto-grow welcome composer whenever the 8264 // prompt changes from outside the textarea â example pill 8265 // click, post-submit clear. Typing still routes through 8266 // @input for synchronous response. 8267 ai_prompt() { 8268 this.$nextTick(() => this.auto_resize_ai_prompt()); 8269 }, 8270 // Editor-lock cleanup. The launcher computed reads the 8271 // store entry directly (and the 1Hz heartbeat keeps it
8272 // fresh), so this watcher is no longer the launcher's 8273 // safety net â it just makes sure `ai_streaming` and the 8274 // editor's readOnly flag release when the entry goes 8275 // terminal, since the morph-preview / editor lock are 8276 // still driven from local flags rather than the store. 8277 ai_stream_event_tick() { 8278 let entry = this.active_codegen_stream_entry; 8279 if (!entry) { 8280 return; 8281 } 8282 if (entry.status !== 'done' && entry.status !== 'error' && entry.status !== 'aborted_local') { 8283 return; 8284 } 8285 // Editor-lock-only cleanup: this watcher does not 8286 // gate launcher visibility (the launcher binds to 8287 // store.codegen_request_state). It exists to release 8288 // ai_streaming / readOnly when the central store 8289 // says the request is over, in case the subscription 8290 // path missed an event. 8291 let needs_clear = this.ai_streaming 8292 || this.ai_streaming_status === 'streaming' 8293 || this.ai_streaming_status === 'queued'; 8294 if (!needs_clear) { 8295 return; 8296 } 8297 this.force_clear_ai_loading_state(); 8298 }, 8299 }, 8300 computed: { 8301 safe_custom_code_value() { 8302 return this.sanitize_custom_code_html(this.custom_code_value); 8303 }, 8304 safe_custom_code() { 8305 return this.sanitize_custom_code_html(this.custom_code || ''); 8306 }, 8307 // Source emptiness, not sanitized HTML â a
8307<script> load is 8308 // still "has code" even though preview strips it. 8309 custom_code_is_empty() { 8310 return !(this.custom_code_value || '').trim(); 8311 }, 8312 custom_code_preview_is_blank() { 8313 return !custom_code_html_has_visible_preview_content(this.safe_custom_code_value); 8314 }, 8315 // Unique external (non-Clubspot-CDN) image URLs referenced 8316 // by this section's persisted HTML â both <img src> and 8317 // inline `background-image: url(...)`. Drives the bulk 8318 // "save external images to my library" affordance. Recomputes 8319 // only when custom_code_value changes, so parsing cost is 8320 // bounded to actual edits. 8321 section_external_image_urls() { 8322 return this.collect_section_external_image_urls(this.custom_code_value); 8323 }, 8324 section_external_image_count() { 8325 return this.section_external_image_urls.length; 8326 }, 8327 ai_loading_pane_style() { 8328 if (!this.ai_loading_pane_height) { 8329 return null; 8330 } 8331 return { 8332 minHeight: this.ai_loading_pane_height + 'px', 8333 }; 8334 }, 8335 ai_loading_wrap_style() { 8336 if (!this.ai_loading_pane_height) { 8337 return null; 8338 } 8339 return { 8340 minHeight: this.ai_loading_pane_height + 'px', 8341 }; 8342 }, 8343 has_ai_thread_for_component() { 8344 if (typeof store === 'undefined') { 8345 return false; 8346 } 8347 let component_id = this.component && this.component.id; 8348 if (!component_id) { 8349 return false; 8350 } 8351 let config_latest = this.component && typeof this.component.get === 'function' 8352 ? (this.component.get('config_latest') || {}) 8353 : {}; 8354 if (config_latest.ai_thread_id) { 8355 return true; 8356 } 8357 let thread_map = store.webbuilder_code_component_has_ai_thread || {}; 8358 return !!thread_map[component_id]; 8359 }, 8360 ai_prompt_loading() { 8361 if (typeof store === 'undefined' || !store.components || !store.components.ai_mcp_panel) { 8362 return false; 8363 } 8364 return !!store.components.ai_mcp_panel.mcp_loading; 8365 }, 8366 ai_prompt_can_submit() { 8367 if (!(this.ai_prompt || '').trim().length) { 8368 return false; 8369 } 8370 return !this.ai_prompt_loading; 8371 }, 8372 // âââ Welcome composer image attachments ââââââââââââââââââ 8373 // 8374 // The welcome-state composer (rendered when a custom-code 8375 // section is empty and there's no prior AI thread) gets a 8376 // paperclip + drag-drop + thumbnail row mirroring the 8377 // chat panel. To avoid duplicating the canvas-resize / 8378 // base64 prep pipeline, we delegate ALL state to the chat 8379 // panel via the global ai_mcp_panel_attach_files helper. 8380 // This computed is just a reactive read of that shared 8381 // state so the welcome composer's thumbnails update in 8382 // lockstep with the chat panel's thumbnails. 8383 welcome_file_upload_enabled() { 8384 if (typeof store === 'undefined' || !store.ai_mcp_panel_component_instance) { 8385 return false; 8386 } 8387 return !!store.ai_mcp_panel_component_instance.mode_enable_file_upload; 8388 }, 8389 welcome_attachments() { 8390 if (typeof store === 'undefined' || !store.components || !store.components.ai_mcp_panel) { 8391 return []; 8392 } 8393 let panel = store.components.ai_mcp_panel; 8394 return Array.isArray(panel.mcp_attachments) ? panel.mcp_attachments : []; 8395 }, 8396 // âââ Single source of truth: store.codegen_request_state ââ 8397 // 8398 // The launcher's "Generatingâ¦" UI reads ONE reactive field: 8399 // store.codegen_request_state[component_id] 8400 // 8401 // That field is owned by store.ai_streams (the central
8402 // streams manager in ai-mcp-panel.html) and written ONLY 8403 // from there as the request transitions through: 8404 // 'starting' â 'queued' â 'streaming' â 'done' / 'error' / 'aborted' 8405 // 8406 // Because `store` is a Vue 3 `reactive()` proxy, both the 8407 // launcher (this file) and the chat panel bind to the same 8408 // key reactively. START and STOP are visible to both 8409 // surfaces from a single write â no subscriptions, no 8410 // ticks, no heartbeats, no watchdog needed. 8411 // 8412 // Local flags (`ai_streaming`, `ai_streaming_buffer`, 8413 // `ai_streaming_status`) still drive the EDITOR LOCK / 8414 // morph preview pane via the subscription path; those 8415 // concerns are deliberately decoupled from the launcher 8416 // so a missed editor-side event can't strand the spinner. 8417 codegen_request_status() { 8418 if (typeof store === 'undefined' || !store.codegen_request_state) { 8419 return null; 8420 } 8421 let component_id = this.component && this.component.id; 8422 if (!component_id) { 8423 return null; 8424 } 8425 return store.codegen_request_state[component_id] || null; 8426 }, 8427 active_codegen_stream_entry() { 8428 /* eslint-disable-next-line no-unused-expressions */ 8429 this.ai_stream_event_tick; 8430 if (typeof store === 'undefined' || !store.ai_streams || !store.ai_streams.get) { 8431 return null; 8432 } 8433 let component_id = this.component && this.component.id; 8434 if (!component_id) { 8435 return null; 8436 } 8437 return store.ai_streams.get('codegen:' + component_id); 8438 }, 8439 effective_ai_streaming_status() { 8440 let s = this.codegen_request_status; 8441 if (s) { 8442 return s; 8443 } 8444 return this.ai_streaming_status; 8445 }, 8446 // Launcher visibility â one rule, one field. 8447 ai_generation_active() { 8448 let s = this.codegen_request_status; 8449 return s === 'starting' || s === 'queued' || s === 'streaming'; 8450 }, 8451 ai_panel_is_open() { 8452 if (typeof store === 'undefined' || !store.components || !store.components.ai_mcp_panel) { 8453 return false; 8454 } 8455 return !!store.components.ai_mcp_panel.expanded; 8456 }, 8457 ai_panel_is_open_for_this_component() { 8458 if (!this.ai_panel_is_open || typeof store === 'undefined') { 8459 return false; 8460 } 8461 store.webbuilder_code_active_component_revision; 8462 let component_id = this.component && this.component.id; 8463 if (!component_id) { 8464 return false; 8465 } 8466 return store.webbuilder_code_active_component_id === component_id; 8467 }, 8468 // Idle Edit with AI lives on the hover toolbar. This in-section 8469 // pill is only the generating/queued indicator. 8470 ai_launcher_visible() { 8471 if (this.ai_panel_is_open) { 8472 return false; 8473 } 8474 return this.ai_generation_active; 8475 }, 8476 // Dots overlay â visible while we have a stream that 8477 // hasn't started emitting code yet. Reads the central 8478 // status for the bookended states; reads the entry's 8479 // code_buffer (still owned by the streams manager) to 8480 // distinguish "streaming but no chunks yet" from 8481 // "streaming with content". 8482 ai_streaming_pending() { 8483 let s = this.codegen_request_status; 8484 if (s === 'starting' || s === 'queued') { 8485 return true; 8486 } 8487 if (s !== 'streaming') { 8488 return false;
8489 } 8490 let entry = this.active_codegen_stream_entry; 8491 if (this.ai_streaming_buffer && this.ai_streaming_buffer.trim()) { 8492 return false; 8493 } 8494 return !!(entry && !(entry.code_buffer || '').trim()); 8495 }, 8496 }, 8497 mounted() { 8498 this.loading = false; 8499 // Listen for the section-actions overlay's "Save external 8500 // images" action. The overlay only has the component Parse 8501 // object, so it broadcasts the id and the matching live 8502 // instance runs the batch (which owns the persist + 8503 // re-render path). 8504 if (typeof store !== 'undefined' && store.eventBus) { 8505 this._save_external_images_handler = (component) => { 8506 let target_id = component && component.id ? component.id : component; 8507 if (this.component && this.component.id === target_id) { 8508 this.import_all_external_images_in_section(); 8509 } 8510 }; 8511 store.eventBus.$on('webbuilderSaveExternalImages', this._save_external_images_handler); 8512 this._webbuilder_refresh_replay_handler = () => { 8513 let component_id = this.component && this.component.id; 8514 if (!component_id || !store.ai_streams || typeof store.ai_streams.get !== 'function') { 8515 return; 8516 } 8517 let entry = store.ai_streams.get('codegen:' + component_id); 8518 if (!entry) { 8519 return; 8520 } 8521 this.subscribe_to_codegen_stream(); 8522 }; 8523 store.eventBus.$on('webbuilderPageRefreshReconciled', this._webbuilder_refresh_replay_handler); 8524 } 8525 if (this.preview_element) { 8526 let config_latest = (this.component && this.component.get('config_latest')) || {}; 8527 this.custom_code_value = config_latest.custom_code || ''; 8528 // Publish the live editor contents to the shared store 8529 // so the chat panel's per-message apply-state badge 8530 // can diff against it reactively without needing a 8531 // back-reference to this component. 8532 this.publish_current_code_to_store(); 8533 this.$nextTick(() => { 8534 this.cm_instance = CodeMirror(this.$refs.cm_editor, { 8535 value: this.custom_code_value, 8536 mode: 'htmlmixed', 8537 lineNumbers: true, 8538 autoCloseBrackets: true, 8539 autoCloseTags: true, 8540 matchBrackets: true, 8541 lineWrapping: true, 8542 tabSize: 2, 8543 indentWithTabs: false, 8544 extraKeys: { 8545 'Tab': function(cm) { 8546 cm.replaceSelection(' ', 'end'); 8547 } 8548 }, 8549 }); 8550 this.cm_instance.on('change', (editor) => { 8551 // Suppress save while we're driving the editor 8552 // from a streaming buffer â the stream's `done` 8553 // handler runs the canonical save once at the 8554 // end, and persisting partial frames would 8555 // spam the API. 8556 if (this._suppress_change_save) { 8557 // Still surface the live value to the 8558 // store so the apply-state badge stays 8559 // accurate during the typewriter â just 8560 // don't fire the debounced save. 8561 this.custom_code_value = editor.getValue(); 8562 this.publish_current_code_to_store(); 8563 return; 8564 } 8565 this.debounce(() => { 8566 let value = editor.getValue(); 8567 this.custom_code_value = value; 8568 this.publish_current_code_to_store(); 8569 save_changes_for_component(this.component, 'custom_code', value); 8570 this.mark_ai_code_revision_changed(); 8571 }); 8572 }); 8573 }); 8574 // Subscribe to this component's codegen stream key so 8575 // the editor + preview update live, independently of 8576 // whether the chat panel is open. Re-subscribing on 8577 // every mount also covers the page-reload recovery 8578 // case â if a passive entry exists for our key, the 8579 // replay event hands us the partial buffer. 8580 this.subscribe_to_codegen_stream(); 8581 } 8582 this.$nextTick(() => { 8583 this.initialize_custom_code_interactions(); 8584 this.auto_resize_ai_prompt(); 8585 this.setup_ai_builder_presence_observer(); 8586 this.publish_active_tab_to_store(); 8587 if (typeof store !== 'undefined' && 8588 store.webbuilder_code_active_component_id === (this.component && this.component.id)) { 8589 store.webbuilder_code_active_component = this; 8590 store.webbuilder_code_active_component_revision = Date.now(); 8591 this.subscribe_to_codegen_stream(); 8592 } 8593 }); 8594 }, 8595 beforeUnmount() { 8596 this.close_text_edit_popover(); 8597 this.cm_instance = null; 8598 if (typeof store !== 'undefined' && store.eventBus && this._save_external_images_handler) { 8599 if (typeof store.eventBus.$off === 'function') { 8600 store.eventBus.$off('webbuilderSaveExternalImages', this._save_external_images_handler); 8601 } else if (store.eventBus.listeners && Array.isArray(store.eventBus.listeners.webbuilderSaveExternalImages)) { 8602 store.eventBus.listeners.webbuilderSaveExternalImages = store.eventBus.listeners.webbuilderSaveExternalImages.filter(callback => { 8603 return callback !== this._save_external_images_handler; 8604 }); 8605 } 8606 this._save_external_images_handler = null; 8607 } 8608 if (typeof store !== 'undefined' && store.eventBus && this._webbuilder_refresh_replay_handler) { 8609 if (typeof store.eventBus.$off === 'function') { 8610 store.eventBus.$off('webbuilderPageRefreshReconciled', this._webbuilder_refresh_replay_handler); 8611 } else if (store.eventBus.listeners && Array.isArray(store.eventBus.listeners.webbuilderPageRefreshReconciled)) { 8612 store.eventBus.listeners.webbuilderPageRefreshReconciled = store.eventBus.listeners.webbuilderPageRefreshReconciled.filter(callback => { 8613 return callback !== this._webbuilder_refresh_replay_handler; 8614 }); 8615 } 8616 this._webbuilder_refresh_replay_handler = null; 8617 } 8618 // Unsubscribe â but do NOT cancel the underlying stream. 8619 // The entry stays alive in store.ai_streams so a remount 8620 // (e.g. component re-rendered after a save) can re-attach 8621 // and continue showing progress. 8622 if (typeof this._codegen_stream_unsubscribe === 'function') { 8623 try { this._codegen_stream_unsubscribe(); } catch (e) {} 8624 this._codegen_stream_unsubscribe = null; 8625 } 8626 if (this._codegen_stream_raf) { 8627 cancelAnimationFrame(this._codegen_stream_raf); 8628 this._codegen_stream_raf = null; 8629 } 8630 if (this._ai_streaming_starting_timer) { 8631 clearTimeout(this._ai_streaming_starting_timer); 8632 this._ai_streaming_starting_timer = null; 8633 } 8634 if (this._ai_welcome_generating_timer) { 8635 clearTimeout(this._ai_welcome_generating_timer); 8636 this._ai_welcome_generating_timer = null; 8637 } 8638 if (this.ai_loading_pane_height_release_timer) { 8639 clearTimeout(this.ai_loading_pane_height_release_timer); 8640 this.ai_loading_pane_height_release_timer = null; 8641 } 8642 if (this._ai_builder_presence_observer) { 8643 this._ai_builder_presence_observer.disconnect(); 8644 this._ai_builder_presence_observer = null; 8645 } 8646 if (this._ai_builder_presence_timeout) { 8647 clearTimeout(this._ai_builder_presence_timeout); 8648 this._ai_builder_presence_timeout = null; 8649 } 8650 // If this component was the active target for the AI 8651 // builder, clear the pointer so the panel doesn't try to 8652 // apply code into a Vue instance that's no longer mounted. 8653 if (typeof store !== 'undefined' && store.webbuilder_code_active_component === this) { 8654 store.webbuilder_code_active_component = null; 8655 store.webbuilder_code_active_component_id = null; 8656 }
8657 // Drop this section's published tab state so a stale 'edit' value 8658 // can't keep the launcher shown after the section is gone. 8659 if (typeof store !== 'undefined' 8660 && store.webbuilder_custom_code_tab_by_client_id 8661 && this.component 8662 && typeof this.component.get === 'function') { 8663 let cid = this.component.get('client_id'); 8664 if (cid) { 8665 delete store.webbuilder_custom_code_tab_by_client_id[cid]; 8666 } 8667 } 8668 }, 8669 methods: { 8670 page_has_multiple_custom_code_components() { 8671 if (typeof store === 'undefined' || !Array.isArray(store.page_components_latest)) { 8672 return false; 8673 } 8674 let custom_code_count = 0; 8675 for (let i = 0; i < store.page_components_latest.length; i++) { 8676 let page_component = store.page_components_latest[i]; 8677 if (page_component && page_component.get && page_component.get('type') === 'custom-code') { 8678 custom_code_count++; 8679 } 8680 if (custom_code_count > 1) { 8681 return true; 8682 } 8683 } 8684 return false; 8685 }, 8686 setup_ai_builder_presence_observer() { 8687 if (!this.preview_element) { 8688 return; 8689 } 8690 if (!this.page_has_multiple_custom_code_components()) { 8691 return; 8692 } 8693 if (typeof IntersectionObserver === 'undefined') { 8694 return; 8695 } 8696 if (!this.$el || this.$el.nodeType !== 1) { 8697 return; 8698 } 8699 this._ai_builder_presence_observer = new IntersectionObserver(entries => { 8700 entries.forEach(entry => { 8701 if (entry && entry.isIntersecting && entry.intersectionRatio >= 0.55) { 8702 this.activate_ai_builder_target_from_presence(); 8703 } 8704 }); 8705 }, { 8706 threshold: [0.55], 8707 }); 8708 this._ai_builder_presence_observer.observe(this.$el); 8709 }, 8710 activate_ai_builder_target_from_presence() { 8711 if (typeof store === 'undefined') { 8712 return; 8713 } 8714 if (!this.page_has_multiple_custom_code_components()) { 8715 return; 8716 } 8717 if (!store.ai_mcp_floating_panel_ui) { 8718 return; 8719 } 8720 let panel_state = store.components && store.components.ai_mcp_panel; 8721 if (!panel_state || !panel_state.expanded) { 8722 return; 8723 } 8724 if (panel_state.mcp_loading || (panel_state.mcp_prompt || '').trim()) { 8725 return; 8726 } 8727 let messages = Array.isArray(panel_state.mcp_messages) ? panel_state.mcp_messages : []; 8728 let last_message = messages.length ? messages[messages.length - 1] : null; 8729 if (last_message && last_message.streaming) { 8730 return; 8731 } 8732 let component_id = this.component && this.component.id ? this.component.id : null; 8733 if (!component_id || store.webbuilder_code_active_component_id === component_id) { 8734 return; 8735 } 8736 if (this._ai_builder_presence_timeout) { 8737 clearTimeout(this._ai_builder_presence_timeout); 8738 } 8739 this._ai_builder_presence_timeout = setTimeout(() => { 8740 this.activate_ai_builder_target(); 8741 this._ai_builder_presence_timeout = null; 8742 }, 120); 8743 }, 8744 activate_ai_builder_target(options) { 8745 if (typeof store === 'undefined') { 8746 return; 8747 } 8748 // Reclaim the per-component codegen mode in case the 8749 // page-level webbuilder-agent launcher switched it. 8750 store.ai_mcp_mode = 'webbuilder-code'; 8751 let opts = options || {}; 8752 let previous_component_id = store.webbuilder_code_active_component_id || null; 8753 store.webbuilder_code_active_component = this; 8754 store.webbuilder_code_active_component_id = this.component && this.component.id ? this.component.id : null; 8755 store.webbuilder_code_active_component_revision = Date.now(); 8756 this.subscribe_to_codegen_stream(); 8757 let panel = store.ai_mcp_panel_component_instance || null; 8758 if (panel && typeof panel.switch_webbuilder_component_thread === 'function') { 8759 panel.switch_webbuilder_component_thread( 8760 store.webbuilder_code_active_component_id, 8761 previous_component_id, 8762 opts 8763 ); 8764 } 8765 }, 8766 set_ai_prompt_example(example) { 8767 this.ai_prompt = example || ''; 8768 }, 8769 // âââ Welcome composer attachment handlers ââââââââââââââââ 8770 // 8771 // All paths funnel through the global helper 8772 // ai_mcp_panel_attach_files which writes into the shared 8773 // chat panel state (canvas-resize + base64 prep happens 8774 // there). The welcome composer is a UI-only mirror â no 8775 // local Blob storage, no local prep. 8776 open_welcome_file_picker() { 8777 if (!this.welcome_file_upload_enabled) { 8778 return; 8779 } 8780 if (this.ai_prompt_loading) { 8781 return; 8782 } 8783 let input = this.$refs && this.$refs.ai_prompt_file_input; 8784 if (!input) { 8785 return; 8786 } 8787 input.click(); 8788 }, 8789 handle_welcome_file_input_change(event) { 8790 let files = event && event.target && event.target.files 8791 ? Array.from(event.target.files) 8792 : []; 8793 if (event && event.target) { 8794 event.target.value = ''; 8795 } 8796 if (!files.length) { 8797 return; 8798 } 8799 if (typeof ai_mcp_panel_attach_files === 'function') { 8800 ai_mcp_panel_attach_files({ 8801 component: this, 8802 files: files, 8803 }); 8804 } 8805 }, 8806 handle_welcome_dragenter(event) { 8807 if (!this.welcome_file_upload_enabled) { 8808 return; 8809 } 8810 if (!this.welcome_event_has_image_files(event)) { 8811 return; 8812 } 8813 event.preventDefault(); 8814 this.welcome_drop_counter++; 8815 this.welcome_drop_active = this.welcome_drop_counter > 0; 8816 }, 8817 handle_welcome_dragover(event) { 8818 if (!this.welcome_file_upload_enabled) { 8819 return; 8820 } 8821 if (!this.welcome_event_has_image_files(event)) { 8822 return; 8823 } 8824 if (event.dataTransfer) { 8825 try { 8826 event.dataTransfer.dropEffect = 'copy'; 8827 } catch (drop_effect_err) {} 8828 } 8829 }, 8830 handle_welcome_dragleave() { 8831 if (!this.welcome_file_upload_enabled) { 8832 return; 8833 } 8834 if (this.welcome_drop_counter > 0) { 8835 this.welcome_drop_counter--; 8836 } 8837 if (this.welcome_drop_counter <= 0) { 8838 this.welcome_drop_counter = 0; 8839 this.welcome_drop_active = false;
8840 } 8841 }, 8842 handle_welcome_drop(event) { 8843 this.welcome_drop_counter = 0; 8844 this.welcome_drop_active = false; 8845 if (!this.welcome_file_upload_enabled) { 8846 return; 8847 } 8848 let files = event && event.dataTransfer && event.dataTransfer.files 8849 ? Array.from(event.dataTransfer.files) 8850 : []; 8851 if (!files.length) { 8852 return; 8853 } 8854 let images = files.filter(function (file) { 8855 return file && typeof file.type === 'string' && file.type.indexOf('image/') === 0; 8856 }); 8857 if (!images.length) { 8858 return; 8859 } 8860 if (typeof ai_mcp_panel_attach_files === 'function') { 8861 ai_mcp_panel_attach_files({ 8862 component: this, 8863 files: images, 8864 }); 8865 } 8866 }, 8867 handle_welcome_paste(event) { 8868 if (!this.welcome_file_upload_enabled) { 8869 return; 8870 } 8871 if (!event || !event.clipboardData || !event.clipboardData.items) { 8872 return; 8873 } 8874 let items = Array.from(event.clipboardData.items); 8875 let pasted = []; 8876 for (let i = 0; i < items.length; i++) { 8877 let item = items[i]; 8878 if (!item || item.kind !== 'file') { 8879 continue; 8880 } 8881 if (typeof item.type !== 'string' || item.type.indexOf('image/') !== 0) { 8882 continue; 8883 } 8884 let file = item.getAsFile && item.getAsFile(); 8885 if (file) { 8886 pasted.push(file); 8887 } 8888 } 8889 if (!pasted.length) { 8890 return; 8891 } 8892 event.preventDefault(); 8893 if (typeof ai_mcp_panel_attach_files === 'function') { 8894 ai_mcp_panel_attach_files({ 8895 component: this, 8896 files: pasted, 8897 }); 8898 } 8899 }, 8900 welcome_event_has_image_files(event) { 8901 if (!event || !event.dataTransfer) { 8902 return false; 8903 } 8904 let types = event.dataTransfer.types; 8905 if (!types) { 8906 return false; 8907 } 8908 for (let i = 0; i < types.length; i++) { 8909 if (types[i] === 'Files' || types[i] === 'application/x-moz-file') { 8910 return true; 8911 } 8912 } 8913 return false; 8914 }, 8915 welcome_remove_attachment(index) { 8916 if (typeof store === 'undefined' || !store.ai_mcp_panel_component_instance) { 8917 return; 8918 } 8919 let panel = store.ai_mcp_panel_component_instance; 8920 if (typeof panel.remove_attachment === 'function') { 8921 panel.remove_attachment(index); 8922 } 8923 }, 8924 // Auto-grow the welcome-page composer textarea. Starts at 8925 // a single line (height matches one line-height) and grows 8926 // up to the CSS max-height (~8 lines), after which the 8927 // textarea scrolls. We reset height to '0' before measuring 8928 // scrollHeight so the height can shrink as well as grow 8929 // (otherwise scrollHeight only ever increases). 8930 // 8931 // Called from @input (typing), the watcher on `ai_prompt` 8932 // (programmatic changes like example pill click / 8933 // post-submit clear), and once on mounted() to settle the 8934 // initial layout. 8935 auto_resize_ai_prompt() { 8936 let el = this.$refs && this.$refs.ai_prompt_textarea; 8937 if (!el) { 8938 return; 8939 } 8940 el.style.height = '0px'; 8941 let next = el.scrollHeight; 8942 if (!next) { 8943 next = 21; 8944 } 8945 el.style.height = next + 'px'; 8946 }, 8947 submit_ai_prompt() { 8948 if (!this.ai_prompt_can_submit) { 8949 return; 8950 } 8951 // The blank welcome composer now seeds the single page agent 8952 // (focused on this section) rather than the per-component 8953 // codegen chat. The page agent owns the conversation + send. 8954 if (typeof ai_mcp_panel_open_agent_with_reference !== 'function') { 8955 this.open_ai_builder(); 8956 return; 8957 } 8958 let opened = ai_mcp_panel_open_agent_with_reference({ 8959 client_id: this.component && this.component.get('client_id'), 8960 component_type: 'custom-code', 8961 seed_prompt: this.ai_prompt,
8962 auto_submit: true, 8963 }); 8964 if (opened) { 8965 this.ai_prompt = ''; 8966 this.start_welcome_generating_state(); 8967 } else { 8968 this.open_ai_builder(); 8969 } 8970 }, 8971 // Swap the welcome composer for the "Generatingâ¦" pane while the 8972 // page agent produces the first result. Cleared when content lands 8973 // (custom_code_value watcher) or by a safety timer. 8974 start_welcome_generating_state() { 8975 this.active_tab = 'preview'; 8976 this.close_text_edit_popover(); 8977 this.ai_welcome_generating = true; 8978 if (this._ai_welcome_generating_timer) { 8979 clearTimeout(this._ai_welcome_generating_timer); 8980 } 8981 this._ai_welcome_generating_timer = setTimeout(() => { 8982 this.clear_welcome_generating_state(); 8983 }, 90000); 8984 }, 8985 clear_welcome_generating_state() { 8986 this.ai_welcome_generating = false; 8987 if (this._ai_welcome_generating_timer) { 8988 clearTimeout(this._ai_welcome_generating_timer); 8989 this._ai_welcome_generating_timer = null; 8990 } 8991 }, 8992 // Writes 'starting' into the central store so the 8993 // launcher reflects intent instantly. store.ai_streams.start 8994 // overwrites this with 'queued' or 'streaming' once the 8995 // entry materializes. The 15s timer is the safety net for 8996 // the rare case where the panel never actually fires a 8997 // submit (e.g. the panel app didn't mount yet). 8998 start_ai_streaming_starting_state() { 8999 this.capture_ai_loading_pane_height(); 9000 this.blur_ai_prompt_before_loading(); 9001 let prior_scroll_y = window.scrollY; 9002 let component_id = this.component && this.component.id; 9003 if (typeof store !== 'undefined' && store.ai_streams && typeof store.ai_streams.mark_starting === 'function' && component_id) { 9004 store.ai_streams.mark_starting(component_id); 9005 } 9006 this.active_tab = 'preview'; 9007 this.close_text_edit_popover(); 9008 this.$nextTick(() => { 9009 if (Math.abs(window.scrollY - prior_scroll_y) > 1) { 9010 window.scrollTo(window.scrollX, prior_scroll_y); 9011 } 9012 }); 9013 if (this._ai_streaming_starting_timer) { 9014 clearTimeout(this._ai_streaming_starting_timer); 9015 } 9016 this._ai_streaming_starting_timer = setTimeout(() => { 9017 this.clear_ai_streaming_starting_state(); 9018 }, 15000); 9019 }, 9020 // Clears the 'starting' bridge from central state if (and 9021 // only if) it's still the current value â never overwrites 9022 // a real downstream status like 'streaming' / 'done'. 9023 clear_ai_streaming_starting_state() { 9024 if (typeof store !== 'undefined' && store.codegen_request_state) { 9025 let component_id = this.component && this.component.id; 9026 if (component_id && store.codegen_request_state[component_id] === 'starting') { 9027 delete store.codegen_request_state[component_id]; 9028 } 9029 } 9030 if (this._ai_streaming_starting_timer) { 9031 clearTimeout(this._ai_streaming_starting_timer); 9032 this._ai_streaming_starting_timer = null; 9033 } 9034 }, 9035 // Idempotent hard-reset of every flag that feeds the 9036 // launcher / loading-overlay computeds. Called from the 9037 // panel's done / fail / snap paths so the launcher always 9038 // converges to idle even if a stream subscriber missed 9039 // an event, threw mid-handler, or fired its `done` while 9040 // the component was unmounted. Safe to call multiple 9041 // times. Does NOT touch the editor contents â the panel's 9042 // apply_generated_code is responsible for the canonical 9043 // code save. 9044 force_clear_ai_loading_state() { 9045 this.clear_ai_streaming_starting_state(); 9046 this.ai_streaming = false;
9047 this.release_ai_loading_pane_height_after_render(); 9048 if (this.ai_streaming_status === 'streaming' || this.ai_streaming_status === 'queued' || this.ai_streaming_status === null) { 9049 this.ai_streaming_status = 'done'; 9050 } 9051 this.ai_streaming_buffer = ''; 9052 this._last_flushed_buffer_len = -1; 9053 this.ai_streaming_child_min_heights = []; 9054 if (this._codegen_stream_raf) { 9055 cancelAnimationFrame(this._codegen_stream_raf); 9056 this._codegen_stream_raf = null; 9057 } 9058 if (this.cm_instance && this.cm_instance.getOption && this.cm_instance.getOption('readOnly')) { 9059 this.cm_instance.setOption('readOnly', false); 9060 } 9061 this.ai_stream_event_tick = (this.ai_stream_event_tick + 1) | 0; 9062 }, 9063 capture_ai_loading_pane_height() { 9064 if (this.ai_loading_pane_height_release_timer) { 9065 clearTimeout(this.ai_loading_pane_height_release_timer); 9066 this.ai_loading_pane_height_release_timer = null; 9067 } 9068 // `$el` can be a Comment / Text node when the root 9069 // `<template v-if>` is currently false (Vue 3 treats 9070 // a falsy single-root v-if as a placeholder comment), 9071 // and it lacks querySelectorAll. Bail in that case 9072 // so the streaming subscriber doesn't throw on every 9073 // pane-height capture. 9074 let root = this.$el; 9075 if (!root || typeof root.querySelectorAll !== 'function') { 9076 return; 9077 } 9078 let panes = Array.prototype.slice.call(root.querySelectorAll('.custom-code-preview-pane')); 9079 let visible = panes.find((pane) => { 9080 if (!pane || pane.classList.contains('custom-code-loading-pane')) { 9081 return false; 9082 } 9083 let style = window.getComputedStyle(pane); 9084 return style.display !== 'none' && pane.getClientRects().length > 0; 9085 }); 9086 if (!visible) { 9087 let hidden_height = this.measure_hidden_custom_code_preview_height(); 9088 if (hidden_height > 0) { 9089 this.ai_loading_pane_height = Math.max(420, hidden_height); 9090 } 9091 return; 9092 } 9093 let rect_height = visible.getBoundingClientRect().height || 0; 9094 let scroll_height = visible.scrollHeight || 0; 9095 let height = Math.ceil(Math.max(rect_height, scroll_height)); 9096 if (height > 0) { 9097 this.ai_loading_pane_height = Math.max(420, height); 9098 } 9099 this.capture_streaming_child_min_heights_from_node(visible); 9100 }, 9101 measure_hidden_custom_code_preview_height() { 9102 let root = this.$el; 9103 if (!root || typeof root.getBoundingClientRect !== 'function') { 9104 return 0; 9105 } 9106 let html = this.safe_custom_code_value || ''; 9107 if (!html.trim()) { 9108 return 0; 9109 } 9110 let root_rect = root.getBoundingClientRect(); 9111 let width = Math.floor(root_rect.width || root.clientWidth || 0); 9112 if (width <= 0 && root.parentElement) { 9113 let parent_rect = root.parentElement.getBoundingClientRect(); 9114 width = Math.floor(parent_rect.width || root.parentElement.clientWidth || 0); 9115 } 9116 if (width <= 0) { 9117 return 0; 9118 } 9119 let probe = document.createElement('div'); 9120 probe.className = 'custom-code-pane custom-code-preview-pane'; 9121 probe.style.position = 'absolute'; 9122 probe.style.visibility = 'hidden'; 9123 probe.style.pointerEvents = 'none'; 9124 probe.style.left = '-10000px'; 9125 probe.style.top = '0'; 9126 probe.style.width = width + 'px'; 9127 probe.style.height = 'auto'; 9128 probe.style.overflow = 'visible'; 9129 probe.style.boxSizing = 'border-box'; 9130 probe.innerHTML = html; 9131 document.body.appendChild(probe); 9132 let rect_height = probe.getBoundingClientRect().height || 0; 9133 let scroll_height = probe.scrollHeight || 0;
9134 let height = Math.ceil(Math.max(rect_height, scroll_height)); 9135 this.capture_streaming_child_min_heights_from_node(probe); 9136 probe.remove(); 9137 return height; 9138 }, 9139 capture_streaming_child_min_heights_from_node(node) { 9140 if (!node || !node.childNodes) { 9141 this.ai_streaming_child_min_heights = []; 9142 return; 9143 } 9144 this.ai_streaming_child_min_heights = Array.prototype.slice.call(node.childNodes).map((child) => { 9145 if (!child || child.nodeType !== 1) { 9146 return 0; 9147 } 9148 let rect_height = child.getBoundingClientRect().height || 0; 9149 let scroll_height = child.scrollHeight || 0; 9150 return Math.ceil(Math.max(rect_height, scroll_height)); 9151 }); 9152 }, 9153 release_ai_loading_pane_height_after_render() { 9154 if (this.ai_loading_pane_height_release_timer) { 9155 clearTimeout(this.ai_loading_pane_height_release_timer); 9156 this.ai_loading_pane_height_release_timer = null; 9157 } 9158 if (!this.ai_loading_pane_height) { 9159 this.ai_streaming_child_min_heights = []; 9160 return; 9161 } 9162 this.ai_loading_pane_height_release_timer = setTimeout(() => { 9163 this.ai_loading_pane_height = null; 9164 this.ai_streaming_child_min_heights = []; 9165 this.ai_loading_pane_height_release_timer = null; 9166 }, 50); 9167 }, 9168 blur_ai_prompt_before_loading() { 9169 let prompt = this.$refs && this.$refs.ai_prompt_textarea; 9170 if (!prompt || document.activeElement !== prompt) { 9171 return; 9172 } 9173 prompt.blur(); 9174 }, 9175 // Launcher click â pin this component as the AI panel's 9176 // active target and expand the panel. The panel runs in 9177 // a separate Vue app (app_ai_mcp, mounted in 9178 // web-builder/primary.ejs) and watches 9179 // store.webbuilder_code_active_component_id; setting it 9180 // here triggers the panel to load this component's thread. 9181 open_ai_builder() { 9182 if (typeof store === 'undefined') { 9183 return; 9184 } 9185 // Open the single page agent focused on this section. (The old 9186 // per-component codegen activation is left dormant below for a 9187 // future live agent->codegen stream hookup.) 9188 if (typeof ai_mcp_panel_open_agent_with_reference === 'function' 9189 && this.component 9190 && typeof this.component.get === 'function') { 9191 ai_mcp_panel_open_agent_with_reference({ 9192 client_id: this.component.get('client_id'), 9193 component_type: 'custom-code', 9194 }); 9195 return; 9196 } 9197 let panel_state = store.components && store.components.ai_mcp_panel; 9198 if (panel_state) { 9199 panel_state.user_toggled = true; 9200 panel_state.expanded = true; 9201 if (panel_state.mcp_panel_width) { 9202 document.body.style.setProperty('--mcp-panel-width', panel_state.mcp_panel_width + 'px'); 9203 } 9204 document.body.classList.add('ai-mcp-expanded'); 9205 } 9206 this.activate_ai_builder_target({ force: true }); 9207 }, 9208 mark_ai_thread_for_component() { 9209 if (typeof store === 'undefined') { 9210 return; 9211 } 9212 let component_id = this.component && this.component.id; 9213 if (!component_id) { 9214 return; 9215 } 9216 if (!store.webbuilder_code_component_has_ai_thread) { 9217 store.webbuilder_code_component_has_ai_thread = {}; 9218 } 9219 store.webbuilder_code_component_has_ai_thread[component_id] = true; 9220 }, 9221 contains_ai_builder_target(target) { 9222 if (!target) { 9223 return false;
9224 } 9225 if (this.$el && this.$el.contains(target)) { 9226 return true; 9227 } 9228 if (this.$refs.cm_editor && this.$refs.cm_editor.contains(target)) { 9229 return true; 9230 } 9231 // The text-edit popover lives at document.body level 9232 // (position: fixed) so it isn't inside $el. Treat clicks 9233 // landing on it as in-component interactions so the AI 9234 // panel's outside-click handler doesn't auto-close 9235 // while the admin is typing in the popover. 9236 if (typeof target.closest === 'function' && target.closest('.cs-text-edit-popover')) { 9237 return true; 9238 } 9239 return false; 9240 }, 9241 get_current_code() { 9242 if (this.cm_instance) { 9243 return this.cm_instance.getValue(); 9244 } 9245 if (typeof this.custom_code_value === 'string') { 9246 return this.custom_code_value; 9247 } 9248 if (this.component) { 9249 let config_latest = this.component.get('config_latest') || {}; 9250 return config_latest.custom_code || ''; 9251 } 9252 return ''; 9253 }, 9254 mark_ai_code_revision_changed() { 9255 if (typeof store === 'undefined') { 9256 return; 9257 } 9258 if (store.webbuilder_code_active_component !== this) { 9259 return; 9260 } 9261 store.webbuilder_code_active_component_revision = Date.now(); 9262 }, 9263 custom_code_render_root() { 9264 let host = null; 9265 if (this.preview_element) { 9266 host = this.$refs.custom_code_preview || null; 9267 } else { 9268 host = this.$refs.custom_code_static || null; 9269 } 9270 if (!host) { 9271 return null; 9272 } 9273 let html = this.preview_element 9274 ? (this.safe_custom_code_value || '') 9275 : (this.safe_custom_code || ''); 9276 let root = this.ensure_isolated_custom_code_host(host, html); 9277 this.apply_first_section_top_spacer(root); 9278 return root; 9279 }, 9280 first_section_top_spacer_px(){ 9281 if( this.first_section_top_spacer_px_override !== null ){ 9282 return normalize_first_section_top_spacer_px(this.first_section_top_spacer_px_override); 9283 } 9284 if( !this.is_first || !this.component ){ 9285 return 0; 9286 } 9287 let config_latest = this.component.get('config_latest') || {}; 9288 let formatting = config_latest.formatting || {}; 9289 return normalize_first_section_top_spacer_px(formatting.first_section_top_spacer_px); 9290 }, 9291 apply_first_section_top_spacer(root){ 9292 if( typeof apply_first_section_top_spacer_to_root === 'function' ){ 9293 apply_first_section_top_spacer_to_root(root, this.first_section_top_spacer_px()); 9294 return; 9295 } 9296 if( !root ){ 9297 return; 9298 } 9299 }, 9300 // Mount sanitized HTML into an open shadow root so hosted theme 9301 // CSS cannot override section styles (preview â live). 9302 ensure_isolated_custom_code_host(host, html) { 9303 if (!host || typeof host.attachShadow !== 'function') { 9304 // Very old browsers: fall back to light-DOM innerHTML. 9305 host.innerHTML = html || ''; 9306 return host; 9307 } 9308 let shadow = host.shadowRoot; 9309 if (!shadow) { 9310 shadow = host.attachShadow({ mode: 'open' }); 9311 // Font Awesome lives on the light DOM page; clone its 9312 // stylesheet so icons inside custom-code still render. 9313 let fa_link = document.querySelector( 9314 'link[href*="font-awesome"], link[href*="fontawesome"], link[href*="FontAwesome"]' 9315 ); 9316 if (fa_link) { 9317 let cloned = fa_link.cloneNode(true); 9318 cloned.setAttribute('data-cs-fa-isolation', '1'); 9319 shadow.appendChild(cloned); 9320 } 9321 let root = document.createElement('div'); 9322 root.className = 'cs-cc-root'; 9323 shadow.appendChild(root); 9324 } 9325 // Only direct shadow children â never a <style> inside the 9326 // section HTML (querySelector would walk into .cs-cc-root). 9327 let isolation_style = null; 9328 let shadow_children = shadow.children; 9329 for (let i = 0; i < shadow_children.length; i++) { 9330 let node = shadow_children[i]; 9331 if (node.tagName === 'STYLE') { 9332 if (node.getAttribute('data-cs-isolation') === '1') { 9333 isolation_style = node; 9334 break; 9335 } 9336 } 9337 } 9338 if (!isolation_style) { 9339 isolation_style = document.createElement('style'); 9340 isolation_style.setAttribute('data-cs-isolation', '1'); 9341 shadow.insertBefore(isolation_style, shadow.firstChild); 9342 } 9343 isolation_style.textContent = CS_CUSTOM_CODE_ISOLATION_CSS; 9344 if (this.preview_element) { 9345 isolation_style.textContent += '\n' + CS_CUSTOM_CODE_PREVIEW_CHROME_CSS; 9346 } 9347 let content_root = shadow.querySelector('.cs-cc-root'); 9348 if (!content_root) { 9349 content_root = document.createElement('div'); 9350 content_root.className = 'cs-cc-root'; 9351 shadow.appendChild(content_root); 9352 } 9353 // Compare against the source we last wrote, not live 9354 // innerHTML. Decoration injects Change/Add chips into 9355 // the tree; a live compare would see that as a mismatch 9356 // and wipe the chips on the next render_root call 9357 // (text-edit decorate runs immediately after). 9358 let next_html = html || ''; 9359 if (content_root.__cs_source_html !== next_html) { 9360 content_root.innerHTML = next_html; 9361 content_root.__cs_source_html = next_html; 9362 } 9363 return content_root; 9364 }, 9365 sanitize_custom_code_html(html) { 9366 if (!html || typeof html !== 'string') { 9367 return ''; 9368 } 9369 let template = document.createElement('template'); 9370 template.innerHTML = html; 9371 template.content.querySelectorAll('script').forEach((node) => { 9372 node.remove(); 9373 }); 9374 // Drop external FA kits â they override Clubspot's Pro icons document-wide. 9375 template.content.querySelectorAll('link[rel="stylesheet"], link[rel="preload"]').forEach((node) => { 9376 let href = String(node.getAttribute('href') || '').toLowerCase(); 9377 if ( 9378 href.indexOf('font-awesome') >= 0 9379 || href.indexOf('fontawesome') >= 0 9380 || href.indexOf('kit.fontawesome') >= 0 9381 ) { 9382 node.remove(); 9383 } 9384 }); 9385 template.content.querySelectorAll('*').forEach((node) => { 9386 Array.prototype.slice.call(node.attributes || []).forEac
9386h((attr) => { 9387 let name = String(attr.name || '').toLowerCase(); 9388 let value = String(attr.value || '').trim().toLowerCase(); 9389 if (name.indexOf('on') === 0) { 9390 node.removeAttribute(attr.name); 9391 } 9392 if ((name === 'href' || name === 'src' || name === 'xlink:href') && value.indexOf('javascript:') === 0) { 9393 node.removeAttribute(attr.name); 9394 } 9395 }); 9396 }); 9397 return template.innerHTML; 9398 }, 9399 initialize_custom_code_interactions() { 9400 let root = this.custom_code_render_root(); 9401 if (!root) { 9402 return; 9403 } 9404 this.apply_first_section_top_spacer(root); 9405 // Show/hide a tab panel. The `hidden` attribute alone is 9406 // not reliable: panels usually carry an inline `display` 9407 // (e.g. `display:flex`), and an inline declaration beats 9408 // the UA `[hidden]{display:none}` rule, so inactive panels 9409 // would stay visible and every tab would render at once. 9410 // Drive `display` directly (remembering each panel's 9411 // original inline value) and keep `hidden` for semantics. 9412 let set_panel_active = (panel, active) => { 9413 if (panel.__csPanelDisplay === undefined) { 9414 panel.__csPanelDisplay = panel.style.display || ''; 9415 } 9416 panel.hidden = !active; 9417 panel.classList.toggle('is-active', active); 9418 panel.style.display = active ? panel.__csPanelDisplay : 'none'; 9419 }; 9420 let find_tab_panels = (group) => { 9421 let panels = Array.prototype.slice.call(group.querySelectorAll('[data-cs-panel]')); 9422 if (panels.length) { 9423 return panels; 9424 } 9425 let panel_scope = group.closest('section') || root; 9426 return Array.prototype.slice.call(panel_scope.querySelectorAll('[data-cs-panel]')); 9427 }; 9428 let sync_tabs = () => { 9429 root.querySelectorAll('[data-cs-tabs]').forEach((group) => { 9430 let tabs = Array.prototype.slice.call(group.querySelectorAll('[data-cs-tab]')); 9431 let panels = find_tab_panels(group); 9432 if (!tabs.length || !panels.length) { 9433 return; 9434 } 9435 let active_tab = tabs.find((tab) => tab.classList.contains('is-active')) || tabs[0]; 9436 let active_name = active_tab.getAttribute('data-cs-tab');
9437 tabs.forEach((tab) => { 9438 let active = tab === active_tab; 9439 tab.classList.toggle('is-active', active); 9440 tab.setAttribute('aria-selected', active ? 'true' : 'false'); 9441 }); 9442 panels.forEach((panel) => { 9443 set_panel_active(panel, panel.getAttribute('data-cs-panel') === active_name); 9444 }); 9445 }); 9446 }; 9447 // The switcher group for a `<select data-cs-select>`: its wrapping 9448 // [data-cs-tabs] if present, else its section, else root â the same 9449 // scope rule tabs use, so a select can drive the same panels. 9450 let find_switcher_group = (el) => el.closest('[data-cs-tabs]') || el.closest('section') || root; 9451 // `<select data-cs-select>` whose `<option value="X">` reveal the 9452 // matching `[data-cs-panel="X"]`. The native control renders/opens 9453 // with no JS; only the panel switch needs the runtime. Ideal for a 9454 // many-option filter (year/category) where tabs would be unwieldy. 9455 let sync_selects = () => { 9456 root.querySelectorAll('[data-cs-select]').forEach((select) => { 9457 let panels = find_tab_panels(find_switcher_group(select)); 9458 if (!panels.length) { 9459 return; 9460 } 9461 let active_name = (select.value !== null && select.value !== '') 9462 ? select.value 9463 : panels[0].getAttribute('data-cs-panel'); 9464 panels.forEach((panel) => { 9465 set_panel_active(panel, panel.getAttribute('data-cs-panel') === active_name); 9466 }); 9467 }); 9468 }; 9469 let sync_all = () => { 9470 sync_tabs(); 9471 sync_selects(); 9472 }; 9473 if (root.__csCustomCodeInteractionsInitialized) { 9474 sync_all(); 9475 if (this.preview_element) { 9476 this.decorate_asset_placeholders(); 9477 this.decorate_editable_text_nodes(); 9478 } 9479 return; 9480 } 9481 root.__csCustomCodeInteractionsInitialized = true; 9482 if (this.preview_element) { 9483 this.decorate_asset_placeholders(); 9484 this.decorate_editable_text_nodes(); 9485 } 9486 root.addEventListener('click', (event) => { 9487 let trigger = event.target && event.target.closest('[data-cs-toggle], [data-cs-tab]'); 9488 if (!trigger || !root.contains(trigger)) { 9489 return; 9490 } 9491 9492 let toggle_selector = trigger.getAttribute('data-cs-toggle'); 9493 if (toggle_selector) { 9494 event.preventDefault(); 9495 let target = root.querySelector(toggle_selector); 9496 if (target) { 9497 let class_name = trigger.getAttribute('data-cs-toggle-class') || 'is-active'; 9498 target.classList.toggle(class_name); 9499 let expanded = target.classList.contains(class_name); 9500 trigger.setAttribute('aria-expanded', expanded ? 'true' : 'false'); 9501 } 9502 } 9503 9504 let tab_name = trigger.getAttribute('data-cs-tab'); 9505 if (tab_name) { 9506 event.preventDefault(); 9507 let group = trigger.closest('[data-cs-tabs]') || root; 9508 group.querySelectorAll('[data-cs-tab]').forEach((tab) => { 9509 tab.classList.toggle('is-active', tab === trigger); 9510 tab.setAttribute('aria-selected', tab === trigger ? 'true' : 'false'); 9511 });
9512 find_tab_panels(group).forEach((panel) => { 9513 set_panel_active(panel, panel.getAttribute('data-cs-panel') === tab_name); 9514 }); 9515 } 9516 }); 9517 root.addEventListener('change', (event) => { 9518 let select = event.target && event.target.closest('[data-cs-select]'); 9519 if (!select || !root.contains(select)) { 9520 return; 9521 } 9522 let active_name = select.value; 9523 find_tab_panels(find_switcher_group(select)).forEach((panel) => { 9524 set_panel_active(panel, panel.getAttribute('data-cs-panel') === active_name); 9525 }); 9526 }); 9527 sync_all(); 9528 }, 9529 // âââ Asset placeholders âââââââââââââââââââââââââââââââââââ 9530 // 9531 // The AI route plants inert `<div data-cs-asset-placeholder>` 9532 // (or `<a>`) elements in the section HTML when it needs an 9533 // asset it couldn't verify. The live (public) site renders 9534 // those placeholders as styled fallback wrappers as-is â 9535 // they're authored to look intentional. In PREVIEW mode 9536 // (admin builder) we overlay a click-to-fill control on top 9537 // of each placeholder so the admin can choose a club photo, 9538 // upload a new one, or paste a URL. On selection we mutate 9539 // the persisted HTML so the next page render shows the 9540 // attached asset. 9541 // 9542 // Two element states: 9543 // - `data-cs-asset-placeholder="<id>"` unfilled slot; 9544 // gets the big full-bleed "Add image" overlay. 9545 // - `data-cs-asset-attached="<id>"` filled slot; 9546 // gets the small top-right "Change" chip. 9547 // 9548 // We keep the wrapper element across the fill transition â 9549 // the wrapper preserves the section's layout box (size, 9550 // border-radius, positioning) and we just swap its inner 9551 // content for an `<img>`/`<a>`/`background-image`. That way 9552 // the section never reflows when an admin fills a slot, and 9553 // styled fallback "frames" the resolved asset. 9554 // 9555 // Sequencing: decorate runs after every preview re-render 9556 // (initialize_custom_code_interactions). The overlay DOM 9557 // lives ONLY in the live preview tree â we never persist 9558 // the overlay markup back to `custom_code`. 9559 9560 decorate_asset_placeholders() { 9561 if (!this.preview_element) { 9562 return; 9563 } 9564 let root = this.custom_code_render_root(); 9565 if (!root) { 9566 return; 9567 } 9568 // Unfilled placeholders â big "Add image" overlay. 9569 let placeholders = root.querySelectorAll('[data-cs-asset-placeholder]'); 9570 placeholders.forEach((placeholder) => { 9571 if (placeholder.__cs_asset_decorated) { 9572 return; 9573 } 9574 placeholder.__cs_asset_decorated = true; 9575 let computed_position = window.getComputedStyle(placeholder).position; 9576 if (computed_position === 'static') { 9577 placeholder.style.position = 'relative'; 9578 } 9579 placeholder.classList.add('cs-asset-placeholder-host'); 9580 placeholder.style.cursor = 'pointer'; 9581 9582 let id = placeholder.getAttribute('data-cs-asset-placeholder') || ''; 9583 let target = (placeholder.getAttribute('data-cs-asset-target') || '').toLowerCase(); 9584 if (target === 'background-image') { 9585 placeholder.classList.add('cs-asset-background-placeholder-host'); 9586 } 9587 let label_attr = (placeholder.getAttribute('data-cs-asset-label') || '').trim(); 9588 let label = label_attr || 'asset'; 9589 9590 if (target === 'background-image') { 9591 let background_chip = document.createElement('button'); 9592 background_chip.type = 'button'; 9593 background_chip.className = 'cs-asset-background-placeholder-chip exclude-from-custom-button-styles'; 9594 background_chip.setAttribute('data-cs-asset-placeholder-id', id); 9595 background_chip.setAttribute('aria-label', 'Add ' + label); 9596 background_chip.setAttribute('title', 'Add ' + label); 9597 background_chip.innerHTML = '<i class="fa-regular fa-image"></i>'; 9598 background_chip.addEventListener('mousedown', (event) => { 9599 event.stopPropagation(); 9600 }); 9601 background_chip.addEventListener('click', (event) => { 9602 event.preventDefault(); 9603 event.stopPropagation(); 9604 this.open_placeholder_menu(id, { mode: 'placeholder' }); 9605 }); 9606 let background_chip_host = placeholder; 9607 if (placeholder.parentElement 9608 && placeholder.parentElement !== root 9609 && root.contains(placeholder.parentElement)) { 9610 background_chip_host = placeholder.parentElement; 9611 if (window.getComputedStyle(background_chip_host).position === 'static') { 9612 background_chip_host.style.position = 'relative'; 9613 } 9614 } 9615 background_chip_host.appendChild(background_chip); 9616 } else {
9617 let overlay = document.createElement('button'); 9618 overlay.type = 'button'; 9619 overlay.className = 'cs-asset-placeholder-overlay exclude-from-custom-button-styles'; 9620 overlay.setAttribute('data-cs-asset-placeholder-id', id); 9621 overlay.innerHTML = 9622 '<span class="cs-asset-placeholder-icon"><i class="fa-regular fa-image"></i></span>' + 9623 '<span class="cs-asset-placeholder-label">Add ' + this.escape_html_text(label) + '</span>'; 9624 overlay.addEventListener('mousedown', (event) => { 9625 event.stopPropagation(); 9626 }); 9627 overlay.addEventListener('click', (event) => { 9628 event.preventDefault(); 9629 event.stopPropagation(); 9630 this.open_placeholder_menu(id, { mode: 'placeholder' }); 9631 }); 9632 placeholder.appendChild(overlay); 9633 } 9634 9635 // Host-level click â make the entire placeholder area 9636 // clickable so admins can tap anywhere inside the 9637 // styled fallback wrapper to open the fill menu, not 9638 // just the centered overlay button. The overlay's own 9639 // listener stops propagation, so we only fire when 9640 // the click originated outside it (e.g. on padding, 9641 // background, or fallback child text/icons). 9642 placeholder.addEventListener('click', (event) => { 9643 if (event.target.closest('.cs-asset-placeholder-overlay')) { 9644 return; 9645 } 9646 if (event.target.closest('.cs-asset-background-placeholder-chip')) { 9647 return; 9648 } 9649 if (target === 'background-image' && event.target !== placeholder) { 9650 return; 9651 } 9652 event.preventDefault(); 9653 event.stopPropagation(); 9654 this.open_placeholder_menu(id, { mode: 'placeholder' }); 9655 }); 9656 }); 9657 9658 // Filled placeholders â small "Change" chip. 9659 let attached = root.querySelectorAll('[data-cs-asset-attached]'); 9660 attached.forEach((attached_el) => { 9661 if (attached_el.__cs_asset_attached_decorated) { 9662 return; 9663 } 9664 attached_el.__cs_asset_attached_decorated = true; 9665 let computed_position = window.getComputedStyle(attached_el).position; 9666 if (computed_position === 'static') { 9667 attached_el.style.position = 'relative'; 9668 } 9669 attached_el.classList.add('cs-asset-attached-host'); 9670 attached_el.style.cursor = 'pointer'; 9671 9672 let id = attached_el.getAttribute('data-cs-asset-attached') || ''; 9673 let change_button = document.createElement('button'); 9674 change_button.type = 'button'; 9675 change_button.className = 'cs-asset-change-button exclude-from-custom-button-styles'; 9676 change_button.setAttribute('data-cs-asset-attached-id', id); 9677 change_button.innerHTML = 9678 '<i class="fa-regular fa-image" style="margin-right:5px;"></i>' + 9679 '<span>Change</span>'; 9680 change_button.addEventListener('mousedown', (event) => { 9681 event.stopPropagation(); 9682 }); 9683 change_button.addEventListener('click', (event) => { 9684 event.preventDefault(); 9685 event.stopPropagation(); 9686 this.open_placeholder_menu(id, { mode: 'attached' }); 9687 }); 9688 attached_el.appendChild(change_button); 9689 9690 // Host-level click â clicking anywhere on the filled
9691 // image / link area opens the same change menu the 9692 // small "Change" hover chip uses. The chip's listener 9693 // stops propagation, so this only fires for clicks 9694 // outside it (e.g. directly on the image surface). 9695 attached_el.addEventListener('click', (event) => { 9696 if (event.target.closest('.cs-asset-change-button')) { 9697 return; 9698 } 9699 event.preventDefault(); 9700 event.stopPropagation(); 9701 this.open_placeholder_menu(id, { mode: 'attached' }); 9702 }); 9703 }); 9704 9705 // Bare `<img>` tags that weren't emitted as a 9706 // `data-cs-asset-*` slot â for example, every image the 9707 // model dropped in during a `capture_web_reference` build 9708 // (`<img src="https://oakmont.../">` directly). Without 9709 // this pass those imgs had no change/import affordance, 9710 // so the only way to swap one out was to hand-edit the 9711 // section HTML. 9712 // 9713 // Implementation: 9714 // - Wrap each bare img in a transient `<span 9715 // class="cs-bare-image-host">` host purely in the 9716 // LIVE preview DOM. The persisted custom_code never 9717 // sees this wrapper â it's recreated every time 9718 // `decorate_asset_placeholders` runs. 9719 // - Append a "Change" chip into the host. The chip 9720 // reuses `.cs-asset-change-button` so it matches 9721 // filled placeholder slots. 9722 // - On click, route to a bare-img menu that uses 9723 // `src`-string replacement against the persisted 9724 // html instead of the `data-cs-asset-attached` 9725 // marker lookup the placeholder pipeline depends 9726 // on. That lets us avoid touching the persisted 9727 // HTML until the admin actually picks a different 9728 // image â there's no surprise `unsaved_changes` 9729 // bump just from opening a reference-built section. 9730 let bare_imgs = Array.from(root.querySelectorAll('img')).filter((img) => { 9731 if (img.__cs_bare_image_decorated) { 9732 return false; 9733 } 9734 if (img.closest('[data-cs-asset-placeholder], [data-cs-asset-attached]')) { 9735 return false; 9736 } 9737 if (img.parentElement && img.parentElement.classList && img.parentElement.classList.contains('cs-bare-image-host')) { 9738 return false; 9739 } 9740 let src = img.getAttribute('src') || ''; 9741 return src.trim().length > 0; 9742 }); 9743 bare_imgs.forEach((img) => { 9744 img.__cs_bare_image_decorated = true; 9745 if (!img.parentNode) { 9746 return; 9747 } 9748 let host = document.createElement('span'); 9749 host.className = 'cs-bare-image-host cs-asset-attached-host'; 9750 host.setAttribute('aria-hidden', 'true'); 9751 // line-height: 0 kills the descender gap an inline 9752 // img would otherwise produce; max-width lets the 9753 // img stay responsive inside flex/grid layouts. 9754 host.style.cssText = 'position: relative; display: inline-block; line-height: 0; max-width: 100%;'; 9755 img.parentNode.insertBefore(host, img); 9756 host.appendChild(img); 9757 9758 let change_button = document.createElement('button'); 9759 change_button.type = 'button'; 9760 change_button.className = 'cs-asset-change-button cs-bare-image-change-button exclude-from-custom-button-styles'; 9761 change_button.innerHTML = 9762 '<i class="fa-regular fa-image" style="margin-right:5px;"></i>' + 9763 '<span>Change</span>'; 9764 change_button.addEventListener('mousedown', (event) => { 9765 event.stopPropagation(); 9766 }); 9767 change_button.addEventListener('click', (event) => { 9768 event.preventDefault(); 9769 event.stopPropagation(); 9770 this.open_bare_image_menu(img); 9771 }); 9772 host.appendChild(change_button); 9773 9774 // Host click â same menu, for clicks on the image 9775 // surface itself (chip stops propagation so this 9776 // only fires when the user clicks outside it). 9777 host.addEventListener('click', (event) => { 9778 if (event.target.closest('.cs-asset-change-button')) { 9779 return; 9780 } 9781 event.preventDefault(); 9782 event.stopPropagation(); 9783 this.open_bare_image_menu(img); 9784 }); 9785 }); 9786 }, 9787 escape_html_text(text) { 9788 if (typeof text !== 'string') { 9789 return ''; 9790 } 9791 let div = document.createElement('div'); 9792 div.appendChild(document.createTextNode(text)); 9793 return div.innerHTML; 9794 }, 9795 open_placeholder_menu(id, options_in) { 9796 let opts = options_in || {}; 9797 let mode = opts.mode || 'placeholder'; 9798 if (typeof handle_common !== 'function') { 9799 // Fallback: jump straight to the image picker. 9800 this.pick_existing_for_placeholder(id); 9801 return; 9802 } 9803 let target_el = this.find_asset_element_by_id(id); 9804 if (!target_el) { 9805 return; 9806 } 9807 let accepts = (target_el.getAttribute('data-cs-asset-accepts') || 'image').toLowerCase(); 9808 let label_attr = (target_el.getAttribute('data-cs-asset-label') || '').trim(); 9809 let label = label_attr || 'asset'; 9810 // Sniff the currently-attached URL so we can offer 9811 // "Copy image to club photos" only when it actually
9812 // points somewhere outside the Clubspot CDN. The same 9813 // helper handles <img src>, <a href>, and inline 9814 // background-image: url(...) wrappers. 9815 let attached_url = mode === 'attached' 9816 ? this.return_attached_url_for_wrapper(target_el) 9817 : null; 9818 let attached_is_external = accepts === 'image' && this.is_external_image_url(attached_url); 9819 let options = []; 9820 // Photos picker â same webbuilder asset picker, filtered to photos. 9821 if (accepts === 'image') { 9822 options.push({ 9823 text: mode === 'attached' ? 'Pick a different photo' : 'Upload or choose from club photos', 9824 subtext: 'Upload a new image or pick an existing one from this club', 9825 fn: () => this.pick_existing_for_placeholder(id), 9826 }); 9827 } 9828 // Documents/files picker â `webbuilder_asset_picker` 9829 // overlay, backed by `browse_webbuilder_assets`. Covers 9830 // PDFs and any public-access club document. Also offered 9831 // for image accepts because admins sometimes store hero 9832 // images in the documents folder. 9833 options.push({ 9834 text: mode === 'attached' 9835 ? 'Pick a different file from club documents' 9836 : (accepts === 'pdf' ? 'Choose from club documents' : 'Choose from club documents'), 9837 subtext: accepts === 'pdf' 9838 ? 'Public-access PDFs in the club documents folder' 9839 : 'Public-access files in the club documents folder', 9840 fn: () => this.pick_document_for_placeholder(id), 9841 }); 9842 options.push({ 9843 text: mode === 'attached' ? 'Paste a different URL' : 'Paste a URL', 9844 subtext: accepts === 'pdf' 9845 ? 'Paste a public PDF link (e.g. a public document on this club)' 9846 : 'Paste a public image URL (Clubspot CDN or external host)', 9847 fn: () => this.paste_url_for_placeholder(id), 9848 }); 9849 if (mode === 'attached' && attached_is_external) { 9850 // Only surface this when the slot is currently 9851 // pointing at a non-Clubspot host - importing a 9852 // CDN URL into itself is a no-op and the server 9853 // rejects it explicitly. Sits ABOVE "Remove" so 9854 // it lines up with the constructive actions 9855 // (pick, paste, copy) rather than the destructive 9856 // one. 9857 options.push({ 9858 text: 'Copy image to club photos', 9859 subtext: 'Save a local copy in your photo library and use that instead of the external URL.', 9860 fn: () => this.run_after_placeholder_menu_close(() => this.import_external_image_for_attached(id)), 9861 }); 9862 } 9863 if (mode === 'attached') { 9864 options.push({ 9865 text: 'Remove this image', 9866 subtext: 'Revert this slot back to its empty placeholder.', 9867 fn: () => this.run_after_placeholder_menu_close(() => this.revert_attached_to_placeholder(id)), 9868 }); 9869 } else { 9870 options.push({ 9871 text: 'Keep empty for now', 9872 subtext: 'Hide this prompt â the slot stays available with a "Change" button if you want to fill it later.', 9873 fn: () => this.run_after_placeholder_menu_close(() => this.remove_placeholder(id)), 9874 }); 9875 } 9876 let params = { 9877 title: mode === 'attached' ? 'Change "' + label + '"' : 'Fill "' + label + '"', 9878 subtitle: mode === 'attached' 9879 ? 'Pick a different photo, paste another URL, or remove the current asset.' 9880 : 'Pick how you want to attach this asset.', 9881 hide_on_selection: false, 9882 options: options, 9883 }; 9884 handle_common(params); 9885 }, 9886 run_after_placeholder_menu_close(fn) { 9887 store.overlays.handle_common = false;
9888 let run_next = () => { 9889 setTimeout(fn, 0); 9890 }; 9891 if (typeof this.$nextTick === 'function') { 9892 this.$nextTick(run_next); 9893 return; 9894 } 9895 run_next(); 9896 }, 9897 pick_existing_for_placeholder(id) { 9898 if (typeof webbuilder_asset_picker !== 'function') { 9899 if (typeof displayMessage === 'function') { 9900 displayMessage('Club photo picker is not loaded. Refresh the page and try again.', true, 5000); 9901 } 9902 return; 9903 } 9904 this.run_after_placeholder_menu_close(() => { 9905 let params = { 9906 title: 'Select club photo', 9907 accepts: 'image', 9908 source: 'photos', 9909 hide_on_selection: true, 9910 fn: (result) => { 9911 let url = result && result.url ? result.url : null; 9912 if (!url) { 9913 return; 9914 } 9915 let attach_params = { 9916 id: id, 9917 url: url, 9918 verified: true, 9919 source: 'club_photos', 9920 visibility: 'public_web', 9921 }; 9922 this.attach_url_to_placeholder(attach_params); 9923 }, 9924 }; 9925 webbuilder_asset_picker(params); 9926 }); 9927 }, 9928 // Opens the documents picker overlay. Same deferred-tick 9929 // dance as `pick_existing_for_placeholder` so the chain of 9930 // handle_common â documents picker doesn't race. 9931 pick_document_for_placeholder(id) { 9932 if (typeof webbuilder_asset_picker !== 'function') { 9933 if (typeof displayMessage === 'function') { 9934 displayMessage('Documents picker is not loaded. Refresh the page and try again.', true, 5000); 9935 } 9936 return; 9937 } 9938 let placeholder = this.find_asset_element_by_id(id); 9939 if (!placeholder) { 9940 return; 9941 } 9942 let accepts = (placeholder.getAttribute('data-cs-asset-accepts') || 'image').toLowerCase(); 9943 this.run_after_placeholder_menu_close(() => { 9944 let params = { 9945 title: accepts === 'pdf' ? 'Select a document' : 'Select a file', 9946 accepts: accepts, 9947 source: 'documents', 9948 hide_on_selection: true, 9949 fn: (result) => { 9950 let url = result && result.url ? result.url : null; 9951 if (!url) { 9952 return; 9953 } 9954 let attach_params = { 9955 id: id, 9956 url: url, 9957 verified: true, 9958 source: result.source || 'public_document', 9959 visibility: result.visibility || 'public_link', 9960 }; 9961 this.attach_url_to_placeholder(attach_params); 9962 }, 9963 }; 9964 webbuilder_asset_picker(params); 9965 }); 9966 }, 9967 paste_url_for_placeholder(id) { 9968 let placeholder = this.find_placeholder_element(id); 9969 if (!placeholder) { 9970 return; 9971 } 9972 let prompt_label = placeholder.getAttribute('data-cs-asset-label') || 'asset'; 9973 let do_attach = (attach) => { 9974 this.attach_url_to_placeholder({ 9975 id: id, 9976 url: attach.url, 9977 verified: attach.verified, 9978 source: attach.source, 9979 visibility: attach.visibility, 9980 }); 9981 }; 9982 // Defer one tick so handle_common can finish dismissing 9983 // before we open the URL-entry modal â otherwise the 9984 // menu overlay stays painted on top. 9985 this.run_after_placeholder_menu_close(() => { 9986 this.prompt_for_pasted_url({ 9987 slot_label: prompt_label, 9988 alt: prompt_label, 9989 do_attach: do_attach, 9990 }); 9991 }); 9992 }, 9993 // âââ Pasted-URL pipeline (shared) ââââââââââââââââââââââââââ 9994 // 9995 // Both the placeholder-marker flow and the bare-<img> flow 9996 // funnel pasted URLs through these three helpers: 9997 // 9998 // prompt_for_pasted_url 9999 // â opens a Clubspot universal_edit_overlay modal for 10000 // URL entry (replaces window.prompt, which blocks 10001 // the event loop, ignores app styling, and on iOS 10002 // leaves overlays painted on top of the OS dialog). 10003 // process_pasted_url 10004 // â verifies the URL server-side and dispatches to 10005 // do_attach() for trusted hosts, or to 10006 // confirm_external_url_action() for external ones. 10007 // confirm_external_url_action 10008 // â opens an are_you_sure modal that rolls what used 10009 // to be two separate browser confirms (the 10010 // "external URL â attach anyway?" confirm and the 10011 // follow-up "save to library?" prompt) into a single 10012 // decision: "Save to club photos" (primary) or 10013 // "Attach as external URL" (secondary). 10014 // 10015 // `do_attach(attach)` is the only caller-specific bit â 10016 // placeholder flow calls attach_url_to_placeholder, bare- 10017 // img flow calls swap_bare_image_src. Both receive the 10018 // same { url, verified, source, visibility } payload. 10019 prompt_for_pasted_url({ slot_label, current_url, alt, do_attach }) { 10020 let label = (typeof slot_label === 'string' && slot_label.trim()) ? slot_label.trim() : 'asset'; 10021 if (typeof universal_edit_overlay !== 'function') { 10022 // Fallback to the browser prompt if the overlay 10023 // isn't loaded for some reason â better than 10024 // silently dropping the action. 10025 let pasted = window.prompt('Paste a URL for "' + label + '":', current_url || ''); 10026 if (!pasted) { 10027 return; 10028 } 10029 this.process_pasted_url({ pasted: pasted, alt: alt, do_attach: do_attach }); 10030 return; 10031 } 10032 universal_edit_overlay({ 10033 title: 'Paste a URL', 10034 subtitle: { label: 'Paste a public URL for "' + label + '". Clubspot
10034CDN links attach directly; external URLs will prompt you to save a local copy.' }, 10035 button_label: 'Attach URL', 10036 hide_on_selection: true, 10037 fields: [{ 10038 key: 'url', 10039 label: 'URL', 10040 value: current_url || '', 10041 type: 'text', 10042 required: true, 10043 }], 10044 fn: (params) => { 10045 let field = (params && params.fields ? params.fields : []).find((f) => f.key === 'url'); 10046 let value = field && typeof field.value === 'string' ? field.value.trim() : ''; 10047 if (!value) { 10048 return; 10049 } 10050 this.process_pasted_url({ pasted: value, alt: alt, do_attach: do_attach }); 10051 }, 10052 }); 10053 }, 10054 process_pasted_url({ pasted, alt, do_attach }) { 10055 let trimmed = (typeof pasted === 'string') ? pasted.trim() : ''; 10056 if (!trimmed) { 10057 return; 10058 } 10059 let club_id = this.return_active_club_id(); 10060 if (!club_id || typeof Parse === 'undefined') {
10061 // Without a club_id we can't run the server-side 10062 // verifier â fall back to attaching the URL with a 10063 // warning so the admin isn't stuck. 10064 if (typeof displayMessage === 'function') { 10065 displayMessage('Could not verify the URL; attaching as-is. Review before publishing.', true, 5000); 10066 } 10067 do_attach({ url: trimmed, verified: false, source: 'pasted_url', visibility: 'unknown' }); 10068 return; 10069 } 10070 Parse.Cloud.run('verify_webbuilder_asset_url', { 10071 club_id: club_id, 10072 url: trimmed, 10073 }, this.parse_session_options()).then((verdict) => { 10074 if (verdict && verdict.allowed) { 10075 do_attach({ 10076 url: verdict.embed_url || trimmed, 10077 verified: true, 10078 source: verdict.source || 'verified_url', 10079 visibility: verdict.visibility || 'public_web', 10080 }); 10081 return; 10082 } 10083 let reason = verdict && verdict.reason ? verdict.reason : 'unverified'; 10084 // External URLs come back as `external_url_not_allowed` 10085 // â surface as a Clubspot modal so the admin can 10086 // choose between importing to the library or 10087 // attaching as-is. The custom-code sanitizer still 10088 // strips dangerous schemes at render time. 10089 if (reason === 'external_url_not_allowed') { 10090 this.confirm_external_url_action({ 10091 url: trimmed, 10092 alt: alt, 10093 on_import: () => { 10094 return this.import_external_image_url(trimmed, alt).then((cdn_url) => { 10095 do_attach({ 10096 url: cdn_url, 10097 verified: true, 10098 source: 'imported_external_url', 10099 visibility: 'public_web', 10100 }); 10101 }); 10102 }, 10103 on_attach_external: () => { 10104 do_attach({ url: trimmed, verified: false, source: 'external', visibility: 'unknown' }); 10105 }, 10106 }); 10107 return; 10108 } 10109 if (typeof displayMessage === 'function') { 10110 displayMessage('URL rejected: ' + reason.replace(/_/g, ' '), true, 5000); 10111 } 10112 }).catch((error) => { 10113 // The verifier cloud function ships in the same 10114 // deploy as this UI, but local devs sometimes don't 10115 // restart their primary server between code pulls. 10116 // Treat "Invalid function" as a soft fallback: attach 10117 // the URL with a warning so the admin isn't stuck, 10118 // and surface the underlying error in the console. 10119 let msg = error && error.message ? error.message : ''; 10120 if (msg.indexOf('Invalid function') !== -1) { 10121 console.warn('[custom-code] verify_webbuilder_asset_url not deployed â attaching URL without verification.'); 10122 if (typeof displayMessage === 'function') { 10123 displayMessage('Attached without verification (server hasn\'t reloaded the new function yet).', true, 5000); 10124 } 10125 do_attach({ url: trimmed, verified: false, source: 'pasted_url', visibility: 'unknown' }); 10126 return; 10127 } 10128 if (typeof displayMessage === 'function') { 10129 displayMessage(msg || 'Could not verify URL.', true, 5000); 10130 } 10131 }); 10132 }, 10133 confirm_external_url_action({ url, alt, on_import, on_attach_external }) { 10134 if (typeof are_you_sure !== 'function') { 10135 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)) { 10136 Promise.resolve().then(() => on_import()).catch(() => {}); 10137 } else { 10138 on_attach_external(); 10139 } 10140 return; 10141 } 10142 let host = ''; 10143 try { 10144 host = new URL(url).hostname || ''; 10145 } catch (err) { 10146 host = ''; 10147 } 10148 let truncated = url.length > 90 ? (url.slice(0, 87) + '...') : url; 10149 let escaped = (typeof url === 'string') ? url.replace(/"/g, '"') : ''; 10150 are_you_sure({ 10151 title: 'External image URL', 10152 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.', 10153 callout: { html: '<a href="' + escaped + '" target="_blank" rel="noopener" style="word-break:break-all;">' + truncated + '</a>' }, 10154 button_label: 'Save to club photos', 10155 warning_button: false, 10156 no_thanks_label: 'Attach as external URL', 10157 fn: () => { 10158 store.components.are_you_sure.loading = true; 10159 Promise.resolve().then(() => on_import()).then(() => { 10160 store.components.are_you_sure.loading = false;
10161 store.overlays.are_you_sure = false; 10162 if (typeof displayMessage === 'function') { 10163 displayMessage('Image saved to your photo library.', false, 4000); 10164 } 10165 }).catch((error) => { 10166 store.components.are_you_sure.loading = false; 10167 if (typeof checkParseError === 'function') { 10168 checkParseError(error); 10169 } 10170 let msg = error && error.message ? error.message : 'Image import failed.'; 10171 if (typeof displayMessage === 'function') { 10172 displayMessage(msg, true, 6000); 10173 } 10174 }); 10175 }, 10176 secondary_fn: () => { 10177 store.overlays.are_you_sure = false; 10178 on_attach_external(); 10179 }, 10180 }); 10181 }, 10182 // Run the `import_external_image_to_club_photos` Cloud 10183 // function and resolve with the canonical CDN URL. 10184 // Centralizes the embed_url fallback + "no URL returned" 10185 // diagnostics that used to be duplicated across both 10186 // `import_external_image_for_attached` and 10187 // `import_external_image_for_bare`. 10188 import_external_image_url(url, alt) { 10189 let club_id = this.return_active_club_id(); 10190 if (!club_id || typeof Parse === 'undefined') { 10191 return Promise.reject(new Error('Could not determine the active club. Refresh and try again.')); 10192 } 10193 return Parse.Cloud.run('import_external_image_to_club_photos', { 10194 club_id: club_id, 10195 url: url, 10196 alt: alt || '', 10197 }, this.parse_session_options()).then((result) => { 10198 let returned_cdn = result && result.cdn_url ? result.cdn_url : null; 10199 let embed_fallback = result && result.result && result.result.embed_url ? result.result.embed_url : null; 10200 let cdn_url = returned_cdn || embed_fallback; 10201 if (!cdn_url) { 10202 let detail = result ? (' (server returned: ' + JSON.stringify(result).slice(0, 160) + ')') : ''; 10203 throw new Error('Image import finished but no URL was returned.' + detail); 10204 } 10205 return cdn_url; 10206 }); 10207 }, 10208 // âââ Section-level "save external images" batch âââââââââââ 10209 // 10210 // Pure CLIENT operation, mirroring upload_webbuilder_asset_photo: 10211 // discover every external image URL in the section, loop the 10212 // existing import_external_image_to_club_photos cloud function 10213 // per URL (with the admin's real session token / permissions), 10214 // then rewrite the persisted HTML to point at the new CDN 10215 // URLs. No AI service involvement. 10216 // 10217 // Parses the persisted source (not the live preview DOM) so 10218 // it sees the canonical URLs and isn't confused by transient 10219 // decoration wrappers. Returns a deduped array of external 10220 // http(s) image URLs found in <img src> and inline 10221 // `background-image: url(...)`. 10222 collect_section_external_image_urls(source_html) { 10223 return webbuilder_collect_external_image_urls(source_html); 10224 }, 10225 import_all_external_images_in_section() { 10226 if (this.importing_external_images) { 10227 return; 10228 } 10229 let urls = this.section_external_image_urls; 10230 if (!urls.length) { 10231 if (typeof displayMessage === 'function') { 10232 displayMessage('No external images to save in this section.', false, 4000); 10233 } 10234 return; 10235 } 10236 let club_id = this.return_active_club_id(); 10237 if (!club_id || typeof Parse === 'undefined') { 10238 if (typeof displayMessage === 'function') { 10239 displayMessage('Could not determine the active club. Refresh and try again.', true, 5000); 10240 } 10241 return; 10242 } 10243 let count = urls.length; 10244 let run_batch = () => { 10245 this.importing_external_images = true; 10246 if (typeof displayMessage === 'function') { 10247 displayMessage('Saving ' + count + ' image' + (count === 1 ? '' : 's') + ' to your photo library...', false, 4000); 10248 } 10249 this.run_external_image_batch(urls).then((summary) => { 10250 this.importing_external_images = false;
10251 this.report_external_image_batch_result(summary); 10252 }).catch((error) => { 10253 this.importing_external_images = false; 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 if (typeof are_you_sure === 'function') { 10264 are_you_sure({ 10265 title: 'Save external images', 10266 subtext: 'This section uses ' + count + ' image' + (count === 1 ? '' : 's') + ' hosted on other sites. Save ' 10267 + (count === 1 ? 'a copy' : 'copies') + ' to your club photo library and swap the section to use ' 10268 + (count === 1 ? 'it' : 'them') + '? This keeps the images available even if the source site changes.', 10269 button_label: 'Save to club photos', 10270 warning_button: false, 10271 fn: () => { 10272 store.overlays.are_you_sure = false; 10273 run_batch(); 10274 }, 10275 }); 10276 return; 10277 } 10278 run_batch(); 10279 }, 10280 // Import each external URL sequentially (bounded to one 10281 // in-flight request â these are byte-level server fetches, 10282 // and a club reference page rarely has more than a handful 10283 // of images). Builds a url->cdn_url map, then rewrites the 10284 // persisted HTML once at the end so the editor only takes a 10285 // single save + re-render. 10286 run_external_image_batch(urls) { 10287 let url_map = {}; 10288 let errors = []; 10289 let chain = Promise.resolve(); 10290 urls.forEach((url) => { 10291 chain = chain.then(() => { 10292 return this.import_external_image_url(url, '').then((cdn_url) => { 10293 url_map[url] = cdn_url; 10294 }).catch((error) => { 10295 errors.push({ url: url, message: error && error.message ? error.message : 'import failed' }); 10296 }); 10297 }); 10298 }); 10299 return chain.then(() => { 10300 let swapped = this.rewrite_section_external_image_urls(url_map); 10301 return { 10302 requested: urls.length, 10303 imported: Object.keys(url_map).length, 10304 errors: errors, 10305 swapped: swapped, 10306 }; 10307 }); 10308 }, 10309 // Replace every occurrence of each imported external URL 10310 // with its new CDN URL across the persisted HTML â both 10311 // <img src> and inline background-image declarations â and 10312 // persist once. Returns the number of URLs actually swapped. 10313 rewrite_section_external_image_urls(url_map) { 10314 let keys = Object.keys(url_map || {}); 10315 if (!keys.length) { 10316 return 0; 10317 } 10318 let swapped = 0; 10319 let mutated = this.mutate_persisted_html((doc) => { 10320 let changed = false; 10321 let imgs = Array.from(doc.querySelectorAll('img[src]')); 10322 for (let img of imgs) { 10323 let src = img.getAttribute('src'); 10324 if (src && url_map[src]) { 10325 img.setAttribute('src', url_map[src]); 10326 changed = true; 10327 } 10328 } 10329 let styled = Array.from(doc.querySelectorAll('[style*="background-image"]')); 10330 for (let el of styled) { 10331 let style = el.getAttribute('style') || ''; 10332 let next_style = style; 10333 keys.forEach((old_url) => { 10334 if (next_style.indexOf(old_url) !== -1) { 10335 next_style = next_style.split(old_url).join(url_map[old_url]); 10336 } 10337 }); 10338 if (next_style !== style) { 10339 el.setAttribute('style', next_style); 10340 changed = true; 10341 } 10342 } 10343 return changed; 10344 }); 10345 if (mutated) { 10346 this.persist_mutated_code(mutated); 10347 swapped = keys.length; 10348 } 10349 return swapped; 10350 }, 10351 report_external_image_batch_result(summary) { 10352 if (typeof displayMessage !== 'function') { 10353 return; 10354 } 10355 let safe = summary || {}; 10356 let imported = safe.imported || 0; 10357 let errors = Array.isArray(safe.errors) ? safe.errors : []; 10358 if (imported > 0 && errors.length === 0) { 10359 displayMessage('Saved ' + imported + ' image' + (imported === 1 ? '' : 's') + ' to your photo library.', false, 4000); 10360 return; 10361 } 10362 if (imported > 0 && errors.length > 0) { 10363 displayMessage('Saved ' + imported + ', but ' + errors.length + ' failed: ' + (errors[0].message || 'unknown error') + '.', true, 6000); 10364 return; 10365 } 10366 displayMessage('Could not save the external images: ' + (errors.length ? (errors[0].message || 'unknown error') : 'nothing imported') + '.', true, 6000); 10367 }, 10368 return_active_club_id() { 10369 if (typeof store !== 'undefined' && store.clubObject && store.clubObject.id) { 10370 return store.clubObject.id; 10371 } 10372 return null; 10373 }, 10374 parse_session_options() { 10375 if (typeof Parse === 'undefined' || !Parse.User || !Parse.User.current()) { 10376 return {}; 10377 } 10378 let token = Parse.User.current().getSessionToken ? Parse.User.current().getSessionToken() : null; 10379 if (!token) { 10380 return {}; 10381 } 10382 return { sessionToken: token }; 10383 },
10384 find_placeholder_element(id) { 10385 return this.find_asset_element_by_id(id); 10386 }, 10387 // Match either `data-cs-asset-placeholder="<id>"` (unfilled) 10388 // or `data-cs-asset-attached="<id>"` (already filled) so the 10389 // action menu can re-open against the same slot regardless 10390 // of state. 10391 find_asset_element_by_id(id) { 10392 let root = this.custom_code_render_root(); 10393 if (!root || !id) { 10394 return null; 10395 } 10396 let escaped = (typeof CSS !== 'undefined' && CSS.escape) ? CSS.escape(id) : id.replace(/"/g, '\\"'); 10397 let selector = 10398 '[data-cs-asset-placeholder="' + escaped + '"], ' + 10399 '[data-cs-asset-attached="' + escaped + '"]'; 10400 return root.querySelector(selector); 10401 }, 10402 // Hostnames that mean "the file is already in the Clubspot 10403 // library." Mirrors the server-side PUBLIC_ASSET_HOSTS set 10404 // in primary/cloud/webbuilder-assets.js. Drift in this list 10405 // is non-fatal: at worst we offer the user "Copy to club 10406 // photos" on a URL that turns out to be local, and the 10407 // server rejects the import with a clear error. The set 10408 // here matters only for the UX surface (showing/hiding the 10409 // menu option). 10410 return_clubspot_asset_hosts() { 10411 return new Set(WEBBUILDER_CLUBSPOT_ASSET_HOSTS); 10412 }, 10413 is_external_image_url(url) { 10414 return webbuilder_is_external_image_url(url); 10415 }, 10416 // Pull the currently-attached URL out of an asset wrapper. 10417 // Handles the three shapes attach_url_to_placeholder writes: 10418 // - <a data-cs-asset-attached href="..."> for documents. 10419 // - <img data-cs-asset-attached src="..."> (rare; the 10420 // wrapper itself is the <img>). 10421 // - <div data-cs-asset-attached style="background-image: 10422 // url(...);"> for the background-image target. 10423 // - A wrapper <div data-cs-asset-attached> whose first 10424 // <img> child carries the real src. 10425 // 10426 // Returns the raw URL string or null when nothing readable 10427 // is attached. 10428 return_attached_url_for_wrapper(wrapper) { 10429 if (!wrapper || wrapper.nodeType !== 1) { 10430 return null; 10431 } 10432 let tag_name = (wrapper.tagName || '').toLowerCase(); 10433 let target = (wrapper.getAttribute('data-cs-asset-target') || '').toLowerCase(); 10434 if (target === 'background-image') { 10435 let style = wrapper.getAttribute('style') || ''; 10436 let match = style.match(/background-image\s*:\s*url\(\s*(['"]?)([^'"\)]+)\1\s*\)/i); 10437 return match ? match[2] : null; 10438 } 10439 if (tag_name === 'a' || target === 'href') { 10440 return wrapper.getAttribute('href') || null; 10441 } 10442 if (tag_name === 'img') { 10443 return wrapper.getAttribute('src') || null; 10444 } 10445 let child_img = wrapper.querySelector('img[src]'); 10446 return child_img ? child_img.getAttribute('src') : null; 10447 }, 10448 // Single-image "Copy image to club photos" flow for an 10449 // already-attached external image. Reads the wrapper's 10450 // current URL, runs the import via the shared 10451 // `import_external_image_url` helper, and swaps the 10452 // returned CDN URL into the same wrapper. The wrapper 10453 // stays put across the swap, so layout doesn't reflow. 10454 import_external_image_for_attached(id) { 10455 let wrapper = this.find_asset_element_by_id(id); 10456 if (!wrapper) { 10457 return; 10458 } 10459 let original_url = this.return_attached_url_for_wrapper(wrapper); 10460 if (!this.is_external_image_url(original_url)) { 10461 if (typeof displayMessage === 'function') { 10462 displayMessage('This image is already in your library.', false, 4000); 10463 } 10464 return; 10465 } 10466 let alt = wrapper.getAttribute('data-cs-asset-label') 10467 || wrapper.getAttribute('alt') 10468 || ''; 10469 if (typeof displayMessage === 'function') { 10470 displayMessage('Importing image to your photo library...', false, 4000); 10471 } 10472 this.import_external_image_url(original_url, alt).then((cdn_url) => { 10473 this.attach_url_to_placeholder({ 10474 id: id, 10475 url: cdn_url, 10476 verified: true, 10477 source: 'imported_external_url', 10478 visibility: 'public_web', 10479 }); 10480 if (typeof displayMessage === 'function') { 10481 displayMessage('Image saved to your photo library.', false, 4000); 10482 } 10483 }).catch((error) => { 10484 if (typeof checkParseError === 'function') { 10485 checkParseError(error); 10486 } 10487 let msg = error && error.message ? error.message : 'Image import failed.'; 10488 if (typeof displayMessage === 'function') { 10489 displayMessage(msg, true, 6000); 10490 } 10491 }); 10492 }, 10493 10494 // âââ Bare <img> change flow âââââââââââââââââââââââââââââââ 10495 // 10496 // Parallel to the placeholder-marker flow above, but keyed 10497 // on the image's current `src` instead of a synthetic id. 10498 // Used by the third pass of `decorate_asset_placeholders` 10499 // that catches bare `<img>` tags from URL-reference builds. 10500 // 10501 // The same handle_common menu shape powers both â the 10502 // bare-img menu just routes its callbacks through src-based 10503 // mutation so the persisted HTML stays bare (no surprise 10504 // wrappers, no surprise unsaved_changes) until the admin 10505 // actually swaps the image. 10506 10507 open_bare_image_menu(img_el) { 10508 if (typeof handle_common !== 'function') { 10509 return; 10510 } 10511 if (!img_el || typeof img_el.getAttribute !== 'function') { 10512 return; 10513 } 10514 let current_src = img_el.getAttribute('src') || ''; 10515 let current_alt = img_el.getAttribute('alt') || ''; 10516 if (!current_src.trim()) { 10517 return; 10518 } 10519 let is_external = this.is_external_image_url(current_src); 10520 let options = []; 10521 options.push({ 10522 text: 'Pick a different photo', 10523 subtext: 'Upload a new image or pick an existing one from this club', 10524 fn: () => this.run_after_placeholder_menu_close(() => this.pick_existing_for_bare_image(img_el)), 10525 }); 10526 options.push({ 10527 text: 'Choose from club documents', 10528 subtext: 'Public-access files in the club documents folder', 10529 fn: () => this.run_after_placeholder_menu_close(() => this.pick_document_for_bare_image(img_el)), 10530 }); 10531 options.push({ 10532 text: 'Paste a different URL', 10533 subtext: 'Paste a public image URL (Clubspot
10533CDN or external host)', 10534 fn: () => this.run_after_placeholder_menu_close(() => this.paste_url_for_bare_image(img_el)), 10535 }); 10536 if (is_external) { 10537 options.push({ 10538 text: 'Copy image to club photos', 10539 subtext: 'Save a local copy in your photo library and use that instead of the external URL.', 10540 fn: () => this.run_after_placeholder_menu_close(() => this.import_external_image_for_bare(img_el)), 10541 }); 10542 } 10543 let label = (current_alt || '').trim() || 'image'; 10544 handle_common({ 10545 title: 'Change "' + label + '"', 10546 subtitle: is_external 10547 ? 'Pick a different photo, paste another URL, or save this external image into your library.' 10548 : 'Pick a different photo or paste another URL.', 10549 hide_on_selection: false, 10550 options: options, 10551 }); 10552 }, 10553 // Persist a swap from one img src to another. Matches by 10554 // exact `src` attribute value, which covers the model's 10555 // output and any direct admin edits (the codegen pipeline 10556 // doesn't normalize URL strings between turns). Also lifts 10557 // a missing `alt` from the new label so SEO/accessibility 10558 // copy survives the swap. 10559 swap_bare_image_src(old_src, new_url, new_alt) { 10560 if (typeof old_src !== 'string' || !old_src.trim()) { 10561 return false; 10562 } 10563 if (typeof new_url !== 'string' || !new_url.trim()) { 10564 return false; 10565 } 10566 let mutated = this.mutate_persisted_html((doc) => { 10567 let imgs = Array.from(doc.querySelectorAll('img[src]')); 10568 let changed = false; 10569 for (let img of imgs) { 10570 if (img.getAttribute('src') !== old_src) { 10571 continue; 10572 } 10573 img.setAttribute('src', new_url); 10574 if (new_alt && !(img.getAttribute('alt') || '').trim()) { 10575 img.setAttribute('alt', new_alt); 10576 } 10577 changed = true; 10578 } 10579 return changed; 10580 }); 10581 if (mutated) { 10582 this.persist_mutated_code(mutated); 10583 return true; 10584 } 10585 return false; 10586 }, 10587 pick_existing_for_bare_image(img_el) { 10588 if (typeof webbuilder_asset_picker !== 'function') { 10589 if (typeof displayMessage === 'function') { 10590 displayMessage('Club photo picker is not loaded. Refresh the page and try again.', true, 5000); 10591 } 10592 return; 10593 } 10594 let old_src = img_el.getAttribute('src') || ''; 10595 if (!old_src) { 10596 return; 10597 } 10598 webbuilder_asset_picker({ 10599 title: 'Select club photo', 10600 accepts: 'image', 10601 source: 'photos', 10602 hide_on_selection: true, 10603 fn: (result) => { 10604 let url = result && result.url ? result.url : null; 10605 if (!url) { 10606 return; 10607 } 10608 this.swap_bare_image_src(old_src, url, result.label || ''); 10609 }, 10610 }); 10611 }, 10612 pick_document_for_bare_image(img_el) { 10613 if (typeof webbuilder_asset_picker !== 'function') { 10614 if (typeof displayMessage === 'function') { 10615 displayMessage('Documents picker is not loaded. Refresh the page and try again.', true, 5000); 10616 } 10617 return; 10618 } 10619 let old_src = img_el.getAttribute('src') || ''; 10620 if (!old_src) { 10621 return; 10622 } 10623 webbuilder_asset_picker({ 10624 title: 'Select a file', 10625 accepts: 'image', 10626 source: 'documents', 10627 hide_on_selection: true, 10628 fn: (result) => { 10629 let url = result && result.url ? result.url : null; 10630 if (!url) { 10631 return; 10632 } 10633 this.swap_bare_image_src(old_src, url, result.label || ''); 10634 }, 10635 }); 10636 }, 10637 paste_url_for_bare_image(img_el) { 10638 let old_src = img_el.getAttribute('src') || ''; 10639 let label = img_el.getAttribute('alt') || 'image'; 10640 if (!old_src) { 10641 return; 10642 } 10643 let do_attach = (attach) => { 10644 this.swap_bare_image_src(old_src, attach.url, label); 10645 }; 10646 this.prompt_for_pasted_url({ 10647 slot_label: label, 10648 alt: label, 10649 do_attach: do_attach, 10650 }); 10651 }, 10652 // Single-image import path for bare imgs. Reads the img's 10653 // current src, runs the same Cloud function the attached- 10654 // wrapper menu uses, and swaps the src for the CDN URL on 10655 // success. The img stays exactly where it is â no wrapper 10656 // upgrade, no layout change. 10657 import_external_image_for_bare(img_el) { 10658 let original_url = img_el.getAttribute('src') || ''; 10659 let alt = img_el.getAttribute('alt') || ''; 10660 if (!this.is_external_image_url(original_url)) { 10661 if (typeof displayMessage === 'function') { 10662 displayMessage('This image is already in your library.', false, 4000); 10663 } 10664 return; 10665 } 10666 if (typeof displayMessage === 'function') { 10667 displayMessage('Importing image to your photo library...', false, 4000); 10668 } 10669 this.import_external_image_url(original_url, alt).then((cdn_url) => { 10670 this.swap_bare_image_src(original_url, cdn_url, alt); 10671 if (typeof displayMessage === 'function') { 10672 displayMessage('Image saved to your photo library.', false, 4000); 10673 } 10674 }).catch((error) => { 10675 if (typeof checkParseError === 'function') { 10676 checkParseError(error); 10677 } 10678 let msg = error && error.message ? error.message : 'Image import failed.'; 10679 if (typeof displayMessage === 'function') { 10680 displayMessage(msg, true, 6000); 10681 } 10682 }); 10683 }, 10684 10685 // Rewrite the persisted custom-code string so the matching 10686 // placeholder element gains a real `src`/`href`/`background- 10687 // image`. We keep the wrapper element across the transition 10688 // â it carries the section's layout box (dimensions, border- 10689 // radius, positioning, etc) â and just rotate its children 10690 // for `target="src"`. The wrapper's `data-cs-asset- 10691 // placeholder` attribute is renamed to `data-cs-asset- 10692 // attached` so: 10693 // 10694 // 1. Subsequent decorate passes show the small "Change" 10695 // overlay instead of the big "Add" overlay. 10696 // 2. The AI model can tell on follow-up turns that the 10697 // slot is filled (and must not be re-placeholderized). 10698 // 10699 // We also accept being called against an already-attached 10700 // element â that's the "Change" flow swapping one URL for 10701 // another. 10702 attach_url_to_placeholder(params) { 10703 let { id, url } = params; 10704 if (!id || typeof url !== 'string' || !url.trim()) { 10705 return; 10706 } 10707 let mutated = this.mutate_persisted_html((doc) => { 10708 let escaped = (typeof CSS !== 'undefined' && CSS.escape) ? CSS.escape(id) : id.replace(/"/g, '\\"'); 10709 let wrapper = doc.querySelector( 10710 '[data-cs-asset-placeholder="' + escaped + '"], ' + 10711 '[data-cs-asset-attached="' + escaped + '"]' 10712 ); 10713 if (!wrapper) { 10714 return false;
10715 } 10716 let tag_name = (wrapper.tagName || '').toLowerCase(); 10717 let target = (wrapper.getAttribute('data-cs-asset-target') || '').toLowerCase(); 10718 if (!target) { 10719 if (tag_name === 'a') { 10720 target = 'href'; 10721 } else if (tag_name === 'img') { 10722 target = 'src'; 10723 } else { 10724 target = 'src'; 10725 } 10726 } 10727 let alt = wrapper.getAttribute('data-cs-asset-label') || wrapper.getAttribute('alt') || ''; 10728 10729 if (target === 'background-image') { 10730 let existing_style = wrapper.getAttribute('style') || ''; 10731 // Strip any existing background-image declaration 10732 // so the new url() wins without doubling up. Also 10733 // wipe any inner content the placeholder used as 10734 // a fallback label â once we've got a real image, 10735 // the gradient/icon "Add a photo" copy would just 10736 // clutter the live site. 10737 let stripped = existing_style.replace(/background-image\s*:[^;]*;?/gi, '').trim(); 10738 let suffix = stripped && !/;\s*$/.test(stripped) ? '; ' : (stripped ? ' ' : ''); 10739 let bg_value = 'background-image: url("' + this.escape_css_url_segment(url) + '"); background-size: cover; background-position: center;'; 10740 wrapper.setAttribute('style', (stripped + suffix + bg_value).trim()); 10741 } else if (target === 'href' || tag_name === 'a') { 10742 wrapper.setAttribute('href', url); 10743 wrapper.setAttribute('target', '_blank'); 10744 wrapper.setAttribute('rel', 'noopener noreferrer'); 10745 } else if (tag_name === 'img') { 10746 wrapper.setAttribute('src', url); 10747 if (alt && !wrapper.getAttribute('alt')) { 10748 wrapper.setAttribute('alt', alt); 10749 } 10750 } else { 10751 // `target=src` on a wrapper element: drop any 10752 // fallback children (icon, text label) and insert 10753 // an <img> sized to fill the wrapper. The wrapper 10754 // continues to carry the layout box, so the live 10755 // site never reflows between empty/filled states. 10756 this.empty_placeholder_inner(wrapper); 10757 let owner = wrapper.ownerDocument || document; 10758 let img = owner.createElement('img'); 10759 img.setAttribute('src', url); 10760 if (alt) { 10761 img.setAttribute('alt', alt); 10762 } 10763 img.setAttribute('style', 'width:100%;height:100%;object-fit:cover;display:block;border-radius:inherit;'); 10764 wrapper.appendChild(img); 10765 } 10766 // Rename the placeholder marker â attached marker 10767 // (same id). Preserve everything else so the model 10768 // still has the role/label/target context next turn. 10769 let placeholder_id = wrapper.getAttribute('data-cs-asset-placeholder'); 10770 if (placeholder_id !== null) { 10771 wrapper.removeAttribute('data-cs-asset-placeholder'); 10772 wrapper.setAttribute('data-cs-asset-attached', placeholder_id); 10773 } 10774 return true; 10775 }); 10776 if (mutated) { 10777 this.persist_mutated_code(mutated); 10778 } 10779 }, 10780 // Drop any inert fallback content the placeholder shipped 10781 // with (e.g. "Add a hero photo" label, decorative icon spans). 10782 // Leaves the wrapper element itself untouched. 10783 empty_placeholder_inner(wrapper) { 10784 if (!wrapper) { 10785 return; 10786 } 10787 while (wrapper.firstChild) { 10788 wrapper.removeChild(wrapper.firstChild); 10789 } 10790 }, 10791 // "Keep empty for now" â dismiss the prominent "Add image" 10792 // overlay but keep the wrapper marked so the admin can 10793 // still attach an asset later via the small Change button. 10794 // We rename `data-cs-asset-placeholder` â `data-cs-asset- 10795 // attached` (same id, no URL set) which: 10796 // - swaps the decorator from big overlay â Change chip, 10797 // - tells the AI to preserve the slot as-is on follow-up 10798 // rewrites (don't re-placeholderize).
10799 // Permanent deletion is a code-editor operation. 10800 remove_placeholder(id) { 10801 if (!id) { 10802 return; 10803 } 10804 let mutated = this.mutate_persisted_html((doc) => { 10805 let escaped = (typeof CSS !== 'undefined' && CSS.escape) ? CSS.escape(id) : id.replace(/"/g, '\\"'); 10806 let placeholder = doc.querySelector('[data-cs-asset-placeholder="' + escaped + '"]'); 10807 if (!placeholder) { 10808 return false; 10809 } 10810 placeholder.removeAttribute('data-cs-asset-placeholder'); 10811 placeholder.setAttribute('data-cs-asset-attached', id); 10812 return true; 10813 }); 10814 if (mutated) { 10815 this.persist_mutated_code(mutated); 10816 } 10817 }, 10818 // "Remove this image" on an attached slot â wipe the 10819 // filled URL/contents and put the slot back into placeholder 10820 // state so the admin can re-fill from scratch. For `src` 10821 // wrappers we also need to drop the inner <img>. 10822 revert_attached_to_placeholder(id) { 10823 if (!id) { 10824 return; 10825 } 10826 let mutated = this.mutate_persisted_html((doc) => { 10827 let escaped = (typeof CSS !== 'undefined' && CSS.escape) ? CSS.escape(id) : id.replace(/"/g, '\\"'); 10828 let wrapper = doc.querySelector('[data-cs-asset-attached="' + escaped + '"]'); 10829 if (!wrapper) { 10830 return false; 10831 } 10832 let tag_name = (wrapper.tagName || '').toLowerCase(); 10833 let target = (wrapper.getAttribute('data-cs-asset-target') || '').toLowerCase(); 10834 if (!target) { 10835 if (tag_name === 'a') { 10836 target = 'href'; 10837 } else if (tag_name === 'img') { 10838 target = 'src'; 10839 } else { 10840 target = 'src'; 10841 } 10842 } 10843 if (target === 'background-image') { 10844 let style = wrapper.getAttribute('style') || ''; 10845 let stripped = style.replace(/background-image\s*:[^;]*;?/gi, '').trim(); 10846 if (stripped) { 10847 wrapper.setAttribute('style', stripped); 10848 } else { 10849 wrapper.removeAttribute('style'); 10850 } 10851 } else if (target === 'href' || tag_name === 'a') { 10852 wrapper.removeAttribute('href'); 10853 wrapper.removeAttribute('target'); 10854 wrapper.removeAttribute('rel'); 10855 } else { 10856 this.empty_placeholder_inner(wrapper); 10857 } 10858 let attached_id = wrapper.getAttribute('data-cs-asset-attached'); 10859 if (attached_id !== null) { 10860 wrapper.removeAttribute('data-cs-asset-attached'); 10861 wrapper.setAttribute('data-cs-asset-placeholder', attached_id); 10862 } 10863 return true; 10864 }); 10865 if (mutated) { 10866 this.persist_mutated_code(mutated); 10867 } 10868 }, 10869 strip_all_asset_attributes(element) { 10870 if (!element) { 10871 return; 10872 } 10873 let attrs_to_remove = []; 10874 Array.prototype.slice.call(element.attributes || []).forEach((attr) => { 10875 let name = String(attr.name || '').toLowerCase(); 10876 if (name.indexOf('data-cs-asset-') === 0) { 10877 attrs_to_remove.push(attr.name); 10878 } 10879 }); 10880 attrs_to_remove.forEach((name) => element.removeAttribute(name)); 10881 }, 10882 escape_css_url_segment(url) { 10883 if (typeof url !== 'string') { 10884 return ''; 10885 } 10886 return url.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); 10887 }, 10888 // Parse the persisted custom-code string into a scratch 10889 // <template>, hand it to `mutator` for in-place changes, 10890 // and serialize back. Returns the new HTML string or null 10891 // if the mutator reported no change. 10892 mutate_persisted_html(mutator) { 10893 let source = typeof this.custom_code_value === 'string' ? this.custom_code_value : ''; 10894 let template = document.createElement('template'); 10895 template.innerHTML = source; 10896 let changed = mutator(template.content); 10897 if (!changed) { 10898 return null; 10899 } 10900 return template.innerHTML; 10901 }, 10902 persist_mutated_code(new_html) { 10903 if (typeof new_html !== 'string') { 10904 return; 10905 } 10906 this.custom_code_value = new_html; 10907 if (this.cm_instance) { 10908 this.cm_instance.setValue(new_html); 10909 } 10910 if (this.component && typeof save_changes_for_component === 'function') { 10911 save_changes_for_component(this.component, 'custom_code', new_html); 10912 } 10913 this.mark_ai_code_revision_changed(); 10914 this.$nextTick(() => this.initialize_custom_code_interactions()); 10915 }, 10916 10917 // âââ Editable text leaves âââââââââââââââââââââââââââââââââ 10918 // 10919 // Walk the rendered preview, find every "text leaf" element 10920 // (any element whose own children are inline-only, plus 10921 // direct text), and attach a click handler that opens an 10922 // inline edit popover. Skip: 10923 // - asset placeholder/attached wrappers (their inner text 10924 // is fallback content the placeholder fill flow wipes), 10925 // - data-cs-toggle / data-cs-tab interactive triggers 10926 // (admin uses those to test interactions in preview), 10927 // - the AI launcher button. 10928 // 10929 // Each decorated element gets a `__cs_text_decorated` flag 10930 // so we don't double-bind across re-decorate passes within 10931 // one render, and a `cs-text-editable-host` class so CSS can 10932 // give it a hover outline + cursor:pointer affordance. 10933 decorate_editable_text_nodes() { 10934 if (!this.preview_element) { 10935 return; 10936 } 10937 let root = this.custom_code_render_root(); 10938 if (!root) { 10939 return; 10940 } 10941 let candidates = this.collect_editable_text_leaves(root);
10942 candidates.forEach((el) => { 10943 if (el.__cs_text_decorated) { 10944 return; 10945 } 10946 el.__cs_text_decorated = true; 10947 el.classList.add('cs-text-editable-host'); 10948 let handler = (event) => { 10949 if (this.should_allow_text_click_through(event)) { 10950 return; 10951 } 10952 event.preventDefault(); 10953 event.stopPropagation(); 10954 this.open_text_edit_popover(el); 10955 }; 10956 el.addEventListener('click', handler); 10957 }); 10958 }, 10959 10960 // DFS-collect text leaves. When an element qualifies, we do 10961 // NOT recurse into it â we want the OUTERMOST text leaf so 10962 // `<h1>Hello <em>world</em></h1>` is edited as one string, 10963 // not two. We also stop recursing into media/script/svg 10964 // wrappers and into asset placeholder/attached wrappers 10965 // (those manage their own UI), since nothing inside should 10966 // be a text leaf anyway. 10967 collect_editable_text_leaves(root) { 10968 let results = []; 10969 let visit = (el) => { 10970 if (!el || el.nodeType !== 1) { 10971 return; 10972 } 10973 let tag = (el.tagName || '').toLowerCase(); 10974 if (TEXT_SKIP_TAGS.has(tag)) { 10975 return; 10976 } 10977 if (this.is_preview_injected_node(el)) { 10978 return; 10979 } 10980 if (el.hasAttribute && ( 10981 el.hasAttribute('data-cs-asset-placeholder') || 10982 el.hasAttribute('data-cs-asset-attached') 10983 )) { 10984 return; 10985 } 10986 if (this.is_editable_text_leaf(el, root)) { 10987 results.push(el); 10988 return; 10989 } 10990 let child_elements = el.children || []; 10991 for (let child of child_elements) { 10992 visit(child); 10993 } 10994 }; 10995 let top = root.children || []; 10996 for (let child of top) { 10997 visit(child); 10998 } 10999 return results; 11000 }, 11001 11002 is_editable_text_leaf(el, root) { 11003 if (!el || el.nodeType !== 1) { 11004 return false; 11005 } 11006 let tag = (el.tagName || '').toLowerCase(); 11007 if (TEXT_SKIP_TAGS.has(tag)) { 11008 return false; 11009 } 11010 if (el.classList && ( 11011 el.classList.contains('cs-asset-placeholder-overlay') || 11012 el.classList.contains('cs-asset-change-button') || 11013 el.classList.contains('cs-asset-background-placeholder-chip') || 11014 el.classList.contains('cs-bare-image-preview-host') || 11015 el.classList.contains('custom-code-ai-launcher') || 11016 el.classList.contains('cs-text-edit-popover') 11017 )) { 11018 return false; 11019 } 11020 // Skip declarative-interaction triggers â admins want to 11021 // click those to test the toggle/tab behavior in preview. 11022 if (el.hasAttribute && ( 11023 el.hasAttribute('data-cs-toggle') || 11024 el.hasAttribute('data-cs-tab') 11025 )) { 11026 return false; 11027 } 11028 // Skip if self or ancestor is an asset placeholder/attached 11029 // wrapper â those wrappers own their fallback content. 11030 let ancestor = el; 11031 while (ancestor && ancestor !== root) { 11032 if (ancestor.nodeType === 1 && ancestor.hasAttribute && ( 11033 ancestor.hasAttribute('data-cs-asset-placeholder') || 11034 ancestor.hasAttribute('data-cs-asset-attached') 11035 )) { 11036 return false;
11037 } 11038 if (this.is_preview_injected_node(ancestor)) { 11039 return false; 11040 } 11041 ancestor = ancestor.parentNode; 11042 } 11043 let has_text = false; 11044 for (let child of el.childNodes) { 11045 if (child.nodeType === 3 && child.nodeValue && child.nodeValue.trim()) { 11046 has_text = true; 11047 break; 11048 } 11049 } 11050 if (!has_text) { 11051 return false; 11052 } 11053 let element_children = el.children || []; 11054 for (let child of element_children) { 11055 let child_tag = (child.tagName || '').toLowerCase(); 11056 if (!TEXT_INLINE_TAGS.has(child_tag)) { 11057 return false; 11058 } 11059 } 11060 return true; 11061 }, 11062 11063 // Walk up from `element` to `root`, recording the index of 11064 // each step among its parent's child NODES (text + element). 11065 // Skip preview-only injected nodes (asset overlays) since 11066 // they don't exist in the persisted source. The same path 11067 // can then be replayed on the parsed source (skipping 11068 //
11068<script>, which sanitize_custom_code_html removes from 11069 // the rendered preview). 11070 compute_text_element_path(element, root) { 11071 if (!element || !root || element === root) { 11072 return null; 11073 } 11074 let path = []; 11075 let current = element; 11076 while (current && current !== root) { 11077 let parent = current.parentNode; 11078 if (!parent) { 11079 return null; 11080 } 11081 let visible_index = -1; 11082 let count = 0; 11083 let found = false; 11084 for (let child of parent.childNodes) { 11085 if (this.is_preview_injected_node(child)) { 11086 continue; 11087 } 11088 if (child === current) { 11089 visible_index = count; 11090 found = true; 11091 break; 11092 } 11093 count++; 11094 } 11095 if (!found || visible_index === -1) { 11096 return null; 11097 } 11098 path.unshift(visible_index); 11099 current = parent; 11100 } 11101 if (current !== root) { 11102 return null; 11103 } 11104 return path; 11105 }, 11106 11107 is_preview_injected_node(node) { 11108 if (!node || node.nodeType !== 1) { 11109 return false; 11110 } 11111 if (!node.classList) { 11112 return false; 11113 } 11114 return ( 11115 node.classList.contains('cs-asset-placeholder-overlay') || 11116 node.classList.contains('cs-asset-change-button') || 11117 node.classList.contains('cs-asset-background-placeholder-chip') || 11118 node.classList.contains('cs-bare-image-host') || 11119 node.classList.contains('cs-bare-image-preview-host') 11120 ); 11121 }, 11122 should_allow_text_click_through(event) { 11123 if (!this._text_click_through_until || Date.now() > this._text_click_through_until) { 11124 return false; 11125 } 11126 let target = event && event.target ? event.target : null; 11127 if (!target || typeof target.closest !== 'function') { 11128 return false; 11129 } 11130 return !!target.closest('a, button, [role="button"], [data-cs-toggle], [data-cs-tab]'); 11131 }, 11132 11133 // Walk a parsed-source tree using a path captured from the 11134 // preview. Skip
11134<script> nodes on the source side because 11135 // sanitize_custom_code_html strips them at render time â 11136 // without skipping, source/preview indices diverge any 11137 // time a section embeds a script tag. 11138 find_text_element_by_path(root, path) { 11139 if (!root || !path) { 11140 return null; 11141 } 11142 let current = root; 11143 for (let target_index of path) { 11144 if (!current || !current.childNodes) { 11145 return null; 11146 } 11147 let visible_index = 0; 11148 let next = null; 11149 for (let child of current.childNodes) { 11150 if ( 11151 child.nodeType === 1 && 11152 (child.tagName || '').toLowerCase() === 'script' 11153 ) { 11154 continue; 11155 } 11156 if (visible_index === target_index) { 11157 next = child; 11158 break; 11159 } 11160 visible_index++; 11161 } 11162 if (!next) { 11163 return null; 11164 } 11165 current = next; 11166 } 11167 return current; 11168 }, 11169 11170 // Inline popover: textarea + Save/Cancel, positioned near 11171 // the clicked element via fixed coords. We close on outside 11172 // click, ESC, or scroll of the preview pane (re-positioning 11173 // on scroll added complexity for little gain â admins will 11174 // re-click after scrolling). 11175 open_text_edit_popover(target_el) { 11176 this.close_text_edit_popover(); 11177 if (!target_el) { 11178 return; 11179 } 11180 let root = this.custom_code_render_root(); 11181 if (!root) { 11182 return; 11183 } 11184 let path = this.compute_text_element_path(target_el, root); 11185 if (!path) { 11186 return; 11187 } 11188 let initial_value = this.read_element_text(target_el); 11189 let link_target = this.find_text_edit_link_target(target_el, root); 11190 let link_path = link_target ? this.compute_text_element_path(link_target, root) : null; 11191 let initial_url = link_target ? this.read_text_edit_url(link_target) : ''; 11192 11193 let popover = document.createElement('div'); 11194 popover.className = 'cs-text-edit-popover'; 11195 popover.setAttribute('data-cs-text-popover', '1'); 11196 11197 let header = document.createElement('div'); 11198 header.className = 'cs-text-edit-popover-header'; 11199 header.textContent = 'Edit text'; 11200 popover.appendChild(header); 11201 11202 let textarea = document.createElement('textarea'); 11203 textarea.className = 'cs-text-edit-textarea'; 11204 textarea.value = initial_value; 11205 textarea.rows = Math.min(8, Math.max(2, initial_value.split('\n').length + 1)); 11206 popover.appendChild(textarea); 11207 11208 let url_input = null; 11209 if (link_target && link_path) { 11210 let url_wrap = document.createElement('label'); 11211 url_wrap.className = 'cs-text-edit-url-field'; 11212 11213 let url_label = document.createElement('span'); 11214 url_label.className = 'cs-text-edit-url-label'; 11215 url_label.textContent = 'URL or path'; 11216 url_wrap.appendChild(url_label); 11217 11218 url_input = document.createElement('input'); 11219 url_input.type = 'text'; 11220 url_input.className = 'cs-text-edit-url-input'; 11221 url_input.value = initial_url; 11222 url_input.placeholder = ''; 11223 url_wrap.appendChild(url_input); 11224 11225 popover.appendChild(url_wrap); 11226 } 11227 11228 let footer = document.createElement('div'); 11229 footer.className = 'cs-text-edit-popover-footer'; 11230 let ask_btn = document.createElement('button'); 11231 ask_btn.type = 'button'; 11232 ask_btn.className = 'cs-text-edit-ask'; 11233 ask_btn.title = 'Ask AI about this text â adds it as a reference in the chat'; 11234 ask_btn.innerHTML = 11235 '<i class="fa-regular fa-sparkles"></i>' + 11236 '<span>Ask</span>'; 11237 let footer_spacer = document.createElement('div'); 11238 footer_spacer.className = 'cs-text-edit-popover-footer-spacer';
11239 let cancel_btn = document.createElement('button'); 11240 cancel_btn.type = 'button'; 11241 cancel_btn.className = 'cs-text-edit-cancel'; 11242 cancel_btn.textContent = 'Cancel'; 11243 let save_btn = document.createElement('button'); 11244 save_btn.type = 'button'; 11245 save_btn.className = 'cs-text-edit-save'; 11246 save_btn.textContent = 'Save'; 11247 footer.appendChild(ask_btn); 11248 footer.appendChild(footer_spacer); 11249 footer.appendChild(cancel_btn); 11250 footer.appendChild(save_btn); 11251 popover.appendChild(footer); 11252 11253 document.body.appendChild(popover); 11254 this.position_text_edit_popover(popover, target_el); 11255 11256 let do_save = () => { 11257 let new_value = textarea.value; 11258 let new_url = url_input ? url_input.value : null; 11259 this.commit_text_edit(path, new_value, link_path, new_url); 11260 this.close_text_edit_popover(); 11261 }; 11262 let do_cancel = () => { 11263 this.close_text_edit_popover(); 11264 }; 11265 let do_ask = () => { 11266 // Snapshot the textarea value (admin may have 11267 // tweaked the text before clicking Ask) and add 11268 // it as a chat reference. We don't persist the 11269 // edit â the chip carries the current draft so 11270 // the AI sees what the admin is looking at. 11271 let snippet = textarea.value; 11272 this.send_text_edit_to_ai_chat(target_el, snippet); 11273 this.close_text_edit_popover(); 11274 }; 11275 save_btn.addEventListener('click', do_save); 11276 cancel_btn.addEventListener('click', do_cancel); 11277 ask_btn.addEventListener('click', do_ask); 11278 textarea.addEventListener('keydown', (event) => { 11279 if (event.key === 'Escape') { 11280 event.preventDefault(); 11281 do_cancel(); 11282 return; 11283 } 11284 if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { 11285 event.preventDefault(); 11286 do_save(); 11287 } 11288 }); 11289 if (url_input) { 11290 url_input.addEventListener('keydown', (event) => { 11291 if (event.key === 'Escape') { 11292 event.preventDefault(); 11293 do_cancel(); 11294 return; 11295 } 11296 if (event.key === 'Enter') { 11297 event.preventDefault(); 11298 do_save(); 11299 } 11300 }); 11301 } 11302 // Stop propagation so our outside-click handler doesn't 11303 // see clicks landing inside the popover and dismiss it. 11304 popover.addEventListener('mousedown', (event) => { 11305 event.stopPropagation(); 11306 }); 11307 popover.addEventListener('click', (event) => { 11308 event.stopPropagation(); 11309 }); 11310 11311 let outside_handler = (event) => { 11312 if (popover.contains(event.target)) { 11313 return; 11314 } 11315 let target = event && event.target ? event.target : null; 11316 if (target && root.contains(target)) { 11317 this._text_click_through_until = Date.now() + 250; 11318 } 11319 do_cancel(); 11320 }; 11321 // Pin the popover to the target element on scroll + 11322 // resize. Capture-phase scroll listener on window 11323 // catches scroll events from any nested scroll 11324 // container (scroll doesn't bubble, but capture fires 11325 // for every ancestor). If the target gets detached 11326 // (e.g. AI re-renders the section, admin switches 11327 // tabs) we close instead of leaving an orphan popover 11328 // pointing at a stale rect. 11329 let reposition = () => { 11330 if (!this._text_popover || this._text_popover.el !== popover) { 11331 return; 11332 } 11333 if (!target_el || !target_el.isConnected) { 11334 do_cancel(); 11335 return; 11336 } 11337 this.position_text_edit_popover(popover, target_el); 11338 }; 11339 // Defer attach so the click that opened the popover 11340 // doesn't immediately close it. Also listen for 11341 // touchstart so taps on a touch device dismiss the 11342 // popover the same way mouse clicks do. 11343 setTimeout(() => { 11344 document.addEventListener('mousedown', outside_handler, true); 11345 document.addEventListener('touchstart', outside_handler, true); 11346 window.addEventListener('scroll', reposition, true); 11347 window.addEventListener('resize', reposition); 11348 }, 0); 11349 11350 this._text_popover = { 11351 el: popover, 11352 target_el: target_el, 11353 outside_handler: outside_handler, 11354 reposition: reposition, 11355 }; 11356 11357 // Highlight the element being edited so it's obvious 11358 // which text the popover targets. 11359 target_el.classList.add('cs-text-editable-active'); 11360 11361 this.$nextTick(() => { 11362 textarea.focus(); 11363 textarea.select(); 11364 }); 11365 }, 11366 11367 close_text_edit_popover() { 11368 if (!this._text_popover) { 11369 return; 11370 } 11371 let popover_state = this._text_popover; 11372 this._text_popover = null; 11373 if (popover_state.outside_handler) { 11374 document.removeEventListener('mousedown', popover_state.outside_handler, true); 11375 document.removeEventListener('touchstart', popover_state.outside_handler, true); 11376 } 11377 if (popover_state.reposition) { 11378 window.removeEventListener('scroll', popover_state.reposition, true); 11379 window.removeEventListener('resize', popover_state.reposition); 11380 } 11381 if (popover_state.target_el && popover_state.target_el.classList) { 11382 popover_state.target_el.classList.remove('cs-text-editable-active'); 11383 } 11384 if (popover_state.el && popover_state.el.parentNode) { 11385 popover_state.el.parentNode.removeChild(popover_state.el); 11386 } 11387 }, 11388 11389 // Position the popover below the element when there's room, 11390 // otherwise above. Use viewport (fixed) coordinates so the 11391 // popover doesn't get clipped by overflow:hidden ancestors 11392 // (the preview pane itself scrolls). 11393 position_text_edit_popover(popover, target_el) { 11394 if (!popover || !target_el) { 11395 return; 11396 } 11397 let rect = target_el.getBoundingClientRect();
11398 // Width: clamp between 240 and 420 px, max 92vw. 11399 let max_width = Math.min(window.innerWidth - 24, 420); 11400 let width = Math.max(240, Math.min(max_width, Math.round(rect.width))); 11401 popover.style.width = width + 'px'; 11402 11403 // Render once so we can measure height for vertical fit. 11404 popover.style.left = '0px'; 11405 popover.style.top = '0px'; 11406 popover.style.visibility = 'hidden'; 11407 popover.style.display = 'block'; 11408 11409 let popover_rect = popover.getBoundingClientRect(); 11410 let popover_height = popover_rect.height; 11411 let gap = 8; 11412 let viewport_h = window.innerHeight; 11413 let viewport_w = window.innerWidth; 11414 11415 let left = Math.round(rect.left); 11416 if (left + width + 8 > viewport_w) { 11417 left = Math.max(8, viewport_w - width - 8); 11418 } 11419 if (left < 8) { 11420 left = 8; 11421 } 11422 11423 let top; 11424 let space_below = viewport_h - rect.bottom; 11425 let space_above = rect.top; 11426 if (space_below >= popover_height + gap || space_below >= space_above) { 11427 top = Math.round(rect.bottom + gap); 11428 if (top + popover_height + 8 > viewport_h) { 11429 top = Math.max(8, viewport_h - popover_height - 8); 11430 } 11431 } else { 11432 top = Math.round(rect.top - popover_height - gap); 11433 if (top < 8) { 11434 top = 8; 11435 } 11436 } 11437 11438 popover.style.left = left + 'px'; 11439 popover.style.top = top + 'px'; 11440 popover.style.visibility = ''; 11441 }, 11442 11443 // Concatenate all text nodes inside the element, preserving 11444 // line breaks for `<br>` so multi-line copy round-trips 11445 // sensibly. Inline emphasis tags collapse to plain text on 11446 // save by design â admins use the Edit tab for fancy 11447 // inline markup. 11448 read_element_text(el) { 11449 if (!el) { 11450 return ''; 11451 } 11452 let parts = []; 11453 let walk = (node) => { 11454 if (!node) { 11455 return; 11456 } 11457 if (node.nodeType === 3) { 11458 parts.push(node.nodeValue || ''); 11459 return; 11460 } 11461 if (node.nodeType !== 1) { 11462 return; 11463 } 11464 let tag = (node.tagName || '').toLowerCase(); 11465 if (tag === 'br') { 11466 parts.push('\n'); 11467 return; 11468 } 11469 if (TEXT_SKIP_TAGS.has(tag)) { 11470 return; 11471 } 11472 for (let child of node.childNodes) { 11473 walk(child); 11474 } 11475 }; 11476 for (let child of el.childNodes) { 11477 walk(child); 11478 } 11479 return parts.join('').replace(/[ \t]+/g, ' ').replace(/\s+\n/g, '\n').replace(/\n\s+/g, '\n').trim(); 11480 }, 11481 11482 // "Ask" button on the text-edit popover â push a chat 11483 // composer chip referencing this element and pin the panel 11484 // open. The shared AI panel handles attachment + 11485 // formatting; we just describe the element. 11486 send_text_edit_to_ai_chat(target_el, snippet) { 11487 if (typeof ai_mcp_panel_attach_context !== 'function') { 11488 if (typeof displayMessage === 'function') { 11489 displayMessage('AI panel is not available right now.', true, 4000); 11490 } 11491 return; 11492 } 11493 if (!target_el) { 11494 return; 11495 } 11496 let tag = (target_el.tagName || 'element').toLowerCase(); 11497 let raw_text = typeof snippet === 'string' && snippet.length 11498 ? snippet 11499 : this.read_element_text(target_el); 11500 let display = (raw_text || '').replace(/\s+/g, ' ').trim(); 11501 let label = display.length > 60 ? display.slice(0, 60).trim() + 'â¦' : display; 11502 if (!label) { 11503 label = '(empty)'; 11504 } 11505 let id = 'ctx_' + Math.random().toString(36).slice(2, 10) + '_' + Date.now().toString(36); 11506 ai_mcp_panel_attach_context({ 11507 component: this, 11508 context: { 11509 id: id, 11510 kind: 'preview-text', 11511 tag: tag, 11512 text: raw_text || '', 11513 label: label, 11514 preview: display, 11515 }, 11516 }); 11517 }, 11518 11519 find_text_edit_link_target(target_el, root) { 11520 if (!target_el || !root || typeof target_el.closest !== 'function') { 11521 return null; 11522 } 11523 let link_target = target_el.closest('a, button'); 11524 if (!link_target || !root.contains(link_target)) { 11525 return null; 11526 } 11527 if (link_target.hasAttribute && ( 11528 link_target.hasAttribute('data-cs-toggle') || 11529 link_target.hasAttribute('data-cs-tab') 11530 )) { 11531 return null; 11532 } 11533 return link_target; 11534 }, 11535 read_text_edit_url(link_target) { 11536 if (!link_target || !link_target.getAttribute) { 11537 return ''; 11538 } 11539 let tag = (link_target.tagName || '').toLowerCase(); 11540 if (tag === 'a') { 11541 return link_target.getAttribute('href') || ''; 11542 } 11543 return link_target.getAttribute('data-cs-href') || ''; 11544 }, 11545 normalize_text_edit_url(value) { 11546 let url = typeof value === 'string' ? value.trim() : ''; 11547 if (!url) { 11548 return ''; 11549 } 11550 let lowered = url.toLowerCase(); 11551 if (lowered.indexOf('javascript:') === 0) { 11552 return ''; 11553 } 11554 return url; 11555 }, 11556 apply_text_edit_url(doc, link_path, url_value) { 11557 if (!doc || !link_path) { 11558 return false;
11559 } 11560 let target = this.find_text_element_by_path(doc, link_path); 11561 if (!target || target.nodeType !== 1) { 11562 return false; 11563 } 11564 let url = this.normalize_text_edit_url(url_value); 11565 let tag = (target.tagName || '').toLowerCase(); 11566 if (tag === 'a') { 11567 if (url) { 11568 target.setAttribute('href', url); 11569 } else { 11570 target.removeAttribute('href'); 11571 } 11572 return true; 11573 } 11574 if (tag !== 'button') { 11575 return false; 11576 } 11577 if (!url) { 11578 target.removeAttribute('data-cs-href'); 11579 return true; 11580 } 11581 // A plain <button> cannot navigate without JavaScript, 11582 // and inline JS is stripped by sanitize_custom_code_html. 11583 // For CTA-like buttons, convert to an anchor while 11584 // preserving classes, children, and safe generic attrs. 11585 let owner = doc.ownerDocument || document; 11586 let anchor = owner.createElement('a'); 11587 Array.prototype.slice.call(target.attributes || []).forEach((attr) => { 11588 let name = String(attr.name || '').toLowerCase(); 11589 if (name === 'type' || name === 'disabled' || name.indexOf('form') === 0) { 11590 return; 11591 } 11592 if (name.indexOf('on') === 0 || name === 'data-cs-href') { 11593 return; 11594 } 11595 anchor.setAttribute(attr.name, attr.value); 11596 }); 11597 anchor.setAttribute('href', url); 11598 while (target.firstChild) { 11599 anchor.appendChild(target.firstChild); 11600 } 11601 if (target.parentNode) { 11602 target.parentNode.replaceChild(anchor, target); 11603 } 11604 return true; 11605 }, 11606 11607 // Apply the edit against the parsed source and persist. 11608 // Multi-line input becomes textNode + <br> + textNode + ... 11609 // so it round-trips to the same lines on next read. 11610 commit_text_edit(path, new_value, link_path, url_value) { 11611 if (!path) { 11612 return; 11613 } 11614 let safe_value = typeof new_value === 'string' ? new_value : ''; 11615 let mutated = this.mutate_persisted_html((doc) => { 11616 let target = this.find_text_element_by_path(doc, path); 11617 if (!target || target.nodeType !== 1) { 11618 return false; 11619 } 11620 while (target.firstChild) { 11621 target.removeChild(target.firstChild); 11622 } 11623 let lines = safe_value.split('\n'); 11624 let owner = doc.ownerDocument || document; 11625 lines.forEach((line, index) => { 11626 if (index > 0) { 11627 target.appendChild(owner.createElement('br')); 11628 } 11629 if (line.length) { 11630 target.appendChild(owner.createTextNode(line)); 11631 } 11632 }); 11633 if (link_path) { 11634 this.apply_text_edit_url(doc, link_path, url_value); 11635 } 11636 return true; 11637 }); 11638 if (mutated !== null) { 11639 this.persist_mutated_code(mutated); 11640 } 11641 }, 11642 11643 // Called from ai-mcp-panel.html. Pushes AI-generated HTML 11644 // into CodeMirror, persists via save_changes_for_component, 11645 // and returns the prior editor value so the panel can offer 11646 // Undo for auto-applied responses. 11647 // 11648 // Streaming-aware: when we've been driving the editor from 11649 // a live stream, the canonical "previous_code" the panel 11650 // wants is the snapshot we took at begin_streaming_code, 11651 // NOT the streamed-in code currently in the buffer. 11652 apply_generated_code(code, options) { 11653 if (typeof code !== 'string') { 11654 return null; 11655 } 11656 this.close_text_edit_popover(); 11657 let opts = options || {}; 11658 if (opts.manual_apply || opts.auto) { 11659 this.clear_ai_streaming_starting_state(); 11660 this.ai_streaming = false;
11661 this.release_ai_loading_pane_height_after_render(); 11662 if (opts.manual_apply || this.ai_streaming_status === 'streaming' || this.ai_streaming_status === 'queued') { 11663 this.ai_streaming_status = 'done'; 11664 } 11665 this.ai_streaming_buffer = ''; 11666 this._last_flushed_buffer_len = -1; 11667 this.ai_streaming_child_min_heights = []; 11668 if (this._codegen_stream_raf) { 11669 cancelAnimationFrame(this._codegen_stream_raf); 11670 this._codegen_stream_raf = null; 11671 } 11672 this.ai_stream_event_tick = (this.ai_stream_event_tick + 1) | 0; 11673 } 11674 let previous_code; 11675 if (this.ai_streaming_pre_code !== null && this.ai_streaming_pre_code !== undefined) { 11676 previous_code = this.ai_streaming_pre_code; 11677 this.ai_streaming_pre_code = null; 11678 } else { 11679 previous_code = this.get_current_code(); 11680 } 11681 let safe_code = this.sanitize_custom_code_html(code); 11682 if (this.cm_instance) { 11683 this._suppress_change_save = true; 11684 this.cm_instance.setValue(safe_code); 11685 this._suppress_change_save = false; 11686 } 11687 this.custom_code_value = safe_code; 11688 this.publish_current_code_to_store(); 11689 this.save_ai_applied_custom_code(safe_code); 11690 this.mark_ai_code_revision_changed(); 11691 if (opts.switch_to_preview !== false) { 11692 this.active_tab = 'preview'; 11693 } 11694 this.$nextTick(() => this.initialize_custom_code_interactions()); 11695 return previous_code; 11696 }, 11697 save_ai_applied_custom_code(code) { 11698 if (!this.component) { 11699 return; 11700 } 11701 let config_latest = this.component.get('config_latest') || this.component.get('config') || {}; 11702 let next_config_latest = Object.assign({}, config_latest, { custom_code: code }); 11703 let ai_description = this.extract_ai_description_from_code(code); 11704 if (ai_description) { 11705 next_config_latest.ai_description = ai_description; 11706 } 11707 this.component.set('config_latest', next_config_latest); 11708 if (typeof save_changes_for_component === 'function') { 11709 save_changes_for_component(this.component, 'custom_code', code); 11710 return; 11711 } 11712 this.component.set('unsaved_changes', true); 11713 this.component.save().catch((error) => { 11714 console.warn('[custom-code] AI custom code save failed:', error && error.message); 11715 }); 11716 }, 11717 11718 // âââ Codegen stream subscription ââââââââââââââââââââââ 11719 // 11720 // Subscribes to store.ai_streams for this component's key. 11721 // The component owns the editor + preview, so it's the 11722 // right place to handle code-side events directly â the 11723 // panel only cares about message text + tool calls. 11724 subscribe_to_codegen_stream() { 11725 if (typeof store === 'undefined' || !store.ai_streams) { 11726 return; 11727 } 11728 let component_id = this.component && this.component.id; 11729 if (!component_id) { 11730 return; 11731 } 11732 if (typeof this._codegen_stream_unsubscribe === 'function') { 11733 try { 11734 this._codegen_stream_unsubscribe(); 11735 } catch (e) {} 11736 this._codegen_stream_unsubscribe = null; 11737 } 11738 let key = 'codegen:' + component_id; 11739 let me = this; 11740 this._codegen_stream_unsubscribe = store.ai_streams.subscribe(key, function (event_type, payload) { 11741 me.handle_codegen_stream_event(event_type, payload); 11742 }); 11743 // If we're (re-)subscribing into an already-terminal 11744 // entry, the synchronous `replay` event above won't 11745 // unwind any stuck loading flags on its own (replay 11746 // for status='done' is intentionally lightweight to 11747 // avoid re-touching the editor). Hard-converge here 11748 // so a stale "Generatingâ¦" label clears on the next 11749 // mount / panel-open. 11750 let entry = (store.ai_streams.get && store.ai_streams.get(key)) || null; 11751 if (entry && (entry.status === 'done' || entry.status === 'error' || entry.status === 'aborted_local')) { 11752 let needs_clear = this.ai_streaming 11753 || this.ai_streaming_status === 'streaming' 11754 || this.ai_streaming_status === 'queued'; 11755 if (needs_clear) { 11756 this.force_clear_ai_loading_state(); 11757 } 11758 } 11759 }, 11760 handle_codegen_stream_event(event_type, payload) { 11761 let safe = payload || {}; 11762 this.ai_stream_event_tick = (this.ai_stream_event_tick + 1) | 0; 11763 switch (event_type) { 11764 case 'replay': 11765 // Late subscriber catch-up. If a stream is 11766 // already in flight we lock the editor + flush 11767 // the accumulated buffer in one shot so the 11768 // preview catches up before the next delta. 11769 if (safe.status === 'streaming' || safe.status === 'queued') { 11770 this.capture_ai_loading_pane_height(); 11771 this.clear_ai_streaming_starting_state(); 11772 if (safe.response_json_buffer) { 11773 this._response_json_buffer = ''; 11774 this._response_json_html = ''; 11775 this._append_split_ready = false;
11776 this._append_head = ''; 11777 this._append_tail = ''; 11778 this.handle_codegen_response_json_delta({ text: safe.response_json_buffer }); 11779 } else if (!this.ai_streaming && safe.code_buffer) { 11780 this.begin_streaming_code(safe.code_buffer || ''); 11781 } else if (safe.code_buffer) { 11782 this.ai_streaming_buffer = safe.code_buffer; 11783 this.schedule_streaming_flush(); 11784 } 11785 this.ai_streaming_status = safe.status; 11786 } else if (safe.status === 'done' && safe.code_buffer) { 11787 // Parent-agent loopback streams are passive: a new 11788 // section may mount only after the child stream has 11789 // already settled. Replay the final buffered HTML so 11790 // the preview catches up even before the page refresh 11791 // reconciles the persisted component. 11792 this.clear_ai_streaming_starting_state(); 11793 this.force_clear_ai_loading_state(); 11794 let replay_code = safe.code_buffer || ''; 11795 if (this.cm_instance && replay_code !== this.cm_instance.getValue()) { 11796 this._suppress_change_save = true; 11797 this.cm_instance.setValue(replay_code); 11798 this._suppress_change_save = false; 11799 } 11800 this.custom_code_value = replay_code; 11801 this.publish_current_code_to_store(); 11802 this.$nextTick(() => this.initialize_custom_code_interactions()); 11803 } else if (safe.final && safe.status === 'done') { 11804 // We mounted right after the standalone stream 11805 // finished â make sure chrome is clear without 11806 // touching the canonical editor value. 11807 this.clear_ai_streaming_starting_state(); 11808 } 11809 break; 11810 case 'status': 11811 this.clear_ai_streaming_starting_state(); 11812 if (safe.status === 'streaming' || safe.status === 'queued') { 11813 this.capture_ai_loading_pane_height(); 11814 } 11815 if (safe.status === 'queued') { 11816 // Show queued chrome without engaging the 11817 // editor lock â nothing is actually being 11818 // generated yet. 11819 this.ai_streaming_status = 'queued'; 11820 } 11821 if (safe.status === 'aborted_local' && this.ai_streaming) { 11822 this.cancel_streaming({ initiated_remotely: true }); 11823 } 11824 break; 11825 case 'code_delta': 11826 if (safe.component_id && this.component && safe.component_id !== this.component.id) { 11827 break; 11828 } 11829 this.clear_ai_streaming_starting_state(); 11830 if (typeof safe.text !== 'string' || !safe.text.length) { 11831 break; 11832 } 11833 if (safe.atomic) { 11834 if (!this.ai_streaming) { 11835 this.begin_streaming_code(safe.text); 11836 } else { 11837 this.ai_streaming_buffer = safe.text; 11838 this.schedule_streaming_flush(); 11839 } 11840 break; 11841 } 11842 if (!this.ai_streaming) { 11843 this.begin_streaming_code(''); 11844 } 11845 this.append_streaming_code(safe.text); 11846 break; 11847 case 'response_json_delta': 11848 this.handle_codegen_response_json_delta(safe); 11849 break; 11850 case 'new_section_created': 11851 if (safe.new_component && this.component && safe.new_component.id !== this.component.id) { 11852 this.force_clear_ai_loading_state(); 11853 } 11854 break; 11855 case 'agent_settle': 11856 // Parent webbuilder-agent build finished/failed. The 11857 // live preview already morphed in the full document via 11858 // the forwarded deltas, so just clear the loading 11859 // overlay WITHOUT a client save â the agent's 11860 // authoritative refresh re-pull reconciles the canonical 11861 // HTML (incl. post-build image-import URL swaps). 11862 this.clear_ai_streaming_starting_state(); 11863 this.force_clear_ai_loading_state(); 11864 if (safe.error) { 11865 this.ai_streaming_status = 'error'; 11866 } 11867 break; 11868 case 'stream_reset': 11869 this.clear_ai_streaming_starting_state(); 11870 this.ai_streaming_buffer = ''; 11871 this._response_json_buffer = ''; 11872 this._response_json_html = ''; 11873 this._append_split_ready = false;
11874 this._append_head = ''; 11875 this._append_tail = ''; 11876 if (this.ai_streaming) { 11877 let restore_code = (this.ai_streaming_pre_code !== null && this.ai_streaming_pre_code !== undefined) 11878 ? this.ai_streaming_pre_code 11879 : this.get_current_code(); 11880 if (this.cm_instance) { 11881 if (this.cm_instance.getValue() !== restore_code) { 11882 this._suppress_change_save = true; 11883 this.cm_instance.setValue(restore_code); 11884 this._suppress_change_save = false; 11885 } 11886 this.cm_instance.setOption('readOnly', false); 11887 } 11888 this.custom_code_value = restore_code; 11889 this.publish_current_code_to_store(); 11890 this.ai_streaming = false; 11891 this.ai_streaming_status = 'streaming'; 11892 }
11893 // Wipe the morph preview too â next deltas 11894 // re-build it from a clean slate. 11895 let stream_root = this.$refs.custom_code_stream_preview; 11896 if (stream_root) { 11897 while (stream_root.firstChild) { 11898 stream_root.removeChild(stream_root.firstChild); 11899 } 11900 } 11901 break; 11902 case 'done': 11903 this.clear_ai_streaming_starting_state(); 11904 if (safe.edit_mode === 'new_section' || safe.edit_mode === 'new_sections') { 11905 this.finish_streaming_code(null); 11906 break; 11907 } 11908 let final_code = null; 11909 if (typeof safe.code === 'string') { 11910 final_code = safe.code; 11911 } else if (safe.result && typeof safe.result.code === 'string') { 11912 final_code = safe.result.code; 11913 } 11914 this.finish_streaming_code(final_code); 11915 break; 11916 case 'error': 11917 this.clear_ai_streaming_starting_state(); 11918 // Leave the partial buffer in place but flip 11919 // the overlay so the admin can dismiss. 11920 this.ai_streaming_status = 'error'; 11921 if (this.ai_streaming) { 11922 this.snap_streaming_done({ error: true }); 11923 } 11924 break; 11925 default: 11926 break; 11927 } 11928 }, 11929 handle_codegen_response_json_delta(payload) { 11930 if (!payload || typeof payload.text !== 'string') { 11931 return; 11932 } 11933 this._response_json_buffer = (this._response_json_buffer || '') + payload.text; 11934 let tool_name = this.detect_streamed_response_action_name(this._response_json_buffer); 11935 if (tool_name === 'set_html') { 11936 this.stream_set_html_from_buffer(); 11937 return; 11938 } 11939 if (tool_name === 'append_html') { 11940 this.stream_append_html_from_buffer(); 11941 return; 11942 } 11943 }, 11944 // Live set_html: the streamed `html` field IS the whole section, so 11945 // forward each new tail slice as a code delta. 11946 stream_set_html_from_buffer() { 11947 let extracted = this.extract_streamed_json_string_field(this._response_json_buffer, 'html'); 11948 if (!extracted || !extracted.value) { 11949 return; 11950 } 11951 let previous = this._response_json_html || ''; 11952 if (extracted.value.length <= previous.length) { 11953 return; 11954 } 11955 let delta = extracted.value.slice(previous.length); 11956 this._response_json_html = extracted.value; 11957 this.clear_ai_streaming_starting_state(); 11958 if (!this.ai_streaming) { 11959 this.begin_streaming_code(''); 11960 } 11961 this.append_streaming_code(delta); 11962 }, 11963 // Live append_html: the streamed `html` field is only the NEW items 11964 // (a fragment). Reconstruct the whole document each delta as 11965 // head + fragment + tail, split at the <!--cs-append-anchor--> so 11966 // the new items render in place while existing items stay stable. 11967 stream_append_html_from_buffer() { 11968 let extracted = this.extract_streamed_json_string_field(this._response_json_buffer, 'html'); 11969 if (!extracted) { 11970 return; 11971 } 11972 let fragment = extracted.value || ''; 11973 if (!this._append_split_ready) { 11974 let code = this.get_current_code() || ''; 11975 let anchor_index = code.indexOf('<!--cs-append-anchor-->'); 11976 if (anchor_index >= 0) { 11977 this._append_head = code.slice(0, anchor_index); 11978 this._append_tail = code.slice(anchor_index); 11979 } else { 11980 this._append_head = code; 11981 this._append_tail = ''; 11982 } 11983 this._append_split_ready = true; 11984 } 11985 this.clear_ai_streaming_starting_state(); 11986 if (!this.ai_streaming) { 11987 this.begin_streaming_code(''); 11988 } 11989 // Fragment grows in the MIDDLE, so set the whole buffer (not an 11990 // append) and let the preview morph reconcile against it. 11991 this.ai_streaming_buffer = this._append_head + fragment + this._append_tail; 11992 this.schedule_streaming_flush(); 11993 }, 11994 detect_streamed_response_action_name(buffer) { 11995 let match = /"name"\s*:\s*"(set_html|patch_html|append_html|create_section|propose_sections)"/.exec(buffer || ''); 11996 if (!match) { 11997 return null; 11998 } 11999 return match[1]; 12000 }, 12001 extract_streamed_json_string_field(buffer, field_name) { 12002 if (!buffer || !field_name) { 12003 return null; 12004 } 12005 let target = '"' + field_name + '"'; 12006 let key_index = buffer.indexOf(target); 12007 if (key_index === -1) { 12008 return null; 12009 } 12010 let colon_index = buffer.indexOf(':', key_index + target.length); 12011 if (colon_index === -1) { 12012 return null; 12013 } 12014 let quote_index = buffer.indexOf('"', colon_index + 1); 12015 if (quote_index === -1) { 12016 return null; 12017 } 12018 let value = ''; 12019 let escaping = false;
12020 let unicode_remaining = 0; 12021 let unicode_buffer = ''; 12022 for (let i = quote_index + 1; i < buffer.length; i++) { 12023 let character = buffer.charAt(i); 12024 if (unicode_remaining > 0) { 12025 unicode_buffer += character; 12026 unicode_remaining--; 12027 if (unicode_remaining === 0) { 12028 let code = parseInt(unicode_buffer, 16); 12029 if (Number.isFinite(code)) { 12030 value += String.fromCharCode(code); 12031 } 12032 unicode_buffer = ''; 12033 } 12034 continue; 12035 } 12036 if (escaping) { 12037 escaping = false; 12038 if (character === 'u') { 12039 unicode_remaining = 4; 12040 unicode_buffer = ''; 12041 } else { 12042 value += this.decode_streamed_json_escape(character); 12043 } 12044 continue; 12045 } 12046 if (character === '\\') { 12047 escaping = true; 12048 continue; 12049 } 12050 if (character === '"') { 12051 return { 12052 value: value, 12053 closed: true, 12054 }; 12055 } 12056 value += character; 12057 } 12058 return { 12059 value: value, 12060 closed: false, 12061 }; 12062 }, 12063 decode_streamed_json_escape(character) { 12064 if (character === 'n') { 12065 return '\n'; 12066 } 12067 if (character === 'r') { 12068 return '\r'; 12069 } 12070 if (character === 't') { 12071 return '\t'; 12072 } 12073 if (character === 'b') { 12074 return '\b'; 12075 } 12076 if (character === 'f') { 12077 return '\f'; 12078 } 12079 return character; 12080 }, 12081 // Lock the editor, snapshot prior code for Undo, switch to 12082 // preview tab. `initial_buffer` is the accumulated code we 12083 // missed if subscribing mid-stream â it seeds the buffer 12084 // and triggers an immediate flush. 12085 begin_streaming_code(initial_buffer) { 12086 if (this.ai_streaming) { 12087 return; 12088 } 12089 this.capture_ai_loading_pane_height(); 12090 this.clear_ai_streaming_starting_state(); 12091 this.ai_streaming = true; 12092 this.ai_streaming_status = 'streaming'; 12093 this.ai_streaming_pre_code = this.get_current_code(); 12094 this.ai_streaming_buffer = (typeof initial_buffer === 'string') ? initial_buffer : ''; 12095 if (this.cm_instance) { 12096 this.cm_instance.setOption('readOnly', 'nocursor'); 12097 } 12098 // Switch to preview so the admin sees the morph render 12099 // by default â most builds are done from preview anyway. 12100 this.active_tab = 'preview'; 12101 this.close_text_edit_popover(); 12102 if (this.ai_streaming_buffer) { 12103 this.schedule_streaming_flush(); 12104 } 12105 }, 12106 append_streaming_code(chunk) { 12107 this.ai_streaming_buffer += chunk; 12108 this.schedule_streaming_flush(); 12109 }, 12110 // Coalesce many code_delta events into one DOM update per 12111 // animation frame. cheap and keeps streaming responsive 12112 // even on chatty 100-chunk-per-second responses. 12113 schedule_streaming_flush() { 12114 if (this._codegen_stream_raf) { 12115 return; 12116 } 12117 let me = this; 12118 this._codegen_stream_raf = requestAnimationFrame(function () { 12119 me._codegen_stream_raf = null; 12120 me.flush_streaming_panes(); 12121 }); 12122 }, 12123 flush_streaming_panes() { 12124 if (!this.ai_streaming) { 12125 return; 12126 } 12127 let buffer = this.ai_streaming_buffer || ''; 12128 // Cheap stable check â avoids reflushing when the buffer length hasn't changed. 12129 if (this._last_flushed_buffer_len === buffer.length) { 12130 return; 12131 } 12132 this._last_flushed_buffer_len = buffer.length; 12133 this.flush_streaming_edit_pane(buffer); 12134 this.flush_streaming_preview_pane(buffer); 12135 }, 12136 flush_streaming_edit_pane(buffer) { 12137 if (!this.cm_instance) { 12138 return; 12139 } 12140 // setValue replaces the doc atomically. That's fine 12141 // for streaming because the doc is not user-editable 12142 // (readOnly: 'nocursor') and the buffer is always 12143 // strictly-grown â selection / scroll resets are 12144 // unavoidable but visually acceptable. 12145 let current = this.cm_instance.getValue(); 12146 if (current === buffer) { 12147 return; 12148 } 12149 this._suppress_change_save = true; 12150 this.cm_instance.setValue(buffer); 12151 this._suppress_change_save = false;
12152 this.custom_code_value = buffer; 12153 this.publish_current_code_to_store(); 12154 }, 12155 // Sequential morph: parse only the stable-prefix (chars up 12156 // through the last '>') of the buffer into a detached 12157 // tree, then walk top-level children in parallel against 12158 // the live preview root. Append-or-replace by index so 12159 // already-rendered sections stay stable while new ones 12160 // stream in below them. 12161 flush_streaming_preview_pane(buffer) { 12162 let root = this.$refs.custom_code_stream_preview; 12163 if (!root) { 12164 return; 12165 } 12166 let last_gt = buffer.lastIndexOf('>'); 12167 if (last_gt < 0) { 12168 // Nothing parseable yet (we haven't even seen one 12169 // closing bracket) â defer the flush. 12170 return; 12171 } 12172 let stable_prefix = buffer.slice(0, last_gt + 1); 12173 // Sanitize first so the morph never plants scripts / 12174 // inline handlers into the live DOM. Same function 12175 // safe_custom_code_value uses for the static pane. 12176 let safe_html = this.sanitize_custom_code_html(stable_prefix); 12177 let template_el = document.createElement('template'); 12178 template_el.innerHTML = safe_html; 12179 let new_nodes = template_el.content.childNodes; 12180 let live_nodes = root.childNodes; 12181 let i = 0; 12182 let max = new_nodes.length; 12183 // Track whether this flush appended brand-new top-level 12184 // nodes. If so the section is growing (new HTML in a 12185 // new_section / full build) and the page should follow 12186 // the bottom. If only existing nodes were morphed in 12187 // place (edit / edits flows replacing content) the 12188 // scroll position should stay put. 12189 let appended_new_node = false; 12190 let last_appended_node = null; 12191 while (i < max) { 12192 let new_node = new_nodes[i]; 12193 let live_node = live_nodes[i]; 12194 let next_node = new_node.cloneNode(true); 12195 this.apply_streaming_child_min_height(next_node, i); 12196 if (!live_node) { 12197 // Append-only path â most common during forward 12198 // streaming. next_node is cloned because new_node lives 12199 // in a <template> document fragment we don't 12200 // want to consume. 12201 root.appendChild(next_node); 12202 appended_new_node = true; 12203 last_appended_node = next_node; 12204 } else { 12205 let new_html = (next_node.outerHTML !== undefined) 12206 ? next_node.outerHTML 12207 : (next_node.textContent || ''); 12208 let live_html = (live_node.outerHTML !== undefined) 12209 ? live_node.outerHTML 12210 : (live_node.textContent || ''); 12211 if (new_html !== live_html) { 12212 root.replaceChild(next_node, live_node); 12213 } 12214 } 12215 i++; 12216 } 12217 // Trim any leftover live nodes â happens on stream_reset 12218 // (we wipe in the handler so this is rare) and on the 12219 // edge case where the model deletes trailing content. 12220 while (live_nodes.length > max) { 12221 root.removeChild(live_nodes[live_nodes.length - 1]); 12222 } 12223 this.$nextTick(() => this.initialize_custom_code_interactions()); 12224 // Forward-streaming auto-follow: new top-level nodes 12225 // were appended this frame, so the user is watching new 12226 // content land. Ask the panel to smooth-scroll the 12227 // window so the bottom of the newly-appended chunk
12228 // stays in view. Edits/replacements skip this branch 12229 // because their scroll position should be preserved. 12230 if (appended_new_node && last_appended_node) { 12231 let panel = (typeof store !== 'undefined') ? store.ai_mcp_panel_component_instance : null; 12232 if (panel && typeof panel.schedule_smooth_scroll_to_streaming_bottom === 'function') { 12233 let scroll_target = (last_appended_node.nodeType === 1) 12234 ? last_appended_node 12235 : root; 12236 panel.schedule_smooth_scroll_to_streaming_bottom(scroll_target); 12237 } 12238 } 12239 }, 12240 apply_streaming_child_min_height(node, index) { 12241 if (!node || node.nodeType !== 1) { 12242 return; 12243 } 12244 let min_height = this.ai_streaming_child_min_heights[index] || 0; 12245 if (min_height <= 0) { 12246 return; 12247 } 12248 node.style.minHeight = min_height + 'px'; 12249 }, 12250 // Snap streaming closed and run the canonical save. Called 12251 // from the SSE `done` event handler. Idempotent â a 12252 // duplicate `done` (e.g. from a cancel race) is safe. 12253 finish_streaming_code(final_code) { 12254 if (typeof final_code !== 'string' && !(this.ai_streaming_buffer || '').trim()) { 12255 this.clear_ai_streaming_starting_state(); 12256 this.ai_streaming = false; 12257 this.release_ai_loading_pane_height_after_render(); 12258 this.ai_streaming_status = 'done'; 12259 this.ai_streaming_buffer = ''; 12260 this._last_flushed_buffer_len = -1; 12261 this.ai_streaming_child_min_heights = []; 12262 if (this._codegen_stream_raf) { 12263 cancelAnimationFrame(this._codegen_stream_raf); 12264 this._codegen_stream_raf = null; 12265 } 12266 if (this.cm_instance) { 12267 this.cm_instance.setOption('readOnly', false); 12268 } 12269 this.ai_stream_event_tick = (this.ai_stream_event_tick + 1) | 0; 12270 return; 12271 } 12272 if (!this.ai_streaming && (this.ai_streaming_status !== 'done')) { 12273 // Late `done` for a stream we never engaged with â 12274 // usually because the panel auto-applied via the 12275 // separate apply_generated_code path. Still settle 12276 // local loading UI so the launcher / pending overlay 12277 // don't get stranded in "Generatingâ¦". 12278 this.clear_ai_streaming_starting_state(); 12279 this.ai_streaming = false; 12280 this.release_ai_loading_pane_height_after_render(); 12281 this.ai_streaming_status = 'done'; 12282 this.ai_streaming_buffer = ''; 12283 this._last_flushed_buffer_len = -1; 12284 this.ai_streaming_child_min_heights = []; 12285 if (this._codegen_stream_raf) { 12286 cancelAnimationFrame(this._codegen_stream_raf); 12287 this._codegen_stream_raf = null; 12288 } 12289 return; 12290 } 12291 this.snap_streaming_done({ final_code: final_code }); 12292 }, 12293 snap_streaming_done(options) { 12294 let opts = options || {}; 12295 let canonical = (typeof opts.final_code === 'string') 12296 ? this.sanitize_custom_code_html(opts.final_code) 12297 : (this.ai_streaming_buffer || ''); 12298 // Editor + persistence writes can throw (CodeMirror 12299 // teardown race, save failures, etc). Wrap so the 12300 // loading-state convergence in `finally` still runs 12301 // and the launcher never gets stranded in 12302 // "Generatingâ¦". 12303 try { 12304 if (this.cm_instance) { 12305 if (this.cm_instance.getValue() !== canonical) { 12306 this._suppress_change_save = true; 12307 this.cm_instance.setValue(canonical); 12308 this._suppress_change_save = false;
12309 } 12310 this.cm_instance.setOption('readOnly', false); 12311 } 12312 this.custom_code_value = canonical; 12313 this.publish_current_code_to_store(); 12314 this.save_ai_applied_custom_code(canonical); 12315 this.mark_ai_code_revision_changed(); 12316 } finally { 12317 this.ai_streaming = false; 12318 this.release_ai_loading_pane_height_after_render(); 12319 this.ai_streaming_status = opts.canceled ? 'aborted_local' : (opts.error ? 'error' : 'done'); 12320 this.ai_streaming_buffer = ''; 12321 this._last_flushed_buffer_len = -1; 12322 this.ai_streaming_child_min_heights = []; 12323 if (this._codegen_stream_raf) { 12324 cancelAnimationFrame(this._codegen_stream_raf); 12325 this._codegen_stream_raf = null; 12326 } 12327 this.ai_stream_event_tick = (this.ai_stream_event_tick + 1) | 0; 12328 } 12329 // Defer the snapshot teardown â the chat panel's `done`
12330 // subscriber will consume it via apply_generated_code in 12331 // the same JS task. Clearing after 5s guards against 12332 // a stale-message Apply click later misreading it as 12333 // the live editor's previous_code. 12334 if (this._clear_pre_code_timer) { 12335 clearTimeout(this._clear_pre_code_timer); 12336 } 12337 this._clear_pre_code_timer = setTimeout(() => { 12338 this.ai_streaming_pre_code = null; 12339 this._clear_pre_code_timer = null; 12340 }, 5000); 12341 this.$nextTick(() => this.initialize_custom_code_interactions()); 12342 }, 12343 // Local soft-cancel. Tells the streams manager to stop 12344 // emitting deltas to subscribers (the upstream LLM keeps 12345 // running for billing + persistence), then snaps our 12346 // local view of the editor to whatever we have so far. 12347 cancel_streaming(options) { 12348 let opts = options || {}; 12349 if (!opts.initiated_remotely && typeof store !== 'undefined' && store.ai_streams) { 12350 let component_id = this.component && this.component.id; 12351 if (component_id) { 12352 store.ai_streams.cancel_local('codegen:' + component_id); 12353 } 12354 } 12355 this.snap_streaming_done({ canceled: true }); 12356 }, 12357 // Echo the current editor code into the shared store so 12358 // the chat panel's apply-state badge can diff against it 12359 // reactively. Setting on `store` directly is safe â the 12360 // panel uses a manual tick for reactivity anyway, and the 12361 // value is also surfaced via a computed in the panel. 12362 publish_current_code_to_store() { 12363 if (typeof store === 'undefined') { 12364 return; 12365 } 12366 if (this.component) { 12367 let config_latest = this.component.get('config_latest') || {}; 12368 let next_config_latest = Object.assign({}, config_latest, { 12369 custom_code: this.custom_code_value || '', 12370 }); 12371 let ai_description = this.extract_ai_description_from_code(this.custom_code_value || ''); 12372 if (ai_description) { 12373 next_config_latest.ai_description = ai_description; 12374 } 12375 this.component.set('config_latest', next_config_latest); 12376 // Do NOT bump store.component_updates here. That field 12377 // drives the `last_update` prop, whose watcher calls 12378 // back into this method â bumping it would re-trigger 12379 // the watcher and spin a reactive feedback loop on 12380 // every edit (runaway re-render â tab hang). The 12381 // canonical save path (save_changes_for_component) 12382 // owns the re-render signal; the live preview here 12383 // re-renders off custom_code_value directly. 12384 if (this.component.id === store.webbuilder_code_active_component_id) { 12385 store.webbuilder_code_active_component_revision = Date.now(); 12386 } 12387 } 12388 if (typeof store.webbuilder_code_active_component_id !== 'undefined' 12389 && this.component 12390 && this.component.id === store.webbuilder_code_active_component_id) { 12391 store.webbuilder_code_current_code = this.custom_code_value || ''; 12392 } 12393 }, 12394 // Mirror this section's active tab (edit/preview) into the shared 12395 // store so the per-section AI launcher (toolbar-option-component) 12396 // can show "Edit with AI" only while the code editor is open. 12397 publish_active_tab_to_store() { 12398 if (typeof store === 'undefined' || !this.preview_element) { 12399 return; 12400 } 12401 let cid = (this.component && typeof this.component.get === 'function') 12402 ? this.component.get('client_id') 12403 : null; 12404 if (!cid) { 12405 return; 12406 } 12407 if (!store.webbuilder_custom_code_tab_by_client_id) { 12408 store.webbuilder_custom_code_tab_by_client_id = {}; 12409 } 12410 store.webbuilder_custom_code_tab_by_client_id[cid] = this.active_tab; 12411 }, 12412 extract_ai_description_from_code(code) { 12413 if (typeof code !== 'string') { 12414 return ''; 12415 } 12416 let description_regex = new RegExp("^\\s*<!--\\s*cs-ai-description:\\s*([\\s\\S]*?)-->", "i"); 12417 let description_match = code.match(description_regex); 12418 if (!description_match || !description_match[1]) { 12419 return ''; 12420 } 12421 return description_match[1].replace(/\s+/g, ' ').trim().slice(0, 160); 12422 }, 12423 }, 12424 }); 12425</script>
12425 12426 12427<style> 12428 .cs-custom-code-wrap{ 12429 position: relative !important; 12430 isolation: isolate; 12431 z-index: 1; 12432 overflow: visible; 12433 } 12434 /* Overlap masks clip short/non-visual sections (script pixels, etc.). */ 12435 .componentPreview > .cs-custom-code-wrap.custom-code-preview-blank{ 12436 -webkit-mask-image: none !important; 12437 mask-image: none !important; 12438 } 12439 .cs-custom-code-live-host{ 12440 display: block; 12441 width: 100%; 12442 } 12443 .cs-custom-code-wrap.first-page-section{ 12444 isolation: auto; 12445 z-index: auto; 12446 } 12447 .cs-custom-code-wrap.active-ai-thread-target{ 12448 z-index: 3; 12449 } 12450 .cs-custom-code-wrap.active-ai-thread-target::after{ 12451 content: ""; 12452 position: absolute; 12453 inset: 0; 12454 z-index: 120; 12455 pointer-events: none; 12456 border: 3px solid rgba(74, 86, 248, 0.62); 12457 box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.75), 0 0 0 4px rgba(74, 86, 248, 0.12); 12458 } 12459 12460 .custom-code-ai-launcher{ 12461 position: absolute !important; 12462 left: auto !important; 12463 right: 125px !important; 12464 bottom: calc(20px + var(--cs-overlap-chrome-lift, 0px)) !important; 12465 z-index: 99 !important; 12466 display: inline-flex !important; 12467 align-items: center !important; 12468 justify-content: center !important; 12469 gap: 8px !important; 12470 pointer-events: auto !important; 12471 touch-action: manipulation !important; 12472 width: auto !important; 12473 min-width: 0 !important; 12474 height: auto !important; 12475 min-height: 45px !important; 12476 max-height: none !important; 12477 padding: 0 15px !important; 12478 border-radius: 6px !important; 12479 background: linear-gradient(135deg, #4a56f8 0%, #7b58ff 100%) !important; 12480 color: #fff !important; 12481 border: none !important; 12482 font-family: Roboto, Arial, Helvetica, sans-serif !important; 12483 font-size: 14px !important; 12484 font-weight: 600 !important; 12485 font-style: normal !important; 12486 line-height: 45px !important; 12487 letter-spacing: 0 !important; 12488 text-transform: none !important; 12489 white-space: nowrap !important; 12490 box-shadow: 0 12px 40px rgba(40, 40, 41, 0.05), 0 0 0 1px rgba(0, 0, 0, 0.01) !important; 12491 transition: transform 0.12s ease, box-shadow 0.12s ease !important; 12492 } 12493 .custom-code-ai-launcher.has-active-stream{ 12494 box-shadow: 0 12px 40px rgba(74, 86, 248, 0.35), 0 0 0 1px rgba(74, 86, 248, 0.25) !important; 12495 } 12496 .custom-code-ai-launcher-spinner{ 12497 display: inline-block; 12498 width: 12px; 12499 height: 12px; 12500 border-radius: 999px; 12501 border: 2px solid rgba(255,255,255,0.4); 12502 border-top-color: #fff; 12503 animation: csCustomCodeStreamSpin 0.9s linear infinite; 12504 } 12505 .custom-code-welcome-generating-spinner{ 12506 display: inline-block; 12507 width: 30px; 12508 height: 30px; 12509 border-radius: 999px; 12510 border: 3px solid rgba(74, 86, 248, 0.2); 12511 border-top-color: #4a56f8; 12512 animation: csCustomCodeStreamSpin 0.9s linear infinite; 12513 } 12514 @keyframes csCustomCodeStreamSpin{ 12515 from { transform: rotate(0deg); } 12516 to { transform: rotate(360deg); } 12517 } 12518 12519 .custom-code-tab-bar{ 12520 position: relative; 12521 z-index: 101; 12522 } 12523 .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){ 12524 padding: 0; 12525 } 12526 12527 /* Welcome composer (Build a custom section) is builder-chrome, 12528 not user-rendered hosted-site content. Hosted sites can apply 12529 a custom theme font that would otherwise cascade in here and 12530 make the heading / textarea look unbranded. Pin everything 12531 inside this pane to the Clubspot dashboard font so the 12532 builder UI is always consistent across sites. !important 12533 because some host themes set font-family with high 12534 specificity on `body *` selectors. */ 12535 .custom-code-welcome-pane, 12536 .custom-code-welcome-pane *:not(i):not([class*="fa-"]), 12537 .custom-code-welcome-pane input, 12538 .custom-code-welcome-pane textarea,
12539 .custom-code-welcome-pane button:not([class*="fa-"]){ 12540 font-family: Roboto, Arial, Helvetica, sans-serif !important; 12541 } 12542 /* Re-assert the Font Awesome stack on the icon elements inside 12543 the welcome pane. The wildcard rule above used `!important` 12544 to beat hosted-site themes that set `font-family` with high 12545 specificity on `body *`, which had the side-effect of also 12546 beating Font Awesome's own selectors (.fa-regular, .fa-solid, 12547 etc.) on the icon `<i>` tags â the icon glyphs are unicode 12548 private-use codepoints, so without the FA font-family they 12549 render as empty tofu boxes. Excluding `i` / `[class*="fa-"]` 12550 from the wildcard above lets FA's own font-family win again, 12551 and this explicit fallback covers any host theme that ALSO 12552 pinned font-family on `i` or `.fa-*` with high specificity. */ 12553 .custom-code-welcome-pane i[class*="fa-"]{ 12554 font-family: "Font Awesome 6 Pro", "Font Awesome 6 Free", "Font Awesome 5 Pro", "Font Awesome 5 Free", "FontAwesome" !important; 12555 } 12556 12557 /* Example-prompt suggestions on the welcome composer. The 12558 ai-mcp-panel.html sheet defines a heavier .ai-composer-pill 12559 look intended for the dashboard chat composer, but that 12560 file doesn't load on hosted-sites pages. Style here is 12561 intentionally low-key â dotted border + normal-weight 12562 muted text â so the suggestions read as inline hints 12563 rather than competing call-to-action buttons. Hover 12564 firms up the border + tints the surface to confirm 12565 they're clickable. */ 12566 .cs-custom-code-wrap .ai-composer-pill{ 12567 display: inline-flex; 12568 align-items: center; 12569 max-width: 100%; 12570 padding: 5px 11px; 12571 border: 1px dotted rgba(8, 19, 51, 0.18); 12572 border-radius: 8px; 12573 background: transparent; 12574 color: #7280a0; 12575 font-size: 12.5px; 12576 line-height: 18px; 12577 font-weight: 400; 12578 letter-spacing: 0; 12579 text-align: left; 12580 white-space: normal; 12581 transition: border-color 0.12s ease, background 0.12s ease, color 0.12s ease; 12582 } 12583 .cs-custom-code-wrap .ai-composer-pill:hover{ 12584 border-color: rgba(74, 86, 248, 0.45); 12585 background: rgba(74, 86, 248, 0.04); 12586 color: #4a56f8; 12587 } 12588 .cs-custom-code-wrap .ai-composer-pill:focus-visible{ 12589 outline: 2px solid rgba(74, 86, 248, 0.35); 12590 outline-offset: 1px; 12591 } 12592 12593 /* Send button on the welcome composer. The ai-mcp-panel.html 12594 sheet defines an .ai-submit-button rule too, but that file 12595 isn't loaded on hosted-sites pages â so we redeclare it 12596 locally to guarantee the blue pill renders here. 12597 - flex centering keeps the paper-plane glyph on the 12598 button's optical center 12599 - paper-plane has visual weight on the right tip, so a 12600 1px left nudge looks balanced */ 12601 .cs-custom-code-wrap .ai-submit-button{ 12602 flex: 0 0 auto; 12603 width: 36px; 12604 min-width: 36px; 12605 height: 36px; 12606 padding: 0; 12607 margin: 0; 12608 border: 0; 12609 border-radius: 999px; 12610 background: #4a56f8; 12611 color: #fff; 12612 display: inline-flex; 12613 align-items: center; 12614 justify-content: center; 12615 line-height: 1; 12616 box-shadow: 0 2px 6px rgba(74, 86, 248, 0.30); 12617 cursor: pointer; 12618 transition: background 0.12s ease, box-shadow 0.12s ease, transform 0.12s ease; 12619 } 12620 .cs-custom-code-wrap .ai-submit-button:hover{ 12621 background: #3a45e0; 12622 box-shadow: 0 4px 12px rgba(74, 86, 248, 0.40); 12623 transform: translateY(-1px); 12624 } 12625 .cs-custom-code-wrap .ai-submit-button .fa-paper-plane{ 12626 font-size: 14px; 12627 line-height: 1; 12628 transform: translateX(-1px); 12629 } 12630 .cs-custom-code-wrap .ai-submit-button.inactive, 12631 .cs-custom-code-wrap .ai-submit-button[disabled]{ 12632 background: #c4cbe2; 12633 color: #fff; 12634 box-shadow: none; 12635 cursor: not-allowed; 12636 transform: none; 12637 } 12638 .cs-custom-code-wrap .ai-submit-button.loading{ 12639 background: #4a56f8; 12640 color: #fff; 12641 cursor: progress; 12642 } 12643 12644 /* Pre-first-delta loading pane. This is intentionally a real 12645 preview pane, not an overlay: while waiting for the first code 12646 chunk, the preview window shows only the dots. The chat panel is 12647 already open for context/progress, so avoid duplicating labels or 12648 exposing the stale welcome/current preview underneath. */ 12649 .custom-code-loading-pane{ 12650 display: flex; 12651 align-items: center; 12652 justify-content: center; 12653 min-height: 420px; 12654 padding: 48px 20px; 12655 background: #fbfcff; 12656 } 12657 .custom-code-loading-pane.is-overlay{ 12658 position: absolute !important; 12659 inset: 0 !important; 12660 z-index: 80 !important; 12661 min-height: 0 !important; 12662 padding: 0 !important; 12663 background: rgba(251, 252, 255, 0.62) !important; 12664 backdrop-filter: blur(1px); 12665 pointer-events: none; 12666 } 12667 .custom-code-empty-state-pane{ 12668 display: flex; 12669 align-items: center; 12670 justify-content: center; 12671 min-height: 420px; 12672 padding: 48px 20px; 12673 background: #fbfcff; 12674 } 12675 .custom-code-nonvisual-pane{ 12676 min-height: 88px; 12677 padding: 20px 16px; 12678 } 12679 .custom-code-empty-state-copy{ 12680 display: inline-flex; 12681 align-items: center; 12682 justify-content: center; 12683 flex-wrap: wrap; 12684 gap: 8px; 12685 max-width: 100%; 12686 text-align: center; 12687 color: #9aa5ba; 12688 font-family: Roboto, Arial, Helvetica, sans-serif !important; 12689 font-size: 14px; 12690 font-style: normal; 12691 line-height: 20px; 12692 font-weight: 500; 12693 } 12694 .custom-code-empty-state-copy i{ 12695 font-size: 14px; 12696 color: #b0bad0; 12697 } 12698 .custom-code-pending-dots{ 12699 display: inline-flex; 12700 align-items: center; 12701 gap: 8px; 12702 } 12703 .custom-code-pending-dots span{ 12704 display: inline-block; 12705 width: 10px; 12706 height: 10px; 12707 border-radius: 50%; 12708 background: #4a56f8; 12709 opacity: 0.35; 12710 animation: csCustomCodePendingPulse 1.2s ease-in-out infinite both; 12711 } 12712 .custom-code-pending-dots span:nth-child(2){ animation-delay: 0.15s; } 12713 .custom-code-pending-dots span:nth-child(3){ animation-delay: 0.30s; }
12714 @keyframes csCustomCodePendingPulse{ 12715 0%, 80%, 100% { transform: scale(0.6); opacity: 0.35; } 12716 40% { transform: scale(1.0); opacity: 1.0; } 12717 } 12718 12719 .custom-code-ai-launcher:hover{ 12720 transform: translateY(-1px) !important; 12721 box-shadow: 0 3px 8px rgba(74, 86, 248, 0.34) !important; 12722 } 12723 12724 .custom-code-ai-launcher .fa-sparkles{ 12725 display: inline-block !important; 12726 flex: 0 0 auto !important; 12727 font-family: "Font Awesome 6 Pro", "Font Awesome 6 Free", "Font Awesome 5 Pro", "Font Awesome 5 Free", "FontAwesome" !important; 12728 font-size: 14px !important; 12729 line-height: 1 !important; 12730 margin: 0 7px 0 0 !important; 12731 } 12732 12733 .custom-code-ai-launcher-label{ 12734 display: inline-block !important; 12735 flex: 0 0 auto !important; 12736 font-family: Roboto, Arial, Helvetica, sans-serif !important; 12737 font-style: normal !important; 12738 letter-spacing: 0 !important; 12739 text-transform: none !important; 12740 } 12741 12742 /* 12743 Placeholder pick/upload/paste overlay. Only rendered in PREVIEW 12744 mode by `decorate_asset_placeholders` â the live (public) site 12745 never sees this class because the overlay element is injected 12746 into the rendered preview DOM and is never persisted back to 12747 the `custom_code` source. 12748 */ 12749 .cs-asset-placeholder-host{ 12750 position: relative; 12751 } 12752 .custom-code-preview-pane .cs-asset-background-placeholder-host:hover{ 12753 outline: 2px dashed rgba(74, 86, 248, 0.55) !important; 12754 outline-offset: -4px !important; 12755 } 12756 .custom-code-preview-pane .cs-asset-background-placeholder-chip{ 12757 position: absolute !important; 12758 top: 10px !important; 12759 right: 10px !important; 12760 z-index: 10000 !important; 12761 display: inline-flex !important; 12762 align-items: center !important; 12763 justify-content: center !important; 12764 width: 34px !important; 12765 height: 34px !important; 12766 padding: 0 !important; 12767 margin: 0 !important; 12768 border: 1px solid rgba(74, 86, 248, 0.45) !important; 12769 border-radius: 999px !important; 12770 background: rgba(255, 255, 255, 0.88) !important; 12771 color: #4a56f8 !important; 12772 box-shadow: 0 6px 18px rgba(8, 19, 51, 0.14) !important; 12773 cursor: pointer !important; 12774 } 12775 .custom-code-preview-pane .cs-asset-background-placeholder-chip:hover{ 12776 background: #ffffff !important; 12777 border-color: rgba(74, 86, 248, 0.85) !important; 12778 } 12779 .custom-code-preview-pane .cs-asset-placeholder-overlay{ 12780 position: absolute !important; 12781 inset: 0 !important; 12782 z-index: 125 !important; 12783 display: flex !important; 12784 flex-direction: column !important; 12785 align-items: center !important; 12786 justify-content: center !important; 12787 gap: 4px !important; 12788 margin: 0 !important; 12789 padding: 12px !important; 12790 border: 2px dashed rgba(74, 86, 248, 0.55) !important; 12791 border-radius: 8px !important; 12792 background: rgba(74, 86, 248, 0.08) !important; 12793 color: #2a2f5a !important; 12794 font-family: Roboto, Arial, Helvetica, sans-serif !important; 12795 font-size: 13px !important; 12796 font-style: normal !important; 12797 font-weight: 500 !important; 12798 line-height: 1.25 !important; 12799 letter-spacing: 0 !important; 12800 text-align: center !important; 12801 white-space: normal !important; 12802 cursor: pointer !important; 12803 backdrop-filter: blur(2px) !important; 12804 transition: background 0.12s ease, border-color 0.12s ease !important; 12805 } 12806 .custom-code-preview-pane .cs-asset-placeholder-overlay:hover{ 12807 background: rgba(74, 86, 248, 0.16) !important; 12808 border-color: rgba(74, 86, 248, 0.85) !important; 12809 } 12810 .custom-code-preview-pane .cs-asset-placeholder-icon{ 12811 display: inline-flex !important; 12812 align-items: center !important; 12813 justify-content: center !important; 12814 width: 28px !important; 12815 height: 28px !important; 12816 flex: 0 0 28px !important; 12817 margin-bottom: 4px !important; 12818 border-radius: 50% !important; 12819 background: rgba(255, 255, 255, 0.65) !important; 12820 color: #4a56f8 !important; 12821 font-size: 14px !important; 12822 font-style: normal !important; 12823 line-height: 1 !important; 12824 letter-spacing: 0 !important; 12825 } 12826 .custom-code-preview-pane .cs-asset-placeholder-label{ 12827 display: inline-block !important; 12828 padding: 4px 10px !important; 12829 border-radius: 999px !important; 12830 background: rgba(255, 255, 255, 0.92) !important; 12831 color: #2a2f5a !important; 12832 font-family: Roboto, Arial, Helvetica, sans-serif !important; 12833 font-size: 13px !important; 12834 font-style: normal !important; 12835 font-weight: 600 !important; 12836 line-height: 1.2 !important; 12837 letter-spacing: 0 !important; 12838 min-width: 118px !important; 12839 max-width: 180px !important; 12840 white-space: normal !important; 12841 } 12842 12843 /* 12844 "Change" overlay for filled placeholder slots 12845 (`[data-cs-asset-attached]`). Light-DOM fallback only â preview 12846 chrome for the isolated shadow tree is in 12847 CS_CUSTOM_CODE_PREVIEW_CHROME_CSS. Always visible, matching 12848 image-with-text / image-grid slot chips. 12849 */ 12850 .cs-asset-attached-host{ 12851 position: relative; 12852 } 12853 .custom-code-preview-pane .cs-asset-change-button{ 12854 position: absolute !important; 12855 top: 8px !important; 12856 right: 8px !important; 12857 z-index: 10000 !important; 12858 display: inline-flex !important; 12859 align-items: center !important; 12860 justify-content: center !important; 12861 padding: 6px 10px !important; 12862 margin: 0 !important; 12863 border: none !important; 12864 border-radius: 6px !important; 12865 background: rgba(20, 24, 45, 0.82) !important; 12866 color: #fff !important; 12867 font-family: Roboto, Arial, Helvetica, sans-serif !important; 12868 font-size: 12px !important; 12869 font-weight: 600 !important; 12870 line-height: 1 !important; 12871 cursor: pointer !important; 12872 opacity: 0.92 !important; 12873 pointer-events: auto !important; 12874 box-shadow: 0 4px 12px rgba(8, 19, 51, 0.22) !important; 12875 transition: opacity 0.12s ease, background 0.12s ease !important; 12876 } 12877 .custom-code-preview-pane .cs-asset-change-button:hover, 12878 .custom-code-preview-pane .cs-asset-change-button:focus{ 12879 opacity: 1 !important; 12880 background: rgba(20, 24, 45, 0.95) !important; 12881 } 12882 .custom-code-preview-pane .cs-ai-asset-hover-highlight{ 12883 outline: 2px solid rgba(74, 86, 248, 0.32) !important; 12884 outline-offset: 4px !important; 12885 box-shadow: 0 0 0 6px rgba(74, 86, 248, 0.08) !important; 12886 transition: outline-color 0.12s ease, box-shadow 0.12s ease !important; 12887 } 12888 12889 /* 12890 Click-to-edit text leaves. Only active in PREVIEW mode â the 12891 host class is added by `decorate_editable_text_nodes` after 12892 every preview re-render and never persisted to source. The 12893 hover affordance is a soft dashed outline with cursor:pointer 12894 so admins can tell the text is editable without the section 12895 feeling like a form. 12896 */ 12897 .custom-code-preview-pane .cs-text-editable-host{ 12898 cursor: text !important; 12899 position: relative !important; 12900 transition: outline-color 0.12s ease, background-color 0.12s ease !important; 12901 outline: 1px dashed transparent !important; 12902 outline-offset: 2px !important; 12903 border-radius: 3px !important; 12904 } 12905 .custom-code-preview-pane .cs-text-editable-host:hover{ 12906 outline-color: rgba(74, 86, 248, 0.55) !important; 12907 background-color: rgba(74, 86, 248, 0.06) !important; 12908 } 12909 .custom-code-preview-pane .cs-text-editable-host.cs-text-editable-active{ 12910 outline: 1px solid rgba(74, 86, 248, 0.85) !important; 12911 background-color: rgba(74, 86, 248, 0.12) !important; 12912 } 12913 12914 /* 12915 Inline text-edit popover. Lives at document.body level (fixed 12916 positioning) so it isn't clipped by overflow:hidden ancestors 12917 in the preview pane. Closed on outside click, ESC, scroll, or 12918 a Vue re-render of the preview. 12919 */ 12920 .cs-text-edit-popover{ 12921 position: fixed !important; 12922 z-index: 10000 !important; 12923 box-sizing: border-box !important; 12924 max-width: 92vw !important; 12925 padding: 10px !important; 12926 background: #fff !important; 12927 border: 1px solid rgba(20, 24, 45, 0.12) !important; 12928 border-radius: 8px !important; 12929 box-shadow: 0 18px 48px rgba(20, 24, 45, 0.18), 0 2px 6px rgba(20, 24, 45, 0.08) !important; 12930 font-family: inherit !important; 12931 font-size: 13px !important; 12932 color: #2a2f5a !important; 12933 } 12934 .cs-text-edit-popover-header{ 12935 display: block !important; 12936 margin-bottom: 6px !important; 12937 font-size: 11px !important; 12938 font-weight: 600 !important; 12939 text-transform: uppercase !important; 12940 letter-spacing: 0.04em !important; 12941 color: #4a4f6a !important; 12942 } 12943 .cs-text-edit-popover .cs-text-edit-textarea{ 12944 display: block !important; 12945 box-sizing: border-box !important; 12946 width: 100% !important; 12947 min-height: 60px !important; 12948 max-height: 240px !important; 12949 padding: 8px 10px !important; 12950 margin: 0 !important; 12951 border: 1px solid rgba(20, 24, 45, 0.18) !important; 12952 border-radius: 6px !important; 12953 background: #fff !important; 12954 color: #14182d !important; 12955 font-family: inherit !important; 12956 font-size: 13px !important; 12957 line-height: 1.4 !important; 12958 resize: vertical !important; 12959 outline: none !important; 12960 box-shadow: none !important; 12961 } 12962 .cs-text-edit-popover .cs-text-edit-textarea:focus{ 12963 border-color: rgba(74, 86, 248, 0.7) !important; 12964 box-shadow: 0 0 0 3px rgba(74, 86, 248, 0.15) !important; 12965 } 12966 .cs-text-edit-popover .cs-text-edit-url-field{ 12967 display: block !important; 12968 margin: 8px 0 0 0 !important; 12969 } 12970 .cs-text-edit-popover .cs-text-edit-url-label{ 12971 display: block !important; 12972 margin: 0 0 4px 0 !important; 12973 color: #4a4f6a !important; 12974 font-size: 11px !important; 12975 font-weight: 600 !important; 12976 line-height: 14px !important; 12977 } 12978 .cs-text-edit-popover .cs-text-edit-url-input{ 12979 display: block !important; 12980 box-sizing: border-box !important; 12981 width: 100% !important; 12982 height: 34px !important; 12983 padding: 7px 10px !important; 12984 margin: 0 !important; 12985 border: 1px solid rgba(20, 24, 45, 0.18) !important; 12986 border-radius: 6px !important; 12987 background: #fff !important; 12988 color: #14182d !important; 12989 font-family: inherit !important; 12990 font-size: 13px !important; 12991 line-height: 18px !important; 12992 outline: none !important; 12993 box-shadow: none !important; 12994 } 12995 .cs-text-edit-popover .cs-text-edit-url-input:focus{ 12996 border-color: rgba(74, 86, 248, 0.7) !important; 12997 box-shadow: 0 0 0 3px rgba(74, 86, 248, 0.15) !important; 12998 } 12999 .cs-text-edit-popover-footer{ 13000 display: flex !important; 13001 align-items: center !important; 13002 gap: 6px !important; 13003 margin-top: 8px !important; 13004 } 13005 .cs-text-edit-popover-footer-spacer{ 13006 flex: 1 1 auto !important; 13007 } 13008 .cs-text-edit-popover-footer button{ 13009 appearance: none !important; 13010 -webkit-appearance: none !important; 13011 display: inline-flex !important; 13012 align-items: center !important; 13013 justify-content: center !important; 13014 padding: 6px 12px !important; 13015 margin: 0 !important; 13016 border-radius: 6px !important; 13017 border: 1px solid transparent !important; 13018 font-family: inherit !important; 13019 font-size: 12px !important; 13020 font-weight: 600 !important; 13021 line-height: 1 !important; 13022 cursor: pointer !important; 13023 transition: background 0.12s ease, transform 0.12s ease, border-color 0.12s ease, color 0.12s ease !important; 13024 } 13025 .cs-text-edit-popover .cs-text-edit-ask{ 13026 padding: 6px 8px !important; 13027 background: transparent !important; 13028 border-color: transparent !important; 13029 color: #4a56f8 !important; 13030 gap: 6px !important; 13031 } 13032 .cs-text-edit-popover .cs-text-edit-ask:hover{ 13033 background: rgba(74, 86, 248, 0.08) !important; 13034 color: #3b46d6 !important; 13035 } 13036 .cs-text-edit-popover .cs-text-edit-ask .fa-sparkles{ 13037 font-size: 12px !important; 13038 } 13039 .cs-text-edit-popover .cs-text-edit-cancel{ 13040 background: #fff !important; 13041 border-radius: 999px !important; 13042 border-color: rgba(20, 24, 45, 0.18) !important; 13043 color: #4a4f6a !important; 13044 } 13045 .cs-text-edit-popover .cs-text-edit-cancel:hover{ 13046 background: rgba(20, 24, 45, 0.04) !important; 13047 } 13048 .cs-text-edit-popover .cs-text-edit-save{ 13049 background: linear-gradient(135deg, #4a56f8 0%, #7b58ff 100%) !important; 13050 border-radius: 999px !important; 13051 color: #fff !important; 13052 border-color: transparent !important; 13053 } 13054 .cs-text-edit-popover .cs-text-edit-save:hover{ 13055 transform: translateY(-1px) !important; 13056 } 13057</style> 13058
13058<script type="text/html" id="template-text"> 13059 <div 13060 v-memo="[last_update, last_element_update]" 13061 :class="[{'hidden': (column_index + 1) > number_of_columns }, 'text-column_' + column_index]" 13062 class="cs-text_text-wrap-column cs-element-stack" 13063 :data-column="column_index" 13064 > 13065 <template 13066 v-for="(element, element_index) in nice_elements" 13067 :key="element.client_id || element_index" 13068 > 13069 <template v-if="preview_element"> 13070 <preview-element-component 13071 :element="element" 13072 :element_type="element.type" 13073 :element_content="element.content" 13074 :element_column_count="element.column_count" 13075 :element_column_gap="element.column_gap" 13076 :column_index="column_index" 13077 :element_index="element_index" 13078 :componentObject="component" 13079 ></preview-element-component> 13080 </template> 13081 <template v-if="!preview_element"> 13082 <element-component 13083 :element_type="element.type" 13084 :element_content="element.content" 13085 :element_column_count="element.column_count" 13086 :element_column_gap="element.column_gap" 13087 :diff_changed="element._wb_diff_changed === true" 13088 :element_index="element_index" 13089 :section_element_animation="section_element_animation" 13090 ></element-component> 13091 </template> 13092 </template> 13093 <!-- handle case where there are no elements (admin only) --> 13094 <template v-if="preview_element && elements && elements.length == 0"> 13095 <div class="no-elements-wrap"> 13096 <div 13097 class="tapped-insert-new-element-relative" 13098 @click="tapped_add_element( $event )" 13099 >+</div> 13100 </div> 13101 </template> 13102 </div> 13103</script>
13103 13104
13105<script> 13106 app_content.component('text-component', { 13107 template: '#template-text', 13108 data() { 13109 return {} 13110 }, 13111 props: { 13112 number_of_columns: [String, Number], 13113 preview_element: Boolean, 13114 component: Object, 13115 last_update: Number, //last component update 13116 last_element_update: Number, //last element update 13117 elements: Array, 13118 column_prop: String, 13119 column_index: Number, 13120 section_element_animation: { 13121 type: String, 13122 default: '', 13123 }, 13124 }, 13125 methods:{ 13126 tapped_add_element(){ 13127 var params = {}; 13128 params["component"] = this.component; 13129 params["insert_after"] = 0; 13130 params["column_index"] = this.column_index; 13131 handle_add_element( params ); 13132 } 13133 }, 13134 computed: { 13135 nice_elements(){ 13136 var nice_elements = []; 13137 13138 if (this.elements){ 13139 nice_elements = this.elements; 13140 } else if (this.column_prop && typeof this.column_prop === 'string'){ 13141 nice_elements = JSON.parse(unescape(decodeURIComponent(this.column_prop))); 13142 } 13143 return nice_elements; 13144 } 13145 }, 13146 }); 13147</script>
13147
13147<script type="text/html" id="template-image-with-text"> 13148 <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"> 13149 <template v-if="!loading && left_right_orientation === 'right'"> 13150 <!-- text first (image on the right) --> 13151 <div class="cs-image-with-text_text-wrap flexGrowOne"> 13152 <div :style="element_style_string"> 13153 <div v-memo="[last_update, last_element_update]" class="element-wrapper cs-element-stack"> 13154 <template 13155 v-for="(element, element_index) in elements_obj" 13156 :key="element.client_id || element_index" 13157 > 13158 <template v-if="preview_element"> 13159 <preview-element-component 13160 :element="element" 13161 :element_type="element.type" 13162 :element_content="element.content" 13163 :element_column_count="element.column_count" 13164 :element_column_gap="element.column_gap" 13165 :element_index="element_index" 13166 :componentObject="component" 13167 ></preview-element-component> 13168 </template> 13169 <template v-if="!preview_element"> 13170 <element-component 13171 :element_type="element.type" 13172 :element_content="element.content" 13173 :element_column_count="element.column_count" 13174 :element_column_gap="element.column_gap" 13175 :diff_changed="element._wb_diff_changed === true" 13176 :element_index="element_index" 13177 :section_element_animation="section_element_animation" 13178 ></element-component> 13179 </template> 13180 </template> 13181 <!-- handle case where there are no elements (admin only) --> 13182 <template v-if="preview_element && elements_obj && elements_obj.length == 0"> 13183 <div class="no-elements-wrap"> 13184 <div 13185 class="tapped-insert-new-element-relative" 13186 @click="tapped_add_element( $event )" 13187 >+</div> 13188 </div> 13189 </template> 13190 </div> 13191 </div> 13192 </div> 13193 <div class="cs-image-with-text_image-wrap flexGrowOne"> 13194 <div class="cs-image-with-text_image-wrap-inner"> 13195 <div :style="check_full_background()" class="cs-image-with-text_image-slider-outer"> 13196 <div 13197 v-if="formatting_obj.background_size !== 'full'" 13198 :class="['cs-image-with-text-image-mat', { background: image_mat === 'show' }]" 13199 ></div> 13200 <image-slider-component 13201 :client_id="client_id" 13202 :formatting="formatting" 13203 :images_string="images_string" 13204 :preview_element="preview_element" 13205 :component="component" 13206 :last_update="last_update" 13207 ></image-slider-component> 13208 </div> 13209 </div> 13210 </div> 13211 </template> 13212 <template v-if="!loading && left_right_orientation === 'left'"> 13213 <!-- image first (text on the right) --> 13214 <div class="cs-image-with-text_image-wrap flexGrowOne"> 13215 <div class="cs-image-with-text_image-wrap-inner"> 13216 <div :style="check_full_background()" class="cs-image-with-text_image-slider-outer"> 13217 <div 13218 v-if="formatting_obj.background_size !== 'full'" 13219 :class="['cs-image-with-text-image-mat', { background: image_mat === 'show' }]" 13220 ></div> 13221 <image-slider-component 13222 :client_id="client_id" 13223 :formatting="formatting" 13224 :images_string="images_string" 13225 :preview_element="preview_element" 13226 :component="component" 13227 :last_update="last_update" 13228 ></image-slider-component> 13229 </div> 13230 </div> 13231 </div> 13232 <div class="cs-image-with-text_text-wrap flexGrowOne"> 13233 <div :style="element_style_string"> 13234 <div 13235 v-memo="[last_update, last_element_update]" 13236 class="element-wrapper cs-element-stack" 13237 > 13238 <template 13239 v-for="(element, element_index) in elements_obj" 13240 :key="element.client_id || element_index" 13241 > 13242 <template v-if="preview_element"> 13243 <preview-element-component 13244 :element="element" 13245 :element_type="element.type"
13246 :element_content="element.content" 13247 :element_column_count="element.column_count" 13248 :element_column_gap="element.column_gap" 13249 :element_index="element_index" 13250 :componentObject="component" 13251 ></preview-element-component> 13252 </template> 13253 <template v-if="!preview_element"> 13254 <element-component 13255 :element_type="element.type" 13256 :element_content="element.content" 13257 :element_column_count="element.column_count" 13258 :element_column_gap="element.column_gap" 13259 :diff_changed="element._wb_diff_changed === true" 13260 :element_index="element_index" 13261 :section_element_animation="section_element_animation" 13262 ></element-component> 13263 </template> 13264 </template> 13265 <!-- handle case where there are no elements (admin only) --> 13266 <template v-if="preview_element && elements_obj && elements_obj.length == 0"> 13267 <div class="no-elements-wrap"> 13268 <div 13269 class="tapped-insert-new-element-relative" 13270 @click="tapped_add_element( $event )" 13271 >+</div> 13272 </div> 13273 </template> 13274 </div> 13275 </div> 13276 </div> 13277 </template> 13278 </div> 13279</script>
13279 13280 13281<style> 13282 .delay5s{ 13283 animation-delay: 0.5s; 13284 } 13285 13286 .view_hidden{ 13287 opacity: 0; 13288 } 13289 13290 @keyframes fade-left-right { 13291 0% { 13292 opacity: 0; 13293 transform: translateX(-100%) scaleX(0.5); 13294 } 13295 100% { 13296 opacity: 1; 13297 transform: translateX(0) scaleX(1); 13298 } 13299 } 13300 13301 @keyframes fade-right-left { 13302 0% { 13303 opacity: 0; 13304 transform: translateX(100%) scaleX(0.5); 13305 } 13306 100% { 13307 opacity: 1; 13308 transform: translateX(0) scaleX(1); 13309 } 13310 } 13311 13312 .fade-left-right { 13313 animation: fade-left-right 1s ease-in-out forwards; 13314 } 13315 13316 .fade-right-left { 13317 animation: fade-right-left 1s ease-in-out backwards; 13318 } 13319</style> 13320
13321<script> 13322 app_content.component('image-with-text-component', { 13323 template: '#template-image-with-text', 13324 data() { 13325 return { 13326 is_admin: false, 13327 loading: true, 13328 } 13329 }, 13330 props: { 13331 class_string: String, 13332 style_string: String, 13333 element_style_string: String, 13334 elements_string: [String, Object], 13335 left_right_orientation: String, 13336 image_mat: String, 13337 //image slider 13338 client_id: String, 13339 formatting: String, 13340 images_string: String, 13341 //admin 13342 preview_element: Boolean, 13343 component: Object, 13344 last_update: Number, //last component update 13345 last_element_update: Number, //last element update 13346 tab_element: Boolean, 13347 }, 13348 mounted() { 13349 this.loading = false; 13350 this.is_admin = window.location.href.indexOf('website-builder') !== -1; 13351 }, 13352 updated(){ 13353 this.createIntersectionObserver(); 13354 }, 13355 methods: { 13356 check_full_background(){ 13357 if ( this.formatting_obj && this.formatting_obj.background_size === 'full' ){ 13358 return { 13359 top: 0, 13360 bottom: 0, 13361 left: 0, 13362 right: 0, 13363 } 13364 } 13365 }, 13366 tapped_add_element(){ 13367 var params = {}; 13368 params["component"] = this.component; 13369 params["insert_after"] = 0; 13370 params["column_index"] = 0; 13371 handle_add_element( params ); 13372 }, 13373 createIntersectionObserver(){ 13374 const options = { 13375 root: null, 13376 rootMargin: '0px', 13377 threshold: 0.5 //trigger animation when at least 50% of the target is visible 13378 }; 13379 const observer = new IntersectionObserver(this.handleIntersection, options); 13380 //observe the target element 13381 observer.observe(this.$refs.animationTarget); 13382 }, 13383 handleIntersection(entries, observer) { 13384 entries.forEach(entry => { 13385 //check if target is intersecting 13386 if (entry.isIntersecting) { 13387 //apply animation class 13388 entry.target.classList.add(this.formatting_obj.animation); 13389 entry.target.classList.remove('view_hidden'); 13390 //stop observing to prevent re-triggering 13391 observer.unobserve(entry.target); 13392 } 13393 }); 13394 }, 13395 }, 13396 computed: { 13397 elements_obj(){ 13398 if (this.elements_string && typeof this.elements_string === 'string'){ 13399 return JSON.parse(unescape(decodeURIComponent(this.elements_string))); 13400 } else if (this.elements_string){ 13401 return this.elements_string; 13402 } else { 13403 return {}; 13404 } 13405 }, 13406 formatting_obj(){ 13407 if (this.formatting){ 13408 return JSON.parse(this.formatting); 13409 } else { 13410 return {}; 13411 } 13412 }, 13413 section_element_animation(){ 13414 let component_animation = resolve_web_builder_component_animation(this.formatting_obj); 13415 return section_element_animation_token(component_animation); 13416 }, 13417 }, 13418 }); 13419</script>
13419
13419<script type="text/html" id="template-image-slider"> 13420 <div class="cs-image-slider-wrap background"> 13421 <div 13422 class="cs-image-slider" 13423 :class="[ 'cs-image-slider-' + filtered_images_length, 'cs-image-slider_' + client_id ]" 13424 :data-client-id="client_id" 13425 data-interval="8000" 13426 data-slider-index="0" 13427 > 13428 <!-- now add the images --> 13429 <template v-if="!loading && filtered_images_length > 0" v-for="(image, index) in filtered_images"> 13430 <template v-if="image && typeof image !== 'object'"> 13431 <template v-if="image && image.indexOf('video') >= 1"> 13432 <div 13433 class="cs-image-slider-card" 13434 :class="[ 13435 'cs-image-slider-card-' + index, 13436 { 'active': index === display_index }, 13437 { 'cs-image-slot-attached-host': preview_element && !is_hero_banner } 13438 ]" 13439 style="display:flex;align-items:center;justify-content:center;" 13440 @click.stop="preview_element && !is_hero_banner && change_image_slot(index)" 13441 > 13442 <video class="cs-background-video" autoplay playsinline loop muted :src="image" style="display:initial;"></video> 13443 <button 13444 v-if="preview_element && !is_hero_banner" 13445 type="button" 13446 class="cs-image-slot-change-chip exclude-from-custom-button-styles" 13447 @click.stop="change_image_slot(index)" 13448 ><i class="fa-regular fa-image"></i><span>Change</span></button> 13449 </div> 13450 </template> 13451 <template v-if="image && typeof image === 'string'"> 13452 <div 13453 class="cs-image-slider-card" 13454 :class="[ 13455 'cs-image-slider-card-' + index, 13456 { 'active': index === display_index }, 13457 { 'cs-image-slot-attached-host': preview_element && !is_hero_banner } 13458 ]" 13459 @click.stop="preview_element && !is_hero_banner && change_image_slot(index)" 13460 > 13461 <div class="cs-background-image-photo" :style="`background-image: url(${image})`"></div> 13462 <button 13463 v-if="preview_element && !is_hero_banner" 13464 type="button" 13465 class="cs-image-slot-change-chip exclude-from-custom-button-styles" 13466 @click.stop="change_image_slot(index)" 13467 ><i class="fa-regular fa-image"></i><span>Change</span></button> 13468 </div> 13469 </template> 13470 </template> 13471 <template v-if="is_image_with_text && !image"> 13472 <div 13473 class="cs-image-slider-card" 13474 :class="[ 13475 'cs-image-slider-card-' + index, 13476 { 'active': index === display_index }, 13477 { 'cs-image-slot-placeholder-host': preview_element } 13478 ]" 13479 @click.stop="fill_image_slot(index)" 13480 > 13481 <div v-if="preview_element" class="cs-image-slot-placeholder"> 13482 <span class="cs-image-slot-placeholder-icon"><i class="fa-regular fa-image"></i></span> 13483 <span class="cs-image-slot-placeholder-label">Add image</span> 13484 </div> 13485 <div v-else class="cs-background-image-photo default-image-icon" style="background-size:30%!important;"></div> 13486 </div> 13487 </template> 13488 </template> 13489 <template v-for="(layer, layer_index) in overlay_layers" :key="'overlay-layer-' + layer_index + '-' + (layer.class_string || layer.kind || '')"> 13490 <div 13491 class="cs-image-overlay" 13492 :class="(layer.class_string || '').trim().split(/\s+/).filter(
13492Boolean)" 13493 :style="layer.style_obj" 13494 ></div> 13495 </template> 13496 </div> 13497 </div> 13498</script>
13498 13499 13500<style> 13501 /* Preview-only "Change" chip for hero-banner / image-with-text slots. */ 13502 .cs-image-slot-attached-host { 13503 cursor: pointer; 13504 } 13505 .cs-image-slot-change-chip { 13506 position: absolute; 13507 top: 12px; 13508 right: 12px; 13509 z-index: 2000; 13510 display: inline-flex; 13511 align-items: center; 13512 gap: 5px; 13513 padding: 6px 10px; 13514 border: none; 13515 border-radius: 6px; 13516 background: rgba(20, 24, 45, 0.82); 13517 color: #fff; 13518 font-family: 'Roboto', Arial, Helvetica, sans-serif; 13519 font-size: 12px; 13520 font-weight: 600; 13521 font-style: normal; 13522 line-height: 1; 13523 letter-spacing: 0; 13524 text-transform: none; 13525 cursor: pointer; 13526 opacity: 0.92; 13527 pointer-events: auto; 13528 box-shadow: 0 4px 12px rgba(8, 19, 51, 0.22); 13529 transition: opacity 0.12s ease, background 0.12s ease; 13530 } 13531 .cs-image-slot-attached-host:hover .cs-image-slot-change-chip, 13532 .cs-image-slot-change-chip:hover, 13533 .cs-image-slot-change-chip:focus { 13534 opacity: 1; 13535 background: rgba(20, 24, 45, 0.95); 13536 } 13537</style> 13538
13539<script> 13540 /* ----- COMPONENT ----- */ 13541 app_content.component('image-slider-component', { 13542 template: '#template-image-slider', 13543 data() { 13544 return { 13545 loading: true, 13546 display_index: 0, 13547 } 13548 }, 13549 props: { 13550 client_id: String, 13551 formatting: String, 13552 images_string: String, 13553 //for preview element 13554 preview_element: { 13555 type: Boolean, 13556 default: false, 13557 }, 13558 component: { 13559 type: Object, 13560 default: null, 13561 }, 13562 last_update: { 13563 type: Number, 13564 default: null, 13565 }, 13566 }, 13567 mounted() { 13568 this.loading = false; 13569 this.sync_display_index_with_config_and_images(); 13570 }, 13571 methods: { 13572 parsed_images(){ 13573 if( !this.images_string ){ 13574 return []; 13575 } 13576 try { 13577 let parsed = JSON.parse(this.images_string); 13578 return Array.isArray(parsed) ? parsed : []; 13579 } catch (error) { 13580 console.error('Error parsing image slider images: ', error); 13581 return []; 13582 } 13583 }, 13584 // image-with-text keeps an empty slot in preview for the Add-image card. 13585 // Hero has no image-layer fill â empty state is the section color_scheme. 13586 preview_images_with_slots(){ 13587 let parsed = this.parsed_images(); 13588 if( !this.preview_element || (!this.is_hero_banner && !this.is_image_with_text) ){ 13589 return parsed; 13590 } 13591 if( this.is_hero_banner ){ 13592 return parsed.filter((image) => { 13593 if( image ){ 13594 return true; 13595 } 13596 return false; 13597 }); 13598 } 13599 if( !parsed.length ){ 13600 return [null]; 13601 } 13602 return parsed; 13603 }, 13604 return_first_filled_index( images ){ 13605 if( !Array.isArray(images) || images.length === 0 ){ 13606 return 0; 13607 } 13608 for( let i = 0; i < images.length; i++ ){ 13609 if( images[i] ){ 13610 return i; 13611 } 13612 } 13613 return 0; 13614 }, 13615 sync_display_index_with_config_and_images(){ 13616 let config_latest = this.component ? (this.component.get('config_latest') || {}) : {}; 13617 let saved_display_index = Number(config_latest.display_index); 13618 let has_saved_display_index = config_latest.display_index === 0 || !isNaN(saved_display_index); 13619 let fallback_index = this.display_index; 13620 if( this.preview_element && this.is_image_with_text ){ 13621 fallback_index = this.return_first_filled_index(this.parsed_images()); 13622 } 13623 let target_index = has_saved_display_index ? saved_display_index : fallback_index; 13624 let max_index = this.filtered_images_length > 0 ? this.filtered_images_length - 1 : 0; 13625 this.display_index = Math.min(Math.max(target_index, 0), max_index); 13626 }, 13627 // Write `value` (a URL string or null) into slot `index`, preserving 13628 // the fixed 5-slot string|null shape the settings panel expects. 13629 write_image_slot( index, value ){ 13630 let component = this.component; 13631 if( !component ){ 13632 return; 13633 } 13634 let current = (component.get('config_latest') && component.get('config_latest').images) || []; 13635 let next = []; 13636 for( let i = 0; i <= 4; i++ ){ 13637 if( i === index ){ 13638 next.push( value ); 13639 } else if( current[i] ){ 13640 next.push( current[i] ); 13641 } else { 13642 next.push( null ); 13643 } 13644 } 13645 this.display_index = index; 13646 save_changes_for_component( component, 'images', next ); 13647 save_changes_for_component( component, 'display_index', index ); 13648 }, 13649 // Empty-slot picker for image-with-text / hero-banner. 13650 fill_image_slot( index ){ 13651 if( !this.preview_element ){ 13652 return; 13653 } 13654 if( typeof webbuilder_open_image_slot_picker !== 'function' ){ 13655 if( typeof displayMessage === 'function' ){ 13656 displayMessage('Image picker is not loaded. Refresh the page and try again.', true, 5000); 13657 } 13658 return; 13659 } 13660 if( !this.component ){ 13661 return; 13662 } 13663 webbuilder_open_image_slot_picker({ 13664 label: 'image', 13665 mode: 'placeholder', 13666 on_pick: ( url ) => { 13667 if( !url ){ 13668 return; 13669 } 13670 this.write_image_slot( index, url ); 13671 }, 13672 }); 13673 }, 13674 // Filled-slot "Change" action â swap or remove the image in slot 13675 // `index` via the shared picker (pick/paste/copy-external/remove), 13676 // matching custom-code's attached-asset menu. 13677 change_image_slot( index ){ 13678 if( !this.preview_element ){ 13679 return; 13680 } 13681 if( typeof webbuilder_open_image_slot_picker !== 'function' ){ 13682 if( typeof displayMessage === 'function' ){ 13683 displayMessage('Image picker is not loaded. Refresh the page and try again.', true, 5000); 13684 } 13685 return; 13686 } 13687 let component = this.component; 13688 if( !component ){ 13689 return; 13690 } 13691 let current = (component.get('config_latest') && component.get('config_latest').images) || []; 13692 let current_url = typeof current[index] === 'string' ? current[index] : ''; 13693 webbuilder_open_image_slot_picker({ 13694 label: 'image', 13695 mode: 'attached', 13696 current_url: current_url, 13697 on_pick: ( url ) => { 13698 if( !url ){ 13699 return; 13700 } 13701 this.write_image_slot( index, url ); 13702 }, 13703 on_remove: () => { 13704 this.write_image_slot( index, null ); 13705 }, 13706 }); 13707 }, 13708 }, 13709 created(){ 13710 setInterval(() => { 13711 if (!this.preview_element){ 13712 if (this.display_index < this.filtered_images_length - 1){ 13713 this.display_index++; 13714 } else { 13715 this.display_index = 0; 13716 } 13717 } 13718 }, 8000); 13719 }, 13720 computed: { 13721 component_type(){ 13722 if( this.component && typeof this.component.get === 'function' ){ 13723 return this.component.get('type'); 13724 } 13725 return null; 13726 }, 13727 is_hero_banner(){ 13728 return this.component_type === 'hero-banner'; 13729 }, 13730 is_image_with_text(){ 13731 return this.component_type === 'image-with-text'; 13732 }, 13733 formatting_obj(){ 13734 if (this.formatting){ 13735 return JSON.parse(this.formatting); 13736 } 13737 }, 13738 overlay_layers(){ 13739 let formatting = this.formatting_obj || {}; 13740 if( typeof build_web_builder_overlay_layers === 'function' ){ 13741 return build_web_builder_overlay_layers(formatting).layers || []; 13742 } 13743 if( typeof build_web_builder_overlay_attrs === 'function' ){ 13744 let attrs = build_web_builder_overlay_attrs(formatting); 13745 return attrs.has_overlay ? [{ class_string: attrs.class_string, style_obj: attrs.style_obj }] : []; 13746 } 13747 let opacity = Number(formatting.overlay_opacity) || 0; 13748 if( opacity > 0 ){ 13749 return [{ class_string: '', style_obj: { 'opacity': opacity / 100 } }]; 13750 } 13751 return []; 13752 }, 13753 filtered_images(){ 13754 if (!this.preview_element){ 13755 return this.parsed_images().filter(image => { 13756 if ( image !== null ){ 13757 return image; 13758 } 13759 }); 13760 } else { 13761 return this.preview_images_with_slots(); 13762 } 13763 return []; 13764 }, 13765 filtered_images_length(){ 13766 if (!this.preview_element){ 13767 return this.parsed_images().filter(image => { 13768 if ( image !== null ){ 13769 return image; 13770 } 13771 }).length; 13772 } else { 13773 return this.preview_images_with_slots().length; 13774 } 13775 return 0; 13776 }, 13777 }, 13778 watch: { 13779 last_update(newVal, oldVal){ 13780 if (newVal !== oldVal){ 13781 this.sync_display_index_with_config_and_images(); 13782 } 13783 }, 13784 } 13785 }); 13786</script>
13786
13786<script type="text/html" id="template-hero-banner"> 13787 <div class="cs-background-image background relative" :class="[class_string, { 'scroll-fade': !is_admin && !tab_element }]" :style="style_string"> 13788 <image-slider-component 13789 :client_id="client_id" 13790 :formatting="formatting" 13791 :images_string="images_string" 13792 :preview_element="preview_element" 13793 :component="component" 13794 :last_update="last_update" 13795 ></image-slider-component> 13796 <div 13797 class="element-wrapper" 13798 :class="'element-wrapper_' + component_type" 13799 :style="element_style_string" 13800 > 13801 <div v-memo="[last_update, last_element_update]" class="cs-element-stack"> 13802 <!-- loop through elements --> 13803 <template 13804 v-for="(element, element_index) in elements_obj" 13805 :key="element.client_id || element_index" 13806 > 13807 <template v-if="preview_element"> 13808 <preview-element-component 13809 :element="element" 13810 :element_type="element.type" 13811 :element_content="element.content" 13812 :element_column_count="element.column_count" 13813 :element_column_gap="element.column_gap" 13814 :element_index="element_index" 13815 :componentObject="component" 13816 ></preview-element-component> 13817 </template> 13818 <template v-if="!preview_element"> 13819 <element-component 13820 :element_type="element.type" 13821 :element_content="element.content" 13822 :element_column_count="element.column_count" 13823 :element_column_gap="element.column_gap" 13824 :diff_changed="element._wb_diff_changed === true" 13825 :element_index="element_index" 13826 :section_element_animation="section_element_animation" 13827 ></element-component> 13828 </template> 13829 </template> 13830 <!-- handle case where there are no elements (admin only) --> 13831 <template v-if="preview_element && elements_obj.length == 0"> 13832 <div class="no-elements-wrap"> 13833 <div 13834 class="tapped-insert-new-element-relative" 13835 @click="tapped_add_element( $event )" 13836 >+</div> 13837 </div> 13838 </template> 13839 </div> 13840 </div> 13841 </div> 13842</script>
13842 13843
13844<script> 13845 app_content.component('hero-banner-component', { 13846 template: '#template-hero-banner', 13847 data() { 13848 return { 13849 is_admin: false, 13850 loading: true, 13851 } 13852 }, 13853 props: { 13854 class_string: String, 13855 style_string: String, 13856 element_style_string: String, 13857 component_type: String, 13858 elements_string: [String, Object], 13859 //image slider 13860 client_id: String, 13861 formatting: String, 13862 images_string: String, 13863 //admin 13864 preview_element: Boolean, 13865 component: Object, 13866 last_update: Number, //last component update 13867 last_element_update: Number, //last element update 13868 tab_element: Boolean, 13869 }, 13870 mounted() { 13871 this.loading = false; 13872 this.is_admin = window.location.href.indexOf('website-builder') !== -1; 13873 }, 13874 methods:{ 13875 tapped_add_element(){ 13876 var params = {}; 13877 params["component"] = this.component; 13878 params["insert_after"] = 0; 13879 params["column_index"] = 0; 13880 handle_add_element( params ); 13881 }, 13882 parsed_hero_images(){ 13883 if( !this.images_string ){ 13884 return []; 13885 } 13886 try { 13887 let parsed = JSON.parse(this.images_string); 13888 return Array.isArray(parsed) ? parsed : []; 13889 } catch (error) { 13890 console.error('Error parsing hero banner images: ', error); 13891 return []; 13892 } 13893 }, 13894 // Prefer the first filled slot so multi-image heroes edit the visible one. 13895 hero_image_slot_index(){ 13896 let images = this.parsed_hero_images(); 13897 for( let i = 0; i < images.length; i++ ){ 13898 if( images[i] ){ 13899 return i; 13900 } 13901 } 13902 return 0; 13903 }, 13904 write_hero_image_slot( index, value ){ 13905 let component = this.component; 13906 if( !component ){ 13907 return; 13908 } 13909 let current = (component.get('config_latest') && component.get('config_latest').images) || []; 13910 let next = []; 13911 for( let i = 0; i <= 4; i++ ){ 13912 if( i === index ){ 13913 next.push( value ); 13914 } else if( current[i] ){ 13915 next.push( current[i] ); 13916 } else { 13917 next.push( null ); 13918 } 13919 } 13920 save_changes_for_component( component, 'images', next ); 13921 }, 13922 tapped_change_hero_image(){ 13923 if( !this.preview_element ){ 13924 return; 13925 } 13926 if( typeof webbuilder_open_image_slot_picker !== 'function' ){ 13927 if( typeof displayMessage === 'function' ){ 13928 displayMessage('Image picker is not loaded. Refresh the page and try again.', true, 5000); 13929 } 13930 return; 13931 } 13932 if( !this.component ){ 13933 return; 13934 } 13935 let index = this.hero_image_slot_index(); 13936 let current = (this.component.get('config_latest') && this.component.get('config_latest').images) || []; 13937 let current_url = typeof current[index] === 'string' ? current[index] : ''; 13938 if( current_url ){ 13939 webbuilder_open_image_slot_picker({ 13940 label: 'image', 13941 mode: 'attached', 13942 current_url: current_url, 13943 on_pick: ( url ) => { 13944 if( !url ){ 13945 return; 13946 } 13947 this.write_hero_image_slot( index, url ); 13948 }, 13949 on_remove: () => { 13950 this.write_hero_image_slot( index, null ); 13951 }, 13952 }); 13953 return; 13954 } 13955 webbuilder_open_image_slot_picker({ 13956 label: 'image', 13957 mode: 'placeholder', 13958 on_pick: ( url ) => { 13959 if( !url ){ 13960 return; 13961 } 13962 this.write_hero_image_slot( index, url ); 13963 }, 13964 }); 13965 }, 13966 }, 13967 computed: { 13968 elements_obj(){ 13969 if (this.elements_string && typeof this.elements_string === 'string'){ 13970 return JSON.parse(unescape(decodeURIComponent(this.elements_string))) 13971 } else { 13972 return this.elements_string 13973 } 13974 return []; 13975 }, 13976 formatting_obj(){ 13977 if( this.formatting && typeof this.formatting === 'string' ){ 13978 try { 13979 return JSON.parse(this.formatting); 13980 } catch (error) { 13981 return {}; 13982 } 13983 } 13984 if( this.formatting && typeof this.formatting === 'object' ){ 13985 return this.formatting; 13986 } 13987 return {}; 13988 }, 13989 section_element_animation(){ 13990 let component_animation = resolve_web_builder_component_animation(this.formatting_obj); 13991 return section_element_animation_token(component_animation); 13992 }, 13993 }, 13994 }); 13995</script>
13995
13995<script type="text/html" id="template-element"> 13996 <template v-if="element_type === 'accordion_header'"> 13997 <div 13998 class="cs-accordion-header" 13999 :class="[element_anim_class, { 'wb-diff-element-changed': diff_changed }]" 14000 :style="element_anim_inline_style" 14001 > 14002 <div class="cs-accordion-header-toggle-wrap flexWrap"> 14003 <div class="cs-accordion-header-toggle">+</div> 14004 </div> 14005 <span v-html="element_content"></span> 14006 </div> 14007 </template> 14008 <template v-if="element_type === 'accordion_body'"> 14009 <div 14010 class="cs-accordion-body" 14011 :class="[element_anim_class, { 'wb-diff-element-changed': diff_changed }]" 14012 v-html="element_content" 14013 :style="element_body_inline_style" 14014 ></div> 14015 </template> 14016 <template v-else-if="element_type !== 'accordion_header' && element_type !== 'accordion_body'"> 14017 <div 14018 :class="[element_anim_class, { 'wb-diff-element-changed': diff_changed }]" 14019 :style="element_body_inline_style" 14020 > 14021 <span v-html="element_content"></span> 14022 </div> 14023 </template> 14024</script>
14024 14025
14026<script> 14027 app_content.component('element-component', { 14028 template: '#template-element', 14029 data() { 14030 return { 14031 loading: true, 14032 } 14033 }, 14034 props: { 14035 element_type: String, 14036 element_content: String, 14037 element_column_count: Number, 14038 element_column_gap: Number, 14039 diff_changed: Boolean, 14040 element_index: { 14041 type: [Number, String], 14042 default: 0, 14043 }, 14044 section_element_animation: { 14045 type: String, 14046 default: '', 14047 }, 14048 }, 14049 mounted() { 14050 this.loading = false; 14051 }, 14052 computed: { 14053 uses_section_element_animation(){ 14054 return this.section_element_animation === 'up' 14055 || this.section_element_animation === 'fade_up' 14056 || this.section_element_animation === 'fade_in'; 14057 }, 14058 element_anim_class(){ 14059 if( this.uses_section_element_animation ){ 14060 return 'cs-el-anim-target'; 14061 } 14062 return ''; 14063 }, 14064 el_index_style_fragment(){ 14065 if( this.uses_section_element_animation ){ 14066 return '--el-index:' + ( Number(this.element_index) || 0 ) + ';'; 14067 } 14068 return ''; 14069 }, 14070 element_anim_inline_style(){ 14071 return this.el_index_style_fragment; 14072 }, 14073 element_body_inline_style(){ 14074 return 'column-count: ' + this.element_column_count + ' !important; column-gap: ' + this.element_column_gap + 'em;' + this.el_index_style_fragment; 14075 }, 14076 }, 14077 }); 14078</script>
14078 14079
14079<script type="text/html" id="template-unlayer-component"> 14080 <template v-if="preview_element"> 14081 <div class="cs-unlayer-component-wrap unlayer-content" :class="class_string"> 14082 <!-- hover button to open unlayer builder --> 14083 <div class="darken-overlay easeFast"> 14084 <div class="hover-toolbar"> 14085 <div class="hover-button flexNoWrap centeredBlock pointer" @click="openUnlayerBuilder()"> 14086 <div class="hover-button-text"> 14087 Update <i class="fa-regular fa-pen-to-square" style="padding-left:3px;"></i> 14088 </div> 14089 </div> 14090 </div> 14091 </div> 14092 <!-- if there is content --> 14093 <div v-if="!loading && unlayerHtml" class="relative" > 14094 <div 14095 :id="'freeform_id_' + component.id" 14096 v-html="return_html_with_css()" 14097 ></div> 14098 </div> 14099 <!-- if there is no content --> 14100 <div v-else-if="!loading"> 14101 <div class="addFreeformContentZone" @click="openUnlayerBuilder()"> 14102 <div 14103 class="quick-action-wrap noMaxWidth centeredBlock" 14104 > 14105 <div class="quick-action-icon-wrap"> 14106 <i class="fa-light fa-scribble"></i> 14107 </div> 14108 <p class="quick-action-title" style="font-family:roboto;">Freeform section</p> 14109 </div> 14110 </div> 14111 </div> 14112 </div> 14113 </template> 14114 <template v-if="!preview_element"> 14115 <div class="cs-unlayer-component-wrap"> 14116 <div class="relative"></div> 14117 <div v-if="!loading" v-html="return_html_with_css()"></div> 14118 </div> 14119 </template> 14120</script>
14120 14121
14122<script> 14123 14124 function createDebounce() { 14125 let timeout = null; 14126 return function (fnc, delayMs) { 14127 clearTimeout(timeout); 14128 timeout = setTimeout(() => { 14129 fnc(); 14130 }, delayMs || 800); 14131 }; 14132 } 14133 app_content.component('unlayer-component', { 14134 template: '#template-unlayer-component', 14135 data() { 14136 return { 14137 unlayerHtml: null, 14138 loading: true, 14139 debounce: createDebounce(), 14140 template: null, 14141 unlayer: '', 14142 } 14143 }, 14144 props: { 14145 unlayer_chunks: Object, 14146 class_string: String, 14147 preview_element: { 14148 type: Boolean, 14149 default: false, 14150 }, 14151 component: { 14152 type: Object, 14153 default: null, 14154 }, 14155 }, 14156 mounted() { 14157 if(!this.unlayer_chunks){ 14158 // find template for component id 14159 this.return_unlayer_data() 14160 } 14161 this.loading = false; 14162 }, 14163 methods: { 14164 return_unlayer_data(){ 14165 let config_latest = this.component.get('config_latest') || {}; 14166 if(!config_latest.unlayer_component || !config_latest.unlayer_component.chunks){ 14167 return; 14168 } 14169 this.unlayerHtml = config_latest.unlayer_component.chunks.body || ''; 14170 this.unlayerHtml = this.unlayerHtml.replace('style="min-height: 100vh;', 'style="min-height: auto;'); 14171 }, 14172 openUnlayerBuilder(){ 14173 let jsonContent; 14174 let config_latest = this.component.get('config_latest') || {}; 14175 if(config_latest.unlayer_component){ 14176 jsonContent = config_latest.unlayer_component.design; 14177 } 14178 let params = { 14179 jsonContent: jsonContent, 14180 fn: this.saveUnlayerContent 14181 } 14182 launch_unlayer_builder(params) 14183 }, 14184 saveUnlayerContent(content){ 14185 let promise = Promise.resolve().then(() => { 14186 let htmlContent = content && content.chunks ? (content.chunks.body || '') : ''; 14187 htmlContent = htmlContent.replace('style="min-height: 100vh;', 'style="min-height: auto;'); 14188 this.unlayerHtml = htmlContent; 14189 save_changes_for_component( this.component, 'unlayer_component', content ); 14190 }) 14191 return promise; 14192 }, 14193 return_html_with_css(){ 14194 let data; 14195 if(this.preview_element){ 14196 let config_latest = this.component.get('config_latest') || {}; 14197 if(!config_latest.unlayer_component || !config_latest.unlayer_component.chunks){ 14198 return ''; 14199 } 14200 data = config_latest.unlayer_component.chunks; 14201 }else{ 14202 data = this.unlayer_chunks; 14203 } 14204 if(!data){ 14205 return ''; 14206 } 14207 let css = data.css || ''; 14208 let body = data.body || ''; 14209 let html_with_css = juice.inlineContent(body, css); 14210 html_with_css = html_with_css.replace('100vh', 'auto') 14211 return html_with_css 14212 } 14213 }, 14214 computed: { 14215 14216 } 14217 }); 14218</script>
14218 14219 14220<style scoped> 14221 14222 @media screen and (max-width: 768px) { 14223 .u-row:not(.no-stack) .u-col:not(:only-child) { 14224 flex: 0 0 50% !important; 14225 max-width: 50% !important; 14226 } 14227 } 14228 @media screen and (max-width: 480px) { 14229 .u-col { 14230 flex: 0 0 100% !important; /* One column per row */ 14231 max-width: 100% !important; 14232 } 14233 } 14234 14235</style>
14235<script type="text/html" id="template-form"> 14236 <div class="form-wrapper centeredBlock centeredText"> 14237 <template v-if="!loading && is_admin"> 14238 <div 14239 v-if="!form_id" 14240 @click="link_form()" 14241 class="noObjectsMSG centeredBlock centeredText" 14242 style="padding: 5em;" 14243 > 14244 <p style="cursor: pointer;"><i class="fa-light fa-list-dropdown smallMarginRight"></i>Click to link a form</p> 14245 </div> 14246 </template> 14247 <template v-if="!loading"> 14248 <div 14249 v-if="form_id && json_form" 14250 id="form-template" 14251 class="cs-min-section-width" 14252 :style="{ 'max-width': formatting_obj.section_width + '%' }" 14253 > 14254 <div 14255 class="form-color-theme" 14256 :style="{ 'background-color': color_theme }"> 14257 </div> 14258 <div class="form-header-wrapper"> 14259 <!-- name --> 14260 <div v-if="json_form && json_form.name" class="form-name flex-row-start"> 14261 <h3 class="default-font-css" style="font-size: 32px; padding: 5px; text-align: left;">{{ json_form.name }}</h3> 14262 </div> 14263 <!-- description --> 14264 <div v-if="json_form && json_form.description" class="form-description flex-row-start tinyMarginTop"> 14265 <p class="default-font-css" style="padding: 5px; text-align: left;">{{ json_form.description }}</p> 14266 </div> 14267 <!-- disabled notice --> 14268 <div v-if="form_disabled" class="form-description flex-row-start tinyMarginTop"> 14269 <p class="extra-copy-v2" style="padding: 5px; text-align: left;">This form is currently disabled and not accepting responses.</p> 14270 </div> 14271 <template v-if="!valid_membership"> 14272 <div 14273 @click="tapped_log_in()" 14274 class="noObjectsMSG leftText" 14275 style="padding: 5px; min-width: fit-content;" 14276 > 14277 <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> 14278 </div> 14279 </template> 14280 </div> 14281 14282 <template v-if="valid_membership && !pending_redirect_url"> 14283 14284 <!-- default fields --> 14285 <template v-if="collect_default_fields && selected_fields.length > 0"> 14286 <default-field-component-wb 14287 :form="json_form" 14288 :is_admin="is_admin" 14289 :default_fields="selected_fields" 14290 :rerender_key="rerender_key" 14291 ></default-field-component-wb> 14292 </template> 14293 <!-- custom fields --> 14294 <template v-if="json_form.custom_fields" v-for="(custom_field, index) in custom_fields"> 14295 <custom-field-component-wb 14296 :id="custom_field.id" 14297 :form="json_form" 14298 :is_admin="is_admin" 14299 :custom_field="custom_field" 14300 :last_update="last_update" 14301 ref="custom_field_components" 14302 @field-updated="handle_field_update" 14303 ></custom-field-component-wb> 14304 </template> 14305 <!-- submit --> 14306 <div style="width: 100%; display: flex; justify-content: space-between; align-items: center;" class="tinyMarginTop"> 14307 <button 14308 v-if="!loading_submission" 14309 @click="tapped_submit_form()" 14310 :style="{ 'background-color': button_disabled ? 'var(--custom-field-grey-2)' : color_theme }" 14311 :class="[ 'default-font-css', 'submit-button', { 'inactive': button_disabled } ]" 14312 :disabled="is_admin" 14313 >Submit</button> 14314 <i 14315 v-if="loading_submission" 14316 class="fa-sharp-duotone fa-solid fa-spinner-third fa-spin smallMarginLeft" 14317 :style="{ 'color': color_theme, 'font-size': '1.2em', 'animation-duration': '1.2s' }" 14318 ></i> 14319 <p 14320 @click="!loading_submission && tapped_clear_form()" 14321 :style="{ 'color': loading_submission ? 'var(--custom-field-grey-2)': color_theme }" 14322 style="font-size: 14px; font-weight: 600; cursor: pointer;" 14323 >Clear form</p> 14324 </div> 14325 </template> 14326 <template v-if="valid_membership && pending_redirect_url"> 14327 <div class="centeredText" style="padding: 2em 5px;"> 14328 <i 14329 class="fa-solid fa-circle-check solid-circle-check" 14330 style="font-size: 48px;" 14331 ></i> 14332 <h3 class="default-font-css smallMarginTop">Response submitted</h3> 14333 <p class="extra-copy-v2 tinyMarginTop">Redirecting...</p> 14334 </div> 14335 </template> 14336 </div> 14337 <div 14338 v-if="form_id && !json_form" 14339 class="noObjectsMSG centeredBlock centeredText" 14340 style="padding: 5em;" 14341 > 14342 <p style="cursor: pointer;"><i class="fa-light fa-list-dropdown smallMarginRight"></i>No form found.</p> 14343 </div> 14344 </template> 14345 </div> 14346</script>
14346 14347 14348<style> 14349 14350 #form-template{ 14351 display: flex; 14352 flex-direction: column; 14353 align-items: center; 14354 justify-content: flex-start; 14355 margin: 0.5em 0.5em 1em 0.5em; 14356 padding: 0 0 1.5em 0; 14357 width: 100%; 14358 max-width: 700px; 14359 height: 100%; 14360 } 14361 #form-template .form-color-theme{ 14362 color: #fff; 14363 height: 10px; 14364 border-top-left-radius: 8px; 14365 border-top-right-radius: 8px; 14366 width: 100%; 14367 padding: 0 1.5em 0 1.5em; 14368 box-sizing: border-box; 14369 } 14370 #form-template .default-font-css{ 14371 font-family: Roboto, Arial, Helvetica, sans-serif; 14372 font-size: 16px; 14373 font-weight: 400; 14374 } 14375 #form-template .flex-row-start{ 14376 display: flex; 14377 flex-direction: row; 14378 align-items: center; 14379 justify-content: flex-start; 14380 } 14381 #form-template .submit-button{ 14382 color: #fff; 14383 border-radius: 6px; 14384 font-size: 14px; 14385 padding: 8px 24px; 14386 cursor: pointer; 14387 border: none; 14388 } 14389 #form-template .submit-button.inactive{ 14390 color: #fff; 14391 border-radius: 6px; 14392 font-size: 14px; 14393 padding: 8px 24px; 14394 cursor: auto; 14395 border: none; 14396 } 14397 .form-wrapper{ 14398 display: flex; 14399 align-items: center; 14400 justify-content: center; 14401 height: fit-content; 14402 width: 100%; 14403 } 14404 .form-header-wrapper{ 14405 border-width: 0px 1px 1px 1px; 14406 border-color: rgba(195, 195, 195, 0.7); 14407 border-style: solid; 14408 width: 100%; 14409 border-bottom-left-radius: 8px; 14410 border-bottom-right-radius: 8px; 14411 padding: 1.45em; 14412 box-sizing: border-box; 14413 } 14414 .form-name{ 14415 color: rgb(32, 33, 36); 14416 box-sizing: border-box; 14417 font-weight: 400; 14418 font-size: 32px; 14419 width: 100%; 14420 min-height: 21px; 14421 border-bottom: 1.5px solid transparent; 14422 transition: border-bottom 0.1s; 14423 } 14424 .form-description{ 14425 color: rgb(32, 33, 36); 14426 box-sizing: border-box; 14427 font-weight: 400; 14428 font-size: 14px; 14429 width: 100%; 14430 line-height: 1.5; 14431 letter-spacing: 0; 14432 min-height: 21px; 14433 border-bottom: 1.5px solid transparent; 14434 transition: border-bottom 0.1s; 14435 } 14436 .form-name.input-is-focused, .form-description.input-is-focused{ 14437 border-bottom: 1.5px solid rgba(195, 195, 195, 0.7); 14438 } 14439</style> 14440
14441<script> 14442 app_content.component("form-component", { 14443 template: "#template-form", 14444 data() { 14445 return { 14446 global: store, 14447 loading: false, 14448 loading_submission: false, 14449 json_form: null, 14450 invalid_membership: false, 14451 selected_fields: [ 14452 { 14453 label: 'First name', 14454 value: 'firstName', 14455 selected: false, 14456 input_value: null, 14457 }, 14458 { 14459 label: 'Last name', 14460 value: 'lastName', 14461 selected: false, 14462 input_value: null, 14463 }, 14464 { 14465 label: 'Email', 14466 value: 'email', 14467 selected: false, 14468 input_value: null, 14469 }, 14470 { 14471 label: 'Mobile', 14472 value: 'mobile', 14473 selected: false, 14474 input_value: null, 14475 }, 14476 ], 14477 login_url: null, 14478 pending_redirect_url: null, 14479 redirect_timeout: null, 14480 }; 14481 14482 }, 14483 props: { 14484 client_id: String, 14485 is_admin: Boolean, 14486 component: Object, 14487 form: Object, 14488 formatting: String, 14489 last_update: String, 14490 }, 14491 mounted() { 14492 this.load_forms( this.client_id ); 14493 }, 14494 beforeUnmount() { 14495 if (this.redirect_timeout) { 14496 clearTimeout(this.redirect_timeout); 14497 } 14498 }, 14499 methods: { 14500 load_forms( client_id, overrides = {} ){ 14501 this.loading = true; 14502 var promise = Parse.Promise.as(); 14503 var thisPromise = promise.then(() => { 14504 var params = {}; 14505 params["client_id"] = client_id; 14506 params["form_id"] = this.form_id; 14507 params["overrides"] = overrides; 14508 params["request_url"] = window.location.href; 14509 return Parse.Cloud.run('retrieve_data_for_component', params); 14510 }).then(retrieved_form => { 14511 this.json_form = retrieved_form; 14512 if(this.json_form.members_only){ 14513 //check if theres a logged in member and redirect them if needed 14514 return this.check_valid_membership().then(is_member => { 14515 if(is_member){ 14516 this.invalid_membership = false; 14517 this.set_default_fields(); 14518 if ( this.is_admin ){ 14519 return Parse.Promise.as(); 14520 } 14521 return this.prefill_member_fields(); 14522 } 14523 }).catch(error => { 14524 if(error === "invalid_membership"){ 14525 this.invalid_membership = true; 14526 this.loading = false; 14527 this.set_login_url(); 14528 // return the error 14529 return Parse.Promise.error("invalid_membership"); 14530 } 14531 }); 14532 }else{ 14533 this.set_default_fields(); 14534 return Parse.Promise.as(); 14535 } 14536 14537 }).then(() => { 14538 this.loading = false; 14539 }, error => { 14540 checkParseError(error); 14541 this.loading = false; 14542 }); 14543 return thisPromise; 14544 }, 14545 prefill_member_fields(){ 14546 let query = new Parse.Query(Parse.User); 14547 query.include('memberObject'); 14548 return query.get(Parse.User.current().id).then(fullUser => { 14549 let memberObject = fullUser.get('memberObject'); 14550 if ( !memberObject ){ 14551 return Promise.resolve(); 14552 } 14553 let member_data = { 14554 firstName: memberObject.get('firstName') || null, 14555 lastName: memberObject.get('lastName') || null, 14556 email: memberObject.get('email') || null, 14557 mobile: memberObject.get('mobile') || null, 14558 };
14559 this.selected_fields.flat().forEach(field => { 14560 if ( member_data[field.value] ){ 14561 field.input_value = member_data[field.value]; 14562 } 14563 }); 14564 return Promise.resolve(); 14565 }); 14566 }, 14567 check_valid_membership(){ 14568 var club_id = $('body').data('clubId'); 14569 var user = Parse.User.current(); 14570 14571 var memberObject; 14572 var promise = Parse.Promise.as(); 14573 var thisPromise = promise.then(() => { 14574 if( user && user.get('memberObject') ){ 14575 //ok, get the member and membership 14576 var query = new Parse.Query("members"); 14577 query.select('membershipObject.status'); 14578 query.select('membershipObject.memberNumber'); 14579 query.select('membershipObject.tierObject'); 14580 query.select('clubObject.web_settings.custom_domain'); 14581 query.include('membershipObject'); 14582 query.include('clubObject.web_settings'); 14583 return query.get( user.get('memberObject').id ); 14584 }else{ 14585 //redirect to login 14586 return Promise.reject("invalid_membership"); 14587 } 14588 }).then(( retrievedMember )=>{ 14589 if( retrievedMember && retrievedMember.get('clubObject')?.id == club_id ){ 14590 //ok, they're legit 14591 memberObject = retrievedMember; 14592 } 14593 14594 if( ! memberObject ){ 14595 //redirect to login 14596 return Promise.reject("invalid_membership"); 14597 } 14598 //check membership 14599 let membershipObject = memberObject.get('membershipObject'); 14600 if( ! membershipObject ){ 14601 return Promise.reject("invalid_membership"); 14602 } 14603 if( membershipObject.get('status') == "canceled" ){ 14604 return Promise.reject("invalid_membership"); 14605 } 14606 var valid_membership = true; 14607 //ok, they're a legit member 14608 return Parse.Promise.as(valid_membership); 14609 }); 14610 return thisPromise; 14611 }, 14612 set_login_url(){ 14613 const clubId = $('body').data('clubId'); // or wherever you're storing it 14614 const query = new Parse.Query("clubs"); 14615 query.include("web_settings"); 14616 query.get(clubId).then(club => { 14617 const webSettings = club.get('web_settings'); 14618 const customDomain = webSettings?.get('custom_domain'); 14619 const currentUrl = window.location.href; 14620 14621 let redirectBase = customDomain 14622 ? `https://${customDomain}` 14623 : "https://theclubspot.com"; 14624 14625 const loginUrl = `${redirectBase}/login?next=${encodeURIComponent(currentUrl)}&msg=${encodeURIComponent("Please log in to view and complete this form.")}`; 14626 this.login_url = loginUrl; 14627 }); 14628 14629 14630 }, 14631 tapped_clear_form(){ 14632 if ( this.collect_default_fields && this.selected_fields.length > 0 ){ 14633 this.selected_fields.flat().forEach(default_field => { 14634 default_field.input_value = null; 14635 }) 14636 } 14637 if ( this.custom_fields && this.$refs.custom_field_components ){ 14638 this.$refs.custom_field_components.forEach(ref => { 14639 ref.clear_field(); 14640 }); 14641 } 14642 }, 14643 maybe_redirect_after_submit(redirect_url){ 14644 if (!redirect_url) { 14645 return false;
14646 } 14647 this.pending_redirect_url = redirect_url; 14648 this.redirect_timeout = setTimeout(() => { 14649 window.location.href = redirect_url; 14650 }, 2000); 14651 return true; 14652 }, 14653 tapped_submit_form(){ 14654 if ( this.form_disabled ){ 14655 return displayMessage("This form is no longer accepting submissions.", true, 4000); 14656 } 14657 if ( this.button_disabled ){ 14658 return displayMessage("Required fields are missing.", true, 4000); 14659 } 14660 let club_id = $('body').data('clubId'); 14661 if ( !club_id || !this.form_id ){ 14662 return displayMessage("There was an issue submitting your form. Please try again, or contact support if the problem persists.", true, 4000); 14663 } 14664 this.loading_submission = true; 14665 if ( this.custom_fields && this.custom_fields.length > 0 ){ 14666 for ( const custom_field of this.custom_fields ){ 14667 if ( custom_field.type === 'number' ){ 14668 if ( custom_field.input_value !== null && custom_field.input_value !== undefined && custom_field.input_value !== '' ){ 14669 if ( isNaN(Number(custom_field.input_value)) ){ 14670 this.loading_submission = false; 14671 displayMessage("Please enter a valid number.", true, 4000); 14672 return; 14673 } 14674 } 14675 } 14676 } 14677 } 14678 let club_timezone = store.clubObject && store.clubObject.get ? (store.clubObject.get('timezone') || 'UTC') : 'UTC'; 14679 return this.prepare_custom_fields_for_submit(this.custom_fields || []).then(preparedCustomFields => { 14680 let params = { 14681 club_id: club_id, 14682 form_id: this.form_id, 14683 selected_fields: this.selected_fields, 14684 collect_default_fields: this.collect_default_fields, 14685 custom_fields: preparedCustomFields, 14686 club_timezone: club_timezone 14687 }; 14688 return Parse.Cloud.run('submit_form_response', params); 14689 }).then((result) => { 14690 this.loading_submission = false; 14691 if (this.maybe_redirect_after_submit(result && result.redirect_url)) { 14692 return; 14693 } 14694 displayMessage('Your response has been successfully submitted', false, 6000); 14695 this.tapped_clear_form(); 14696 }).catch(error => { 14697 this.loading_submission = false; 14698 checkParseError(error); 14699 displayMessage(error.message || error, true, 4000); 14700 }); 14701 }, 14702 prepare_custom_fields_for_submit(custom_fields){ 14703 let promise = Promise.resolve(); 14704 let thisPromise = promise.then(() => { 14705 if( !custom_fields || custom_fields.length === 0 ){ 14706 return Promise.resolve([]); 14707 } 14708 let preparedCustomFields = []; 14709 let promises = []; 14710 for (let index = 0; index < custom_fields.length; index++) { 14711 let methodToRun = (customField, index) => { 14712 let innerPromise = promise.then(() => { 14713 let preparedCustomField = { 14714 id: customField.id, 14715 name: customField.name, 14716 type: customField.type, 14717 required: customField.required, 14718 input_value: customField.input_value, 14719 custom_field_options: customField.custom_field_options || [], 14720 order: index, 14721 }; 14722 if( customField.type !== 'file_upload' ){ 14723 preparedCustomFields.push(preparedCustomField); 14724 return Promise.resolve(); 14725 } 14726 preparedCustomField.input_value = ''; 14727 if( customField.document_id ){ 14728 preparedCustomField.document_id = customField.document_id; 14729 preparedCustomFields.push(preparedCustomField); 14730 return Promise.resolve(); 14731 } 14732 let uploadFile = this.return_upload_file_from_custom_field(customField); 14733 if( !uploadFile || !uploadFile.name ){ 14734 preparedCustomFields.push(preparedCustomField); 14735 return Promise.resolve(); 14736 } 14737 let uploadParams = { 14738 file: uploadFile, 14739 fileName: uploadFile.name 14740 }; 14741 return upload_private_file_v2(uploadParams).then(uploadResult => { 14742 preparedCustomField.uploaded_file_url = uploadResult.url; 14743 preparedCustomField.uploaded_file_key = uploadResult.file_key; 14744 preparedCustomField.uploaded_file_name = uploadFile.name; 14745 preparedCustomFields.push(preparedCustomField); 14746 return Promise.resolve(); 14747 }); 14748 }); 14749 promises.push(innerPromise); 14750 }; 14751 methodToRun(custom_fields[index], index); 14752 } 14753 return Promise.all(promises).then(() => { 14754 return Promise.resolve(preparedCustomFields); 14755 }); 14756 }); 14757 return thisPromise; 14758 }, 14759 return_upload_file_from_custom_field(customField){ 14760 if( !customField ){ 14761 return null; 14762 } 14763 if( customField.input_value && customField.input_value.name ){ 14764 return customField.input_value; 14765 } 14766 if( Array.isArray(customField.input_value) && customField.input_value.length > 0 ){ 14767 let firstFile = customField.input_value[0]; 14768 if( firstFile && firstFile.name ){ 14769 return firstFile; 14770 } 14771 } 14772 if( customField.input_value && customField.input_value.file && customField.input_value.file.name ){ 14773 return customField.input_value.file; 14774 } 14775 if( customField.input_value && customField.input_value.value && customField.input_value.value.name ){ 14776 return customField.input_value.value; 14777 } 14778 return null; 14779 }, 14780 returnIsVideo(filename) { 14781 var ext = this.getExtension(filename); 14782 switch (ext.toLowerCase()) { 14783 case 'm4v': 14784 case 'avi': 14785 case 'mpg': 14786 case 'mp4': 14787 case 'mp3': 14788 case 'mov': 14789 // etc 14790 return true; 14791 } 14792 return false;
14793 }, 14794 getExtension(filename) { 14795 var parts = filename.split('.'); 14796 return parts[parts.length - 1]; 14797 }, 14798 load_clean_image( file, type, newFileName ){ 14799 return new Promise((resolve, reject) => { 14800 setTimeout(() => { 14801 loadImage(file, canvas => { 14802 if (canvas) { 14803 this.clean_image_callback(canvas, type, newFileName, file) 14804 .then(url => resolve(url)) 14805 .catch(err => reject(err)); 14806 } else { 14807 reject(new Error('Failed to load image')); 14808 } 14809 }, 14810 { orientation: 1, canvas: true } 14811 ); 14812 }, 500); 14813 }); 14814 }, 14815 clean_image_callback( canvas, type, newFileName, file ){ 14816 return new Promise((resolve, reject) => { 14817 if (canvas) { 14818 var url = canvas.toDataURL(type); 14819 canvas.toBlob(blob => { 14820 uploadFileToAWSV2( blob, newFileName ) 14821 .then(uploadUrl => resolve(uploadUrl)) 14822 .catch(err => reject(err)); 14823 }, type); 14824 } else { 14825 reject(new Error('canvas is null')); 14826 } 14827 }); 14828 }, 14829 link_form(){ 14830 store.components.link_form.component = this.component; 14831 store.overlays.link_form = true; 14832 }, 14833 set_default_fields(){ 14834 if ( this.json_form && this.default_fields.length > 0 ){ 14835 this.selected_fields = this.selected_fields.map(default_field => { 14836 if ( this.default_fields.indexOf(default_field.value ) !== -1 ){ 14837 return { 14838 ...default_field, 14839 selected: true, 14840 } 14841 } else { 14842 return { 14843 ...default_field, 14844 selected: false, 14845 } 14846 } 14847 }); 14848 } 14849 if ( this.default_configuration === 'list' ){ 14850 //list 14851 this.selected_fields = this.selected_fields.filter(field => field.selected); 14852 } else if ( this.default_configuration === 'grid' ){ 14853 //grid 14854 this.selected_fields = this.selected_fields.filter(field => field.selected).reduce((acc, field) => { 14855 if ( field.value === 'firstName' || field.value === 'lastName' ){ 14856 acc[0].push(field); 14857 } else { 14858 acc[1].push(field); 14859 } 14860 return acc; 14861 }, [[], []]) 14862 } 14863 }, 14864 handle_field_update({ id, value }){ 14865 const field = this.custom_fields.find(f => f.id === id); 14866 if (field) { 14867 field.input_value = value; 14868 } 14869 }, 14870 }, 14871 computed: { 14872 form_id(){ 14873 if ( typeof( this.form ) === 'string' ){ 14874 return JSON.parse(this.form).id; 14875 } 14876 return this.form.id || null; 14877 }, 14878 color_theme(){ 14879 if ( this.json_form && this.json_form.color_theme ){ 14880 return this.json_form.color_theme; 14881 } 14882 return '#5280ff'; 14883 }, 14884 custom_fields(){ 14885 if ( this.json_form && this.json_form.custom_fields ){ 14886 return this.json_form.custom_fields; 14887 } 14888 return []; 14889 }, 14890 default_fields(){ 14891 if ( this.json_form && this.json_form.default_fields ){ 14892 return this.json_form.default_fields; 14893 } 14894 return []; 14895 }, 14896 collect_default_fields(){ 14897 if ( this.json_form && this.json_form.collect_default_fields ){ 14898 return this.json_form.collect_default_fields; 14899 } 14900 return false;
14901 }, 14902 default_configuration(){ 14903 if ( this.json_form && this.json_form.default_configuration ){ 14904 return this.json_form.default_configuration; 14905 } 14906 return 'list'; 14907 }, 14908 missing_default_field(){ 14909 if ( this.json_form && this.collect_default_fields && this.selected_fields.length > 0 ){ 14910 let default_values = this.default_configuration === 'list' 14911 ? this.selected_fields.map(field => field.input_value) 14912 : this.selected_fields.flatMap(arr => arr.map(field => field.input_value)); 14913 return default_values.includes(null) || default_values.includes(''); 14914 } 14915 return false; 14916 }, 14917 missing_custom_field(){ 14918 if ( this.json_form && this.custom_fields ){ 14919 for ( let i = 0; i < this.custom_fields.length; i++ ){ 14920 let custom_field = this.custom_fields[i]; 14921 let option_values = custom_field.custom_field_options && custom_field.custom_field_options.length > 0 14922 ? custom_field.custom_field_options.map(option => option.selected) 14923 : []; 14924 if ( custom_field.required && ( !custom_field.input_value && option_values.indexOf(true) === -1 ) ){ 14925 return true; 14926 } 14927 } 14928 return false; 14929 } 14930 }, 14931 form_disabled(){ 14932 if ( this.json_form && this.json_form.disabled ){ 14933 return true; 14934 } 14935 return false; 14936 }, 14937 button_disabled(){ 14938 if ( this.is_admin ){ 14939 return true; 14940 }else{ 14941 return this.form_disabled || this.missing_custom_field || this.missing_default_field; 14942 } 14943 }, 14944 formatting_obj(){ 14945 if ( this.formatting ){ 14946 return JSON.parse(this.formatting); 14947 } 14948 return; 14949 }, 14950 valid_membership(){ 14951 if(this.json_form && this.json_form.members_only){ 14952 return !this.invalid_membership; 14953 } 14954 //not members only, doesn't matter 14955 return true; 14956 }, 14957 }, 14958 watch: { 14959 form_id(newVal, oldVal){ 14960 if (newVal !== oldVal){ 14961 this.load_forms( this.client_id ); 14962 } 14963 } 14964 } 14965 }) 14966</script>
14966
14966<script type="text/html" id="template-tab-bar"> 14967 <template v-if="is_web_builder()"> 14968 <template v-if="config_latest()['tabs']?.length > 0"> 14969 <div 14970 class="tab-bar-wrapper" 14971 :style="{ 14972 gap: (config_latest().formatting.section_width || 50) + 'px', 14973 height: (config_latest().formatting.section_height || 50) + 'px', 14974 }" 14975 > 14976 <div 14977 v-for="tab in config_latest()['tabs']" 14978 :key="tab.id" 14979 style="width: 200px; display: flex; align-items: center; justify-content: center;" 14980 > 14981 <div 14982 @click="selected_tab_id = tab.id" 14983 style="width: 100%; text-align: center;" 14984 class="tab-bar-label" 14985 :class="{ 'active': selected_tab_id === tab.id }" 14986 > 14987 <span> 14988 <preview-element-component 14989 v-if="tab.elements" 14990 :element="tab.elements" 14991 :element_type="tab.elements.type" 14992 :element_content="tab.elements.content" 14993 :componentObject="component" 14994 :disable_remove="true" 14995 :disable_column="true" 14996 :disable_new_element="true" 14997 ></preview-element-component> 14998 </span> 14999 </div> 15000 </div> 15001 </div> 15002 <web-builder-components 15003 v-if="selected_tab_id" 15004 :component="return_page_component(selected_tab_id)" 15005 :component_index="0" 15006 :preview_element="true" 15007 ></web-builder-components> 15008 </template> 15009 <template v-else> 15010 <div 15011 class="noObjectsMSG centeredBlock centeredText" 15012 style="padding: 5em;" 15013 > 15014 <p style="cursor: pointer;"><i class="fa-light fa-table-columns smallMarginRight"></i>Use the left side menu to start adding tabs</p> 15015 </div> 15016 </template> 15017 </template> 15018 <template v-if="!is_web_builder() && !loading"> 15019 <template v-if="hosted_config()['tabs']?.length > 0"> 15020 <div 15021 class="tab-bar-wrapper" 15022 :style="{ 15023 gap: (hosted_config().formatting.section_width || 50) + 'px', 15024 height: (hosted_config().formatting.section_height || 50) + 'px', 15025 }" 15026 > 15027 <div 15028 v-for="tab in hosted_config()['tabs']" 15029 :key="tab.id" 15030 style="width: 200px; display: flex; align-items: center; justify-content: center;" 15031 > 15032 <div 15033 @click="selected_tab_id = tab.id" 15034 style="width: 100%; text-align: center;" 15035 class="tab-bar-label" 15036 :class="{ 'active': selected_tab_id === tab.id }" 15037 > 15038 <element-component 15039 v-if="tab.elements" 15040 :element="tab.elements" 15041 :element_type="tab.elements.type" 15042 :element_content="tab.elements.content" 15043 :componentObject="component" 15044 :disable_remove="true" 15045 :disable_column="true" 15046 :disable_new_element="true" 15047 ></element-component> 15048 </div> 15049 </div> 15050 </div> 15051 <web-builder-components 15052 v-if="selected_tab_id" 15053 :component="return_hosted_page_component(selected_tab_id)" 15054 :component_index="0" 15055 :tab_element="true" 15056 :preview_element="false" 15057 >
15057</web-builder-components> 15058 </template> 15059 </template> 15060</script>
15060 15061 15062<style> 15063 .tab-bar-wrapper{ 15064 display: flex; 15065 flex-direction: row; 15066 align-items: center; 15067 justify-content: center; 15068 border-top: 1.5px solid rgb(204, 203, 203); 15069 border-bottom: 1.5px solid rgb(204, 203, 203); 15070 box-sizing: border-box; 15071 } 15072 .tab-bar-label span { 15073 position: relative; 15074 display: inline-block; 15075 padding-bottom: 4px; 15076 border-bottom: none !important; 15077 text-decoration: none !important; 15078 } 15079 .tab-bar-label span::after { 15080 content: ""; 15081 position: absolute; 15082 bottom: 0; 15083 left: 50%; 15084 width: 0; 15085 height: 1px; 15086 background-color: #000; 15087 transform: translateX(-50%); 15088 transition: width 0.3s ease; 15089 } 15090 .tab-bar-label.active span::after { 15091 width: 100%; 15092 } 15093</style> 15094
15095<script> 15096 app_content.component('tab-bar-component', { 15097 template: '#template-tab-bar', 15098 data() { 15099 return { 15100 loading: true, 15101 selected_tab_id: null, 15102 hosted_tab_component: null, 15103 hosted_page_components: [], 15104 } 15105 }, 15106 props: { 15107 client_id: String, 15108 component: { 15109 type: Object, 15110 default: null, 15111 }, 15112 last_update: Number, 15113 }, 15114 mounted() { 15115 if(this.is_web_builder()){ 15116 this.selected_tab_id = this.config_latest()?.tabs?.[0]?.id || null; 15117 }else{ 15118 this.load_tab_bar( this.client_id ); 15119 } 15120 }, 15121 methods: { 15122 load_tab_bar( client_id, overrides = {} ){ 15123 this.loading = true; 15124 var promise = Parse.Promise.as(); 15125 var thisPromise = promise.then(() => { 15126 var params = {}; 15127 params["client_id"] = client_id; 15128 params["overrides"] = overrides; 15129 params["request_url"] = window.location.href; 15130 return Parse.Cloud.run('retrieve_data_for_component', params); 15131 }).then(response => { 15132 this.hosted_tab_component = response.tab_component || null; 15133 this.hosted_page_components = response.page_components || []; 15134 this.selected_tab_id = this.hosted_config()?.tabs?.[0]?.id || null; 15135 }).catch(error => { 15136 checkParseError(error); 15137 }).finally(() => { 15138 this.loading = false; 15139 }); 15140 return thisPromise; 15141 }, 15142 return_page_component(id){ 15143 return store.page_components_latest.find(c => c.id === id) 15144 }, 15145 return_hosted_page_component(id){ 15146 return this.hosted_page_components.find(c => c.id === id) 15147 }, 15148 config_latest(){ 15149 if(this.component && this.component.get('config_latest')){ 15150 return this.component.get('config_latest'); 15151 } 15152 }, 15153 hosted_config(){ 15154 if(this.hosted_tab_component && this.hosted_tab_component.get('config')){ 15155 return this.hosted_tab_component.get('config'); 15156 } 15157 }, 15158 is_web_builder(){ 15159 return window.location.href.includes('website-builder'); 15160 }, 15161 }, 15162 watch: { 15163 last_update(newVal) { 15164 if (this.is_web_builder()) { 15165 this.selected_tab_id = this.config_latest()?.tabs?.[0]?.id || null; 15166 } 15167 } 15168 } 15169 }); 15170</script>
15170
15170<script type="text/html" id="template-web-builder-components"> 15171 <div 15172 :id="'componentPreview_' + component.get('client_id')" 15173 class="componentPreview" 15174 :class="{'hidePreview': tab_element}" 15175 :data-client-id="component.get('client_id')" 15176 :style="return_preview_style(component)" 15177 > 15178 <div 15179 v-if="component.get('visibility') === 'members_only'" 15180 class="event-page-callout" 15181 :class="{ 'no-hover': !can_open_visibility_settings }" 15182 style="width:fit-content;font-size:12px;position: absolute;z-index:100;display:flex;align-items:center;justify-content:center;" 15183 :title="can_open_visibility_settings ? 'Edit visibility' : null" 15184 @click.stop="open_members_only_visibility_settings" 15185 > 15186 <i class="fa-solid fa-user-shield"></i> 15187 <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> 15188 </div> 15189 <template v-if="component.get('type') === 'iframe'"> 15190 <iframe-component 15191 :class_string="return_attributes(component, 'classString')" 15192 :style_string="return_attributes(component, 'style_string')" 15193 :iframe_src="return_attributes(component, 'config_latest').iframe_src" 15194 :component="component" 15195 :formatting_string="return_attributes(component, 'formatting')" 15196 :last_update="return_last_update(component)" 15197 ></iframe-component> 15198 <toolbar-option-component 15199 @click.stop 15200 :component="component" 15201 :is_first="component_index === 0" 15202 :disable_options="disable_options(component)" 15203 ></toolbar-option-component> 15204 </template> 15205 <template v-if="component.get('type') === 'camera-feed'"> 15206 <camera-feed-component 15207 :class_string="return_attributes(component, 'classString')" 15208 :style_string="return_attributes(component, 'style_string')" 15209 :client_id="return_attributes(component, 'client_id')" 15210 :camera_feed_id="return_attributes(component, 'config_latest').camera_feed_id" 15211 :refresh_interval_ms="return_attributes(component, 'config_latest').refresh_interval_ms" 15212 :component="component" 15213 :formatting_string="return_attributes(component, 'formatting')" 15214 :last_update="return_last_update(component)" 15215 ></camera-feed-component> 15216 <toolbar-option-component 15217 @click.stop 15218 :component="component" 15219 :is_first="component_index === 0" 15220 :disable_options="disable_options(component)" 15221 ></toolbar-option-component> 15222 </template> 15223 <template v-if="component.get('type') === 'blog-list'"> 15224 <blog-list-component 15225 :client_id="return_attributes(component, 'client_id')" 15226 :class_string="return_attributes(component, 'classString')" 15227 :style_string="return_attributes(component, 'style_string')" 15228 :element_style_string="return_attributes(component, 'element_style')" 15229 :formatting="return_attributes(component, 'formatting')" 15230 :component_type="return_attributes(component, 'component_type')" 15231 :is_larchmont="global.is_larchmont" 15232 :component="component" 15233 :last_update="return_last_update(component)" 15234 ></blog-list-component> 15235 <toolbar-option-component 15236 @click.stop 15237 :component="component" 15238 :is_first="component_index === 0" 15239 :disable_options="disable_options(component)" 15240 ></toolbar-option-component> 15241 </template> 15242 <template v-if="component.get('type') === 'calendar'"> 15243 <membership-calendar-component 15244 :class_string="return_attributes(component, 'classString')" 15245 :client_id="return_attributes(component, 'client_id')" 15246 :style_string="return_attributes(component, 'style_string')" 15247 :component="component" 15248 :last_update="return_last_update(component)" 15249 ></membership-calendar-component> 15250 <toolbar-option-component 15251 @click.stop 15252 :component="component" 15253 :is_first="component_index === 0" 15254 :disable_options="disable_options(component)" 15255 ></toolbar-option-component> 15256 </template> 15257 <template v-if="component.get('type') === 'membership-directory'"> 15258 <membership-directory-component 15259 :formatting="JSON.parse(return_attributes(component, 'formatting'))" 15260 :class_string="return_attributes(component, 'classString')" 15261 :client_id="return_attributes(component, 'client_id')" 15262 :style_string="return_attributes(component, 'style_string')" 15263 :element_style_string="return_attributes(component, 'element_style')" 15264 :component_type="return_attributes(component, 'component_type')" 15265 :last_update="return_last_update(component)" 15266 ></membership-directory-component> 15267 <toolbar-option-component 15268 @click.stop 15269 :component="component" 15270 :is_first="component_index === 0" 15271 :disable_options="disable_options(component)" 15272 ></toolbar-option-component> 15273 </template> 15274 <template v-if="component.get('type') === 'event-list'"> 15275 <event-list-component 15276 :class_string="return_attributes(component, 'classString')" 15277 :style_string="return_attributes(component, 'style_string')" 15278 :element_style_string="return_attributes(component, 'element_style')" 15279 :formatting="return_attributes(component, 'formatting')" 15280 :filter_types="return_attributes(component, 'filter_types')" 15281 :filter_tag_ids="return_attributes(component, 'filter_tag_ids')" 15282 :event_dates="return_attributes(component, 'event_dates')" 15283 :component_type="return_attributes(component, 'component_type')" 15284 :client_id="return_attributes(component, 'client_id')" 15285 :component="component" 15286 :last_update="return_last_update(component)" 15287 ></event-list-component> 15288 <toolbar-option-component 15289 @click.stop 15290 :component="component" 15291 :is_first="component_index === 0" 15292 :disable_options="disable_options(component)" 15293 ></toolbar-option-component> 15294 </template> 15295 <template v-if="component.get('type') === 'image-grid'"> 15296 <image-grid-component 15297 :client_id="return_attributes(component, 'client_id')" 15298 :class_string="return_attributes(component, 'classString')" 15299 :image_grid_layout="return_attributes(component, 'image_grid_layout')" 15300 :images_per_row="return_attributes(component, 'images_per_row')" 15301 :caption_size="return_attributes(component, 'caption_size')" 15302 :subtext_size="return_attributes(component, 'subtext_size')" 15303 :link_label_size="return_attributes(component, 'link_label_size')" 15304 :text_position="return_attributes(component, 'text_position')" 15305 :caption_font_weight="return_attributes(component, 'caption_font_weight')" 15306 :image_grid_opacity="return_attributes(component, 'image_grid_overlay_opacity')" 15307 :image_grid_border_radius="return_attributes(component, 'image_grid_border_radius')" 15308 :formatting="return_attributes(component, 'formatting')" 15309 :is_admin="preview_element" 15310 :data_source_type="return_attributes(component, 'data_source')['type']" 15311 :images_arr="return_attributes(component, 'images')" 15312 :images_string="JSON.stringify(return_attributes(component, 'images'))" 15313 :component="component" 15314 :last_update="return_last_update(component)" 15315 ></image-grid-component> 15316 <toolbar-option-component 15317 @click.stop 15318 :component="component" 15319 :is_first="component_index === 0" 15320 :disable_options="disable_options(component)" 15321 ></toolbar-option-component> 15322 </template> 15323 <template v-if="component.get('type') === 'photo-gallery'"> 15324 <photo-gallery-component 15325 :client_id="return_attributes(component, 'client_id')" 15326 :component="component" 15327 :photo_album="return_attributes(component, 'photo_album')"
15328 :show_caption="return_attributes(component, 'show_caption')" 15329 :caption_size="return_attributes(component, 'caption_size')" 15330 :caption_font_weight="return_attributes(component, 'caption_font_weight')" 15331 :photos_per_row="return_attributes(component, 'photos_per_row')" 15332 :is_admin="preview_element" 15333 :last_update="return_last_update(component)" 15334 ></photo-gallery-component> 15335 <toolbar-option-component 15336 @click.stop 15337 :component="component" 15338 :is_first="component_index === 0" 15339 :disable_options="disable_options(component)" 15340 ></toolbar-option-component> 15341 </template> 15342 <template v-if="component.get('type') === 'form'"> 15343 <form-component 15344 :client_id="return_attributes(component, 'client_id')" 15345 :component="component" 15346 :form="return_attributes(component, 'form')" 15347 :formatting="return_attributes(component, 'formatting')" 15348 :is_admin="preview_element" 15349 :last_update="return_last_update(component)" 15350 ></form-component> 15351 <toolbar-option-component 15352 @click.stop 15353 :component="component" 15354 :is_first="component_index === 0" 15355 :disable_options="disable_options(component)" 15356 ></toolbar-option-component> 15357 </template> 15358 <template v-if="component.get('type') === 'image-with-text'"> 15359 <image-with-text-component 15360 :client_id="return_attributes(component, 'client_id')" 15361 :formatting="return_attributes(component, 'formatting')" 15362 :images_string="JSON.stringify(return_attributes(component, 'images'))" 15363 :class_string="return_attributes(component, 'classString')" 15364 :style_string="return_attributes(component, 'style_string')" 15365 :element_style_string="return_attributes(component, 'element_style')" 15366 :image_mat="return_attributes(component, 'image_mat')" 15367 :elements_string="return_attributes(component, 'elements')" 15368 :left_right_orientation="return_attributes(component, 'left_right_orientation')" 15369 :preview_element="preview_element" 15370 :component="component" 15371 :last_update="return_last_update(component)" 15372 :last_element_update="return_last_element_update( component )" 15373 :tab_element="tab_element" 15374 ></image-with-text-component> 15375 <toolbar-option-component 15376 @click.stop 15377 :component="component" 15378 :is_first="component_index === 0" 15379 :disable_options="disable_options(component)" 15380 ></toolbar-option-component> 15381 </template> 15382 <template v-if="component.get('type') === 'text' || component.get('type') === 'table'"> 15383 <div 15384 class="cs-text background flexWrap relative" 15385 :class="return_attributes(component, 'classString')" 15386 :style="return_attributes(component, 'style_string')" 15387 > 15388 <div 15389 v-if="return_attributes(component, 'columns').length > 0" 15390 class="element-wrapper flexNoWrap flexWrapOnMobile" 15391 :class="'element-wrapper_' + return_attributes(component, 'component_type')" 15392 :style="return_attributes(component, 'element_style')" 15393 > 15394 <template 15395 v-for="(elements, column_index) in return_attributes(component, 'columns')" 15396 :key="column_index" 15397 > 15398 <text-component 15399 :number_of_columns="return_attributes(component, 'number_of_columns')" 15400 :preview_element="preview_element" 15401 :component="component" 15402 :last_update="return_last_update(component)" 15403 :last_element_update="return_last_element_update( component )" 15404 :elements="elements" 15405 :column_index="column_index" 15406 :section_element_animation="return_attributes(component, 'section_element_animation')" 15407 ></text-component> 15408 </template> 15409 </div> 15410 </div> 15411 <toolbar-option-component 15412 @click.stop 15413 :component="component" 15414 :is_first="component_index === 0" 15415 :disable_options="disable_options(component)" 15416 ></toolbar-option-component> 15417 </template> 15418 <template v-if="component.get('type') === 'hero-banner'"> 15419 <hero-banner-component 15420 ref="heroBanner" 15421 :client_id="return_attributes(component, 'client_id')" 15422 :formatting="return_attributes(component, 'formatting')" 15423 :class_string="return_attributes(component, 'classString')" 15424 :style_string="return_attributes(component, 'style_string')" 15425 :element_style_string="return_attributes(component, 'element_style')" 15426 :component_type="return_attributes(component, 'component_type')" 15427 :elements_string="return_attributes(component, 'elements')" 15428 :images_string="JSON.stringify(return_attributes(component, 'images'))" 15429 :preview_element="preview_element" 15430 :component="component" 15431 :last_update="return_last_update(component)" 15432 :last_element_update="return_last_element_update( component )" 15433 :tab_element="tab_element" 15434 ></hero-banner-component> 15435 <button 15436 v-if="preview_element" 15437 type="button" 15438 class="cs-image-slot-change-chip cs-hero-image-change-chip exclude-from-custom-button-styles" 15439 @click.stop="tapped_change_hero_image()" 15440 ><i class="fa-regular fa-image"></i><span>Change</span></button> 15441 <toolbar-option-component 15442 @click.stop 15443 :component="component" 15444 :is_first="component_index === 0" 15445 :disable_options="disable_options(component)" 15446 ></toolbar-option-component> 15447 </template> 15448 <template v-if="component.get('type') === 'custom-code'"> 15449 <custom-code-component 15450 :class_string="return_attributes(component, 'classString')" 15451 :preview_element="preview_element" 15452 :is_first="is_first_visible_component(component)" 15453 :component="component" 15454 :last_update="return_last_update(component)" 15455 ></custom-code-component> 15456 <toolbar-option-component 15457 @click.stop 15458 :component="component" 15459 :is_first="component_index === 0" 15460 :disable_options="disable_options(component)" 15461 ></toolbar-option-component> 15462 </template> 15463 <template v-if="component.get('type') === 'unlayer-component'"> 15464 <unlayer-component 15465 :class_string="return_attributes(component, 'classString')" 15466 :preview_element="preview_element" 15467 :component="component" 15468 :last_update="return_last_update(component)" 15469 ></unlayer-component> 15470 <toolbar-option-component 15471 @click.stop 15472 :component="component" 15473 :is_first="component_index === 0" 15474 :disable_options="disable_options(component)" 15475 ></toolbar-option-component> 15476 </template> 15477 <!-- <template v-if="component.get('type') === 'tab-bar'"> 15478 <tab-bar-component 15479 :client_id="return_attributes(component, 'client_id')" 15480 :preview_element="preview_element" 15481 :component="component" 15482 :last_update="return_last_update(component)" 15483 ></tab-bar-component> 15484 <toolbar-option-component 15485 @click.stop 15486 :component="component" 15487 :is_first="component_index === 0" 15488 :disable_options="disable_options(component)" 15489 ></toolbar-option-component> 15490 </template> --> 15491 </div> 15492</script>
15492 15493 15494<style> 15495 /* Hero Change chip: sibling of the section under .componentPreview so 15496 overlap masks / fades can't cover it. */ 15497 .componentPreview > .cs-hero-image-change-chip { 15498 position: absolute; 15499 top: auto; 15500 right: auto; 15501 bottom: calc(12px + var(--cs-overlap-chrome-lift, 0px)); 15502 left: 50%; 15503 transform: translateX(-50%); 15504 z-index: 3000; 15505 display: inline-flex; 15506 align-items: center; 15507 gap: 5px; 15508 padding: 6px 10px; 15509 border: none; 15510 border-radius: 6px; 15511 background: rgba(20, 24, 45, 0.82); 15512 color: #fff; 15513 font-family: 'Roboto', Arial, Helvetica, sans-serif; 15514 font-size: 12px; 15515 font-weight: 600; 15516 font-style: normal; 15517 line-height: 1; 15518 letter-spacing: 0; 15519 text-transform: none; 15520 cursor: pointer; 15521 opacity: 0; 15522 pointer-events: none; 15523 box-shadow: 0 4px 12px rgba(8, 19, 51, 0.22); 15524 transition: opacity 0.12s ease, background 0.12s ease; 15525 } 15526 .componentPreview:hover > .cs-hero-image-change-chip, 15527 .componentPreview > .cs-hero-image-change-chip:focus { 15528 opacity: 0.92; 15529 pointer-events: auto; 15530 } 15531 .componentPreview:hover > .cs-hero-image-change-chip:hover, 15532 .componentPreview > .cs-hero-image-change-chip:focus { 15533 opacity: 1; 15534 background: rgba(20, 24, 45, 0.95); 15535 } 15536 .componentPreview.settings-active > .cs-hero-image-change-chip { 15537 opacity: 0 !important; 15538 visibility: hidden !important; 15539 pointer-events: none !important; 15540 } 15541</style> 15542
15543<script> 15544 app_content.component('web-builder-components', { 15545 template: '#template-web-builder-components', 15546 data() { 15547 return { 15548 global: store, 15549 is_admin: false, 15550 } 15551 }, 15552 props: { 15553 component: Object, 15554 component_index: Number, 15555 tab_element: Boolean, 15556 preview_element: Boolean, 15557 last_update: Number, 15558 }, 15559 methods: { 15560 component_is_linked_to_tab_bar( componentObject ){ 15561 if (!componentObject || !Array.isArray(store.page_components_latest)) { 15562 return false; 15563 } 15564 return store.page_components_latest.some(c => { 15565 if (!c || !c.get || c.get('type') !== 'tab-bar') { 15566 return false; 15567 } 15568 let tabs = (c.get('config_latest') && c.get('config_latest').tabs) || []; 15569 return tabs.some(tab => { 15570 return tab && tab.id === componentObject.id; 15571 }); 15572 }); 15573 }, 15574 is_first_visible_component( componentObject ){ 15575 if (!componentObject || !Array.isArray(store.page_components_latest)) { 15576 return this.component_index === 0; 15577 } 15578 let first_visible = store.page_components_latest.find(c => { 15579 return c && !this.component_is_linked_to_tab_bar(c); 15580 }); 15581 return !!(first_visible && first_visible.id === componentObject.id); 15582 }, 15583 disable_options( componentObject ){ 15584 return this.component_is_linked_to_tab_bar(componentObject); 15585 }, 15586 open_members_only_visibility_settings(){ 15587 if( typeof window.wb_open_component_visibility_settings !== 'function' ){ 15588 return; 15589 } 15590 window.wb_open_component_visibility_settings(this.component); 15591 }, 15592 tapped_change_hero_image(){ 15593 let hero = this.$refs.heroBanner; 15594 if( !hero || typeof hero.tapped_change_hero_image !== 'function' ){ 15595 return; 15596 } 15597 hero.tapped_change_hero_image(); 15598 }, 15599 return_last_element_update( componentObject ){ 15600 return store.element_updates ? store.element_updates[ componentObject.id ] : 0; 15601 }, 15602 return_last_update(componentObject){ 15603 return store.component_updates ? store.component_updates[ componentObject.id ] : 0; 15604 }, 15605 return_preview_style(componentObject){ 15606 let type = componentObject.get('type'); 15607 if ( type === 'hero-banner' || type === 'image-with-text' ){ 15608 return this.return_attributes(componentObject, 'background_image_style'); 15609 } 15610 }, 15611 return_attributes(componentObject, attribute){ 15612 let attributes = {}; 15613 if(componentObject){ 15614 var type = componentObject.get('type'); 15615 var config_latest = componentObject.get('config_latest') || returnDefaultConfigForComponent( type ); 15616 var images = config_latest.images; 15617 if(!images){ 15618 // images = []; 15619 images = [null, null, null, null, null] 15620 } 15621 var formatting = config_latest.formatting; 15622 if( ! formatting ){ 15623 formatting = returnDefaultFormattingForComponent( type ); 15624 } 15625 var color_scheme = config_latest.color_scheme; 15626 if( !color_scheme ){ 15627 let default_config = returnDefaultConfigForComponent( type ); 15628 color_scheme = (default_config && default_config.color_scheme) ? default_config.color_scheme : 'white'; 15629 } 15630 if (store.is_larchmont && store.pageObject.id === 'QAcpSXHP5G'){ 15631 color_scheme = 'larchmont_secondary'; 15632 } 15633 //start the class string up here 15634 var classString = ''; 15635 classString += ' color_scheme_' + color_scheme; 15636 //now generate markup 15637 var background_image_style = ""; 15638 var style_string = ""; 15639 if( formatting.section_height ){ 15640 // Browser vh is the outer window; the canvas is #previewZoneHeight. 15641 // Use cqh (preview container) so heights match iframe/live viewport, 15642 // including absolute/sticky header overlay. Keep vh as fallback. 15643 let section_vh = ( formatting.section_height ).toString(); 15644 style_string += "min-height:" + section_vh + "vh;"; 15645 style_string += "min-height:calc(var(--wb-preview-vh, 1vh) * " + section_vh + ");"; 15646 style_string += "min-height:" + section_vh + "cqh;"; 15647 } 15648 if( formatting.align_items 15649 && ( typeof is_web_builder_element_stack_component !== 'function' 15650 || is_web_builder_element_stack_component( type ) ) ){ 15651 style_string += "align-items:" + formatting.align_items + ";" ; 15652 } 15653 if ( formatting.background_size === 'inset' ){ 15654 background_image_style += "padding: 30px;" ; 15655 } else if ( formatting.background_size === 'cover' ){ 15656 background_image_style += "padding: 0px;" ; 15657 } 15658 //element wrapper 15659 var element_style = ""; 15660 //extra_columns 15661 var extra_columns = config_latest.extra_columns; 15662 if( extra_columns ){ 15663 for (var c = extra_columns.length - 1; c >= 0; c--) { 15664 classString += ' show_' + extra_columns[c]; 15665 } 15666 } 15667 //images per row (image grid) 15668 var images_per_row = config_latest.images_per_row; 15669 if( ! images_per_row ){ 15670 images_per_row = 3; 15671 } 15672 //photos per row (photo gallery) 15673 var photos_per_row = config_latest.photos_per_row; 15674 if( ! photos_per_row ){ 15675 photos_per_row = 4; 15676 } 15677 //left/right orientation (image with text) 15678 var left_right_orientation = config_latest.left_r
15678ight_orientation; 15679 if( ! left_right_orientation ){ 15680 left_right_orientation = "left"; 15681 } 15682 classString += " orientation-" + left_right_orientation; 15683 //number of columns (text) 15684 var number_of_columns = config_latest.number_of_columns; 15685 if( ! number_of_columns ){ 15686 number_of_columns = 3; 15687 } 15688 classString += " number_of_columns-" + number_of_columns; 15689 15690 // Opt-in refinement tokens (padding, fade edges, scroll/element 15691 // anim, max-width cap). Absent keys leave classString/styles unchanged. 15692 let refinement_result = apply_web_builder_refinement_formatting({ 15693 formatting: formatting, 15694 class_string: classString, 15695 element_style: element_style, 15696 activate_immediately: ( typeof window !== 'undefined' && window.location.href.indexOf('website-builder') !== -1 ), 15697 is_first_section: this.is_first_visible_component(componentObject), 15698 }); 15699 classString = refinement_result.class_string; 15700 element_style = refinement_result.element_style; 15701 15702 //overlay opacity 15703 var opacity = 0; 15704 if( formatting.overlay_opacity ){ 15705 opacity = formatting.overlay_opacity; 15706 } 15707 //photo album id 15708 var photo_album = config_latest.photo_album; 15709 if( ! photo_album ){ 15710 photo_album = { 15711 id: null, 15712 name: null, 15713 }; 15714 } 15715 //form id 15716 var form = config_latest.form; 15717 if( ! form ){ 15718 form = { 15719 id: null, 15720 name: null, 15721 }; 15722 } 15723 //image with text 15724 var image_mat = config_latest.image_mat ? config_latest.image_mat : 'show'; 15725 //filter types (event list) 15726 var filter_types = config_latest.filter_types ? JSON.stringify(config_latest.filter_types) : null; 15727 //filter tag IDs (event list) 15728 var filter_tag_ids = config_latest.filter_tag_ids ? JSON.stringify(config_latest.filter_tag_ids) : null; 15729 //event_dates (event list) 15730 var event_dates = config_latest.event_dates ? config_latest.event_dates : 'upcoming'; 15731 //caption style 15732 if( formatting.caption_style ){ 15733 classString += " caption_style-" + formatting.caption_style; 15734 } 15735 //caption alignment 15736 if( formatting.caption_align && ( formatting.caption_align == "top" || formatting.caption_align == "bottom" || formatting.caption_align == "under" ) ){ 15737 classString += ' ' + formatting.caption_align + '-align-image-caption'; 15738 } 15739 var show_caption = formatting.show_caption; 15740 if( ! show_caption ){ 15741 show_caption = false; 15742 } 15743 var caption_size = formatting.caption_size; 15744 if( ! caption_size ){ 15745 caption_size = "24"; 15746 } 15747 var subtext_size = formatting.subtext_size; 15748 if( ! subtext_size ){ 15749 subtext_size = "24"; 15750 } 15751 var link_label_size = formatting.link_label_size; 15752 if( ! link_label_size ){ 15753 link_label_size = "24"; 15754 } 15755 var text_position = formatting.text_position; 15756 if( ! text_position ){ 15757 text_position = "center"; 15758 } 15759 var caption_font_weight = formatting.caption_font_weight; 15760 if( ! caption_font_weight ){ 15761 caption_font_weight = "400"; 15762 } 15763 var image_grid_overlay_opacity = formatting.image_grid_overlay_opacity; 15764 if( ! image_grid_overlay_opacity ){ 15765 image_grid_overlay_opacity = "40"; 15766 } 15767 var image_grid_border_radius = formatting.image_grid_border_radius; 15768 if ( ! image_grid_border_radius ){ 15769 image_grid_border_radius = "0" 15770 } 15771 var image_grid_layout = formatting.layout; 15772 if ( ! image_grid_layout ){ 15773 image_grid_layout = "grid" 15774 } 15775 //add all variables to attribute object 15776 attributes['client_id'] = componentObject.get('client_id'); 15777 attributes['component_type'] = componentObject.get('type'); 15778 if( componentObject.get('config') && componentObject.get('config').event_types ){ 15779 attributes['event_types'] = componentObject.get('config').event_types; 15780 } 15781 // attributes['images'] = componentObject.get('images'); 15782 attributes['type'] = type; 15783 attributes['config_latest'] = config_latest; 15784 attributes['data_source'] = config_latest['data_source']; 15785 attributes['images'] = images; 15786 attributes['formatting'] = JSON.stringify(formatting); 15787 attributes['color_scheme'] = color_scheme; 15788 attributes['classString'] = classString; 15789 attributes['style_string'] = style_string; 15790 attributes['elements'] = config_latest['elements']; 15791 attributes['element_style'] = element_style; 15792 attributes['opacity'] = opacity;
15793 attributes['extra_columns'] = extra_columns; 15794 attributes['images_per_row'] = images_per_row; 15795 attributes['photos_per_row'] = photos_per_row; 15796 attributes['left_right_orientation'] = left_right_orientation; 15797 attributes['columns'] = config_latest.columns; 15798 attributes['number_of_columns'] = number_of_columns; 15799 attributes['show_caption'] = show_caption; 15800 attributes['caption_size'] = caption_size; 15801 attributes['subtext_size'] = subtext_size; 15802 attributes['link_label_size'] = link_label_size; 15803 attributes['text_position'] = text_position; 15804 attributes['caption_font_weight'] = caption_font_weight; 15805 attributes['image_grid_overlay_opacity'] = image_grid_overlay_opacity; 15806 attributes['image_grid_border_radius'] = image_grid_border_radius; 15807 attributes['image_grid_layout'] = image_grid_layout; 15808 attributes['background_image_style'] = background_image_style; 15809 attributes['photo_album'] = photo_album; 15810 attributes['form'] = form; 15811 attributes['filter_types'] = filter_types; 15812 attributes['filter_tag_ids'] = filter_tag_ids; 15813 attributes['event_dates'] = event_dates; 15814 attributes['image_mat'] = image_mat; 15815 let section_element_animation = refinement_result.section_element_animation || ''; 15816 attributes['section_element_animation'] = section_element_animation; 15817 //return requested attribute 15818 return attributes[attribute]; 15819 } 15820 }, 15821 }, 15822 computed: { 15823 can_open_visibility_settings(){ 15824 return typeof window.wb_open_component_visibility_settings === 'function'; 15825 }, 15826 }, 15827 }); 15828</script>
15828 15829 <!-- overlays --> 15830
15830<script type="text/html" id="template-view-all-events"> 15831 <bottom-sheet-overlay 15832 id="overlay_view_all_events" 15833 @dismiss_overlay="dismiss_overlay()" 15834 > 15835 <template #header> 15836 <div class="sticky-wrap overflowHidden"> 15837 <div class="posScrollbar table-tab-wrap noPaddingBottom"> 15838 <div class="flexNoWrap table-tab-inner"> 15839 <div class="table-tab-option" :class="{'active': date_filter === 'all'}" @click="handleDatesFilter('all')" style="margin-left:22px;">All</div> 15840 <div class="table-tab-option noMarginLeft" :class="{'active': date_filter === 'upcoming'}" @click="handleDatesFilter('upcoming')">Upcoming</div> 15841 <div class="table-tab-option noMarginLeft" :class="{'active': date_filter === 'past'}" @click="handleDatesFilter('past')">Past</div> 15842 </div> 15843 </div> 15844 </div> 15845 <div class="searchBarWrap inline-search relative noMargin overflowHidden"> 15846 <div class="inputWrap searchBar searchBar_event-list flexGrowOne boxShadowBottom maxFourHundo"> 15847 <input 15848 class="easeFast" 15849 placeholder="Search events..." 15850 v-model="search" 15851 > 15852 </div> 15853 </div> 15854 </template> 15855 15856 <!-- body content --> 15857 <template #body> 15858 <div 15859 class="event-list-outer-contain lightBorderTop scrollable" 15860 :class="class_string" 15861 :style="style_string" 15862 > 15863 <div 15864 class="event-list-wrap centeredBlock centeredText event-list-inner-contain" 15865 > 15866 <div class="fff noPadding overflowHidden"> 15867 <div v-if="!loading && sorted_event_data.length >= 1" class="tableInsert hundop flexWrapEvent"> 15868 <template 15869 v-for="(event_data, index) in sorted_event_data" 15870 :key="event_key(event_data)" 15871 > 15872 <p 15873 v-if="is_first_visible_event_of_month(event_data)" 15874 class="cs-section-header" 15875 >{{ month_string( event_data ) }}</p> 15876 <event-list-event-component 15877 :event_data="event_data" 15878 :event_props="return_event_props( event_data )" 15879 @tapped-event-list-event="tapped_event_list_event" 15880 ></event-list-event-component> 15881 </template> 15882 </div> 15883 <no-objects-found 15884 v-if="!loading && sorted_event_data.length == 0" 15885 subtext="No events found" 15886 icon_class="fa-light fa-calendar-days" 15887 ></no-objects-found> 15888 </div> 15889 <div v-if="loading" class="cardBodySpinnerWrap active relative"> 15890 <div class="cardBodySpinner centeredBlock"></div> 15891 </div> 15892 </div> 15893 </div> 15894 </template> 15895 15896 </bottom-sheet-overlay> 15897</script>
vendor: 1 bytes, line 15897
15897
15898<script> 15899 15900 function view_all_events( params ){ 15901 store.components.view_all_events.event_data = params.event_data; 15902 store.components.view_all_events.class_string = params.class_string; 15903 store.components.view_all_events.style_string = params.style_string; 15904 store.components.view_all_events.element_style_string = params.element_style_string; 15905 store.components.view_all_events.props_generator = params.props_generator; 15906 store.overlays.view_all_events = true; 15907 } 15908 //global store 15909 store.components["view_all_events"] = { 15910 event_data: [], 15911 date_filter: 'upcoming', 15912 search: '', 15913 loading: false, 15914 sortObject: { 15915 order: 'ascending', 15916 key: 'startDate' 15917 }, 15918 //passed props 15919 class_string: '', 15920 style_string: '', 15921 element_style_string: '', 15922 props_generator: null, //function to generate props for the individual events 15923 }; 15924 15925 //define the component 15926 app_overlays.component("view-all-events-overlay", { 15927 template: "#template-view-all-events", 15928 data() { 15929 return store.components.view_all_events 15930 }, 15931 mounted(){ 15932 15933 }, 15934 methods: { 15935 month_string( event_data ){ 15936 let start_date = new Date(event_data.ts); 15937 return format_utc_date_v2({ 15938 "utcDate": start_date, 15939 "timezone": store.timezone, 15940 "formatString": "MMMM YYYY" 15941 }); 15942 }, 15943 event_key( event_data ){ 15944 return event_data.event.id + '_' + event_data.ts; 15945 }, 15946 is_first_visible_event_of_month( event_data ){ 15947 return this.first_visible_events.includes( this.event_key(event_data) ); 15948 }, 15949 return_event_props( event_data ){ 15950 return this.props_generator( event_data ); 15951 }, 15952 sortEventList( event_data, sortObject ){ 15953 event_data = [... event_data]; 15954 if( ! sortObject ){ 15955 sortObject = {} 15956 } 15957 if( ! sortObject.key ){ 15958 sortObject.key = "startDate"; 15959 } 15960 if( ! sortObject.order ){ 15961 sortObject.order = "ascending" 15962 } 15963 if( sortObject.key == "name" ){ 15964 if( sortObject.order == "ascending" ){ 15965 return event_data.sort( this.sortByEventName_ascending ); 15966 }else{ 15967 return event_data.sort( this.sortByEventName_descending ); 15968 } 15969 }else if( sortObject.key == "startDate" ){ 15970 if( sortObject.order == "ascending" ){ 15971 return event_data.sort( this.sortByStartDate_event_ascending ); 15972 }else{ 15973 return event_data.sort( this.sortByStartDate_event_descending ); 15974 } 15975 }else if( sortObject.key == "endDate" ){ 15976 if( sortObject.order == "ascending" ){ 15977 return event_data.sort( this.sortByEndDate_event_ascending ); 15978 }else{ 15979 return event_data.sort( this.sortByEndDate_event_descending ); 15980 } 15981 } 15982 return event_data; 15983 }, 15984 sortByEventName_ascending( a, b ){ 15985 let event_a = a.event; 15986 let event_b = b.event; 15987 var aName = ''; 15988 if( event_a.get('name') ){ 15989 aName += event_a.get('name').trim().toLowerCase(); 15990 } 15991 var bName = ''; 15992 if( event_b.get('name') ){ 15993 bName += event_b.get('name').trim().toLowerCase(); 15994 } 15995 if(aName < bName) { return -1; } 15996 if(aName > bName) { return 1; } 15997 //they had the same name 15998 return 0; 15999 }, 16000 sortByEventName_descending( a, b ){ 16001 let event_a = a.event; 16002 let event_b = b.event; 16003 var aName = ''; 16004 if( event_a.get('name') ){ 16005 aName += event_a.get('name').trim().toLowerCase(); 16006 } 16007 var bName = ''; 16008 if( event_b.get('name') ){ 16009 bName += event_b.get('name').trim().toLowerCase(); 16010 } 16011 if(aName > bName) { return -1; } 16012 if(aName < bName) { return 1; } 16013 //they had the same name 16014 return 0; 16015 }, 16016 sortByStartDate_event_ascending( a, b ){ 16017 let a_ts = a.ts; 16018 let b_ts = b.ts; 16019 if(a_ts < b_ts) { return -1; } 16020 if(a_ts > b_ts) { return 1; } 16021 return 0; 16022 }, 16023 sortByStartDate_event_descending( a, b ){ 16024 let a_ts = a.ts; 16025 let b_ts = b.ts; 16026 if(a_ts > b_ts) { return -1; } 16027 if(a_ts < b_ts) { return 1; } 16028 return 0; 16029 }, 16030 sortByEndDate_event_ascending( a, b ){ 16031 let a_end_ts = a.end_ts; 16032 let b_end_ts = b.end_ts; 16033 if(a_end_ts < b_end_ts) { return -1; } 16034 if(a_end_ts > b_end_ts) { return 1; } 16035 return 0; 16036 }, 16037 sortByEndDate_event_descending( a, b ){ 16038 let a_end_ts = a.end_ts; 16039 let b_end_ts = b.end_ts; 16040 if(a_end_ts < b_end_ts) { return 1; } 16041 if(a_end_ts > b_end_ts) { return -1; } 16042 return 0; 16043 }, 16044 tapped_event_list_event( event_data ){ 16045 let event = event_data.event; 16046 if(event && event.className == 'events') { 16047 $('#overlay_event-description-popup h3').text(event.get('name')); 16048 let start_date = this.start_date( event_data ); 16049 let nice_date = format_utc_date_v2({ 16050 "utcDate": start_date, 16051 "timezone": store.timezone, 16052 "formatString": "MMM D, YYYY" 16053 }); 16054 $('#overlay_event-description-popup .metric_start_date').text( nice_date ); 16055 if( this.show_time( event ) ){ 16056 let event_time = format_utc_date_v2({ 16057 "utcDate": start_date, 16058 "timezone": store.timezone, 16059 "formatString": "h:mm A" 16060 }); 16061 $('#overlay_event-description-popup .metric_start_date_time').text( event_time ).removeClass('incomplete'); 16062 }else{ 16063 $('#overlay_event-description-popup .metric_start_date_time').text('Not provided').addClass('incomplete'); 16064 } 16065 if(event.get('description')) { 16066 $('#overlay_event-description-popup .metric_description').html(event.get('description')).removeClass('incomplete'); 16067 } else { 16068 $('#overlay_event-description-popup .metric_description').text('Not provided').addClass('incomplete'); 16069 } 16070 fadeIn('#overlay_event-description-popup'); 16071 } 16072 }, 16073 start_date( event_data ){ 16074 if( event_data.ts ){ 16075 return new Date(event_data.ts); 16076 } 16077 return null; 16078 }, 16079 show_time( event ){ 16080 if( 16081 event.get('calendarOnly') && event.get('all_day') 16082 ){ 16083 return false;
16084 }else if( 16085 event.className == "camps" || 16086 event.className == "regattas" || 16087 event.className == "subevents" 16088 ){ 16089 return false; 16090 } 16091 return true; 16092 }, 16093 handleDatesFilter( option ){ 16094 this.date_filter = option; 16095 // handle sortObject["order"] here instead of in loadEventList post-retrieval for better UX 16096 if ( option === 'upcoming' ){ 16097 this.sortObject["order"] = "ascending"; 16098 } else { 16099 this.sortObject["order"] = "descending"; 16100 } 16101 }, 16102 dismiss_overlay(){ 16103 store.overlays.view_all_events = false; 16104 } 16105 }, 16106 computed: { 16107 first_visible_events(){ 16108 let first_visible_events = []; 16109 let handled_keys = []; 16110 //loop through the event_data 16111 this.sorted_event_data.forEach(event_data => { 16112 let start_date = new Date(event_data.ts); 16113 let start_components = return_date_components_v2({ 16114 "date": start_date, 16115 "timezone": store.timezone 16116 }); 16117 let key = start_components.year + '_' + start_components.month; 16118 if( ! handled_keys.includes(key) ){ 16119 first_visible_events.push( this.event_key(event_data) ); 16120 handled_keys.push(key); 16121 } 16122 }); 16123 return first_visible_events; 16124 }, 16125 pre_filtered_event_data(){ 16126 //filter by date first 16127 let pre_filtered_event_data = [...this.event_data]; 16128 var timestamp = new Date().getTime(); 16129 if ( this.date_filter !== 'all' ){ 16130 pre_filtered_event_data = pre_filtered_event_data.filter(event_data => { 16131 let start = event_data.ts; 16132 let end = event_data.end_ts; 16133 if (this.date_filter === 'past'){ 16134 if( start < timestamp && ( end < timestamp || ! end || end == "undefined" ) ){ 16135 return true; 16136 } 16137 } else if (this.date_filter === 'upcoming'){ 16138 if ( start > timestamp || end > timestamp ){ 16139 return true; 16140 } 16141 // Single-day events: ts and end_ts are both midnight, so they get filtered out 16142 // when the event is today. Include if the event's date is today or later. 16143 if ( start === end || !end ){ 16144 let startOfEventDay = new Date(start); 16145 startOfEventDay.setUTCHours(0, 0, 0, 0); 16146 let startOfToday = new Date(); 16147 startOfToday.setUTCHours(0, 0, 0, 0); 16148 if ( startOfEventDay.getTime() >= startOfToday.getTime() ){ 16149 return true; 16150 } 16151 } 16152 } 16153 return false; 16154 }) 16155 } 16156 return pre_filtered_event_data; 16157 }, 16158 filtered_event_data(){ 16159 let filtered_event_data = this.pre_filtered_event_data; 16160 //filter by search 16161 if (this.search.length > 0){ 16162 filtered_event_data = filtered_event_data.filter(event_data => { 16163 let name = event_data.event.get('name').toLowerCase(); 16164 if ( name.includes(this.search.toLowerCase()) ){ 16165 return event_data; 16166 } 16167 }) 16168 } 16169 // handle tab selection using filteredEvents 16170 var yesterday = new Date(); 16171 yesterday.setDate( yesterday.getDate() - 1 ); 16172 var timestamp = yesterday.getTime(); 16173 16174 //filter by tag 16175 if( this.selected_event_tag_id ){ 16176 filtered_event_data = filtered_event_data.filter(event_data => { 16177 let event_tag_ids = event_data.event_tag_ids; 16178 return event_tag_ids && event_tag_ids.includes(this.selected_event_tag_id); 16179 }) 16180 } 16181 //return them 16182 return filtered_event_data; 16183 }, 16184 sorted_event_data(){ 16185 return this.sortEventList( this.filtered_event_data, this.sortObject ); 16186 }, 16187 } 16188 }); 16189 16190</script>
16190
16190<script type="text/html" id="template-view-photo-carousel"> 16191 <div 16192 id="overlay_view-photo-carousel" 16193 class="carousel-v-overlay" 16194 :class="{ 'carousel-v-overlay-blur': should_blur_background }" 16195 @touchstart="touched_overlay()" 16196 @mousedown="touched_overlay()" 16197 @click.self="clicked_overlay()" 16198 > 16199 <div 16200 class="carousel-container" 16201 @click.self="clicked_overlay()" 16202 > 16203 <div class="actions-container" @click.stop> 16204 <div 16205 class="event-page-callout close-action-container" 16206 :class="{ 'carousel-action-icon-only': use_icon_only_actions }" 16207 style="color: #fff;" 16208 @click.stop="dismiss_overlay()" 16209 > 16210 <i class="fa-regular fa-xmark" style="color: #fff;"></i> 16211 <template v-if="!use_icon_only_actions">Close</template> 16212 </div> 16213 <div 16214 v-if="can_show_download" 16215 class="event-page-callout" 16216 :class="{ 'carousel-action-icon-only': use_icon_only_actions }" 16217 style="color: #fff;" 16218 @click.stop="tapped_download()" 16219 > 16220 <template v-if="!downloading"> 16221 <i class="fa-regular fa-arrow-down-to-line" style="color: #fff;"></i> 16222 <template v-if="!use_icon_only_actions">Download</template> 16223 </template> 16224 <template v-else> 16225 <i class="fa-regular fa-check" style="color: #fff;"></i> 16226 <template v-if="!use_icon_only_actions">Downloaded</template> 16227 </template> 16228 </div> 16229 <div 16230 v-if="can_show_share" 16231 class="event-page-callout" 16232 :class="{ 'carousel-action-icon-only': use_icon_only_actions }" 16233 style="color: #fff;" 16234 @click.stop="tapped_share()" 16235 > 16236 <template v-if="!sharing"> 16237 <i class="fa-regular fa-arrow-up-from-bracket" style="color: #fff;"></i> 16238 <template v-if="!use_icon_only_actions">Share</template> 16239 </template> 16240 <template v-else> 16241 <i class="fa-regular fa-check" style="color: #fff;"></i> 16242 <template v-if="!use_icon_only_actions">Link copied</template> 16243 </template> 16244 </div> 16245 </div> 16246 <i v-if="!hide_nav_arrows" class="fa-light fa-chevron-left nav-arrow" @click.stop="prev()"></i> 16247 <div v-if="current_photo" class="carousel-photo-container" @click="clicked_overlay()"> 16248 <div class="carousel-photo-frame" @click.stop> 16249 <video 16250 v-if="returnIsVideo(current_photo.file_name) && current_photo.url" 16251 class="carousel-photo" 16252 :class="{ 'is-loaded': detail_photo_loaded }" 16253 autoplay muted loop playsinline 16254 :key="'video-' + current_index" 16255 @loadeddata="detail_photo_loaded = true" 16256 > 16257 <source :src="current_photo.url" type="video/mp4"> 16258 </video> 16259 <img 16260 v-else-if="current_photo.url" 16261 class="carousel-photo" 16262 :class="{ 'is-loaded': detail_photo_loaded }" 16263 :src="current_photo.url" 16264 :key="'photo-' + current_index" 16265 alt="" 16266 ref="detail_photo" 16267 @load="detail_photo_loaded = true" 16268 > 16269 <template v-if="show_caption"> 16270 <p class="carousel-caption" v-if="current_photo.caption" :style="{ 'font-size': caption_size + 'px', 'font-weight': caption_font_weight }">{{ current_photo.caption }}</p> 16271 </template> 16272 </div> 16273 </div> 16274 <div v-else class="carousel-photo-container" @click="clicked_overlay()"> 16275 <p class="carousel-caption">No media found</p> 16276 </div> 16277 <i v-if="!hide_nav_arrows" class="fa-light fa-chevron-right nav-arrow" @click.stop="next()"></i> 16278 </div> 16279 </div> 16280</script>
16280 16281 16282<style> 16283 .carousel-v-overlay{ 16284 display: flex; 16285 align-items: center; 16286 justify-content: center; 16287 cursor: auto; 16288 background-color: transparent; 16289 position: fixed; 16290 z-index: 1000 !important; 16291 left: 0px; 16292 right: 0px; 16293 top: 0px; 16294 bottom: 0px; 16295 width: 100vw; 16296 height: 100vh; 16297 } 16298 .carousel-v-overlay::before{ 16299 content: ''; 16300 position: absolute; 16301 inset: 0; 16302 z-index: 0; 16303 pointer-events: none; 16304 background-color: rgba(8,8,19,0.64); 16305 opacity: 0; 16306 animation: carousel-overlay-fade 0.3s ease forwards; 16307 } 16308 .carousel-v-overlay-blur::before{ 16309 backdrop-filter: blur(6px); 16310 } 16311 @keyframes carousel-overlay-fade { 16312 0% { 16313 opacity: 0; 16314 } 16315 100% { 16316 opacity: 1; 16317 } 16318 } 16319 @keyframes carousel-chrome-fade { 16320 0% { 16321 opacity: 0; 16322 } 16323 100% { 16324 opacity: 1; 16325 } 16326 } 16327 @keyframes carousel-photo-scale { 16328 0% { 16329 transform: scale(0.8); 16330 } 16331 100% { 16332 transform: scale(1); 16333 } 16334 } 16335 .carousel-container{ 16336 display: flex; 16337 justify-content: space-between; 16338 align-items: center; 16339 height: 100vh; 16340 width: 98vw; 16341 position: relative; 16342 z-index: 1; 16343 overflow: hidden; 16344 cursor: auto; 16345 background-color: rgba(0, 0, 0, 0); 16346 } 16347 .carousel-container > .nav-arrow { 16348 font-size: 28px; 16349 padding: 1em; 16350 color: #fff; 16351 z-index: 1; 16352 opacity: 0; 16353 animation: carousel-nav-fade 0.3s ease forwards; 16354 transition: opacity 0.3s ease; 16355 cursor: pointer; 16356 } 16357 @keyframes carousel-nav-fade { 16358 0% { 16359 opacity: 0; 16360 } 16361 100% { 16362 opacity: 0.6; 16363 } 16364 } 16365 .carousel-container > .nav-arrow:hover { 16366 opacity: 1; 16367 } 16368 .carousel-photo-container { 16369 display: flex; 16370 flex-direction: column; 16371 justify-content: center; 16372 align-items: center; 16373 flex: 1 1 auto; 16374 width: auto; 16375 height: 70%; 16376 min-width: 0; 16377 min-height: 200px; 16378 cursor: pointer; 16379 } 16380 .carousel-photo-frame { 16381 display: flex; 16382 flex-direction: column; 16383 justify-content: center; 16384 align-items: center; 16385 max-width: 100%; 16386 max-height: 100%; 16387 cursor: auto; 16388 animation: carousel-photo-scale 0.3s ease forwards; 16389 } 16390 .carousel-photo { 16391 width: auto; 16392 height: auto; 16393 max-width: 100%; 16394 max-height: 70vh; 16395 object-fit: contain; 16396 background-size: contain; 16397 background-repeat: no-repeat; 16398 background-position: center; 16399 opacity: 0; 16400 transition: opacity 0.35s ease; 16401 } 16402 .carousel-photo.is-loaded { 16403 opacity: 1; 16404 } 16405 .carousel-caption{ 16406 margin: 10px 0 0 0; 16407 text-transform: uppercase; 16408 letter-spacing: 2px; 16409 color: #fff; 16410 padding: 5px; 16411 border-radius: 2px; 16412 background-color: rgba(0, 0 ,0, 0.1); 16413 } 16414 .actions-container{ 16415 position: fixed; 16416 top: 9px; 16417 right: 9px; 16418 display: flex; 16419 align-items: center; 16420 justify-content: center; 16421 opacity: 0; 16422 animation: carousel-chrome-fade 0.3s ease forwards; 16423 } 16424 .actions-container > .event-page-callout.close-action-container{ 16425 position: fixed; 16426 top: 9px; 16427 left: 9px; 16428 } 16429 .actions-container > .event-page-callout{ 16430 padding-left: 10px; 16431 padding-right: 10px; 16432 border-radius: 5px; 16433 border: 1px solid #e4ebf1; 16434 font-size: 13px; 16435 margin: 6px; 16436 color: #6a7482; 16437 padding-bottom: 8px; 16438 padding-top: 8px; 16439 cursor: pointer; 16440 } 16441 .actions-container > .event-page-callout i{ 16442 padding-right:3px; 16443 } 16444 .actions-container > .event-page-callout:hover{ 16445 color: #081333; 16446 } 16447 .actions-container > .event-page-callout.carousel-action-icon-only{ 16448 border: none; 16449 background: transparent; 16450 padding: 6px; 16451 margin: 4px; 16452 min-width: auto; 16453 } 16454 .actions-container > .event-page-callout.carousel-action-icon-only i{ 16455 padding-right: 0; 16456 font-size: 28px; 16457 } 16458</style> 16459
16460<script> 16461 16462 function downloadImage( url, filename ) { 16463 return fetch(url, { mode: 'cors' }) 16464 .then(response => { 16465 if (!response.ok) { 16466 throw new Error(`Network response was not ok: ${response.statusText}`); 16467 } 16468 return response.blob(); 16469 }) 16470 .then(blob => { 16471 const url = window.URL.createObjectURL(blob); 16472 const a = document.createElement('a'); 16473 a.style.display = 'none'; 16474 a.href = url; 16475 a.download = filename; 16476 // Append the anchor to the body 16477 document.body.appendChild(a); 16478 a.click(); 16479 // Remove the anchor from the body 16480 document.body.removeChild(a); 16481 window.URL.revokeObjectURL(url); 16482 }) 16483 .catch(err => console.error('Error downloading the image:', err)); 16484 } 16485 16486 store.components.view_photo_carousel = { 16487 json_photos: [], 16488 current_index: 0, 16489 downloading: false, 16490 sharing: false, 16491 detail_photo_loaded: false, 16492 json_config: { 16493 allow_share: false, 16494 allow_download: false, 16495 context: '', 16496 }, 16497 } 16498 16499 app_overlays.component("view-photo-carousel", { 16500 template: "#template-view-photo-carousel", 16501 data() { 16502 return store.components.view_photo_carousel; 16503 }, 16504 mounted() { 16505 this.bound_keydown = (event) => { 16506 this.handle_detail_keydown(event); 16507 }; 16508 window.addEventListener('keydown', this.bound_keydown); 16509 this.$nextTick(() => { 16510 this.check_detail_photo_cached(); 16511 }); 16512 }, 16513 unmounted() { 16514 if( this.bound_keydown ){ 16515 window.removeEventListener('keydown', this.bound_keydown); 16516 } 16517 }, 16518 watch: { 16519 current_index(){ 16520 this.detail_photo_loaded = false; 16521 this.$nextTick(() => { 16522 this.check_detail_photo_cached(); 16523 }); 16524 }, 16525 }, 16526 methods: { 16527 return_config_value( key, default_value ){ 16528 if ( this.json_config && this.json_config[key] !== undefined && this.json_config[key] !== null ){ 16529 return this.json_config[key]; 16530 } 16531 return default_value; 16532 }, 16533 handle_detail_keydown( event ){ 16534 if( !event ){ 16535 return; 16536 } 16537 let tag = event.target && event.target.tagName; 16538 if( tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' ){ 16539 return; 16540 } 16541 if( event.key === 'ArrowLeft' ){ 16542 event.preventDefault(); 16543 this.prev(); 16544 return; 16545 } 16546 if( event.key === 'ArrowRight' ){ 16547 event.preventDefault(); 16548 this.next(); 16549 } 16550 }, 16551 check_detail_photo_cached(){ 16552 if( this.current_photo && this.returnIsVideo(this.current_photo.file_name) ){ 16553 this.detail_photo_loaded = true; 16554 return; 16555 } 16556 let el = this.$refs.detail_photo; 16557 if( el && el.complete && el.naturalWidth ){ 16558 this.detail_photo_loaded = true; 16559 } 16560 }, 16561 async tapped_download(){ 16562 if ( !this.current_photo || !this.current_photo.url ){ 16563 return; 16564 } 16565 let url = this.current_photo.url; 16566 let file_name = this.current_photo.file_name || ('photo-' + (this.current_index + 1)); 16567 this.downloading = true; 16568 try { 16569 downloadImage( url, file_name ).then(() => { 16570 displayMessage("File downloaded.", false, 6000); 16571 this.downloading = false;
16572 }).catch(() => { 16573 displayMessage("Sorry, something went wrong with your download. Please try again, or contact support if the problem persists.", true, 8000); 16574 this.downloading = false; 16575 }) 16576 } catch (error) { 16577 displayMessage("Sorry, something went wrong with your download. Please try again, or contact support if the problem persists.", true, 8000); 16578 } 16579 }, 16580 returnIsVideo(filename) { 16581 var ext = this.getExtension(filename); 16582 switch (ext.toLowerCase()) { 16583 case 'm4v': 16584 case 'avi': 16585 case 'mpg': 16586 case 'mp4': 16587 case 'mp3': 16588 case 'mov': 16589 // etc 16590 return true; 16591 } 16592 return false; 16593 }, 16594 getExtension(filename) { 16595 var parts = filename.split('.'); 16596 return parts[parts.length - 1]; 16597 }, 16598 tapped_share(){ 16599 copyToClipboard( window.location.href ); 16600 displayMessage("Link copied.", false, 6000); 16601 this.sharing = true; 16602 setTimeout(() => { 16603 this.sharing = false; 16604 }, 3000); 16605 }, 16606 prev(){ 16607 if ( this.current_index === 0 ){ 16608 this.current_index = this.json_photos.length - 1; 16609 } else { 16610 this.current_index -= 1; 16611 } 16612 }, 16613 next(){ 16614 if ( this.current_index === this.json_photos.length - 1 ){ 16615 this.current_index = 0; 16616 } else { 16617 this.current_index += 1; 16618 } 16619 }, 16620 dismiss_overlay(){ 16621 store.overlays.view_photo_carousel = false; 16622 }, 16623 clicked_overlay(){ 16624 if( ! should_close_overlay( this.last_touch ) ){ 16625 return 16626 } 16627 this.dismiss_overlay(); 16628 }, 16629 touched_overlay(){ 16630 this.last_touch = new Date().getTime(); 16631 }, 16632 }, 16633 computed: { 16634 current_photo(){ 16635 if ( this.json_photos && this.json_photos.length > 0 && this.json_photos[this.current_index] ){ 16636 return this.json_photos[this.current_index]; 16637 } 16638 return null; 16639 }, 16640 can_show_download(){ 16641 var default_value = false; 16642 return this.return_config_value('allow_download', default_value) === true; 16643 }, 16644 can_show_share(){ 16645 var default_value = true; 16646 return this.return_config_value('allow_share', default_value) === true; 16647 }, 16648 show_caption(){ 16649 var default_value = false; 16650 return this.return_config_value('show_caption', default_value) === true; 16651 }, 16652 caption_size(){ 16653 var default_value = 16; 16654 return this.return_config_value('caption_size', default_value); 16655 }, 16656 caption_font_weight(){ 16657 var default_value = 400; 16658 return this.return_config_value('caption_font_weight', default_value); 16659 }, 16660 should_blur_background(){ 16661 var default_value = false; 16662 return this.return_config_value('blur_background', default_value) === true; 16663 }, 16664 use_icon_only_actions(){ 16665 var default_value = false; 16666 return this.return_config_value('icon_only_actions', default_value) === true; 16667 }, 16668 hide_nav_arrows(){ 16669 let default_value = false; 16670 return this.return_config_value('hide_nav_arrows', default_value) === true; 16671 }, 16672 } 16673 }); 16674 16675</script>
16675
16675<script type="text/html" id="template-bottom-sheet-overlay"> 16676 16677 <div 16678 class="v-overlay scrollable hugePaddingBottom active" 16679 @touchstart.self="touched_overlay()" 16680 @mousedown.self="touched_overlay()" 16681 @click.self="clicked_overlay()" 16682 :class="[{'noPointer': prevent_close}, {'bottom-sheet-blur': blur_background}]" 16683 > 16684 <div 16685 class="standardOverlayCard centeredBlock bottom-sheet" 16686 :class="[ 16687 {'short': height == 'short' || is_short}, 16688 {'narrow': narrow}, 16689 {'noMaxWidth': full_width} 16690 ]" 16691 > 16692 16693 <!-- generic header content --> 16694 <div class="bottom-sheet-overlay-header"> 16695 16696 <div 16697 v-if="! prevent_close" 16698 class="relative" 16699 > 16700 <div 16701 class="overlay-header-handle-wrap centeredBlock" 16702 @click.stop="toggle_height()" 16703 > 16704 <div class="overlay-header-handle"></div> 16705 </div> 16706 <i 16707 class="overlay-x-button fa-regular fa-xmark pointer" 16708 @click.stop="dismiss_overlay()" 16709 ></i> 16710 </div> 16711 16712 <!-- header content injected here --> 16713 <slot name="header"></slot> 16714 16715 </div> 16716 16717 <!-- body content injected here --> 16718 <slot name="body"></slot> 16719 16720 <!-- footer content injected here --> 16721 <slot name="footer"></slot> 16722 16723 </div> 16724 </div> 16725 16726</script>
16726 16727 16728<style> 16729 16730 .standardOverlayCard.bottom-sheet.narrow{ 16731 max-width: 600px; 16732 } 16733 16734 .standardOverlayCard.bottom-sheet.medium{ 16735 max-width: 800px; 16736 } 16737 16738 .standardOverlayCard.bottom-sheet{ 16739 position: fixed; 16740 bottom: 0px; 16741 top: 50px; 16742 left: 0px; 16743 right: 0px; 16744 z-index: 1001; 16745 background-color: #fff; 16746 box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); 16747 transition: bottom 0.3s cubic-bezier(0.25, 0.1, 0.25, 1); 16748 border-top-left-radius: 10px; 16749 border-top-right-radius: 10px; 16750 padding-bottom: 10px; 16751 padding-top: 10px; 16752 max-width: 1100px; 16753 margin-top: 0px; 16754 border-bottom-left-radius: 0px; 16755 border-bottom-right-radius: 0px; 16756 } 16757 16758 .standardOverlayCard.bottom-sheet.short{ 16759 top: 50%; 16760 } 16761 16762 @media (max-height: 600px) { 16763 16764 .standardOverlayCard.bottom-sheet.short{ 16765 top: 150px; 16766 } 16767 16768 } 16769 16770 .standardOverlayCard.bottom-sheet h3{ 16771 padding-top:0px; 16772 } 16773 16774 .standardOverlayCard.bottom-sheet{ 16775 display: flex; 16776 flex-direction: column; 16777 padding-bottom:0px; 16778 padding-top: 0px; 16779 } 16780 16781 .standardOverlayCard.bottom-sheet .bottom-sheet-overlay-card-header 16782 { 16783 flex: 0 0 auto; /* Fixed height, doesn't grow or shrink */ 16784 } 16785 16786 .standardOverlayCard.bottom-sheet .standardCardFooter{ 16787 position: absolute; 16788 bottom: 0px; 16789 padding-bottom: var(--cs-safe-area-bottom, 10px); 16790 left: 0px; 16791 right: 0px; 16792 background-color: #fff; 16793 border-top: 1px solid #e4ebf1; 16794 z-index: 10; 16795 } 16796 16797 .standardOverlayCard.bottom-sheet .standardCardBody{ 16798 flex: 1 1 auto; /* Flexible height, can grow and shrink */ 16799 padding-bottom: 90px !important; 16800 } 16801 16802 .overlay-header-handle-wrap{ 16803 padding: 10px; 16804 cursor: pointer; 16805 max-width: 120px; 16806 } 16807 .overlay-header-handle{ 16808 width: 70px; 16809 height: 3px; 16810 border-radius: 10px; 16811 background-color: #acb3ba; 16812 margin-left: auto; 16813 margin-right: auto; 16814 margin-top: 8px; 16815 margin-bottom: 8px; 16816 } 16817 .overlay-x-button{ 16818 position: absolute; 16819 right: 14px; 16820 color: #535e6c; 16821 font-size: 20px; 16822 top: 12px; 16823 } 16824</style> 16825
16826<script> 16827 16828 //define the component 16829 app_overlays.component("bottom-sheet-overlay", { 16830 template: "#template-bottom-sheet-overlay", 16831 data() { 16832 return { 16833 height: "tall" 16834 } 16835 }, 16836 props: { 16837 blur_background: { 16838 type: Boolean, 16839 default: false 16840 }, 16841 narrow: { 16842 type: Boolean, 16843 default: false 16844 }, 16845 prevent_close: { 16846 type: Boolean, 16847 default: false 16848 }, 16849 full_width: { 16850 type: Boolean, 16851 default: false 16852 }, 16853 is_short: { 16854 type: Boolean, 16855 default: false 16856 } 16857 }, 16858 mounted(){ 16859 const universalEditOverlay = document.getElementById('overlay_universal-edit'); 16860 if (universalEditOverlay) { 16861 universalEditOverlay.style.zIndex = "1000000"; 16862 } 16863 const membershipValidationOverlay = document.getElementById('overlay_verifyMembership'); 16864 if (membershipValidationOverlay) { 16865 membershipValidationOverlay.style.zIndex = "1000000"; 16866 } 16867 }, 16868 beforeUnmount() { 16869 //reset the z-index 16870 const universalEditOverlay = document.getElementById('overlay_universal-edit'); 16871 if (universalEditOverlay) { 16872 universalEditOverlay.style.zIndex = ""; // Revert to default 16873 } 16874 const membershipValidationOverlay = document.getElementById('overlay_verifyMembership'); 16875 if (membershipValidationOverlay) { 16876 membershipValidationOverlay.style.zIndex = ""; // Revert to default 16877 } 16878 }, 16879 methods: { 16880 dismiss_overlay(){ 16881 this.$emit('dismiss_overlay'); 16882 }, 16883 toggle_height(){ 16884 if( this.height === "tall" ){ 16885 this.height = "short"; 16886 }else{ 16887 this.height = "tall"; 16888 } 16889 }, 16890 clicked_overlay(){ 16891 if( this.prevent_close ){ 16892 return; 16893 } 16894 if (should_close_overlay(this.last_touch)) { 16895 this.dismiss_overlay(); 16896 } 16897 }, 16898 touched_overlay(){ 16899 this.last_touch = new Date().getTime(); 16900 }, 16901 }, 16902 }); 16903 16904 16905</script>
16905 16906 16907<style> 16908 .bottom-sheet-blur{ 16909 backdrop-filter: blur(6px); 16910 } 16911 16912 @keyframes bottom-sheet-fade-in { 16913 from { opacity: 0; } 16914 to { opacity: 1; } 16915 } 16916</style>
16916<script type="text/html" id="template-show-event-details-in-member-cal"> 16917 <bottom-sheet-overlay 16918 @dismiss_overlay="dismiss_overlay()" 16919 :narrow="true" 16920 > 16921 <!-- header content --> 16922 <template #header></template> 16923 16924 <!-- body content --> 16925 <template #body> 16926 <div 16927 class="standardCardBody fff scrollable " 16928 > 16929 <h2 v-if="event_json.title" class="event-page-name centeredText marginTop">{{ event_json.title }}</h2> 16930 <div class="flexWrap smallMarginBottom tinyMarginTop"> 16931 <p v-if="date_string" class="hundop flexNoWrapLeftAlign"> 16932 <i class="fa-light fa-calendar-week" style="font-size:20px;"></i> 16933 <span class="leftText tinyMarginLeft">{{ date_string }}</span> 16934 </p> 16935 <p v-if="time_string" class="hundop flexNoWrapLeftAlign" style="margin-top: 12px;"> 16936 <i class="fa-light fa-clock" style="font-size:20px;"></i> 16937 <span class="leftText tinyMarginLeft">{{ time_string }}</span> 16938 </p> 16939 <!-- in-portal booking links (eg. group classes surfaced in the member 16940 portal booking hub) navigate within the SPA instead of opening the 16941 standalone page in a new tab --> 16942 <p v-if="event_json.in_portal_path" class="hundop flexNoWrapLeftAlign" style="margin-top: 12px;"> 16943 <i class="fa-light fa-link" style="font-size:20px;"></i> 16944 <span class="leftText tinyMarginLeft"> 16945 <a class="pointer" @click="tapped_in_portal_link()">Follow link</a> 16946 </span> 16947 </p> 16948 <p v-else-if="event_json.external_event_url" class="hundop flexNoWrapLeftAlign" style="margin-top: 12px;"> 16949 <i class="fa-light fa-link" style="font-size:20px;"></i> 16950 <span class="leftText tinyMarginLeft"> 16951 <a target="_blank" :href="event_json.external_event_url">Follow link</a> 16952 </span> 16953 </p> 16954 </div> 16955 16956 <div 16957 v-if="event_image_url" 16958 style="display:flex; width:100%; justify-content:center;" 16959 > 16960 <div 16961 v-if="image_crop_style == 'cover'" 16962 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;" 16963 > 16964 <img 16965 :src="event_image_url" 16966 style="width:100%; height:100%; display:block; object-fit:cover;" 16967 @error="console.log('event image failed to load:', event_image_url)" 16968 /> 16969 </div> 16970 16971 <div 16972 v-else 16973 style="width:100%; max-width:580px; height:400px; position:relative; overflow:hidden; border:1px solid #e4ebf1; border-radius:5px;" 16974 > 16975 <div 16976 :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)' }" 16977 > 16978 <div 16979 style="position:absolute; top:0; right:0; left:0; bottom:0; background-color: rgba(253,253,253,0.8);" 16980 ></div> 16981 </div> 16982 <div 16983 :style="{ backgroundImage: 'url(' + event_image_url + ')', position: 'absolute', top: '0', left: '0', right: '0', bottom: '0', backgroundRepeat: 'no-repeat', backgroundPosition: 'center', backgroundSize: 'contain' }" 16984 ></div> 16985 </div> 16986 </div> 16987 <div 16988 class="event-page-details flex-col" 16989 > 16990 <div class="event-page-details-wrapper maxSixFifty flex-col centeredText"> 16991 <p 16992 v-if="event_json.description" 16993 class="smallMarginTop tiptap-container marginBottom" 16994 style="line-height: 1.5;" 16995 v-html="event_json.description"> 16996 </p> 16997 </div> 16998 </div> 16999 </div> 17000 </template> 17001 17002 17003 </bottom-sheet-overlay> 17004</script>
17004 17005 17006
17007<script> 17008 17009 function follow_member_cal_event_link(event_json) { 17010 if (event_json.in_portal_path) { 17011 updateURL(event_json.in_portal_path); 17012 displayViewForUrlPath(); 17013 return true; 17014 } 17015 if (event_json.external_event_url) { 17016 window.open(event_json.external_event_url, '_blank'); 17017 return true; 17018 } 17019 return false; 17020 } 17021 17022 function show_event_details_in_member_cal(params) { 17023 store.components.show_event_details_in_member_cal.event_json = params.event_json; 17024 store.overlays.show_event_details_in_member_cal = true; 17025 } 17026 17027 store.components["show_event_details_in_member_cal"] = { 17028 event_json: null, 17029 } 17030 17031 app_overlays.component("show-event-details-in-member-cal-overlay", { 17032 template: "#template-show-event-details-in-member-cal", 17033 data() { 17034 return store.components["show_event_details_in_member_cal"]; 17035 }, 17036 methods: { 17037 dismiss_overlay(){ 17038 store.overlays.show_event_details_in_member_cal = false; 17039 }, 17040 tapped_in_portal_link(){ 17041 this.dismiss_overlay(); 17042 follow_member_cal_event_link(this.event_json); 17043 }, 17044 }, 17045 computed: { 17046 event_image_url(){ 17047 if(this.event_json && this.event_json.image && this.event_json.image.url){ 17048 return this.event_json.image.url; 17049 } 17050 return null; 17051 }, 17052 image_crop_style(){ 17053 if(this.event_json && this.event_json.image && this.event_json.image.crop){ 17054 return this.event_json.image.crop; 17055 } 17056 return null; 17057 }, 17058 date_string(){ 17059 var start_date_string = format_utc_date_v2({ 17060 utcDate: this.event_json.ts, 17061 formatString: 'MMMM D, YYYY', 17062 timezone: store.clubObject.get('timezone') 17063 }); 17064 if(this.event_json.isAllDay){ 17065 //it could span multiple days, check if we should show the end date 17066 var endDate = this.event_json.end_ts || null; 17067 if(endDate){ 17068 //compare the date/month/year only 17069 var startDateObj = new Date(this.event_json.ts); 17070 var endDateObj = new Date(endDate); 17071 if(startDateObj.getUTCFullYear() !== endDateObj.getUTCFullYear() ||startDateObj.getUTCMonth() !== endDateObj.getUTCMonth() || startDateObj.getUTCDate() !== endDateObj.getUTCDate()){ 17072 //different dates, show the end date 17073 start_date_string += ` - ${ format_utc_date_v2({ 17074 utcDate: endDate, 17075 formatString: 'MMMM D, YYYY', 17076 timezone: store.clubObject.get('timezone') 17077 })}`; 17078 } 17079 } 17080 } 17081 return start_date_string; 17082 }, 17083 time_string(){ 17084 if(this.event_json.isAllDay){ 17085 return ""; 17086 } 17087 var startDate = this.event_json.ts; 17088 var endDate = this.event_json.end_ts || null; 17089 var time_string = format_utc_date_v2({ 17090 utcDate: this.event_json.ts, 17091 formatString: 'h:mm A', 17092 timezone: store.clubObject.get('timezone') 17093 }); 17094 if(endDate && endDate !== startDate) { 17095 time_string += ` - ${ format_utc_date_v2({ 17096 utcDate: endDate, 17097 formatString: 'h:mm A', 17098 timezone: store.clubObject.get('timezone') 17099 })}`; 17100 } 17101 return time_string; 17102 } 17103 }, 17104 }); 17105</script>
17105 17106 17107 17108 <!-- define/register shared vue components --> 17109 17110
17110<script type="text/html" id="template-calendar-date-picker"> 17111 <vue-date-picker 17112 v-model="selected_date" 17113 :mode="mode" 17114 :modes="['year', 'month']" 17115 :monthPicker="true" 17116 :yearPicker="true" 17117 @update:modelValue="date_changed()" 17118 :clearable="false" 17119 format="MMM-d-yyyy" 17120 auto-apply 17121 :teleport="true" 17122 :month-change-on-scroll="false" 17123 menu-class-name="cs-calendar-date-picker-menu" 17124 input-class-name="cs-calendar-date-picker-input" 17125 > 17126 <template #input-icon> 17127 <i class="fa-regular fa-calendar-arrow-down"></i> 17128 </template> 17129 <template #calendar-icon> 17130 <i class="fa-regular fa-arrow-turn-down-left"></i> 17131 </template> 17132 <template #arrow-left> 17133 <i class="fa-regular fa-chevron-left"></i> 17134 </template> 17135 <template #arrow-right> 17136 <i class="fa-regular fa-chevron-right"></i> 17137 </template> 17138 </vue-date-picker> 17139 </script>
17139 17140 17141 <style> 17142 17143 .cs-calendar-date-picker-menu .dp__btn{ 17144 color: #000000 !important; 17145 background: none !important; 17146 font: inherit !important; 17147 line-height: normal !important; 17148 } 17149 17150 .cs-calendar-date-picker-input{ 17151 width: 0px; 17152 padding-left: 26px; 17153 padding-right: 22px; 17154 text-align: center; 17155 margin-right: 8px; 17156 overflow: hidden; 17157 padding-top: 7px; 17158 padding-bottom: 7px; 17159 border: 1px solid #e4ebf1; 17160 } 17161 17162 .cs-calendar-menu-wrap .dp__input_icon i{ 17163 font-size: 16px !important; 17164 transform: none; 17165 } 17166 17167 </style> 17168 17169
17169<script> 17170 17171 app_content.component("calendar-date-picker-component", { 17172 template: "#template-calendar-date-picker", 17173 data() { 17174 return { 17175 selected_date: new Date(), 17176 mode: 'month', 17177 } 17178 }, 17179 props: { 17180 handle_button_click: Function, 17181 }, 17182 methods: { 17183 date_changed(){ 17184 this.handle_button_click( 'date-picker', this.selected_date ); 17185 }, 17186 }, 17187 }); 17188 17189 </script>
17189 17190 17191
17191<script> 17192 app_content.component('VueDatePicker', VueDatePicker); 17193 </script>
17193 17194
17194<script type="text/html" id="template-calendar-grid"> 17195 <div class="centeredOnMobile smallMarginTop" :class="{'ninetyPercent': !is_membership_cal, 'standardCard': !is_membership_cal}"> 17196 <!-- main options header // stays consistent across all views --> 17197 <div class="standardCardBody noBorderTop calendar noPadding fff" style="overflow: none;"> 17198 <!-- options for non-membership cal --> 17199 <div v-if="filterOptions?.length >= 1 && !is_membership_cal" class="cal-filter-option-wrap scrollable paddingLeftRight tinyPaddingTop smallPaddingBottom"> 17200 <div class="flexNoWrap"> 17201 <template v-for="option in filterOptions"> 17202 <div 17203 class="cs-cal-filter-option" 17204 :class="[ 17205 'cs-cal-filter-option_' + option.data_option, 17206 { 'active': selectedFilterOptions.indexOf(option.data_option) !== -1 } 17207 ]" 17208 :data-option="option.data_option" 17209 @click="$emit('handleFilterSelection', option)" 17210 > 17211 {{ option.name }} 17212 </div> 17213 </template> 17214 </div> 17215 </div> 17216 <!-- main calendar header // stays consistent across all views --> 17217 <!-- options for membership cal --> 17218 <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"> 17219 <div v-if="filterOptions?.length >= 1 && is_membership_cal" class="scrollable posScrollbar lightBorderTop noPaddingBottom"> 17220 <div class="flexNoWrap" :style="generateWrapStyle('options')"> 17221 <template v-for="option in filterOptions"> 17222 <div 17223 class="cs-cal-filter-option" 17224 :class="{ 17225 ['cs-cal-filter-option_' + option.data_option]: true, 17226 'active': selectedFilterOptions.indexOf(option.data_option) !== -1 17227 }" 17228 :data-option="option.data_option" 17229 @click="$emit('handleFilterSelection', option)" 17230 > 17231 {{ option.name }} 17232 </div> 17233 </template> 17234 </div> 17235 </div> 17236 <div class="cs-calendar-menu-wrap flexNoWrap" :style="generateWrapStyle('cs-calendar-menu-wrap')"> 17237 <div class="flexWrapLeftAlign"> 17238 <div class="flexNoWrap"> 17239 <div class="cs-calendar-button roboto hideOnMobile" data-option="today" @click="handleButtonClick('today')">Today</div> 17240 <div class="cs-calendar-button cs-calendar-button-previous roboto" data-option="previous" @click="handleButtonClick('previous')"></div> 17241 <div class="cs-calendar-button cs-calendar-button-next roboto" data-option="next" @click="handleButtonClick('next')"></div> 17242 </div> 17243 <p v-if="calendarTitle" class="cs-calendar-date-range-title roboto">{{ calendarTitle }}</p> 17244 </div> 17245 <div class="flexGrowOne roboto"></div> 17246 <!-- calendar date picker --> 17247 <calendar-date-picker-component 17248 :handle_button_click="handleButtonClick" 17249 ></calendar-date-picker-component> 17250 <div class="vue-cs-calendar-select-wrap onWhite roboto"> 17251 <select 17252 class="vue-cs-calendar-view-select roboto" 17253 v-model="view_preference" 17254 @change="changeViewPreference" 17255 > 17256 <option value="Month">Month</option> 17257 <option value="Week">Week</option> 17258 <option value="Day">Day</option> 17259 </select> 17260 </div> 17261 </div> 17262 <!-- inject month view --> 17263 <div v-if="view_preference === 'Month' && !loading" class="flexGrid"> 17264 <!-- header -->
17265 <div class="daysOfWeek-grid"> 17266 <template v-for="day in daysOfWeek"> 17267 <calendar-cell-component 17268 class="grid-cell dayOfWeek" 17269 :class="{ 'borderClose': day === 'Sat' }" 17270 :day="day.toUpperCase()" 17271 cellType="dayOfWeek" 17272 :view_preference="view_preference" 17273 ></calendar-cell-component> 17274 </template> 17275 </div> 17276 <!-- events --> 17277 <div class="calendar-grid"> 17278 <template v-for="(calendarObj, i) in calendar"> 17279 <calendar-cell-component 17280 class="grid-cell" 17281 :client_id="client_id" 17282 :date="calendarObj.date" 17283 :class="{ 17284 'notCurrentMonth': calendarObj.class === 'prevMonthDate' || calendarObj.class === 'nextMonthDate', 17285 'currentMonth': calendarObj.class === 'currentMonthDate', 17286 'borderClose': calendarObj.date.day() === 6, 17287 'borderTopRightBottomClose': i >= 35 && i <= 40, 17288 'borderTopBottomClose': i === calendar.length - 1 17289 }" 17290 :month_class="calendarObj.class" 17291 :day="calendarObj.day" 17292 :events="calendarObj.events" 17293 cellType="calendarObj" 17294 :view_preference="view_preference" 17295 :defaultDate="calendarObj.defaultDate" 17296 :monthIndex="i" 17297 @createSchedule_emitted="createSchedule_emitted" 17298 @clickSchedule_emitted="clickSchedule_emitted" 17299 :is_membership_cal="is_membership_cal" 17300 ></calendar-cell-component> 17301 </template> 17302 </div> 17303 </div> 17304 <!-- inject week view --> 17305 <div v-if="view_preference === 'Week' && !loading" class="flexGrid"> 17306 <!-- header --> 17307 <div class="placeholder-daysOfWeek-grid"> 17308 <template v-for="day in placeholder_daysOfWeek"> 17309 <calendar-cell-component v-if="day.dayOfWeek === null" 17310 class="grid-cell-week dayOfWeek hide" 17311 :day="day" 17312 cellType="dayOfWeek" 17313 :view_preference="view_preference" 17314 ></calendar-cell-component> 17315 <calendar-cell-component v-if="day.dayOfWeek !== null" 17316 class="grid-cell-week dayOfWeek" 17317 :day="day" 17318 cellType="dayOfWeek" 17319 :view_preference="view_preference" 17320 :defaultDate="day.defaultDate" 17321 ></calendar-cell-component> 17322 </template> 17323 <!-- all day events --> 17324 <template v-for="allDay in placeholder_daysOfWeek"> 17325 <calendar-cell-component v-if="allDay.dayOfWeek === null" 17326 class="grid-cell-week allDay" 17327 :allDay="allDay" 17328 cellType="allDay_events" 17329 :view_preference="view_preference" 17330 :is_membership_cal="is_membership_cal" 17331 ></calendar-cell-component> 17332 <calendar-cell-component v-if="allDay.dayOfWeek !== null" 17333 class="grid-cell-week allDay" 17334 :allDay="allDay" 17335 cellType="allDay_events" 17336 :view_preference="view_preference" 17337 @createSchedule_emitted="createSchedule_emitted" 17338 @clickSchedule_emitted="clickSchedule_emitted" 17339 :is_membership_cal="is_membership_cal" 17340 ></calendar-cell-component> 17341 </template> 17342 </div> 17343 <!-- timestamps and events --> 17344 <div class="placeholder-calendar-grid"> 17345 <template v-for="(timestamp, i) in timestamps"> 17346 <template v-for="(calendarObj, j) in calendar"> 17347 <calendar-cell-component 17348 v-if="j === 0" 17349 class="grid-cell-week timestamp" 17350 :date="calendarObj.date" 17351 :timestamp="i % 4 === 0 ? formatShortTime( timestamp.customFormat ) : null" 17352 :events="[]" 17353 cellType="timestamp" 17354 :view_preference="view_preference" 17355 :is_membership_cal="is_membership_cal" 17356 :class="{ 'borderBottomClose': i === timestamps.length - 1 }" 17357 ></calendar-cell-component> 17358 <calendar-cell-component 17359 v-else 17360 class="grid-cell-week" 17361 :style="calendarObj.defaultDate ? { 'background-color': 'rgba( 175, 213, 240, 0.1)' } : null" 17362 :class="{ 17363 'showBorder': i % 4 === 0, 17364 'hideBorder': i === 0 || i % 4 !== 0 17365 }" 17366 :date="calendarObj.date" 17367 :events="generateEventForTimestamp( timestamp.customFormat, calendarObj.events, calendarObj.date )" 17368 cellType="calendarObj" 17369 :view_preference="view_preference" 17370 @createSchedule_emitted="createSchedule_emitted" 17371 @clickSchedule_emitted="clickSchedule_emitted" 17372 ></calendar-cell-component> 17373 </template> 17374 </template> 17375 </div> 17376 </div> 17377 <!-- inject day view --> 17378 <div v-if="view_preference === 'Day' && !loading" class="flexGrid"> 17379 <!-- header -->
17380 <div class="day-daysOfWeek-grid"> 17381 <template v-for="day in today_daysOfWeek"> 17382 <calendar-cell-component v-if="day.dayOfWeek === null" 17383 class="grid-cell-week dayOfWeek hide" 17384 :day="day" 17385 cellType="dayOfWeek" 17386 :view_preference="view_preference" 17387 ></calendar-cell-component> 17388 <calendar-cell-component v-if="day.dayOfWeek !== null" 17389 class="grid-cell-week day" 17390 :day="day" 17391 cellType="dayOfWeek" 17392 :view_preference="view_preference" 17393 :defaultDate="day.defaultDate" 17394 ></calendar-cell-component> 17395 </template> 17396 <!-- all day events --> 17397 <template v-for="allDay in today_daysOfWeek"> 17398 <calendar-cell-component v-if="allDay.dayOfWeek === null" 17399 class="grid-cell-week allDay" 17400 :allDay="allDay" 17401 cellType="allDay_events" 17402 :view_preference="view_preference" 17403 :is_membership_cal="is_membership_cal" 17404 ></calendar-cell-component> 17405 <calendar-cell-component v-if="allDay.dayOfWeek !== null" 17406 class="grid-cell-week allDay" 17407 :allDay="allDay" 17408 cellType="allDay_events" 17409 :view_preference="view_preference" 17410 @createSchedule_emitted="createSchedule_emitted" 17411 @clickSchedule_emitted="clickSchedule_emitted" 17412 :is_membership_cal="is_membership_cal" 17413 ></calendar-cell-component> 17414 </template> 17415 </div> 17416 <!-- timestamps and events --> 17417 <div class="day-calendar-grid"> 17418 <template v-for="(timestamp, i) in timestamps"> 17419 <template v-for="(calendarObj, j) in calendar"> 17420 <calendar-cell-component v-if="j === 0" 17421 class="grid-cell-week timestamp" 17422 :date="calendarObj.date" 17423 :timestamp="i % 4 === 0 ? formatShortTime( timestamp.customFormat ) : null" 17424 :events="[]" 17425 cellType="timestamp" 17426 :view_preference="view_preference" 17427 :is_membership_cal="is_membership_cal" 17428 :class="{ 'borderBottomClose': i === timestamps.length - 1 }" 17429 ></calendar-cell-component> 17430 <calendar-cell-component v-if="j !== 0" 17431 class="grid-cell-week" 17432 :style="calendarObj.defaultDate ? { 'background-color': 'rgba( 175, 213, 240, 0.1)' } : null" 17433 :class="{ 17434 'showBorder': i % 4 === 0, 17435 'hideBorder': i === 0 || i % 4 !== 0 17436 }" 17437 :date="calendarObj.date" 17438 :events="generateEventForTimestamp( timestamp.customFormat, calendarObj.events, calendarObj.date )" 17439 cellType="calendarObj" 17440 :view_preference="view_preference" 17441 @createSchedule_emitted="createSchedule_emitted" 17442 @clickSchedule_emitted="clickSchedule_emitted" 17443 ></calendar-cell-component> 17444 </template> 17445 </template> 17446 </div> 17447 </div> 17448 <!-- spinner while loading --> 17449 <div v-if="loading" class="cardBodySpinnerWrap relative active"> 17450 <div class="cardBodySpinner centeredBlock"></div> 17451 </div> 17452 </div> 17453 </div> 17454 </div> 17455</script>
vendor: 1 bytes, line 17455
17455
17456<script> 17457 /* ----- STORE ----- */ 17458 store.components["calendar_grid"] = { 17459 calendar: [], 17460 loading: true, 17461 view_preference: 'Month', 17462 dropdownOpen: false, 17463 daysOfWeek: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], 17464 placeholder_daysOfWeek: [], 17465 today_daysOfWeek: [], 17466 view_options: [ 'Month', 'Week', 'Day' ], 17467 calendarTitle: null, 17468 //dates and times 17469 currentDate: dayjs() || new Date(), 17470 defaultDate: dayjs() || new Date(), 17471 currentMonth: dayjs().format('MMMM'), 17472 startOfWeek: dayjs().startOf('week'), 17473 endOfWeek: dayjs().endOf('week'), 17474 currentYear: dayjs().year(), 17475 timestamps: [], 17476 currentTime: dayjs().format('HH:mm:ss'), 17477 }; 17478 17479 /* ----- COMPONENT ----- */ 17480 app_content.component("calendar-grid-component", { 17481 template: "#template-calendar-grid", 17482 data() { 17483 return store.components['calendar_grid'] 17484 }, 17485 created(){ 17486 if (this.timestamps.length === 0){ 17487 this.generateTimestamps(); 17488 } 17489 }, 17490 props: { 17491 client_id: String, 17492 class_string: String, 17493 style_string: String, 17494 filterOptions: Array, 17495 selectedFilterOptions: Array, 17496 schedules: Array, 17497 is_membership_cal: Boolean, 17498 load_cal: Function, 17499 handleFilterSelection: Function, 17500 createSchedule: Function, 17501 clickSchedule: Function, 17502 }, 17503 methods: { 17504 createSchedule_emitted( date ){ 17505 this.$emit('createSchedule', date) 17506 }, 17507 clickSchedule_emitted( event ){ 17508 this.$emit('clickSchedule', event ) 17509 }, 17510 formatShortTime( timeString ){ 17511 if (!timeString){ 17512 return '' // placeholder for 11:45-12:00 block 17513 } 17514 // if (timeString === '12:00 AM'){ 17515 // return Intl.DateTimeFormat().resolvedOptions().timeZone; 17516 // } 17517 const [hours, minutes] = timeString.split(':'); 17518 const AM_PM = minutes.split(' ')[1] 17519 return `${hours} ${AM_PM}`; 17520 }, 17521 generateWrapStyle( type ){ 17522 if (type === 'cs-calendar-wrap'){ 17523 return this.style_string === undefined ? {'width': '100%'} : this.style_string; 17524 } else if (type === 'options' && this.is_membership_cal){ 17525 return { 17526 'padding-top': '15px', 17527 'padding-right': '15px', 17528 'padding-left': '15px', 17529 'padding-bottom': '5px', 17530 'border-width': '0 1.5px 0 1.5px', 17531 'border-color': '#e2e8e8', 17532 'border-style': 'solid', 17533 'overflow': 'auto', 17534 } 17535 } else if (type === 'cs-calendar-menu-wrap' && this.is_membership_cal){ 17536 return { 17537 'border-width': '0 1.5px 0 1.5px', 17538 'border-color': '#e2e8e8', 17539 'border-style': 'solid' 17540 } 17541 } 17542 }, 17543 generateEventForTimestamp( timestamp, events, date ){ 17544 // do not want to display all day or multiple day events -- being handled separately 17545 let eventsArr = []; 17546 if (events.length > 0){ 17547 for (let i = 0; i < events.length; i++){ 17548 let event = events[i]; 17549 // normal event 17550 if ((event.hours_mins_start && event.hours_mins_end) && !event.isAllDay && event.calendarOnly) { 17551 let startTime = dayjs().set('hour', event.hours_mins_start.hours).set('minute', event.hours_mins_start.minutes); 17552 let endTime = dayjs().set('hour', event.hours_mins_end.hours).set('minute', event.hours_mins_end.minutes).subtract(15, 'minutes'); 17553 17554 let formattedStartTime = startTime.format('h:mm A'); 17555 let formattedEndTime = endTime.format('h:mm A'); 17556 17557 let timesArray = [formattedStartTime]; 17558 while (startTime.isBefore(endTime)) { 17559 startTime = startTime.add(15, 'minutes'); 17560 timesArray.push(startTime.format('h:mm A')); 17561 } 17562 if ( formattedStartTime === timestamp ){ 17563 eventsArr.push({ event, numOfTimeBlocks: timesArray.length }); 17564 } 17565 // recurring event 17566 }
17566 else if ( (event.start && event.end) && (event.start !== event.end) && !event.isAllDay && !event.isMultipleDay && event.calendarOnly ){ 17567 let startTime = dayjs(event.start); 17568 let endTime = dayjs(event.end).subtract(15, 'minutes'); 17569 17570 let formattedStartTime = startTime.format('h:mm A'); 17571 let formattedEndTime = endTime.format('h:mm A'); 17572 17573 let timesArray = [formattedStartTime]; 17574 17575 while (startTime.isBefore(endTime)) { 17576 startTime = startTime.add(15, 'minutes'); 17577 timesArray.push(startTime.format('h:mm A')); 17578 } 17579 if ( formattedStartTime === timestamp ){ 17580 eventsArr.push({ event, numOfTimeBlocks: timesArray.length }); 17581 } 17582 // non-calendarOnly events 17583 } else if ( (event.start && event.end) && (event.start !== event.end) && !event.calendarOnly ){ 17584 let startTime; 17585 let endTime; 17586 17587 if (dayjs(event.start).startOf('day').$d.toString() === dayjs(date).startOf('day').$d.toString()){ 17588 startTime = dayjs(event.start); 17589 endTime = dayjs(event.end).subtract(15, 'minutes'); 17590 } else if (dayjs(event.end).startOf('day').$d.toString() === dayjs(date).startOf('day').$d.toString()) { 17591 startTime = dayjs(event.end).startOf('day'); 17592 endTime = dayjs(event.end).subtract(15, 'minutes'); 17593 } else { 17594 startTime = dayjs().startOf('day'); 17595 endTime = dayjs().endOf('day'); 17596 } 17597 17598 let formattedStartTime = startTime.format('h:mm A'); 17599 let formattedEndTime = endTime.format('h:mm A'); 17600 17601 let timesArray = [formattedStartTime]; 17602 17603 while (startTime.isBefore(endTime)) { 17604 startTime = startTime.add(15, 'minutes'); 17605 timesArray.push(startTime.format('h:mm A')); 17606 } 17607 if ( formattedStartTime === timestamp ){ 17608 eventsArr.push({ event, numOfTimeBlocks: timesArray.length }); 17609 } 17610 } 17611 } 17612 } 17613 if (eventsArr.length > 0){ 17614 return eventsArr; 17615 } else { 17616 return []; 17617 } 17618 }, 17619 changeViewPreference(){ 17620 this.loading = true; 17621 //and re-load data 17622 this.$emit('load_cal', this.client_id); 17623 }, 17624 calculateTitleRange(){ 17625 if ( this.view_preference === 'Month' ){ 17626 this.calendarTitle = `${this.currentMonth} ${this.currentYear.toString()}`; 17627 } else if ( this.view_preference === 'Week' ){ 17628 this.calendarTitle = `${this.startOfWeek.format('MMMM')} ${this.startOfWeek.$D} - ${this.endOfWeek.format('MMMM')} ${this.endOfWeek.$D}`; 17629 } else if ( this.view_preference === 'Day' ){ 17630 this.calendarTitle = `${this.currentMonth} ${this.currentDate.$D}, ${this.currentYear}`; 17631 } 17632 }, 17633 handleButtonClick( option, selectedDate ){ 17634 this.loading = true; 17635 if (this.view_preference === 'Month'){ 17636 if( option === "today" ){ 17637 this.currentDate = dayjs(); 17638 this.currentMonth = this.currentDate.format('MMMM') 17639 this.currentYear = this.currentDate.$y 17640 this.startOfWeek = this.currentDate.startOf('week'); 17641 this.endOfWeek = this.currentDate.endOf('week'); 17642 }else if( option === "next" ){ 17643 this.currentDate = this.currentDate.add(1, 'month').startOf('month'); 17644 this.currentMonth = this.currentDate.format('MMMM') 17645 this.currentYear = this.currentDate.$y 17646 this.startOfWeek = this.currentDate.startOf('week'); 17647 this.endOfWeek = this.currentDate.endOf('week'); 17648 17649 }else if( option === "previous" ){ 17650 this.currentDate = this.currentDate.subtract(1, 'month').startOf('month'); 17651 this.currentMonth = this.currentDate.format('MMMM') 17652 this.currentYear = this.currentDate.$y 17653 this.startOfWeek = this.currentDate.startOf('week'); 17654 this.endOfWeek = this.currentDate.endOf('week'); 17655 }else if ( option === "date-picker" ){ 17656 let { month, year } = selectedDate; 17657 this.currentDate = dayjs(new Date(year, month, 1)); 17658 this.currentMonth = this.currentDate.format('MMMM') 17659 this.currentYear = this.currentDate.$y 17660 this.startOfWeek = this.currentDate.startOf('week'); 17661 this.endOfWeek = this.currentDate.endOf('week'); 17662 } 17663 } else if (this.view_preference === 'Week'){ 17664 if( option === "today" ){ 17665 this.currentDate = dayjs(); 17666 this.currentMonth = this.currentDate.format('MMMM') 17667 this.currentYear = this.currentDate.$y 17668 this.startOfWeek = this.currentDate.startOf('week'); 17669 this.endOfWeek = this.currentDate.endOf('week'); 17670 }else if( option === "next" ){ 17671 this.currentDate = this.currentDate.add(1, 'week'); 17672 this.currentMonth = this.currentDate.format('MMMM') 17673 this.currentYear = this.currentDate.$y 17674 this.startOfWeek = this.currentDate.startOf('week'); 17675 this.endOfWeek = this.currentDate.endOf('week'); 17676 }else if( option === "previous" ){ 17677 this.currentDate = this.currentDate.subtract(1, 'week'); 17678 this.currentMonth = this.currentDate.format('MMMM') 17679 this.currentYear = this.currentDate.$y 17680 this.startOfWeek = this.currentDate.startOf('week'); 17681 this.endOfWeek = this.currentDate.endOf('week'); 17682 }else if ( option === "date-picker" ){ 17683 let { month, year } = selectedDate; 17684 this.currentDate = dayjs(new Date(year, month, 1)); 17685 this.currentMonth = this.currentDate.format('MMMM') 17686 this.currentYear = this.currentDate.$y 17687 this.startOfWeek = this.currentDate.startOf('week'); 17688 this.endOfWeek = this.currentDate.endOf('week'); 17689 } 17690 } else if (this.view_preference === 'Day'){ 17691 if( option === "today" ){ 17692 this.currentDate = dayjs(); 17693 this.currentMonth = this.currentDate.format('MMMM') 17694 this.currentYear = this.currentDate.$y 17695 this.startOfWeek = this.currentDate.startOf('week'); 17696 this.endOfWeek = this.currentDate.endOf('week'); 17697 }else if( option === "next" ){ 17698 this.currentDate = this.currentDate.add(1, 'day'); 17699 this.currentMonth = this.currentDate.format('MMMM') 17700 this.currentYear = this.currentDate.$y 17701 this.startOfWeek = this.currentDate.startOf('week'); 17702 this.endOfWeek = this.currentDate.endOf('week'); 17703 }else if( option === "previous" ){ 17704 this.currentDate = this.currentDate.subtract(1, 'day'); 17705 this.currentMonth = this.currentDate.format('MMMM') 17706 this.currentYear = this.currentDate.$y 17707 this.startOfWeek = this.currentDate.startOf('week'); 17708 this.endOfWeek = this.currentDate.endOf('week'); 17709 }else if ( option === "date-picker" ){ 17710 let { month, year } = selectedDate; 17711 this.currentDate = dayjs(new Date(year, month, 1)); 17712 this.currentMonth = this.currentDate.format('MMMM') 17713 this.currentYear = this.currentDate.$y 17714 this.startOfWeek = this.currentDate.startOf('week'); 17715 this.endOfWeek = this.currentDate.endOf('week'); 17716 } 17717 } 17718 //and re-load data 17719 this.$emit('load_cal', this.client_id); 17720 }, 17721 /* ----- date methods ----- */ 17722 generateTimestamps(){ 17723 const startTime = dayjs().startOf('day'); 17724 const endTime = dayjs().endOf('day'); 17725 let currentTime = startTime; 17726 while (currentTime.isBefore(endTime)) { 17727 this.timestamps.push({ 17728 customFormat: currentTime.format('h:mm A'), 17729 militaryFormat: currentTime.format('HH:mm:ss'), 17730 }); 17731 currentTime = currentTime.add(15, 'minutes'); 17732 } 17733 this.timestamps.push({ 17734 customFormat: null, 17735 militaryFormat: null, 17736 }) 17737 }, 17738 generateCalendar( ){ 17739 console.log("generate calendar") 17740 this.loading = true; 17741 if ( this.view_preference === 'Month'){ 17742 // current month start date, end date, total days calculation 17743 const start = this.currentDate.startOf('month'); 17744 const end = start.endOf('month'); 17745 const totalDays = end.diff(start, 'day') + 1; 17746 // number of calendar days to add before and after current month days 17747 const spaceBefore = start.day(); 17748 const spaceAfter = 42 - (spaceBefore + totalDays); 17749 // generate number of days for grid cell of 42 17750 this.calendar = [...Array(spaceBefore)].map((_, i) => { 17751 const prevMonthDate = start.subtract(spaceBefore - i, 'day'); 17752 return { 17753 date: prevMonthDate, 17754 day: prevMonthDate.$D, 17755 class: 'prevMonthDate', 17756 events: [], 17757 defaultDate: false, 17758 } 17759 }).concat([...Array(totalDays)].map((_, i) => { 17760 const currentMonthDate = start.add(i, 'day'); 17761 if (this.defaultDate.startOf('day').$d.toString() === currentMonthDate.$d.toString()){ 17762 return { 17763 date: currentMonthDate, 17764 day: currentMonthDate.$D === 1 ? `${currentMonthDate.format('MMM')} ${currentMonthDate.$D}` : currentMonthDate.$D, 17765 class: 'currentMonthDate', 17766 events: [], 17767 defaultDate: true, 17768 } 17769 }else { 17770 return { 17771 date: currentMonthDate, 17772 day: currentMonthDate.$D === 1 ? `${currentMonthDate.format('MMM')} ${currentMonthDate.$D}` : currentMonthDate.$D, 17773 class: 'currentMonthDate', 17774 events: [], 17775 defaultDate: false, 17776 } 17777 } 17778 })).concat([...Array(spaceAfter)].map((_, i) => { 17779 const nextMonthDate = end.add(i + 1, 'day'); 17780 return { 17781 date: nextMonthDate, 17782 day: nextMonthDate.$D === 1 ? `${nextMonthDate.format('MMM')} ${nextMonthDate.$D}` : nextMonthDate.$D, 17783 class: 'nextMonthDate', 17784 events: [], 17785 defaultDate: false, 17786 } 17787 })) 17788 } else if ( this.view_preference === 'Week' ){ 17789 console.log("generate week A") 17790 // start and end of current week 17791 const startOfWeek = this.currentDate.startOf('week'); 17792 const endOfWeek = this.currentDate.endOf('week'); 17793 17794 const weekDays = [{ date: null, day: null, events: [] }]; 17795 let currentDay = startOfWeek; 17796 while (currentDay.isBefore(endOfWeek) || currentDay.isSame(endOfWeek, 'day')) { 17797 weekDays.push(currentDay); 17798 currentDay = currentDay.add(1, 'day'); 17799 } 17800 this.placeholder_daysOfWeek = [null, 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], 17801 this.placeholder_daysOfWeek = this.placeholder_daysOfWeek.map((dayOfTheWeek, i) => { 17802 if (JSON.stringify(this.defaultDate.startOf('day').$d) === JSON.stringify(weekDays[i].$d)){ 17803 return { 17804 dayOfWeek: dayOfTheWeek, 17805 day: weekDays[i].$D, 17806 date: weekDays[i], 17807 events: [], 17808 defaultDate: true, 17809 } 17810 } else { 17811 return { 17812 dayOfWeek: dayOfTheWeek, 17813 day: weekDays[i].$D, 17814 date: weekDays[i], 17815 events: [], 17816 defaultDate: false, 17817 } 17818 } 17819 }) 17820 console.log("generate week B") 17821 this.mapAllDayEvents(); 17822 this.calendar = weekDays.map(date => { 17823 if(JSON.stringify(this.defaultDate.startOf('day').$d) === JSON.stringify(date.$d)){ 17824 return { 17825 date: date, 17826 events: [], 17827 defaultDate: true, 17828 } 17829 } else { 17830 return { 17831 date: date, 17832 events: [], 17833 defaultDate: false, 17834 } 17835 } 17836 }) 17837 console.log("generate week C") 17838 } else if ( this.view_preference === 'Day' ){ 17839 this.today_daysOfWeek = [null, this.daysOfWeek[this.currentDate.day()]] 17840 this.today_daysOfWeek = this.today_daysOfWeek.map((dayOfTheWeek, i) => { 17841 if (i === 1 && this.defaultDate.startOf('day').$d.toString() === this.currentDate.startOf('day').$d.toString()){ 17842 return { 17843 dayOfWeek: dayOfTheWeek, 17844 day: dayOfTheWeek !== null ? this.currentDate.$D : null, 17845 date: dayOfTheWeek !== null ? this.currentDate : null, 17846 events: [], 17847 defaultDate: true, 17848 } 17849 } else { 17850 return { 17851 dayOfWeek: dayOfTheWeek,
17851 17852 day: dayOfTheWeek !== null ? this.currentDate.$D : null, 17853 date: dayOfTheWeek !== null ? this.currentDate : null, 17854 events: [], 17855 defaultDate: false, 17856 } 17857 } 17858 }) 17859 this.mapAllDayEvents(); 17860 this.calendar = [{ date: null, day: null, events: [] }, this.currentDate ].map((date, i) => { 17861 if(i === 1 && this.defaultDate.startOf('day').$d.toString() === date.startOf('day').$d.toString()){ 17862 return { 17863 date: date, 17864 events: [], 17865 defaultDate: true, 17866 } 17867 } else { 17868 return { 17869 date: date, 17870 events: [], 17871 defaultDate: false, 17872 } 17873 } 17874 }) 17875 } 17876 // map events onto calendar days 17877 this.mapCalendarEvents_v2(); 17878 }, 17879 // mapCalendarEvents(){ 17880 // let multipleDaySchedules = []; 17881 // this.calendar.map(day => { 17882 // let calendarDay; 17883 // if ( day.date !== null ){ 17884 // calendarDay = dayjs(day.date.$d).format('MMMM DD, YYYY'); 17885 // } 17886 // this.schedules.forEach(schedule => { 17887 // let scheduleStart = dayjs( schedule.start ).format('MMMM DD, YYYY'); 17888 // let scheduleEnd = dayjs( schedule.end ).format('MMMM DD, YYYY'); 17889 // let scheduleOnDay = scheduleStart === calendarDay && scheduleStart === scheduleEnd; 17890 // let scheduleOnDay_isAllDay = scheduleStart === calendarDay && schedule.isAllDay; 17891 // // check to see if there are any multiple event days 17892 // if ( multipleDaySchedules.length > 0 ){ 17893 // multipleDaySchedules.forEach((multipleDaySchedule, i) => { 17894 // if ( multipleDaySchedule.daysRemaining > 0 && calendarDay === multipleDaySchedule.scheduleStart.format('MMMM DD, YYYY')){ 17895 // day.events.push(multipleDaySchedule.schedule) 17896 // multipleDaySchedule.scheduleStart = multipleDaySchedule.scheduleStart.add(1, 'day') 17897 // multipleDaySchedule.daysRemaining--; 17898 // } else if ( multipleDaySchedule.daysRemaining === 0 ){ 17899 // multipleDaySchedules.splice(i, 1) 17900 // } 17901 // }) 17902 // } 17903 // // there is an event or all day event on this day 17904 // if ( scheduleOnDay || scheduleOnDay_isAllDay ){ 17905 // day.events.push(schedule) 17906 // // there is a multiple day event that starts on this day 17907 // } else if ( scheduleStart === calendarDay && scheduleStart !== scheduleEnd && !schedule.isAllDay ){ 17908 // multipleDaySchedules.push({ 17909 // schedule: schedule, 17910 // scheduleStart: dayjs(scheduleStart, { format: 'MMMM DD, YYYY' }).add(1, 'day'), 17911 // daysRemaining: dayjs(scheduleEnd, { format: 'MMMM DD, YYYY' }).diff(dayjs(scheduleStart, { format: 'MMMM DD, YYYY' }), 'day') - 1 17912 // }) 17913 // schedule.isMultipleDay = true; 17914 // day.events.push(schedule); 17915 // } 17916 // }) 17917 // return day; 17918 // }) 17919 // console.log('this is final mapping: ', this.calendar) 17920 // this.loading = false;
17921 // }, 17922 mapCalendarEvents_v2(){ 17923 console.log("map calendar events v2"); 17924 let previousDayEventOrder = []; 17925 this.calendar.map((day, dayIndex) => { 17926 let calendarDay; 17927 if ( day.date !== null ){ 17928 calendarDay = dayjs(day.date.$d) 17929 } 17930 this.schedules.forEach(schedule => { 17931 let scheduleStart = dayjs( schedule.start ) 17932 let scheduleEnd = dayjs( schedule.end ) 17933 let scheduleOnDay = scheduleStart.format('MMMM DD, YYYY') === calendarDay.format('MMMM DD, YYYY') && scheduleStart.format('MMMM DD, YYYY') === scheduleEnd.format('MMMM DD, YYYY'); 17934 let scheduleOnDay_isAllDay = scheduleStart.format('MMMM DD, YYYY') === calendarDay.format('MMMM DD, YYYY') && schedule.isAllDay; 17935 // event falls on current day OR it's an all day event 17936 // added scheduleOnDay && scheduleOnDay_isAllDay 1/25/24 for regatta subevents start start and end on the same day 17937 if ( scheduleOnDay && !scheduleOnDay_isAllDay || scheduleOnDay && scheduleOnDay_isAllDay ){ 17938 day.events.push(schedule) 17939 } 17940 // it's the first day of a multiple day event 17941 // these checks are not thorough enough 17942 else if (scheduleStart.format('MMMM DD, YYYY') === calendarDay.format('MMMM DD, YYYY') && scheduleStart.format('MMMM DD, YYYY') !== scheduleEnd.format('MMMM DD, YYYY')){ 17943 schedule.isMultipleDay = true; 17944 day.events.push(schedule) 17945 } 17946 // it's a multiple day event and the current date falls between the start and end date 17947 else if ( scheduleStart.format('MMMM DD, YYYY') !== calendarDay.format('MMMM DD, YYYY') && (calendarDay.isAfter(scheduleStart) && calendarDay.isBefore(scheduleEnd)) ){ 17948 schedule.isMultipleDay = true; 17949 day.events.push(schedule) 17950 } 17951 }) 17952 17953 /* ui stacking logic for month view */ 17954 if (this.view_preference === 'Month'){ 17955 if ( dayIndex !== 0 ){ 17956 let currentDayEventOrder; 17957 let currentDayIndexOrder = {}; 17958 let notMatchingEvents = []; 17959 let matchingEvents = []; 17960 for (let i = 0; i < day.events.length; i++){ 17961 let currentDayEventId = day.events[i].id; 17962 let previousDayIndex = previousDayEventOrder?.indexOf(currentDayEventId); 17963 if (previousDayIndex === -1){ 17964 notMatchingEvents.push(day.events[i]) 17965 } else if (previousDayIndex !== -1) { 17966 currentDayIndexOrder[previousDayIndex] = day.events[i]; 17967 matchingEvents.push(day.events[i]); 17968 } 17969 } 17970 17971 let missingIndexes = []; 17972 for (let j = 0; j < 3; j++ ){ 17973 if (Object.keys(currentDayIndexOrder).indexOf(j.toString()) === -1 ){ 17974 missingIndexes.push(j) 17975 } 17976 } 17977 17978 let placeholder = { 17979 bgColor: '', 17980 calendarId: '', 17981 calendarOnly: '', 17982 category: '', 17983 className: '', 17984 color: '', 17985 end: '', 17986 id: 'placeholder', 17987 raw: {dot_color: ''}, 17988 start: '', 17989 title: 'placeholder', 17990 }; 17991 17992 for (let k = 0; k < missingIndexes.length; k++){ 17993 let missingIndexValue = missingIndexes[k]; 17994 currentDayIndexOrder[missingIndexValue] = notMatchingEvents[k] || placeholder; 17995 } 17996 17997 let remainingIndexes = day.events.length - 3; 17998 let calculateRemainingEvents = matchingEvents.length > 3 ? matchingEvents.concat(notMatchingEvents).splice(3) : [...notMatchingEvents].splice(missingIndexes.length); 17999 18000 if (remainingIndexes > 0){ 18001 for (let l = 3; l < 3 + remainingIndexes; l++){ 18002 let targetIndex = matchingEvents.length > 3 ? l - 3 : l - 3; 18003 //added placeholder for white lake yacht club bugs 18004 currentDayIndexOrder[l] = calculateRemainingEvents[targetIndex] || placeholder; 18005 } 18006 } 18007 18008 const sortedObj = Object.fromEntries( 18009 Object.entries(currentDayIndexOrder).sort(([keyA], [keyB]) => parseInt(keyA) - parseInt(keyB)) 18010 ); 18011 currentDayEventOrder = Object.values(sortedObj); 18012 //set order of previous day events using event ids 18013 previousDayEventOrder = currentDayEventOrder.map(event =>
18013 { return event.id }); 18014 //set current day events to newly sorted order 18015 day.events = currentDayEventOrder.filter((event, i, self) => event.id === 'placeholder' || (event.id !== 'placeholder' && i === self.findIndex(e => e.id === event.id))); 18016 } 18017 } 18018 18019 return day; 18020 }) 18021 this.loading = false; 18022 }, 18023 mapAllDayEvents(){ 18024 let daysOfWeekArr; 18025 if (this.view_preference === 'Week'){ 18026 daysOfWeekArr = this.placeholder_daysOfWeek; 18027 } else if (this.view_preference === 'Day'){ 18028 daysOfWeekArr = this.today_daysOfWeek; 18029 } 18030 let multipleDaySchedules = []; 18031 daysOfWeekArr = daysOfWeekArr.map((day, i) => { 18032 this.schedules.forEach((schedule, i) => { 18033 if ( (i !== 0 && this.view_preference === 'Day' || i !== 0 && this.view_preference === 'Week') && ( schedule.calendarOnly || schedule.category === 'allday') ){ 18034 let calendarDay = dayjs(day.date) 18035 let scheduleStart = dayjs( schedule.start ) 18036 let scheduleEnd = dayjs( schedule.end ) 18037 let scheduleOnDay_isAllDay = scheduleStart.format('MMMM DD, YYYY') === calendarDay.format('MMMM DD, YYYY') && schedule.isAllDay; 18038 // it's an all day event 18039 if ( scheduleOnDay_isAllDay ){ 18040 day.events.push(schedule) 18041 } 18042 // it's the first day of a multiple day event 18043 else if (scheduleStart.format('MMMM DD, YYYY') === calendarDay.format('MMMM DD, YYYY') && scheduleStart.format('MMMM DD, YYYY') !== scheduleEnd.format('MMMM DD, YYYY')){ 18044 schedule.isMultipleDay = true; 18045 day.events.push(schedule) 18046 } 18047 // it's a multiple day event and the current date falls between the start and end date 18048 else if ( scheduleStart.format('MMMM DD, YYYY') !== calendarDay.format('MMMM DD, YYYY') && (calendarDay.isAfter(scheduleStart) && calendarDay.isBefore(scheduleEnd)) ){ 18049 schedule.isMultipleDay = true; 18050 day.events.push(schedule) 18051 } 18052 } 18053 }) 18054 return day; 18055 }) 18056 if (this.view_preference === 'Week'){ 18057 this.placeholder_daysOfWeek = daysOfWeekArr; 18058 } else if (this.view_preference === 'Day'){ 18059 this.today_daysOfWeek = daysOfWeekArr; 18060 } 18061 }, 18062 }, 18063 }) 18064</script>
18064
18064<script type="text/html" id="template-calendar-cell"> 18065 <div @click="$emit('createSchedule_emitted', date)"> 18066 <!-- cell for month view --> 18067 <template v-if="view_preference === 'Month'"> 18068 <div class="cell-content" :class="cellType"> 18069 <div v-if="day" class="cell-day" :class="cellType" :style="checkDefaultDate( day, defaultDate, 'month' )">{{ day }}</div> 18070 <!-- all events --> 18071 <div v-if="cellType === 'calendarObj' && events.length > 0" class="cell-events-month" ref="cell" @click.stop> 18072 <template v-for="(event, index) in events"> 18073 <span 18074 v-if="index <= 2" 18075 :class="{ 18076 'cell-event-month': event.id !== 'placeholder', 18077 'transparent-event': event.id === 'placeholder' 18078 }" 18079 :style="generateEventStyleForMonth( event, 'event_body', date, monthIndex )" 18080 @click="handleScheduleClickForMonth( event )" 18081 ><div :style="generateEventStyleForMonth( event, 'status_dot', '', '', month_class )"></div><div class="event-title" :style="generateFontSize('eventTitle', event)">{{ generateEventTitle( event ) }}</div></span> 18082 <span v-if="index >= 3 && index === events.length - 1" class="overflow-cell-events" ref="overflow-cell" @click="toggleOverflowView( date, events, $event )"> 18083 <div :style="[ { display: 'flex' }, generateFontSize('eventTitle') ]" class="event-title">{{ index - 2 }} more</div> 18084 </span> 18085 </template> 18086 </div> 18087 </div> 18088 </template> 18089 <!-- cell for week view --> 18090 <template v-if="view_preference === 'Week'"> 18091 <div class="cell-content-week" :class="cellType"> 18092 <div v-if="day" :class="{ 'week-cellDay-container' : day.dayOfWeek !== null }"> 18093 <div class="cell-day numberHeader" :class="cellType" :style="[{ 'font-size': '25px', 'font-weight': '400' }, checkDefaultDate(day, defaultDate, 'week_header')]">{{ day.day }}</div> 18094 <div class="cell-day dayHeader" :class="cellType">{{ day.dayOfWeek !== null ? day.dayOfWeek.toUpperCase() : null }}</div> 18095 </div> 18096 <div v-if="timestamp" class="cell-day" :class="cellType"> 18097 <div class="timestamp" :style="generateFontSize('timestamp')">{{ timestamp }}</div> 18098 </div> 18099 <!-- all day events --> 18100 <div v-if="allDay"> 18101 <div v-if="allDay.dayOfWeek === null" class="allDay_events" :class="cellType" :style="generateFontSize('allDay')">All Day</div> 18102 <div class="cell-events-month"> 18103 <template v-if="allDay.dayOfWeek !== null" v-for="event in allDay.events"> 18104 <span 18105 :style="generateEventStyleForWeek( event, 'allDay_event_body' )" 18106 class="allDay-cell-event" 18107 @click="$emit('clickSchedule_emitted', event)" 18108 >{{ generateEventTitle( event ) }}</span> 18109 </template> 18110 </div> 18111 </div> 18112 <!-- all events --> 18113 <div v-if="cellType === 'calendarObj' && events.length > 0" class="cell-events-week" @click.stop> 18114 <template v-for="(event, index) in events"> 18115 <span 18116 class="cell-event-week" 18117 :style="generateEventStyleForWeek( event, 'event_body' )" 18118 @click="$emit('clickSchedule_emitted', event.event)" 18119 ><div></div>{{ generateEventTitle( event.event ) }}</span> 18120 </template> 18121 </div> 18122 </div> 18123 </template> 18124 <!-- cell for day view --> 18125 <template v-if="view_preference === 'Day'"> 18126 <div class="cell-content-week" :class="cellType"> 18127 <div v-if="day" :class="{ 'day-cellDay-container' : day.dayOfWeek !== null }"> 18128 <div class="cell-day numberHeader" :class="cellType" :style="[{ 'font-size': '25px', 'font-weight': '400' }, checkDefaultDate(day, defaultDate, 'day_header')]">{{ day.day }}</div> 18129 <div class="cell-day dayHeader" :class="cellType">{{ day.dayOfWeek !== null ? day.dayOfWeek.toUpperCase() : null }}</div> 18130 </div> 18131 <div v-if="timestamp" class="cell-day" :class="cellType"> 18132 <div class="timestamp" :style="generateFontSize('timestamp')">{{ timestamp }}</div> 18133 </div> 18134 <!-- all day events --> 18135 <div v-if="allDay"> 18136 <div v-if="allDay.dayOfWeek === null" class="allDay_events" :class="cellType" :style="generateFontSize('allDay')">All Day</div> 18137 <div class="cell-events-month"> 18138 <template v-if="allDay.dayOfWeek !== null" v-for="event in allDay.events">
18139 <span 18140 :style="generateEventStyleForWeek( event, 'allDay_event_body' )" 18141 class="allDay-cell-event" 18142 @click="$emit('clickSchedule_emitted', event)" 18143 >{{ generateEventTitle( event ) }}</span> 18144 </template> 18145 </div> 18146 </div> 18147 <!-- all events --> 18148 <div v-if="cellType === 'calendarObj' && events.length > 0" class="cell-events-week" @click.stop> 18149 <template v-for="(event, index) in events"> 18150 <span 18151 class="cell-event-week" 18152 :style="generateEventStyleForWeek( event, 'event_body' )" 18153 @click="$emit('clickSchedule_emitted', event.event )" 18154 ><div></div>{{ generateEventTitle( event.event ) }}</span> 18155 </template> 18156 </div> 18157 </div> 18158 </template> 18159 </div> 18160</script>
18160 18161
18162<script> 18163 /* ----- STORE ----- */ 18164 store.components["calendar_cell"] = { 18165 cellWidth: this.getCellWidth, 18166 foo: null, 18167 }; 18168 /* ----- COMPONENT ----- */ 18169 app_content.component("calendar-cell-component", { 18170 template: "#template-calendar-cell", 18171 data() { 18172 return store.components['calendar_cell'] 18173 }, 18174 mounted(){ 18175 window.addEventListener('resize', this.handleResize); 18176 this.$nextTick(() => { 18177 this.handleResize(); 18178 }); 18179 }, 18180 beforeDestroy(){ 18181 window.removeEventListener('resize', this.handleResize); 18182 }, 18183 props: { 18184 client_id: String, 18185 date: Object, 18186 defaultDate: Boolean, 18187 day: { 18188 type: [String, Number, Object], 18189 required: false, 18190 }, 18191 allDay: Object, 18192 timestamp: String, 18193 currentTime: String, 18194 events: Array, 18195 cellType: String, 18196 view_preference: String, 18197 monthIndex: Number, 18198 month_class: String, 18199 is_membership_cal: Boolean, 18200 cell_emitted: Function, 18201 createSchedule_emitted: Function, 18202 clickSchedule_emitted: Function, 18203 }, 18204 methods: { 18205 handleResize(){ 18206 this.cellWidth = this.getCellWidth(); 18207 }, 18208 getCellWidth(){ 18209 const cell = this.$refs.cell; 18210 // Get the computed width of the cell 18211 if (cell){ 18212 this.cellWidth = cell.getBoundingClientRect().width; 18213 return cell.getBoundingClientRect().width; 18214 } 18215 }, 18216 checkDefaultDate( day, defaultDate, type ){ 18217 if( defaultDate ){ 18218 if ( type === 'month' ){ 18219 if ( day.length > 2){ 18220 return { 18221 'padding': '2.5px', 18222 'background-color': '#4a56f8', 18223 'color': '#fff', 18224 'min-width': 'fit-content', 18225 'max-width': 'fit-content', 18226 'min-height': 'fit-content', 18227 'max-height': 'fit-content', 18228 } 18229 } else { 18230 return { 18231 'background-color': '#4a56f8', 18232 'color': '#fff', 18233 'min-width': '22px', 18234 'max-width': '22px', 18235 'min-height': '22px', 18236 'max-height': '22px', 18237 } 18238 } 18239 } else if ( type === 'week_header' || type === 'day_header' ){ 18240 return { 18241 'font-weight': '575', 18242 // 'color': '#4a56f8', 18243 } 18244 } 18245 } else if ( !defaultDate ){ 18246 if ( type === 'month' ){ 18247 if ( day.length > 2){ 18248 return { 18249 'padding': '2.5px', 18250 'min-width': 'fit-content', 18251 'max-width': 'fit-content', 18252 'min-height': 'fit-content', 18253 'max-height': 'fit-content', 18254 } 18255 } else { 18256 return { 18257 'min-width': '22px', 18258 'max-width': '22px', 18259 'min-height': '22px', 18260 'max-height': '22px', 18261 } 18262 } 18263 } 18264 } 18265 }, 18266 generateEventTitle( eventObject ){ 18267 if ( !eventObject.isAllDay && !eventObject.isMultipleDay ) { 18268 return eventObject.raw.nice_time !== undefined ? `${eventObject.raw.nice_time} ${eventObject.title}` : `${eventObject.title}`; 18269 } else if ( eventObject.isAllDay || eventObject.isMultipleDay ) { 18270 return eventObject.title || 'Not Provided'; 18271 } 18272 }, 18273 generateFontSize( type, eventObject ){ 18274 if (this.is_membership_cal){ 18275 if (type === 'timestamp') { 18276 return { 18277 'font-size': '12px', 18278 'font-weight': '300', 18279 } 18280 } else if (type === 'allDay'){ 18281 return { 18282 'font-size': '13px', 18283 } 18284 } else if (type === 'eventTitle'){ 18285 if (eventObject?.id === 'placeholder'){ 18286 return { 18287 'color': 'transparent', 18288 'font-size': '13px', 18289 } 18290 } 18291 return { 18292 'font-size': '13px', 18293 } 18294 } 18295 } else { 18296 if (type === 'timestamp') { 18297 return { 18298 'font-size': '10px', 18299 'font-weight': '300', 18300 } 18301 } else if (type === 'allDay'){ 18302 return { 18303 'font-size': '11px', 18304 } 18305 }else if (type === 'eventTitle'){ 18306 if (eventObject?.id === 'placeholder'){ 18307 return { 18308 'color': 'transparent', 18309 'font-size': '12.5px', 18310 } 18311 } 18312 return { 18313 'font-size': '12.5px', 18314 } 18315 } 18316 } 18317 }, 18318 generateEventStyleForMonth( eventObject, type, date = null, monthIndex = null, month_class = null ){ 18319 let styleObject = {}; 18320 const dotColor = eventObject.raw.dot_color; 18321 const bgColor = eventObject.bgColor || '#e8edf1'; 18322 const color = /* eventObject.color || */ this.get_contrast_color( bgColor ) || 'inherit'; 18323 if ( type === 'status_dot' ){ 18324 if ( eventObject.id === 'placeholder' ){ 18325 styleObject = { 18326 'color': 'trasparent', 18327 } 18328 } else if ( !eventObject.isAllDay && !eventObject.isMultipleDay ) { 18329 if ( month_class === 'currentMonthDate' ){ 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 ( month_class === 'prevMonthDate' || month_class === 'nextMonthDate' ){ 18341 styleObject = { 18342 'min-width': '2.2px', 18343 'max-width': '2.2px', 18344 'min-height': '2.2px', 18345 'max-height': '2.2px', 18346 'border': `2.2px solid rgb(232, 237, 241)`, 18347 'border-radius': '50%', 18348 'background-color': `rgb(232, 237, 241)`, 18349 'margin': '0 5px 0 5px', 18350 } 18351 } 18352 } else if ( eventObject.isAllDay || eventObject.isMultipleDay ){ 18353 styleObject = { 18354 'margin-right': '3.5px', 18355 } 18356 } 18357 } else if ( type === 'event_body' ){ 18358 if ( eventObject.isAllDay && !eventObject.isMultipleDay ) { 18359 styleObject = { 18360 'background-color': bgColor, 18361 'color': color, 18362 }; 18363 } else if ( eventObject.isMultipleDay ){ 18364 let calendarDay = dayjs(date.$d).format('MMMM DD, YYYY'); 18365 let scheduleStart = dayjs( eventObject.start ).format('MMMM DD, YYYY'); 18366 let scheduleEnd = dayjs( eventObject.end ).format('MMMM DD, YYYY'); 18367 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; 18368 let endsAtMidnight = dayjs( eventObject.end ).format('HH:mm:ss') === dayjs().startOf('day').format('HH:mm:ss'); 18369 if (calendarDay === scheduleStart || monthIndex % 7 === 0 ){ 18370 let remainingDays = dayjs(scheduleEnd, { format: 'MMMM DD, YYYY' }).diff(dayjs(calendarDay, { format: 'MMMM DD, YYYY' }), 'day'); 18371 //handle non-calendarOnly events since end time is different than calendarOnly events 18372 if (!eventObject.calendarOnly && !isOneDay && !endsAtMidnight ){ 18373 remainingDays += 1; 18374 } 18375 if (this.cellWidth === undefined){ 18376 styleObject = { 18377 'background-color': bgColor, 18378 'color': color, 18379 'z-index': '5', 18380 'width' : `${remainingDays*this.getCellWidth()}px` 18381 }; 18382 } else { 18383 styleObject = { 18384 'background-color': bgColor, 18385 'color': color, 18386 'z-index': '5', 18387 'width': `${remainingDays*this.cellWidth}px` 18388 }; 18389 } 18390 } 18391 } 18392 } 18393 return styleObject 18394 }, 18395 generateEventStyleForWeek( eventObject, type ){ 18396 const event = eventObject.event || eventObject; 18397 const numOfTimeBlocks = eventObject.numOfTimeBlocks; 18398 18399 let styleObject = {}; 18400 const dotColor = event?.raw.dot_color; 18401 const bgColor = event?.bgColor || '#e8edf1'; 18402 const color = /* event?.color */ this.get_contrast_color( bgColor ) || 'inherit'; 18403 18404 if ( type === 'event_body' ){ 18405 if ( event ) { 18406 styleObject = { 18407 'background-color': dotColor || bgColor, 18408 'color': color, 18409 'width': '50%', 18410 'border-width': '1px 1px 1px 1.5px', 18411 'border-left-color': 'black', 18412 'border-top-color':'white', 18413 'border-right-color':'transparent', 18414 'border-bottom-color': 'white', 18415 'border-style': 'solid', 18416 'border-radius': '2px', 18417 'opacity': '85%', 18418 'z-index': '5', 18419 'height': `${(numOfTimeBlocks*12) - 5}px`, /* (number of 15 min increments * .placeholder-calendar-grid > grid-auto-rows) - .cell-event-week > padding(top) */ 18420 }; 18421 } 18422 } else if ( type === 'allDay_event_body' ){ 18423 if ( event ) { 18424 styleObject = { 18425 'background-color': dotColor || bgColor, 18426 'color': color, 18427 }; 18428 } 18429 } 18430 return styleObject 18431 }, 18432 toggleOverflowView( date, eventsArr, event ){ 18433 if (store.overlays.overflow_overlay){
18434 store.overlays.overflow_overlay = false; 18435 } else { 18436 store.components.overflow_overlay.cellPosition = { 18437 top: (event.clientY + window.scrollY)-(230/2), //230 overlay height 18438 left: (event.clientX + window.scrollX)-(230/2), //230 overlay width 18439 } 18440 if (this.is_membership_cal){ 18441 store.components.overflow_overlay.is_membership_cal = true; 18442 } 18443 store.components.overflow_overlay.client_id = this.client_id; 18444 store.components.overflow_overlay.date = date; 18445 store.components.overflow_overlay.events = eventsArr; 18446 store.overlays.overflow_overlay = true; 18447 } 18448 }, 18449 handleScheduleClickForMonth( event ){ 18450 if (event.id !== 'placeholder') { 18451 this.$emit('clickSchedule_emitted', event); 18452 } else { 18453 this.$emit('createSchedule_emitted', this.date); 18454 } 18455 }, 18456 //methods to determine font color in contrast to bg color 18457 calculate_luminance(color) { 18458 const hex = color.substring(1); //remove # 18459 const rgb = parseInt(hex, 16); //convert to RGB 18460 const r = (rgb >> 16) & 0xff; //extract the red channel 18461 const g = (rgb >> 8) & 0xff; //extract the green channel 18462 const b = (rgb >> 0) & 0xff; //extract the blue channel 18463 //convert RGB to linear values 18464 const sR = r / 255; 18465 const sG = g / 255; 18466 const sB = b / 255; 18467 //calculate luminance 18468 const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; 18469 return luminance; 18470 }, 18471 get_contrast_color(background) { 18472 const backgroundLuminance = this.calculate_luminance(background); 18473 const fontColor = backgroundLuminance > 0.5 ? '#000000' : '#ffffff'; 18474 return fontColor; 18475 } 18476 }, 18477 }) 18478</script>
18478
18478<script type="text/html" id="template-overflow-overlay"> 18479 <div v-if="global.overlays.overflow_overlay" id="overlay_overflow_overlay" :style="generateStyle()" class="calendar-overlay active" @click.self="global.overlays.overflow_overlay=false;"> 18480 <div class="calendarOverlayCard"> 18481 <div class="overlay-events-contain"> 18482 <div v-if="me.date" class="overflow-date-contain"> 18483 <span class="overflow-dateOfWeek">{{ me.date.format('ddd') }}</span> 18484 <span class="overflow-day">{{ me.date.$D }}</span> 18485 </div> 18486 </div> 18487 <template v-for="event in me.events"> 18488 <template v-if="event.id !== 'placeholder'"> 18489 <span 18490 class="cell-event-overlay" 18491 :style="generateEventStyleForMonth( event, 'event_body' )" 18492 @click="clickSchedule( event )" 18493 ><div :style="generateEventStyleForMonth( event, 'status_dot' )"></div><div class="event-title">{{ generateEventTitle( event ) }}</div></span> 18494 </template> 18495 </template> 18496 </div> 18497 <div class="xButton-calendarOverlay" @click.self="global.overlays.overflow_overlay=false;"></div> 18498 </div> 18499 </div> 18500</script>
18500 18501
18502<script> 18503 //global store 18504 store.components["overflow_overlay"] = { 18505 client_id: null, 18506 date: null, 18507 events: [], 18508 cellPosition: {}, 18509 loading: false, 18510 is_membership_cal: false, 18511 }; 18512 //define the component 18513 app_overlays.component("overflow-overlay", { 18514 template: "#template-overflow-overlay", 18515 data() { 18516 return { 18517 global: store, 18518 me: store.components.overflow_overlay 18519 } 18520 }, 18521 methods: { 18522 generateStyle(){ 18523 const cellPosition = this.me.cellPosition; 18524 const viewportHeight = window.innerHeight; 18525 const viewportWidth = window.innerWidth; 18526 const overlayHeight = 230; 18527 const overlayWidth = 230; 18528 18529 let top = cellPosition.top; 18530 let left = cellPosition.left; 18531 18532 // Adjust top position if overlay goes off-screen 18533 // if ( top + overlayHeight > viewportHeight) { 18534 // } 18535 18536 // Adjust left position if overlay goes off-screen 18537 if ( left + overlayWidth > viewportWidth) { 18538 let difference = left + overlayWidth; 18539 left -= difference - viewportWidth; // 20px margin from right 18540 } 18541 return { 18542 'top': `${top}px`, 18543 'left': `${left}px` 18544 }; 18545 }, 18546 generateEventTitle( eventObject ){ 18547 if ( !eventObject.isAllDay && !eventObject.isMultipleDay ) { 18548 return eventObject.raw.nice_time !== undefined ? `${eventObject.raw.nice_time} ${eventObject.title}` : `${eventObject.title}`; 18549 } else if ( eventObject.isAllDay || eventObject.isMultipleDay ) { 18550 return eventObject.title || 'Not Provided'; 18551 } 18552 }, 18553 generateEventStyleForMonth( eventObject, type ){ 18554 let styleObject = {}; 18555 const dotColor = eventObject.raw.dot_color; 18556 const bgColor = eventObject.bgColor || '#e8edf1'; 18557 const color = /* eventObject.color */ this.get_contrast_color( bgColor ) || 'inherit'; 18558 if ( type === 'status_dot' ){ 18559 if ( !eventObject.isAllDay && !eventObject.isMultipleDay ) { 18560 styleObject = { 18561 'min-width': '2.2px', 18562 'max-width': '2.2px', 18563 'min-height': '2.2px', 18564 'max-height': '2.2px', 18565 'border': `2.2px solid ${dotColor || '#333'}`, 18566 'border-radius': '50%', 18567 'background-color': `${dotColor || '#333'}`, 18568 'margin': '0 5px 0 5px', 18569 } 18570 } else if ( eventObject.isAllDay || eventObject.isMultipleDay ){ 18571 styleObject = { 18572 'margin-right': '3.5px', 18573 } 18574 } 18575 } else if ( type === 'event_body' ){ 18576 if ( eventObject.isAllDay || eventObject.isMultipleDay ) { 18577 styleObject = { 18578 'background-color': bgColor, 18579 'color': color, 18580 'height': '22px', 18581 }; 18582 } 18583 } 18584 return styleObject 18585 }, 18586 clickSchedule(event){ 18587 if ( this.me.client_id === 'primary' ){ 18588 store.eventBus.$emit('clickSchedule_events_cal', event) 18589 store.overlays.overflow_overlay = false; 18590 } else if ( this.me.client_id === 'banquets' ){ 18591 store.eventBus.$emit('clickSchedule_banquets_cal', event) 18592 store.overlays.overflow_overlay = false; 18593 } else if ( this.me.client_id === 'bookings' ){ 18594 store.eventBus.$emit('clickSchedule_reservations_cal', event)
18595 store.overlays.overflow_overlay = false; 18596 } else if ( this.me.client_id === 'time_blocks' ){ 18597 store.eventBus.$emit('clickSchedule_time_blocks_cal', event) 18598 store.overlays.overflow_overlay = false; 18599 } else if (this.me.is_membership_cal ){ 18600 store.eventBus.$emit('clickSchedule_membership_cal', event) 18601 store.overlays.overflow_overlay = false; 18602 } 18603 }, 18604 //methods to determine font color in contrast to bg color 18605 calculate_luminance(color) { 18606 const hex = color.substring(1); //remove # 18607 const rgb = parseInt(hex, 16); //convert to RGB 18608 const r = (rgb >> 16) & 0xff; //extract the red channel 18609 const g = (rgb >> 8) & 0xff; //extract the green channel 18610 const b = (rgb >> 0) & 0xff; //extract the blue channel 18611 //convert RGB to linear values 18612 const sR = r / 255; 18613 const sG = g / 255; 18614 const sB = b / 255; 18615 //calculate luminance 18616 const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; 18617 return luminance; 18618 }, 18619 get_contrast_color(background) { 18620 const backgroundLuminance = this.calculate_luminance(background); 18621 const fontColor = backgroundLuminance > 0.5 ? '#000000' : '#ffffff'; 18622 return fontColor; 18623 } 18624 }, 18625 }); 18626 18627 18628</script>
18628
18628<script type="text/html" id="template-custom-field-wb"> 18629 <div class="custom-field-wrapper tinyMarginTop" :id="custom_field.id"> 18630 <div class="flex-row-start" style="width: 100%;"> 18631 <!-- question --> 18632 <div class="flex-column-start tinyMarginRight" style="width: 100%;"> 18633 <div class="custom-field flex-row-start"> 18634 <p v-if="custom_field && custom_field.name" class="default-font-body">{{ custom_field.name }}</p> 18635 <span v-if="custom_field && custom_field.required" class="flex-row-start"> 18636 <p style="color: rgba(255, 0, 0, 0.885); margin: 0 5.5px 0 3px;">*</p> 18637 <p class="default-font-body" style="font-size: 14px; color: rgb(150, 152, 154); font-style: italic;">(required)</p> 18638 </span> 18639 </div> 18640 </div> 18641 </div> 18642 <!-- response options --> 18643 <div style="width: 100%;"> 18644 <!-- short response --> 18645 <template v-if="custom_field && custom_field.type === 'text'"> 18646 <span class="flex-row-start tinyMarginBottom"> 18647 <input 18648 type="text" 18649 placeholder="Your answer" 18650 class="border-bottom-grey-2 default-font-body" 18651 style="width: 50%; line-height: 24px;" 18652 v-model="input_value" 18653 :disabled="is_admin" 18654 > 18655 </span> 18656 </template> 18657 <!-- paragraph --> 18658 <template v-if="custom_field && custom_field.type === 'textarea'"> 18659 <span class="flex-row-start tinyMarginBottom"> 18660 <input 18661 type="text" 18662 placeholder="Your answer" 18663 class="border-bottom-grey-2 default-font-body" 18664 style="width: 100%; line-height: 24px;" 18665 v-model="input_value" 18666 :disabled="is_admin" 18667 > 18668 </span> 18669 </template> 18670 <!-- multiple choice, checkbox --> 18671 <div v-if="custom_field.type === 'radio' || custom_field.type === 'checkbox'" class="flex-column-start"> 18672 <template v-for="(option, index) in custom_field_options" :id="index"> 18673 <span class="flex-row-start" style="position: relative; width: 100%; left: -5px;"> 18674 <!-- multiple choice --> 18675 <template v-if="custom_field.type === 'radio'"> 18676 <span v-if="!option.selected" @click="select_mc_option( true, option )" class="flex-row-start"> 18677 <i class="fa-light fa-circle options-icon"></i> 18678 <p v-if="option && option.name" class="default-font-body">{{ option.name }}</p> 18679 </span> 18680 <span v-if="option.selected" @click="select_mc_option( false, option )" class="flex-row-start"> 18681 <i class="fa-solid fa-circle-dot options-icon"></i> 18682 <p v-if="option && option.name" class="default-font-body">{{ option.name }}</p> 18683 </span> 18684 </template> 18685 <!-- checkbox --> 18686 <template v-if="custom_field.type === 'checkbox'"> 18687 <span v-if="!option.selected" @click="option.selected = true;" class="flex-row-start"> 18688 <i class="fa-light fa-square options-icon"></i> 18689 <p v-if="option && option.name" class="default-font-body">{{ option.name }}</p> 18690 </span> 18691 <span v-if="option.selected" @click="option.selected = false;" class="flex-row-start"> 18692 <i class="fa-solid fa-square-check options-icon"></i> 18693 <p v-if="option && option.name" class="default-font-body">{{ option.name }}</p> 18694 </span> 18695 </template> 18696 </span> 18697 </template> 18698 </div> 18699 <!-- dropdown --> 18700 <template v-if="custom_field.type === 'select' && custom_field_options"> 18701 <div id="response-dropdown" :style="{ 18702 borderBottomLeftRadius: dropdown_open ? '0' : '4px', 18703 borderBottomRightRadius: dropdown_open ? '0' : '4px' 18704 }"> 18705 <!-- dropdown closed --> 18706 <div id="selected-response" @click="dropdown_open = !dropdown_open;"> 18707 <p v-if="selected_dropdown_index < 0" class="response-option default-font-body">Choose</p> 18708 <p v-if="selected_dropdown_index >= 0 && custom_field_options && custom_field_options[selected_dropdown_index]" class="response-option default-font-body"> 18709 {{ custom_field_options[selected_dropdown_index].name }} 18710 </p> 18711 <i v-if="!dropdown_open" class="fa-solid fa-sort-down" style="color: rgb(95, 95, 95)"></i> 18712 <i v-if="dropdown_open" class="fa-solid fa-sort-up" style="color: rgba(95, 95, 95)"></i> 18713 </div> 18714 <!-- dropdown open --> 18715 <div v-if="dropdown_open" id="response-options"> 18716 <div 18717 class="response-option border-bottom-grey-2" 18718 @click="select_dropdown_option( null, -1 )" 18719 :style="[ return_selected_style( -1 ) ]" 18720 > 18721 <p class="default-font-body" style="color: rgb(150, 152, 154);">Choose</p> 18722 </div> 18723 <div 18724 v-for="(option, index) in custom_field_options" 18725 class="response-option" 18726 :key="option.value" 18727 @click="select_dropdown_option( option, index )" 18728 :style="[ return_selected_style( index ) ]" 18729 > 18730 <p class="default-font-body">{{ option.name }}</p> 18731 </div> 18732 </div> 18733 </div> 18734 </template> 18735 <!-- searchable dropdown (same option model as select) --> 18736 <template v-if="custom_field && custom_field.type === 'searchable_select' && custom_field_options"> 18737 <div class="tinyMarginBottom" style="width: 100%;"> 18738 <rich-select-dropdown
18739 :value="get_searchable_select_value()" 18740 :options="rich_wb_dropdown_options()" 18741 :placeholder="custom_field.name || 'Select...'" 18742 @input="set_searchable_select_from_rich($event)" 18743 :showFieldLabelWhenSelected="true" 18744 :searchable="true" 18745 :searchSubtext="false" 18746 ></rich-select-dropdown> 18747 </div> 18748 </template> 18749 <!-- numeric value --> 18750 <template v-if="custom_field && custom_field.type === 'number'"> 18751 <span class="flex-row-start tinyMarginBottom"> 18752 <input 18753 type="text" 18754 placeholder="Number" 18755 class="border-bottom-grey-2 inherit-font-css" 18756 style="width: 20%; line-height: 24px;" 18757 v-model="input_value" 18758 @input="check_input_is_num()" 18759 :disabled="is_admin" 18760 > 18761 <span v-if="display_num_warning" class="flex-row-start" @mouseover="display_popup = true;" @mouseleave="display_popup = false;"> 18762 <i class="fa-solid fa-triangle-exclamation" style="color: rgba(255, 0, 0, 0.885); font-size: 1em; cursor: pointer;"></i> 18763 </span> 18764 <div v-if="display_popup" class="display-popup" style="font-size: 12px;">This value must be a number.</div> 18765 </span> 18766 </template> 18767 <!-- file upload --> 18768 <template v-if="custom_field && custom_field.type === 'file_upload'"> 18769 <file-upload-component 18770 @file_selected="file_selected" 18771 :valid_file_types="valid_file_types" 18772 ></file-upload-component> 18773 </template> 18774 <!-- date value --> 18775 <template v-if="custom_field && custom_field.type === 'date'"> 18776 <span class="flex-row-start tinyMarginBottom"> 18777 <input 18778 type="date" 18779 placeholder="mm/dd/yyyy" 18780 class="border-bottom-grey-2 default-font-body" 18781 style="width: 20%; line-height: 24px;" 18782 v-model="input_value" 18783 :disabled="is_admin" 18784 > 18785 </span> 18786 </template> 18787 <!-- rating --> 18788 <template v-if="custom_field && custom_field.type === 'rating'"> 18789 <div class="flex-row-start tinyMarginBottom" style="gap: 12px; padding-top: 4px;"> 18790 <i 18791 v-for="star in 5" 18792 :key="star" 18793 :class="star <= input_value ? 'fa-solid fa-star' : 'fa-regular fa-star'" 18794 style="font-size: 28px; color: #4a56f8; cursor: pointer;" 18795 @click="set_rating( star )" 18796 ></i> 18797 </div> 18798 </template> 18799 </div> 18800 </div> 18801</script>
vendor: 1 bytes, line 18801
18801
18802<script> 18803 app_content.component("custom-field-component-wb", { 18804 template: "#template-custom-field-wb", 18805 data() { 18806 return { 18807 selected_dropdown_index: -1, //default 18808 dropdown_open: false, 18809 custom_field_name: '', 18810 custom_field_required: false, 18811 display_num_warning: false, 18812 display_popup: false, 18813 input_value: '', 18814 } 18815 }, 18816 props: { 18817 form: Object, 18818 is_admin: Boolean, 18819 custom_field: Object, 18820 last_update: Number, 18821 }, 18822 methods: { 18823 return_selected_style( index ){ 18824 if ( index === this.selected_dropdown_index ){ 18825 return { 18826 'background-color': 'rgba(26, 115, 232, .09)', 18827 } 18828 } 18829 }, 18830 clear_dropdown_index(){ 18831 this.selected_dropdown_index = -1; 18832 }, 18833 clear_field(){ 18834 this.input_value = ''; 18835 this.selected_dropdown_index = -1; 18836 this.display_num_warning = false; 18837 if ( this.custom_field_options ){ 18838 this.custom_field_options.forEach(option => { 18839 option.selected = false; 18840 }); 18841 } 18842 }, 18843 select_dropdown_option( option, index ){ 18844 this.selected_dropdown_index = index; 18845 this.dropdown_open = false; 18846 if ( option ){ 18847 option.selected = true; 18848 this.unselect_other_options( option, false ); 18849 } else if ( !option ){ 18850 this.unselect_other_options( option, true ); 18851 } 18852 }, 18853 select_mc_option( select, option ){ 18854 if ( select ){ 18855 option.selected = true; 18856 this.unselect_other_options( option, false ); 18857 } else if ( !select ){ 18858 option.selected = false; 18859 } 18860 }, 18861 get_searchable_select_value(){ 18862 if ( !this.custom_field_options || !this.custom_field_options.length ) return null; 18863 const selected = this.custom_field_options.find(o => o.selected); 18864 return selected ? selected.id : null; 18865 }, 18866 rich_wb_dropdown_options(){ 18867 if ( !this.custom_field_options ) return []; 18868 return this.custom_field_options.map(o => ({ id: o.id, name: o.name || '' })); 18869 }, 18870 set_searchable_select_from_rich(val){ 18871 let id = null; 18872 if ( val != null && typeof val === 'object' && val.id != null ){ 18873 id = val.id; 18874 } else { 18875 id = val; 18876 } 18877 if ( !id ){ 18878 this.select_dropdown_option(null, -1); 18879 return; 18880 } 18881 const idx = this.custom_field_options.findIndex(o => o.id === id); 18882 if ( idx === -1 ){ 18883 this.select_dropdown_option(null, -1); 18884 return; 18885 } 18886 this.select_dropdown_option(this.custom_field_options[idx], idx); 18887 }, 18888 unselect_other_options( option, clear_all = false ){ 18889 if ( clear_all ){ 18890 this.custom_field_options.forEach( custom_field_option => { 18891 custom_field_option.selected = false; 18892 }) 18893 } else if ( !clear_all ){ 18894 this.custom_field_options.forEach( custom_field_option => { 18895 if ( custom_field_option.id !== option.id ){ 18896 custom_field_option.selected = false; 18897 } 18898 }) 18899 } 18900 }, 18901 check_input_is_num(){ 18902 let number = Number(this.input_value); 18903 if ( isNaN(number) || !this.input_value ){ 18904 this.display_num_warning = true; 18905 } else { 18906 this.input_value = number; 18907 this.display_num_warning = false;
18908 } 18909 }, 18910 file_selected( data ){ 18911 let file = data.value; 18912 if (file) { 18913 this.input_value = file; 18914 } 18915 }, 18916 set_rating( star ){ 18917 if ( this.is_admin ){ 18918 return; 18919 } 18920 this.input_value = star; 18921 }, 18922 }, 18923 computed: { 18924 custom_field_options(){ 18925 if ( this.custom_field && this.custom_field.custom_field_options ){ 18926 return this.custom_field.custom_field_options; 18927 } 18928 return []; 18929 }, 18930 valid_file_types(){ 18931 return [ ]; 18932 }, 18933 }, 18934 watch: { 18935 input_value( newValue ){ 18936 this.$emit('field-updated', { 18937 id: this.custom_field.id, 18938 value: newValue 18939 }); 18940 } 18941 } 18942 }) 18943</script>
18943 18944 18945 18946<style scoped> 18947 :root { 18948 --custom-field-grey-1: rgba(180, 180, 180, 0.8); 18949 --custom-field-grey-2: rgba(195, 195, 195, 0.7); 18950 --custom-field-grey-3: rgb(154, 160, 166); 18951 --custom-field-primary: #202124; 18952 } 18953 .custom-field-wrapper{ 18954 width: 100%; 18955 border: 1px solid rgba(195, 195, 195, 0.7); 18956 border-radius: 8px; 18957 padding: 1.45em; 18958 padding-top: 10px; 18959 background-color: #fff; 18960 box-sizing: border-box; 18961 } 18962 .custom-field-wrapper .options-icon{ 18963 color: var(--custom-field-grey-3); 18964 margin-right: 8px; 18965 padding: 7.5px; 18966 cursor: pointer; 18967 border-radius: 50%; 18968 font-size: 1.1em; 18969 } 18970 .custom-field-wrapper .options-icon:hover{ 18971 background-color: rgba(150, 150, 150, 0.1); 18972 } 18973 .custom-field-wrapper .default-font-body{ 18974 font-family: Roboto, Arial, Helvetica, sans-serif; 18975 font-size: 15px; 18976 font-weight: 400; 18977 text-align: left; 18978 } 18979 .custom-field-wrapper .file-upload-wrapper{ 18980 height: 90px; 18981 width: 50%; 18982 border: 1px dashed var(--custom-field-grey-1); 18983 } 18984 .custom-field-wrapper .inherit-font-css{ 18985 font-family: inherit; 18986 font-size: inherit; 18987 font-weight: inherit; 18988 } 18989 .custom-field-wrapper .flex-row-center{ 18990 display: flex; 18991 flex-direction: row; 18992 align-items: center; 18993 justify-content: center; 18994 } 18995 .custom-field-wrapper .flex-column-center{ 18996 display: flex; 18997 flex-direction: column; 18998 align-items: center; 18999 justify-content: center; 19000 } 19001 .custom-field-wrapper .flex-row-start{ 19002 display: flex; 19003 flex-direction: row; 19004 align-items: center; 19005 justify-content: flex-start; 19006 } 19007 .custom-field-wrapper .flex-column-start{ 19008 display: flex; 19009 flex-direction: column; 19010 align-items: flex-start; 19011 justify-content: center; 19012 } 19013 .custom-field-wrapper .custom-field{ 19014 color: rgb(32, 33, 36); 19015 box-sizing: border-box; 19016 font-weight: 400; 19017 font-size: 14px; 19018 width: 100%; 19019 line-height: 2.5; 19020 letter-spacing: 0; 19021 } 19022 .custom-field-wrapper .custom-field > .custom-field-input{ 19023 font-family: inherit; 19024 font-size: inherit; 19025 font-weight: inherit; 19026 width: inherit; 19027 line-height: inherit; 19028 background-color: rgb(248, 249, 250); 19029 padding: 5px 0 5px 15px; 19030 } 19031 .custom-field-wrapper .border-bottom-grey-2, .custom-field-wrapper .input-is-focused{ 19032 border-bottom: 1.25px solid var(--custom-field-grey-2); 19033 } 19034 .custom-field-wrapper input { 19035 transition: border-color 0.2s; 19036 } 19037 #response-dropdown{ 19038 position: relative; 19039 display: flex; 19040 flex-direction: column; 19041 align-items: flex-start; 19042 justify-content: center; 19043 font-size: 14px; 19044 color: var(--custom-field-primary); 19045 background-color: #fff; 19046 width: 33%; 19047 padding: 12px; 19048 border: 1px solid var(--custom-field-grey-1); 19049 border-top-left-radius: 4px; 19050 border-top-right-radius: 4px; 19051 } 19052 #response-dropdown > #selected-response{ 19053 display: flex; 19054 flex-direction: row; 19055 align-items: center; 19056 justify-content: space-between; 19057 width: 100%; 19058 cursor: pointer; 19059 } 19060 #response-options{ 19061 position: absolute; 19062 top: 100%; 19063 left: -1px; 19064 z-index: 10; 19065 background-color: #fff; 19066 border: 1px solid var(--custom-field-grey-1); 19067 border-bottom-left-radius: 4px; 19068 border-bottom-right-radius: 4px; 19069 width: 100%; 19070 } 19071 #response-options > .response-option{ 19072 display: flex; 19073 flex-direction: row; 19074 align-items: center; 19075 justify-content: flex-start; 19076 padding: 12px; 19077 font-family: Roboto; 19078 cursor: pointer; 19079 } 19080 #response-options > .response-option:hover{ 19081 background-color: rgba(150, 150, 150, 0.2); 19082 } 19083 .display-popup{ 19084 background-color: rgb(245, 245, 245); 19085 padding: 5px; 19086 border-radius: 6px; 19087 position: relative; 19088 left: -100px; 19089 bottom: 30px; 19090 z-index: 10; 19091 width: fit-content; 19092 } 19093</style> 19094 19095
19095<script type="text/html" id="template-rich-select-dropdown"> 19096 <div class="relative"> 19097 <div v-if="showFieldLabelWhenSelected && has_selected_value" class="rich-select-floating-label"> 19098 <span class="rich-select-trigger-title">{{ label || placeholder }}</span> 19099 </div> 19100 <div 19101 class="rich-select-dropdown relative" ref="container" 19102 style="width: 100%;" 19103 > <!-- Closed state: box showing selected option(s) --> 19104 <div 19105 class="rich-select-trigger flexNoWrap align-items-center pointer" 19106 :class="[triggerClass, { 'rich-select-trigger-open': isOpen, 'rich-select-trigger-borderless': borderless, 'rich-select-trigger-disabled': disabled }]" 19107 @click="toggle()" 19108 > 19109 <slot name="indicator"></slot> 19110 <template v-if="multiple"> 19111 <template v-if="selectedOptionsForDisplay.length === 0"> 19112 <span class="rich-select-placeholder">{{ label || placeholder }}</span> 19113 </template> 19114 <template v-else-if="selectedOptionsForDisplay.length === 1"> 19115 <div v-if="optionImage(selectedOptionsForDisplay[0])" class="rich-select-option-icon-wrap"> 19116 <img v-if="optionImageIsUrl(selectedOptionsForDisplay[0])" :src="optionImage(selectedOptionsForDisplay[0])" class="rich-select-option-icon" alt=""> 19117 <i v-else :class="optionImage(selectedOptionsForDisplay[0])" class="rich-select-option-icon fa"></i> 19118 </div> 19119 <div class="rich-select-trigger-text"> 19120 <span class="rich-select-trigger-title">{{ optionTitle(selectedOptionsForDisplay[0]) }}</span> 19121 <span v-if="optionSubtext(selectedOptionsForDisplay[0])" class="rich-select-trigger-subtext">{{ optionSubtext(selectedOptionsForDisplay[0]) }}</span> 19122 </div> 19123 </template> 19124 <template v-else> 19125 <span class="rich-select-trigger-title">{{ selectedOptionsForDisplay.length }} selected</span> 19126 </template> 19127 </template> 19128 <template v-else> 19129 <template v-if="selectedOptionSingle"> 19130 <div v-if="optionImage(selectedOptionSingle)" class="rich-select-option-icon-wrap"> 19131 <img v-if="optionImageIsUrl(selectedOptionSingle)" :src="optionImage(selectedOptionSingle)" class="rich-select-option-icon" alt=""> 19132 <i v-else :class="optionImage(selectedOptionSingle)" class="rich-select-option-icon fa"></i> 19133 </div> 19134 <div class="rich-select-trigger-text"> 19135 <span class="rich-select-trigger-title" :class="{ 'rich-select-trigger-title-no-subtext': !optionSubtext(selectedOptionSingle) }">{{ optionTitle(selectedOptionSingle) }}</span> 19136 <span v-if="optionSubtext(selectedOptionSingle)" class="rich-select-trigger-subtext">{{ optionSubtext(selectedOptionSingle) }}</span> 19137 </div> 19138 </template> 19139 <template v-else-if="disabled && disabledSubtext"> 19140 <div class="rich-select-trigger-text"> 19141 <span class="rich-select-placeholder">{{ label || placeholder }}</span> 19142 <span class="rich-select-trigger-subtext">{{ disabledSubtext }}</span> 19143 </div> 19144 </template> 19145 <template v-else> 19146 <span class="rich-select-placeholder">{{ label || placeholder }}</span> 19147 </template> 19148 </template> 19149 <div class="flexGrowOne"></div> 19150 <i class="fa-light fa-chevron-down rich-select-chevron" :class="{ 'rich-select-chevron-open': isOpen }" style="z-index: 10;"></i> 19151 </div> 19152 19153 <!-- Open state: dropdown panel (hovering above content) --> 19154 <div 19155 v-show="isOpen"
19156 class="rich-select-dropdown-panel boxShadowBottom moreBlur" 19157 :class="{ 'rich-select-dropdown-panel--fixed': uses_fixed_dropdown_panel && isOpen }" 19158 :style="fixedDropdownPanelInlineStyle" 19159 ref="dropdownPanel" 19160 @mouseover="isMouseOver = true" 19161 @mouseout="isMouseOver = false" 19162 tabindex="-1" 19163 @focusout="scheduleClose" 19164 > 19165 <div v-if="searchable" class="rich-select-search-wrap relative"> 19166 <input 19167 ref="searchInput" 19168 v-model="searchQuery" 19169 class="rich-select-search-input" 19170 :aria-label="search_placeholder || 'Search...'" 19171 @click.stop 19172 > 19173 <span 19174 v-if="!searchQuery" 19175 class="unclickable" 19176 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;" 19177 >{{ search_placeholder || 'Search...' }}</span> 19178 </div> 19179 <div class="rich-select-dropdown-list scrollable" ref="dropdownList"> 19180 <div v-if="loading" class="rich-select-option flexNoWrap align-items-center unclickable"> 19181 <div class="rich-select-option-text"> 19182 <span class="rich-select-option-subtext">Searching...</span> 19183 </div> 19184 </div> 19185 <div 19186 v-if="showNoneOption" 19187 class="rich-select-option flexNoWrap align-items-center" 19188 :class="{ 'rich-select-option-selected': value == null }" 19189 @click="selectOption(null)" 19190 > 19191 <div class="rich-select-option-text"> 19192 <span class="rich-select-option-title">None</span> 19193 </div> 19194 </div> 19195 <!-- Suggested section: only renders when the parent passes 19196 lookup-derived candidates AND the user hasn't typed a 19197 search query. Header + divider use inline styles per 19198 the no-new-classes rule for HTML files. --> 19199 <template v-if="visibleSuggestedOptions.length > 0"> 19200 <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> 19201 <div 19202 v-for="option in visibleSuggestedOptions" 19203 :key="'suggested-' + optionId(option)" 19204 class="rich-select-option flexNoWrap align-items-center" 19205 :class="{ 19206 'rich-select-option-selected': isSelected(option), 19207 'unclickable': option && (option.disabled || option.visually_disabled) 19208 }" 19209 @click="selectOption(option)" 19210 > 19211 <template v-if="multiple"> 19212 <i v-if="isSelected(option)" class="fa-solid fa-square-check rich-select-option-check"></i> 19213 <i v-else class="fa-light fa-square rich-select-option-check"></i> 19214 </template> 19215 <div v-if="optionImage(option)" class="rich-select-option-icon-wrap"> 19216 <img v-if="optionImageIsUrl(option)" :src="optionImage(option)" class="rich-select-option-icon" alt=""> 19217 <i v-else :class="optionImage(option)" class="rich-select-option-icon fa"></i> 19218 </div> 19219 <div class="rich-select-option-text">
19220 <span class="rich-select-option-title" :class="optionTitleClass(option)">{{ optionTitle(option) }}</span> 19221 <span v-if="optionSubtext(option)" class="rich-select-option-subtext">{{ optionSubtext(option) }}</span> 19222 </div> 19223 <slot name="option-action" :option="option"></slot> 19224 </div> 19225 <div style="height: 1px; background-color: #e4ebf1; margin: 4px 0;"></div> 19226 </template> 19227 <div 19228 v-for="option in nonSuggestedFilteredOptions" 19229 :key="optionId(option)" 19230 class="rich-select-option flexNoWrap align-items-center" 19231 :class="{ 19232 'rich-select-option-selected': isSelected(option), 19233 'unclickable': option && (option.disabled || option.visually_disabled) 19234 }" 19235 @click="selectOption(option)" 19236 > 19237 <template v-if="multiple"> 19238 <i v-if="isSelected(option)" class="fa-solid fa-square-check rich-select-option-check"></i> 19239 <i v-else class="fa-light fa-square rich-select-option-check"></i> 19240 </template> 19241 <div v-if="optionImage(option)" class="rich-select-option-icon-wrap"> 19242 <img v-if="optionImageIsUrl(option)" :src="optionImage(option)" class="rich-select-option-icon" alt=""> 19243 <i v-else :class="optionImage(option)" class="rich-select-option-icon fa"></i> 19244 </div> 19245 <div class="rich-select-option-text"> 19246 <span class="rich-select-option-title" :class="optionTitleClass(option)">{{ optionTitle(option) }}</span> 19247 <span v-if="optionSubtext(option)" class="rich-select-option-subtext">{{ optionSubtext(option) }}</span> 19248 </div> 19249 <slot name="option-action" :option="option"></slot> 19250 </div> 19251 <div v-if="!loading && nonSuggestedFilteredOptions.length == 0" class="rich-select-option flexNoWrap align-items-center unclickable"> 19252 <div class="rich-select-option-text"> 19253 <span class="rich-select-option-subtext">{{ empty_text || "No options found" }}</span> 19254 </div> 19255 </div> 19256 </div> 19257 <slot name="panel-footer"></slot> 19258 </div> 19259 </div> 19260 </div> 19261</script>
19261 19262
19263<script> 19264(function() { 19265 function optionId(option) { 19266 if (!option) return ''; 19267 return option.id != null ? option.id : (option.get ? option.get('id') : option); 19268 } 19269 function optionTitle(option) { 19270 if (!option) return ''; 19271 return option.title != null ? option.title : (option.name != null ? option.name : (option.get ? (option.get('title') || option.get('name')) : '')); 19272 } 19273 function optionSubtext(option) { 19274 if (!option) return ''; 19275 return option.subtext != null ? option.subtext : (option.get ? option.get('subtext') : ''); 19276 } 19277 function optionTitleClass(option) { 19278 if (!option) return ''; 19279 return option.title_class != null ? option.title_class : (option.get ? option.get('title_class') : ''); 19280 } 19281 function optionImage(option) { 19282 if (!option) return null; 19283 return option.image != null ? option.image : (option.icon != null ? option.icon : (option.get ? (option.get('image') || option.get('icon')) : null)); 19284 } 19285 function optionImageIsUrl(option) { 19286 var img = optionImage(option); 19287 return img && (typeof img === 'string' && (img.indexOf('http') === 0 || img.indexOf('/') === 0 || img.indexOf('data:') === 0)); 19288 } 19289 19290 var richSelectDropdownConfig = { 19291 template: "#template-rich-select-dropdown", 19292 emits: ['input', 'update:value', 'change', 'search'], 19293 data() { 19294 return { 19295 isOpen: false, 19296 isMouseOver: false, 19297 closeTimeout: null, 19298 searchQuery: '', 19299 fixedPanelPositionStyle: null, 19300 _fixedPanelScrollCleanup: null, 19301 auto_fix_dropdown_panel: false, 19302 }; 19303 }, 19304 mounted() { 19305 this.auto_fix_dropdown_panel = !!this.$refs.container.closest('.wb-inline-controls'); 19306 }, 19307 watch: { 19308 isOpen: function(is_open) { 19309 if (!this.uses_fixed_dropdown_panel) { 19310 return; 19311 } 19312 if (is_open) { 19313 this.scroll_trigger_into_safe_view_if_needed(); 19314 this.sync_fixed_dropdown_panel(); 19315 this.$nextTick(() => { 19316 this.sync_fixed_dropdown_panel(); 19317 this.teardown_fixed_dropdown_panel_listeners(); 19318 this._fixedPanelScrollCleanup = this.setup_fixed_dropdown_panel_listeners(); 19319 }); 19320 } else { 19321 this.fixedPanelPositionStyle = null; 19322 this.teardown_fixed_dropdown_panel_listeners(); 19323 } 19324 }, 19325 searchQuery: function(updated_query) { 19326 if (!this.searchable) { 19327 return; 19328 } 19329 this.$emit('search', updated_query); 19330 } 19331 }, 19332 props: { 19333 options: { 19334 type: Array, 19335 default: function() { return []; } 19336 }, 19337 value: { 19338 type: [Object, Array, String, Number], 19339 default: null 19340 }, 19341 multiple: { 19342 type: Boolean, 19343 default: false 19344 }, 19345 placeholder: { 19346 type: String, 19347 default: "Select..." 19348 }, 19349 label: { 19350 type: String, 19351 default: "" 19352 }, 19353 triggerClass: { 19354 type: String, 19355 default: "" 19356 }, 19357 /** If true, options may be Backbone models with .get('key') */ 19358 parse_objects: { 19359 type: Boolean, 19360 default: false 19361 }, 19362 showFieldLabelWhenSelected: { 19363 type: Boolean, 19364 default: false 19365 }, 19366 searchable: { 19367 type: Boolean, 19368 default: false 19369 }, 19370 search_placeholder: { 19371 type: String, 19372 default: "" 19373 }, 19374 remote_search: { 19375 type: Boolean, 19376 default: false 19377 }, 19378 loading: { 19379 type: Boolean, 19380 default: false 19381 }, 19382 empty_text: { 19383 type: String, 19384 default: '' 19385 }, 19386 /** When true, the closed trigger renders without its border/box so it can sit flush inside a table cell. */ 19387 borderless: { 19388 type: Boolean, 19389 default: false 19390 }, 19391 searchSubtext: { 19392 type: Boolean, 19393 default: true 19394 }, 19395 /** When true, the open panel uses position:fixed so it escapes overflow:auto
19395scroll parents (e.g. lodging step). */ 19396 dropdownPanelFixed: { 19397 type: Boolean, 19398 default: false 19399 }, 19400 /** When true, a "None" option is shown at the top of the list to clear the selection. */ 19401 showNoneOption: { 19402 type: Boolean, 19403 default: false 19404 }, 19405 /** 19406 * Optional, ranked list of options to surface at the top of the 19407 * dropdown panel under a "Suggested" header. Items must use the 19408 * same shape as `options` (id + title/name). The component 19409 * doesn't decide what's "suggested" â that's the parent's job 19410 * (e.g. the add-product modal feeds in the top N candidates from 19411 * its similar-products lookup). Hidden once the user starts 19412 * typing in the search input so search results aren't fragmented 19413 * across two sections. 19414 */ 19415 suggested_options: { 19416 type: Array, 19417 default: function() { return []; } 19418 }, 19419 /** Header label rendered above suggested_options. */ 19420 suggested_label: { 19421 type: String, 19422 default: 'Suggested' 19423 }, 19424 /** When true, the trigger can't be opened and renders in a muted state. */ 19425 disabled: { 19426 type: Boolean, 19427 default: false 19428 }, 19429 /** Explanatory line rendered under the placeholder when disabled (e.g. "No products found"). */ 19430 disabledSubtext: { 19431 type: String, 19432 default: null 19433 } 19434 }, 19435 computed: { 19436 uses_fixed_dropdown_panel() { 19437 return this.dropdownPanelFixed || this.auto_fix_dropdown_panel; 19438 }, 19439 fixedDropdownPanelInlineStyle() { 19440 if (!this.uses_fixed_dropdown_panel || !this.isOpen || !this.fixedPanelPositionStyle) { 19441 return null; 19442 } 19443 return this.fixedPanelPositionStyle; 19444 }, 19445 displayOptions() { 19446 return this.options || []; 19447 }, 19448 filteredOptions() { 19449 if (this.remote_search) { 19450 return this.displayOptions; 19451 } 19452 if (!this.searchable || !this.searchQuery.trim()) { 19453 return this.displayOptions; 19454 } 19455 var normalized_search_query = this.searchQuery.trim().toLowerCase(); 19456 var searchSubtext = this.searchSubtext; 19457 return this.displayOptions.filter(function(option) { 19458 var title = (optionTitle(option) || '').toLowerCase(); 19459 if (title.indexOf(normalized_search_query) !== -1) { 19460 return true; 19461 } 19462 if (searchSubtext) { 19463 var option_subtext = (optionSubtext(option) || '').toLowerCase(); 19464 if (option_subtext.indexOf(normalized_search_query) !== -1) { 19465 return true; 19466 } 19467 } 19468 return false; 19469 }); 19470 }, 19471 // Suggested-section visibility. Hidden once the user types so the 19472 // search hit list doesn't get split across two sections. 19473 visibleSuggestedOptions() { 19474 if (!Array.isArray(this.suggested_options) || this.suggested_options.length === 0) { 19475 return []; 19476 } 19477 if (this.searchable && this.searchQuery && this.searchQuery.trim()) { 19478 return []; 19479 } 19480 return this.suggested_options; 19481 }, 19482 // Filtered options minus anything already rendered in the 19483 // suggested section, so suggested items don't appear twice in 19484 // the panel. 19485 nonSuggestedFilteredOptions() { 19486 var suggested = this.visibleSuggestedOptions; 19487 if (!suggested.length) { 19488 return this.filteredOptions; 19489 } 19490 var suggestedIds = {}; 19491 suggested.forEach(function(option) { 19492 suggestedIds[optionId(option)] = true; 19493 }); 19494 return this.filteredOptions.filter(function(option) { 19495 return !suggestedIds[optionId(option)]; 19496 }); 19497 }, 19498 selectedOptionSingle() { 19499 if (this.multiple || !this.options.length) return null; 19500 var v = this.value; 19501 if (v == null) return null; 19502 var id = typeof v === 'object' && v !== null && (v.id != null || (v.get && v.get('id') != null)) 19503 ? (v.id != null ? v.id : v.get('id')) 19504 : v; 19505 return this.options.find(function(o) { return optionId(o) == id; }) || null; 19506 }, 19507 selectedOptionsForDisplay() { 19508 if (!this.multiple || !Array.isArray(this.value)) return []; 19509 var ids = this.value; 19510 return this.options.filter(function(o) { return ids.some(function(id) { return id == optionId(o); }); }); 19511 }, 19512 has_selected_value() { 19513 if (this.multiple) { 19514 return this.selectedOptionsForDisplay.length > 0; 19515 } 19516 return !!this.selectedOptionSingle; 19517 } 19518 }, 19519 methods: { 19520 optionId: function(o) { return optionId(o); }, 19521 optionTitle: function(o) { return optionTitle(o); }, 19522 optionSubtext: function(o) { return optionSubtext(o); }, 19523 optionTitleClass: function(option) { return optionTitleClass(option); },
19524 optionImage: function(o) { return optionImage(o); }, 19525 optionImageIsUrl: function(o) { return optionImageIsUrl(o); }, 19526 isSelected(option) { 19527 var id = optionId(option); 19528 if (this.multiple && Array.isArray(this.value)) 19529 return this.value.some(function(v) { return v == id; }); 19530 var single = this.value; 19531 if (single == null) return false; 19532 var currentId = typeof single === 'object' && single !== null 19533 ? (single.id != null ? single.id : (single.get ? single.get('id') : null)) 19534 : single; 19535 return currentId == id; 19536 }, 19537 toggle() { 19538 if (this.disabled) { 19539 return; 19540 } 19541 if (this.closeTimeout) { 19542 clearTimeout(this.closeTimeout); 19543 this.closeTimeout = null; 19544 } 19545 this.isOpen = !this.isOpen; 19546 if (this.isOpen) { 19547 this.searchQuery = ''; 19548 this.$nextTick(() => { 19549 if (this.$refs.dropdownList) { 19550 this.$refs.dropdownList.scrollTop = 0; 19551 } 19552 if (this.uses_fixed_dropdown_panel && this.searchable) { 19553 requestAnimationFrame(() => { 19554 requestAnimationFrame(() => { 19555 this.focus_open_panel(); 19556 }); 19557 }); 19558 } else { 19559 this.focus_open_panel(); 19560 } 19561 }); 19562 document.addEventListener('click', this.handleClickOutside); 19563 } else { 19564 this.searchQuery = ''; 19565 document.removeEventListener('click', this.handleClickOutside); 19566 } 19567 }, 19568 focus_open_panel() { 19569 if (!this.isOpen) { 19570 return; 19571 } 19572 if (this.searchable && this.$refs.searchInput) { 19573 this.$refs.searchInput.focus(); 19574 } else if (this.$refs.dropdownPanel) { 19575 this.$refs.dropdownPanel.focus(); 19576 } 19577 }, 19578 scheduleClose() { 19579 var self = this; 19580 this.closeTimeout = setTimeout(function() { 19581 if (!self.isMouseOver) { 19582 self.isOpen = false; 19583 document.removeEventListener('click', self.handleClickOutside); 19584 } 19585 }, 150); 19586 }, 19587 handleClickOutside(e) { 19588 var panel = this.$refs.dropdownPanel; 19589 var inPanel = panel && panel.contains(e.target); 19590 var inContainer = this.$refs.container && this.$refs.container.contains(e.target); 19591 if (!inContainer && !inPanel) { 19592 this.isOpen = false; 19593 document.removeEventListener('click', this.handleClickOutside); 19594 } 19595 }, 19596 return_visual_viewport() { 19597 let view = window.visualViewport; 19598 if (view) { 19599 return { 19600 height: view.height, 19601 width: view.width, 19602 offset_top: view.offsetTop || 0, 19603 offset_left: view.offsetLeft || 0, 19604 }; 19605 } 19606 return { 19607 height: window.innerHeight, 19608 width: window.innerWidth, 19609 offset_top: 0, 19610 offset_left: 0, 19611 }; 19612 }, 19613 scroll_trigger_into_safe_view_if_needed() { 19614 if (!this.searchable || !this.$refs.container) { 19615 return; 19616 } 19617 let view = this.return_visual_viewport(); 19618 let trigger_rect = this.$refs.container.getBoundingClientRect(); 19619 if (view.height - trigger_rect.bottom >= 200) { 19620 return; 19621 } 19622 let node = this.$refs.container.parentElement; 19623 while (node && node !== document.documentElement) { 19624 let style = window.getComputedStyle(node); 19625 let overflow_y = style.overflowY; 19626 let overflow = style.overflow; 19627 if (overflow_y === 'auto' || overflow_y === 'scroll' || overflow === 'auto' || overflow === 'scroll') {
19628 let parent_rect = node.getBoundingClientRect(); 19629 let delta = trigger_rect.top - parent_rect.top - 8; 19630 if (delta > 0) { 19631 node.scrollTop = node.scrollTop + delta; 19632 } 19633 return; 19634 } 19635 node = node.parentElement; 19636 } 19637 }, 19638 sync_fixed_dropdown_panel() { 19639 if (!this.uses_fixed_dropdown_panel || !this.isOpen || !this.$refs.container) { 19640 return; 19641 } 19642 let trigger_rect = this.$refs.container.getBoundingClientRect(); 19643 let view = this.return_visual_viewport(); 19644 let edge = 8; 19645 let gap = 4; 19646 let desired_height = 280; 19647 let min_usable = 160; 19648 let space_below = view.height - trigger_rect.bottom - edge; 19649 let space_above = trigger_rect.top - edge; 19650 let visual_top = trigger_rect.bottom + gap; 19651 let max_height = Math.min(desired_height, space_below); 19652 19653 if (space_below < min_usable && space_above > space_below) { 19654 max_height = Math.min(desired_height, space_above); 19655 visual_top = trigger_rect.top - gap - max_height; 19656 } else if (space_below < 80 && space_above >= 80) { 19657 max_height = Math.min(desired_height, space_above); 19658 visual_top = trigger_rect.top - gap - max_height; 19659 } else if (space_below < 80 && space_above < 80) { 19660 visual_top = edge; 19661 max_height = view.height - edge * 2; 19662 } else if (space_below < min_usable) { 19663 max_height = Math.max(space_below, 0); 19664 visual_top = trigger_rect.bottom + gap; 19665 } 19666 19667 if (visual_top < edge) { 19668 max_height = max_height - (edge - visual_top); 19669 visual_top = edge; 19670 } 19671 if (visual_top + max_height > view.height - edge) { 19672 max_height = view.height - edge - visual_top; 19673 } 19674 if (max_height < 80) { 19675 max_height = Math.min(80, Math.max(0, view.height - edge * 2)); 19676 visual_top = Math.min(visual_top, view.height - edge - max_height); 19677 visual_top = Math.max(edge, visual_top); 19678 } 19679 19680 //keyboard pan: getBoundingClientRect is visual, position:fixed is layout 19681 this.fixedPanelPositionStyle = { 19682 position: 'fixed', 19683 top: (visual_top + view.offset_top) + 'px', 19684 left: (trigger_rect.left + view.offset_left) + 'px', 19685 width: trigger_rect.width + 'px', 19686 minWidth: trigger_rect.width + 'px', 19687 maxHeight: max_height + 'px', 19688 zIndex: 10050, 19689 }; 19690 }, 19691 setup_fixed_dropdown_panel_listeners() { 19692 let sync_panel = () => { 19693 this.sync_fixed_dropdown_panel(); 19694 }; 19695 let scroll_nodes = []; 19696 let node = this.$refs.container; 19697 while (node && node !== document.documentElement) { 19698 let style = window.getComputedStyle(node); 19699 let overflow_y = style.overflowY; 19700 let overflow_x = style.overflowX; 19701 let overflow = style.overflow; 19702 if (overflow_y === 'auto' || overflow_y === 'scroll' || overflow_x === 'auto' || overflow_x === 'scroll' || overflow === 'auto' || overflow === 'scroll') { 19703 node.addEventListener('scroll', sync_panel, { passive: true }); 19704 scroll_nodes.push(node); 19705 } 19706 node = node.parentElement; 19707 } 19708 window.addEventListener('resize', sync_panel); 19709 window.addEventListener('scroll', sync_panel, true);
19710 let visual_viewport = window.visualViewport; 19711 if (visual_viewport) { 19712 visual_viewport.addEventListener('resize', sync_panel); 19713 visual_viewport.addEventListener('scroll', sync_panel); 19714 } 19715 return () => { 19716 scroll_nodes.forEach((scroll_node) => { 19717 scroll_node.removeEventListener('scroll', sync_panel); 19718 }); 19719 window.removeEventListener('resize', sync_panel); 19720 window.removeEventListener('scroll', sync_panel, true); 19721 if (visual_viewport) { 19722 visual_viewport.removeEventListener('resize', sync_panel); 19723 visual_viewport.removeEventListener('scroll', sync_panel); 19724 } 19725 }; 19726 }, 19727 teardown_fixed_dropdown_panel_listeners() { 19728 if (this._fixedPanelScrollCleanup) { 19729 this._fixedPanelScrollCleanup(); 19730 this._fixedPanelScrollCleanup = null; 19731 } 19732 }, 19733 selectOption(option) { 19734 if (option && option.disabled) { 19735 return; 19736 } 19737 var id = optionId(option); 19738 if (this.multiple) { 19739 var arr = Array.isArray(this.value) ? this.value.slice() : []; 19740 var idx = arr.indexOf(id); 19741 if (idx >= 0) { 19742 arr.splice(idx, 1); 19743 } else { 19744 arr.push(id); 19745 } 19746 this.$emit('input', arr); 19747 this.$emit('update:value', arr); 19748 this.$emit('change', arr); 19749 } else { 19750 this.$emit('input', option); 19751 this.$emit('update:value', option); 19752 this.$emit('change', option); 19753 this.isOpen = false; 19754 document.removeEventListener('click', this.handleClickOutside); 19755 } 19756 } 19757 }, 19758 beforeDestroy() { 19759 document.removeEventListener('click', this.handleClickOutside); 19760 if (this.closeTimeout) { 19761 clearTimeout(this.closeTimeout); 19762 } 19763 this.teardown_fixed_dropdown_panel_listeners(); 19764 } 19765 }; 19766 app_content.component("rich-select-dropdown", richSelectDropdownConfig); 19767 app_overlays.component("rich-select-dropdown", richSelectDropdownConfig); 19768})(); 19769</script>
19769 19770 19771<style> 19772.rich-select-floating-label { 19773 position: absolute; 19774 left: 10px; 19775 top: -4px; 19776 z-index: 10; 19777 font-size: 11px; 19778 padding-left: 8px; 19779 padding-right: 8px; 19780 background-color: #fff; 19781 color: #8493a0; 19782 text-transform: uppercase; 19783 letter-spacing: 1px; 19784} 19785.rich-select-floating-label .rich-select-trigger-title { 19786 font-size: 11px; 19787 color: #8493a0; 19788} 19789 19790.rich-select-dropdown { 19791 display: inline-block; 19792 min-width: 180px; 19793} 19794 19795.rich-select-trigger { 19796 border: 1px solid #e4ebf1; 19797 border-radius: 8px; 19798 padding: 10px 12px; 19799 min-height: 32px; 19800 -webkit-user-select: none; 19801 user-select: none; 19802 transition: border-color 0.15s ease, box-shadow 0.15s ease; 19803} 19804 19805.rich-select-trigger:hover { 19806 border-color: #c5cdd6; 19807} 19808 19809.rich-select-trigger.rich-select-trigger-borderless, 19810.rich-select-trigger.rich-select-trigger-borderless:hover, 19811.rich-select-trigger.rich-select-trigger-borderless.rich-select-trigger-open { 19812 border-color: transparent; 19813 box-shadow: none; 19814 padding-left: 2px; 19815 padding-right: 2px; 19816} 19817 19818.rich-select-trigger.rich-select-trigger-disabled, 19819.rich-select-trigger.rich-select-trigger-disabled:hover { 19820 background-color: #fbfcfe; 19821 border-color: #e4ebf1; 19822 cursor: default; 19823} 19824 19825.rich-select-trigger.rich-select-trigger-disabled .rich-select-chevron { 19826 opacity: 0.4; 19827} 19828 19829.rich-select-trigger-open, 19830.rich-select-trigger.rich-select-trigger-open { 19831 /* border-color: #2e59d6; */ 19832 /* box-shadow: 0 0 0 1px #2e59d6; */ 19833 border-color: #e4ebf1; 19834 box-shadow: none; 19835 outline: none; 19836} 19837 19838.rich-select-dropdown:focus-within .rich-select-trigger { 19839 border-color: #e4ebf1; 19840 box-shadow: none; 19841 outline: none; 19842} 19843 19844.rich-select-trigger:focus, 19845.rich-select-trigger:focus-visible, 19846.rich-select-dropdown-panel:focus, 19847.rich-select-dropdown-panel:focus-visible { 19848 border-color: #e4ebf1; 19849 box-shadow: none; 19850 outline: none; 19851} 19852 19853.rich-select-trigger-text { 19854 /* flex: 1; */ 19855 min-width: 0; 19856 display: flex; 19857 flex-direction: column; 19858 gap: 2px; 19859} 19860 19861.rich-select-trigger-title { 19862 font-size: 15px; 19863 color: #081333; 19864 font-family: 'roboto', sans-serif; 19865} 19866 19867.rich-select-trigger-title-no-subtext { 19868 font-size: 18px; 19869 font-weight: 100; 19870 color: #081333; 19871 font-family: 'roboto', sans-serif; 19872} 19873 19874.rich-select-trigger-subtext { 19875 font-size: 13px; 19876 color: #83909d; 19877} 19878 19879.rich-select-placeholder { 19880 font-size: 15px; 19881 color: #83909d; 19882} 19883 19884.rich-select-option-icon-wrap { 19885 width: 32px; 19886 height: 32px; 19887 min-width: 32px; 19888 border-radius: 50%; 19889 overflow: hidden; 19890 background-color: #e8eaf1; 19891 display: flex; 19892 align-items: center; 19893 justify-content: center; 19894 margin-right: 10px; 19895} 19896 19897.rich-select-option-icon { 19898 width: 100%; 19899 height: 100%; 19900 object-fit: cover; 19901} 19902 19903.rich-select-option-icon.fa { 19904 width: auto; 19905 height: auto; 19906 font-size: 16px; 19907 color: #52596f; 19908 background: none !important; 19909} 19910.rich-select-option-icon.fa::before { 19911 background: none !important; 19912} 19913 19914.rich-select-chevron { 19915 margin-left: 8px; 19916 font-size: 12px; 19917 color: #83909d; 19918 transition: transform 0.2s ease; 19919} 19920 19921.rich-select-chevron.rich-select-chevron-open { 19922 transform: rotate(180deg); 19923} 19924 19925/* Dropdown panel: floats below trigger, above surrounding content */ 19926.rich-select-dropdown-panel { 19927 position: absolute; 19928 top: 100%; 19929 left: 0; 19930 margin-top: 4px; 19931 z-index: 1001; 19932 background-color: #fff; 19933 border: 1px solid #e4ebf1; 19934 border-radius: 5px; 19935 overflow: hidden; 19936 padding: 6px 0; 19937 max-height: 280px; 19938 min-width: 100%; 19939 display: flex; 19940 flex-direction: column; 19941 transform-origin: top center; 19942 animation: rich-select-dropdown-panel-open 0.11s ease-out; 19943} 19944 19945/* v-show flips display, which restarts the animation on every open */ 19946@keyframes rich-select-dropdown-panel-open { 19947 from { 19948 opacity: 0; 19949 transform: translateY(-5px) scaleY(0.96); 19950 } 19951 to { 19952 opacity: 1; 19953 transform: translateY(0) scaleY(1); 19954 } 19955} 19956 19957/* Fixed panel: escapes overflow:auto ancestors (position + size from inline style) */ 19958.rich-select-dropdown-panel--fixed { 19959 position: fixed; 19960 top: auto; 19961 left: auto; 19962 margin-top: 0; 19963 min-width: 0; 19964 max-height: none; 19965} 19966 19967.rich-select-search-wrap { 19968 padding: 6px 8px; 19969 border-bottom: 1px solid #e4ebf1; 19970 flex-shrink: 0; 19971} 19972 19973.rich-select-search-input { 19974 width: 100%; 19975 border: 1px solid #e4ebf1; 19976 border-radius: 5px; 19977 padding: 8px 10px; 19978 font-size: 14px; 19979 font-family: 'roboto', sans-serif; 19980 outline: none; 19981 box-sizing: border-box; 19982} 19983 19984.rich-select-search-input:focus { 19985 /* border-color: #2e59d6; */ 19986 /* box-shadow: 0 0 0 1px #2e59d6; */ 19987 border-color: #e4ebf1; 19988 box-shadow: none; 19989 outline: none; 19990} 19991 19992.rich-select-dropdown-list { 19993 flex: 1 1 auto; 19994 overflow-y: auto; 19995 overflow-x: hidden; 19996} 19997 19998.rich-select-option { 19999 padding: 10px 14px; 20000 cursor: pointer; 20001 transition: background-color 0.1s ease; 20002} 20003 20004.rich-select-option:hover { 20005 background-color: #f6f8f9; 20006} 20007 20008.rich-select-option-selected { 20009 background-color: #f0f4ff; 20010} 20011 20012.rich-select-option-check { 20013 margin-right: 10px; 20014 font-size: 18px; 20015 color: #52596f; 20016} 20017 20018.rich-select-option-selected .rich-select-option-check { 20019 color: var(--custom-mp-accent, #2e59d6); 20020} 20021 20022.rich-select-option-text { 20023 flex: 1; 20024 min-width: 0; 20025 display: flex; 20026 flex-direction: column; 20027 gap: 2px; 20028} 20029 20030.rich-select-option-title { 20031 font-size: 15px; 20032 color: #081333; 20033 font-family: 'roboto', sans-serif; 20034} 20035 20036.rich-select-option-subtext { 20037 font-size: 13px; 20038 color: #83909d; 20039} 20040</style> 20041
20041<script type="text/html" id="template-default-field-wb"> 20042 <div class="default-field-wrapper tinyMarginTop"> 20043 <span class="flex-row-start"> 20044 <p style="color: rgba(255, 0, 0, 0.885); margin: 0 5.5px 0 3px;">*</p> 20045 <p class="default-font-body" style="font-size: 14px; color: rgb(150, 152, 154); font-style: italic;">(required)</p> 20046 </span> 20047 <!-- default fields --> 20048 <div class="flex-row-start" style="width: 100%;"> 20049 <div class="flex-column-start" style="width: 100%;"> 20050 <template v-if="default_configuration === 'list'" v-for="(field, index) in default_fields" :id="index"> 20051 <div class="flex-row-start smallMarginTop" style="width: 100%;"> 20052 <input 20053 type="text" 20054 :placeholder="field.label" 20055 class="default-field-input border-bottom-grey-2 default-font-body" 20056 style="width: 100%;" 20057 v-model="field.input_value" 20058 :disabled="is_admin" 20059 > 20060 </div> 20061 </template> 20062 <template v-if="default_configuration === 'grid'" v-for="(row, i) in default_fields" :id="i"> 20063 <div class="flex-row-start smallMarginTop" style="width: 100%;"> 20064 <template v-for="(field, j) in row" :id="j"> 20065 <input 20066 type="text" 20067 :placeholder="field.label" 20068 class="default-field-input border-bottom-grey-2 default-font-body marginRight" 20069 style="width: 100%;" 20070 v-model="field.input_value" 20071 :disabled="is_admin" 20072 > 20073 </template> 20074 </div> 20075 </template> 20076 </div> 20077 </div> 20078 </div> 20079</script>
20079 20080 20081<style scoped> 20082 :root { 20083 --default-field-grey-1: rgba(180, 180, 180, 0.8); 20084 --default-field-grey-2: rgba(195, 195, 195, 0.7); 20085 --default-field-grey-3: rgb(154, 160, 166); 20086 --default-field-primary: #202124; 20087 } 20088 .default-field-wrapper{ 20089 width: 100%; 20090 border: 1px solid rgba(195, 195, 195, 0.7); 20091 border-radius: 8px; 20092 padding: 1.45em; 20093 background-color: #fff; 20094 box-sizing: border-box; 20095 } 20096 .default-field-wrapper .default-font-body{ 20097 font-family: Roboto, Arial, Helvetica, sans-serif; 20098 font-size: 15px; 20099 font-weight: 400; 20100 } 20101 .default-field-wrapper .configuration-icon{ 20102 font-size: 1em; 20103 cursor: pointer; 20104 padding: 5px; 20105 border-radius: 50%; 20106 color: rgba(95, 95, 95); 20107 background-color: #fff; 20108 transition: color 0.2s, background-color 0.2s; 20109 } 20110 .default-field-wrapper .configuration-icon:hover{ 20111 color: var(--default-field-primary); 20112 background-color: rgba(180, 180, 180, 0.15); 20113 } 20114 .default-field-wrapper .file-upload-wrapper{ 20115 height: 90px; 20116 width: 50%; 20117 border: 1px dashed var(--default-field-grey-1); 20118 } 20119 .default-field-wrapper .inherit-font-css{ 20120 font-family: inherit; 20121 font-size: inherit; 20122 font-weight: inherit; 20123 } 20124 .default-field-wrapper .flex-row-center{ 20125 display: flex; 20126 flex-direction: row; 20127 align-items: center; 20128 justify-content: center; 20129 } 20130 .default-field-wrapper .flex-column-center{ 20131 display: flex; 20132 flex-direction: column; 20133 align-items: center; 20134 justify-content: center; 20135 } 20136 .default-field-wrapper .flex-row-start{ 20137 display: flex; 20138 flex-direction: row; 20139 align-items: center; 20140 justify-content: flex-start; 20141 } 20142 .default-field-wrapper .flex-row-end{ 20143 display: flex; 20144 flex-direction: row; 20145 align-items: center; 20146 justify-content: flex-end; 20147 } 20148 .default-field-wrapper .flex-column-start{ 20149 display: flex; 20150 flex-direction: column; 20151 align-items: flex-start; 20152 justify-content: center; 20153 } 20154 .default-field-wrapper .default-field{ 20155 color: rgb(32, 33, 36); 20156 box-sizing: border-box; 20157 font-weight: 400; 20158 font-size: 14px; 20159 width: 100%; 20160 line-height: 2.5; 20161 letter-spacing: 0; 20162 } 20163 .default-field-wrapper .default-field > .default-field-input{ 20164 font-family: inherit; 20165 font-size: inherit; 20166 font-weight: inherit; 20167 width: inherit; 20168 line-height: inherit; 20169 background-color: rgb(248, 249, 250); 20170 padding: 5px 0 5px 15px; 20171 } 20172 .default-field-wrapper .border-bottom-grey-2, .default-field-wrapper .input-is-focused{ 20173 border-bottom: 1.25px solid var(--default-field-grey-2); 20174 } 20175 .default-field-wrapper input { 20176 transition: border-color 0.2s; 20177 } 20178</style> 20179
20180<script> 20181 app_content.component("default-field-component-wb", { 20182 template: "#template-default-field-wb", 20183 data() { 20184 return { 20185 20186 } 20187 }, 20188 props: { 20189 form: Object, //json 20190 is_admin: Boolean, 20191 default_fields: Array, 20192 rerender_key: String, 20193 }, 20194 mounted() { 20195 20196 }, 20197 methods: { 20198 20199 }, 20200 computed: { 20201 default_configuration(){ 20202 if ( this.form && this.form.default_configuration ){ 20203 return this.form.default_configuration; 20204 } 20205 return 'list'; 20206 }, 20207 } 20208 }) 20209</script>
20209 20210
20210<script type="text/html" id="template-file-upload-component"> 20211 <div> 20212 <p v-if="label" class="tinyPaddingBottom subtext evenSmaller">{{ label }}</p> 20213 <div 20214 class="file-upload-container lightBorderAllAround overflowHidden" 20215 :class="{ 'file-selected': hasFiles, 'upload-loading': loading }" 20216 @dragover.prevent="!loading && (isDragging = true)" 20217 @dragleave.prevent="!loading && (isDragging = false)" 20218 @drop.prevent="handle_drop" 20219 > 20220 <div 20221 class="file-upload-inner relative flexWrap align-items-center flexGrowOne" 20222 > 20223 <input 20224 type="file" 20225 id="fileInput" 20226 ref="fileInput" 20227 class="absoluteInput" 20228 @change="handle_file_upload" 20229 :multiple="multiple" 20230 :accept="acceptAttribute" 20231 :disabled="loading" 20232 /> 20233 <div v-if="loading" class="file-upload-loading-overlay"> 20234 <div class="cardBodySpinner small-spinner centeredBlock active"></div> 20235 </div> 20236 <div 20237 class="upload-placeholder flexNoWrapCenter align-items-center" 20238 :style="{ opacity: loading ? 0.55 : 1 }" 20239 > 20240 <div> 20241 <i :class="icon ? icon : 'fa-light fa-file-arrow-up'" class="upload-icon tinyMarginBottom"></i> 20242 <p class="upload-text">Drag and drop here or click to upload</p> 20243 <p class="upload-subtext">{{ fileTypeMessage }}</p> 20244 </div> 20245 </div> 20246 </div> 20247 20248 <div 20249 class="selected-file-wrap flexNoWrap lightBorderTop align-items-center" 20250 v-if="hasFiles && !loading" 20251 > 20252 <template v-if="multiple"> 20253 <div class="flexColumn flexGrowOne selected-file-list"> 20254 <div 20255 v-for="(file, index) in selectedFiles" 20256 :key="file.name + index" 20257 class="flexNoWrap align-items-center" 20258 :class="{ 'smallMarginBottom': index !== selectedFiles.length - 1 }" 20259 > 20260 <img 20261 v-if="imagePreviewURLs[return_file_key(file, index)]" 20262 :src="imagePreviewURLs[return_file_key(file, index)]" 20263 class="file-preview-thumb" 20264 alt="Image preview" 20265 > 20266 <p class="selected-file-name flexGrowOne"> 20267 <i class="fa-solid fa-square-check" style="padding-right:3px;"></i> {{ file.name }} 20268 </p> 20269 <div 20270 class="remove-file-wrap pointer" 20271 :class="{ unclickable: loading }" 20272 @click="remove_file(index)" 20273 > 20274 <i class="fa-light fa-trash-can"></i> 20275 </div> 20276 </div> 20277 </div> 20278 </template> 20279 <template v-else> 20280 <div class="flexNoWrap align-items-center flexGrowOne"> 20281 <img 20282 v-if="selectedFiles[0] && imagePreviewURLs[return_file_key(selectedFiles[0], 0)]" 20283 :src="imagePreviewURLs[return_file_key(selectedFiles[0], 0)]" 20284 class="file-preview-thumb" 20285 alt="Image preview" 20286 > 20287 <p class="selected-file-name flexGrowOne"><i class="fa-solid fa-square-check" style="padding-right:3px;"></i> {{ selectedFiles[0].name }}</p> 20288 </div> 20289 <div 20290 class="remove-file-wrap pointer" 20291 :class="{ unclickable: loading }" 20292 @click="remove_file" 20293 > 20294 <i 20295 class="fa-light fa-trash-can" 20296 ></i> 20297 </div> 20298 </template> 20299 </div> 20300 20301 </div> 20302 </div> 20303 20304</script>
20304 20305
20306<script> 20307 20308 var config = { 20309 template: "#template-file-upload-component", 20310 data() { 20311 return { 20312 selectedFiles: [], 20313 imagePreviewURLs: {}, 20314 isDragging: false, 20315 //file type mappings 20316 fileTypeMappings: { 20317 csv: [".csv", "text/csv", "application/csv", "application/vnd.ms-excel"], 20318 pdf: [".pdf", "application/pdf"], 20319 image: [".jpg", ".jpeg", ".png", "image/jpeg", "image/png"], 20320 docx: [".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"], 20321 video: [ 20322 ".mp4", ".webm", ".avi", ".mov", ".mkv", ".flv", ".wmv", 20323 "video/mp4", "video/webm", "video/x-msvideo", "video/quicktime", 20324 "video/x-matroska", "video/x-flv", "video/x-ms-wmv" 20325 ] 20326 } 20327 } 20328 }, 20329 props: { 20330 icon: String, 20331 label: String, 20332 valid_file_types: { 20333 type: Array, 20334 default: () => [] 20335 }, 20336 multiple: { 20337 type: Boolean, 20338 default: false 20339 }, 20340 loading: { 20341 type: Boolean, 20342 default: false, 20343 }, 20344 }, 20345 mounted(){ 20346 20347 }, 20348 beforeUnmount(){ 20349 this.revoke_image_previews(); 20350 }, 20351 beforeDestroy(){ 20352 this.revoke_image_previews(); 20353 }, 20354 methods: { 20355 open_file_picker() { 20356 if( this.loading ) { 20357 return; 20358 } 20359 this.$refs.fileInput.click(); 20360 }, 20361 handle_file_upload(event) { 20362 if( this.loading ) return; 20363 const files = Array.from(event.target.files || []); 20364 this.set_files_from_input(files); 20365 }, 20366 handle_drop(event) { 20367 if( this.loading ) return; 20368 event.preventDefault(); 20369 const files = Array.from(event.dataTransfer.files || []); 20370 this.set_files_from_input(files); 20371 this.isDragging = false; 20372 }, 20373 set_files_from_input(files) { 20374 if (!files.length) { 20375 this.selectedFiles = []; 20376 return; 20377 } 20378 const validFiles = files.filter(file => this.is_valid_file_type(file)); 20379 if (!validFiles.length) { 20380 alert("Invalid file type. Please upload a valid file."); 20381 this.selectedFiles = []; 20382 this.$refs.fileInput.value = ""; 20383 return; 20384 } 20385 if (validFiles.length !== files.length) { 20386 alert("Some files were skipped because they are not allowed."); 20387 } 20388 if (this.multiple) { 20389 // Append rather than replace so re-dropping or "add 20390 // another" grows the batch. Dedupe on name/size/mtime so 20391 // picking the same file twice doesn't create a duplicate. 20392 const existingKeys = new Set( 20393 this.selectedFiles.map(existingFile => `${existingFile.name}_${existingFile.size}_${existingFile.lastModified}`) 20394 ); 20395 const filesToAdd = validFiles.filter(file => { 20396 return !existingKeys.has(`${file.name}_${file.size}_${file.lastModified}`); 20397 }); 20398 this.selectedFiles = this.selectedFiles.concat(filesToAdd); 20399 } else { 20400 this.selectedFiles = [validFiles[0]]; 20401 } 20402 }, 20403 is_valid_file_type(file) { 20404 const fileExtension = `.${file.name.split('.').pop().toLowerCase()}`; 20405 const fileType = (file.type || "").toLowerCase(); 20406 if (!this.valid_file_types.length){ 20407 // If no restrictions, allow all , but limit to standard types for security 20408 const defaultAllowedTypes = this.default_file_types; 20409 return defaultAllowedTypes.includes(fileExtension) || defaultAllowedTypes.includes(fileType); 20410 } 20411 20412 return this.resolvedFileTypes.includes(fileExtension) || this.resolvedFileTypes.includes(fileType); 20413 }, 20414 clear_files() { 20415 this.revoke_image_previews(); 20416 this.selectedFiles = []; 20417 this.$refs.fileInput.value = ""; 20418 }, 20419 return_file_key(file, index = 0){ 20420 if(!file){ 20421 return ''; 20422 } 20423 return `${file.name}_${file.size}_${file.lastModified}_${index}`; 20424 }, 20425 is_image_file(file){ 20426 if(!file || !file.name){ 20427 return false;
20428 } 20429 const fileType = (file.type || "").toLowerCase(); 20430 if(fileType.startsWith("image/")){ 20431 return true; 20432 } 20433 const fileExtension = `.${file.name.split('.').pop().toLowerCase()}`; 20434 return [".jpg", ".jpeg", ".png", ".gif", ".webp"].includes(fileExtension); 20435 }, 20436 refresh_image_previews(files){ 20437 this.revoke_image_previews(); 20438 const nextPreviewURLs = {}; 20439 (files || []).forEach((file, index) => { 20440 if(this.is_image_file(file)){ 20441 const key = this.return_file_key(file, index); 20442 nextPreviewURLs[key] = URL.createObjectURL(file); 20443 } 20444 }); 20445 this.imagePreviewURLs = nextPreviewURLs; 20446 }, 20447 revoke_image_previews(){ 20448 Object.values(this.imagePreviewURLs || {}).forEach((url) => { 20449 try { 20450 URL.revokeObjectURL(url); 20451 } catch (e) { 20452 // no-op 20453 } 20454 }); 20455 this.imagePreviewURLs = {}; 20456 }, 20457 remove_file(index) { 20458 if( this.loading ) return; 20459 if (this.multiple && typeof index === "number") { 20460 this.selectedFiles.splice(index, 1); 20461 if (!this.selectedFiles.length) { 20462 this.$refs.fileInput.value = ""; 20463 } 20464 this.$emit("file_selected", { 20465 value: this.multiple ? this.selectedFiles : this.selectedFiles[0] 20466 }); 20467 } else { 20468 this.clear_files(); 20469 } 20470 } 20471 }, 20472 computed: { 20473 hasFiles() { 20474 return this.selectedFiles && this.selectedFiles.length > 0; 20475 }, 20476 resolvedFileTypes() { 20477 if(this.valid_file_types && this.valid_file_types.length > 0){ 20478 return this.valid_file_types.flatMap(type => this.fileTypeMappings[type] || [type]); 20479 }else{ 20480 return this.default_file_types; 20481 } 20482 }, 20483 acceptAttribute() { 20484 return this.resolvedFileTypes.join(","); 20485 }, 20486 fileTypeMessage() { 20487 if (!this.valid_file_types.length) { 20488 // No restrictions 20489 return ""; 20490 } 20491 20492 // Get only extensions and remove dots for display 20493 const displayTypes = this.valid_file_types 20494 .flatMap(type => this.fileTypeMappings[type] || [type]) 20495 .filter(type => type.startsWith(".")) // Show only file extensions 20496 .map(type => type.replace(".", "").toUpperCase()) // Remove dot and uppercase 20497 20498 return `You may upload ${displayTypes.join(", ")} files`; 20499 }, 20500 default_file_types(){ 20501 return [ 20502 // Documents 20503 ".pdf", "application/pdf", 20504 ".doc", ".docx", 20505 "application/msword", 20506 "application/vnd.openxmlformats-officedocument.wordprocessingml.document", 20507 ".rtf", "application/rtf", 20508 ".txt", "text/plain", 20509 20510 // Spreadsheets 20511 ".xls", ".xlsx", ".csv", 20512 "application/vnd.ms-excel", 20513 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", 20514 "text/csv", "application/csv", "application/vnd.ms-excel", 20515 20516 // Presentations 20517 ".ppt", ".pptx", 20518 "application/vnd.ms-powerpoint", 20519 "application/vnd.openxmlformats-officedocument.presentationml.presentation", 20520 20521 // Images 20522 ".jpg", ".jpeg", ".png", ".gif", ".webp", 20523 "image/jpeg", "image/png", "image/gif", "image/webp", 20524 // Videos 20525 ".mp4", ".webm", ".avi", ".mov", ".mkv", ".flv", ".wmv", 20526 "video/mp4", "video/webm", 20527 ]; 20528 } 20529 }, 20530 watch: { 20531 selectedFiles(newVal, oldVal){ 20532 this.refresh_image_previews(newVal); 20533 this.$emit("file_selected", { 20534 value: this.multiple ? newVal : newVal[0] 20535 }); 20536 } 20537 } 20538 } 20539 20540 //define the component 20541 app_content.component("file-upload-component", config); 20542 app_overlays.component("file-upload-component", config); 20543 20544 20545</script>
20545 20546 20547<style scoped> 20548 20549 .remove-file-wrap{ 20550 padding-left: 4px; 20551 padding-right: 4px; 20552 } 20553 20554 .selected-file-wrap{ 20555 padding: 12px; 20556 background-color: #fff; 20557 } 20558 20559 /* Ensure both children take full width but account for padding */ 20560 .file-upload-inner, .selected-file-wrap { 20561 width: 100%; 20562 flex: 1; /* Make sure they stretch */ 20563 box-sizing: border-box; /* Includes padding in width calculation */ 20564 padding: 12px; /* Add your desired padding */ 20565 } 20566 20567 .selected-file-name{ 20568 color: #292e4a; 20569 font-size: 16px; 20570 line-height: 20px; 20571 } 20572 .file-preview-thumb{ 20573 width: 42px; 20574 height: 42px; 20575 object-fit: cover; 20576 border-radius: 6px; 20577 border: 1px solid #e4ebf1; 20578 margin-right: 10px; 20579 flex-shrink: 0; 20580 } 20581 20582 .file-upload-inner{ 20583 min-height: 160px; 20584 } 20585 .file-upload-container { 20586 display: flex; 20587 flex-direction: column; 20588 align-items: center; 20589 justify-content: center; 20590 width: 100%; 20591 border-radius: 8px; 20592 background-color: #f8f9fb; 20593 cursor: pointer; 20594 transition: all 0.3s ease; 20595 } 20596 .file-upload-container.upload-loading { 20597 cursor: not-allowed; 20598 } 20599 .file-upload-loading-overlay { 20600 position: absolute; 20601 top: 0; 20602 right: 0; 20603 bottom: 0; 20604 left: 0; 20605 z-index: 3; 20606 display: flex; 20607 justify-content: center; 20608 align-items: center; 20609 background: rgba(255, 255, 255, 0.6); 20610 } 20611 .file-upload-container.upload-loading .upload-text, 20612 .file-upload-container.upload-loading .upload-subtext, 20613 .file-upload-container.upload-loading .selected-file-name { 20614 color: transparent !important; 20615 } 20616 .file-upload-container.upload-loading .upload-icon { 20617 color: transparent !important; 20618 } 20619 .file-upload-container:hover:not(:has(.selected-file-wrap:hover)), 20620 .file-upload-container.dragging:not(:has(.selected-file-wrap:hover)) { 20621 border-color: var(--custom-mp-accent, #4a56f8); 20622 } 20623 .file-upload-container:hover .upload-icon:not(:has(.selected-file-wrap:hover)), 20624 .file-upload-container.dragging .upload-icon:not(:has(.selected-file-wrap:hover)){ 20625 color: var(--custom-mp-accent, #4a56f8); 20626 } 20627 20628 .upload-placeholder { 20629 text-align: center; 20630 padding: 20px; 20631 } 20632 20633 .upload-icon{ 20634 color: #788097; 20635 font-size: 28px; 20636 padding-right: 16px; 20637 } 20638 .upload-text { 20639 font-size: 16px; 20640 font-weight: 500; 20641 color: #081333; 20642 } 20643 .upload-subtext { 20644 font-size: 14px; 20645 color: #8890a3; 20646 padding-top: 4px; 20647 line-height: 18px; 20648 } 20649</style> 20650
20650<script type="text/html" id="template-no-objects-found"> 20651 20652 <div 20653 class="marginTopBottom" 20654 style="display: flex; flex-direction: column; justify-content: center; align-items: center;" 20655 > 20656 <i 20657 :class="icon_class" 20658 class="no-objects-icon" 20659 ></i> 20660 <p class="no-objects-subtext">{{ subtext }}</p> 20661 <p v-if="call_to_action" class="no-objects-subtext"><a @click="call_to_action.fn">{{ call_to_action.text }}</a></p> 20662 </div> 20663 20664</script>
20664 20665
20666<script> 20667 20668/* ----- COMPONENT ----- */ 20669 20670let no_objects_config = { 20671 template: "#template-no-objects-found", 20672 data() { 20673 return {} 20674 }, 20675 props: { 20676 icon_class: { 20677 type: String, 20678 required: true, 20679 default: "fa-regular fa-magnifying-glass", 20680 }, 20681 subtext: { 20682 type: String, 20683 required: true, 20684 default: "No items found.", 20685 }, 20686 call_to_action: { 20687 type: Object, 20688 required: false, 20689 default: null, 20690 } 20691 }, 20692 mounted() { 20693 20694 20695 }, 20696 methods: { 20697 20698 20699 } 20700 20701} 20702app_content.component("no-objects-found", no_objects_config); 20703app_overlays.component("no-objects-found", no_objects_config); 20704if (typeof app_ai_mcp !== 'undefined') { 20705 app_ai_mcp.component("no-objects-found", no_objects_config); 20706} 20707 20708</script>
20708 20709 20710<style> 20711 .no-objects-icon{ 20712 font-size: 18px; 20713 padding: 15px; 20714 background-color: rgba(230, 232, 237, 0.667); 20715 border-radius: 50%; 20716 color: #6a7482; 20717 height: 20px; 20718 width: 20px; 20719 text-align: center; 20720 } 20721 .no-objects-subtext{ 20722 font-size: 16px; 20723 padding: 2px; 20724 margin-top: 5px; 20725 color: #9ca4b7; 20726 text-align: center; 20727 } 20728</style> 20729 20730 20731 20732
20732<script type="text/html" id="template-icon-button"> 20733 <button 20734 class="card-header-button-v3 easeFast flexNoWrap align-items-center relative icon-button centeredText flexGrowOne" 20735 :class="[ 20736 {'unclickable': disabled || loading}, 20737 {'full-width': full_width}, 20738 {'warning': warning}, 20739 {'text-hides-on-mobile': hide_text_on_mobile} 20740 ]" 20741 :disabled="disabled || loading" 20742 @click="tapped_button()" 20743 style="width: auto; max-width: fit-content;" 20744 :style="height ? {'height': height + 'px', 'line-height': height + 'px', 'padding': '0 8px'} : {}" 20745 :style="no_margin_right ? {'margin-right': '0'} : {}" 20746 > 20747 <i 20748 v-if="corrected_icon_position == 'left' && ! image_url" 20749 :class="icon_class" 20750 :style="{ opacity: loading ? 0 : 1 }" 20751 ></i> 20752 <div 20753 v-else-if="corrected_icon_position == 'left' && image_url" 20754 class="icon-button-avatar" 20755 :style="{ backgroundImage: 'url(' + image_url + ')', opacity: loading ? 0 : 1 }" 20756 ></div> 20757 <span :style="{ opacity: loading ? 0 : 1 }" :class="{ 'hideOnMobile': hide_text_on_mobile }">{{ label }}</span> 20758 <i 20759 v-if="corrected_icon_position == 'right' && ! image_url" 20760 :class="icon_class" 20761 style="padding-left: 8px;" 20762 :style="{ opacity: loading ? 0 : 1 }" 20763 ></i> 20764 <div 20765 v-else-if="corrected_icon_position == 'right' && image_url" 20766 class="icon-button-avatar icon-button-avatar-right" 20767 :style="{ backgroundImage: 'url(' + image_url + ')', opacity: loading ? 0 : 1 }" 20768 ></div> 20769 <div v-if="loading" class="icon-button-spinner-wrap"> 20770 <div class="card-header-button-v3-spinner easeFast"></div> 20771 </div> 20772 <a v-if="href" :href="href" :target="target" class="absoluteA"></a> 20773 </button> 20774</script>
vendor: 1 bytes, line 20774
20774
20775<script> 20776 var config = { 20777 template: "#template-icon-button", 20778 props: { 20779 disabled: { 20780 type: Boolean, 20781 default: false, 20782 }, 20783 label: { 20784 type: String, 20785 default: 'Done', 20786 }, 20787 icon_class: String, 20788 image_url: { 20789 type: String, 20790 default: null, 20791 }, 20792 icon_position: { 20793 type: String, 20794 default: 'left', 20795 }, 20796 loading: { 20797 type: Boolean, 20798 default: false, 20799 }, 20800 href: { 20801 type: String, 20802 default: null, 20803 }, 20804 target: { 20805 type: String, 20806 default: '', 20807 }, 20808 full_width: { 20809 type: Boolean, 20810 default: false, 20811 }, 20812 warning: { 20813 type: Boolean, 20814 default: false, 20815 }, 20816 no_margin_right: { 20817 type: Boolean, 20818 default: false, 20819 }, 20820 hide_text_on_mobile: { 20821 type: Boolean, 20822 default: false, 20823 } 20824 }, 20825 methods: { 20826 tapped_button() { 20827 if (this.loading || this.disabled) return; 20828 this.$emit('clicked'); 20829 }, 20830 }, 20831 computed: { 20832 corrected_icon_position(){ 20833 return this.icon_position || 'left'; 20834 }, 20835 }, 20836 } 20837 app_content.component("icon-button", config); 20838 if (typeof app_overlays !== "undefined") { 20839 app_overlays.component("icon-button", config); 20840 } 20841 20842</script>
20842 20843 20844<style> 20845 .icon-button.full-width{ 20846 width: 100% !important; 20847 max-width: unset !important; 20848 justify-content: center !important; 20849 text-align: center !important; 20850 line-height: 30px; 20851 font-size: 15px; 20852 } 20853 .icon-button.taller { 20854 padding-top: 12px; 20855 padding-bottom: 12px; 20856 } 20857 .icon-button.warning { 20858 color: #9b1818 !important; 20859 } 20860 .icon-button-spinner-wrap { 20861 position: absolute; 20862 left: 0; 20863 right: 0; 20864 top: 0; 20865 bottom: 0; 20866 display: flex; 20867 justify-content: center; 20868 align-items: center; 20869 } 20870 .icon-button-avatar { 20871 width: 18px; 20872 height: 18px; 20873 border-radius: 50%; 20874 background-size: cover; 20875 background-position: center; 20876 background-repeat: no-repeat; 20877 flex-shrink: 0; 20878 margin-right: 4px; 20879 } 20880 .icon-button-avatar-right { 20881 margin-right: 0; 20882 margin-left: 4px; 20883 } 20884</style> 20885 <!-- new calendar --> 20886 <template id="template-booking-calendar"> 20887 <div 20888 class="centeredOnMobile smallMarginTop" 20889 :class="{'standardCard ninetyPercent': calendar_type !== 'web_component'}" 20890 > 20891 <div 20892 class="standardCardBody noBorderTop calendar noPadding fff native-tab-bar-padding-bottom" 20893 > 20894 <div class="flexNoWrapLeftAlign hundop" style="display: flex; width: 100%;" :style="[{ height: height || '100vh' }]"> 20895 <div 20896 style="flex: 1; min-width: 0;" 20897 :class="{ scrollable: calendar_type !== 'web_component' }" 20898 > 20899 <div 20900 class="cs-calendar-wrap noBorder" 20901 :style="generateWrapStyle('cs-calendar-wrap')" 20902 > 20903 <!-- NAVIGATION MENU --> 20904 <div 20905 class="cs-calendar-menu-wrap flexNoWrap lightBorderBottom" 20906 :style="generateWrapStyle('cs-calendar-menu-wrap')" 20907 > 20908 <div class="flexWrapLeftAlign flexGrowOne smallMarginRight align-items-center"> 20909 <div class="flexNoWrap"> 20910 <div class="card-header-button-v3 roboto hideOnMobile" data-option="today" @click="handleButtonClick('today')">Today</div> 20911 <div class="card-header-button-v3 cs-calendar-button-previous roboto" data-option="previous" @click="handleButtonClick('previous')"></div> 20912 <div class="card-header-button-v3 cs-calendar-button-next roboto" data-option="next" @click="handleButtonClick('next')"></div> 20913 </div> 20914 <p 20915 v-if="calendarTitle" 20916 class="cs-calendar-date-range-title roboto textLeft noMarginLeft hideOnMobile" 20917 >{{ calendarTitle }}</p> 20918 <p 20919 v-if="calendarTitle_noYear" 20920 class="cs-calendar-date-range-title roboto textLeft noMarginLeft showOnMobile" 20921 >{{ calendarTitle_noYear }}</p> 20922 <div class="flexGrowOne hideOnMobile" style="max-width: fit-content;"> 20923 <refresh-component 20924 @refresh-table="refresh_calendar" 20925 style="margin-left: 2px;" 20926 ></refresh-component> 20927 </div> 20928 </div> 20929 20930 <!-- nav butttons --> 20931 <div class="flexWrapLeftAlign align-items-center"> 20932 <div class="flexGrowOne"></div> 20933 <div class="flexWrap align-items-center calendar-menu-v2"> 20934 <calendar-date-picker-component 20935 :handle_button_click="handleButtonClick" 20936 ></calendar-date-picker-component> 20937 </div> 20938 20939 <div 20940 class="hideOnMobile card-header-button-v3 pointer noWrap centeredText relative" 20941 > 20942 {{ view_preference }} 20943 <i class="fa-light fa-chevron-down" style="padding-left: 3px;"></i> 20944 <select 20945 v-model="v_model_view_preference" 20946 @change="changeViewPreference" 20947 class="pointer" 20948 style="position:absolute;
20948left:0px;top:0px;width:100%;height:100%;opacity:0;" 20949 > 20950 <option value="Month">Month</option> 20951 <option value="Week">Week</option> 20952 <option value="Day">Day</option> 20953 </select> 20954 </div> 20955 <div 20956 v-if="!show_calendar_tags && !disable_filtering" 20957 class="flexWrap align-items-center calendar-menu-v2 tinyMarginTop" 20958 > 20959 <h3 20960 class="calendar-list menu-icon" 20961 > 20962 <i 20963 class="fa-regular fa-bars pointer" 20964 @click="show_calendar_tags = !show_calendar_tags" 20965 ></i> 20966 </h3> 20967 </div> 20968 </div> 20969 20970 </div> 20971 20972 <div 20973 class="relative" 20974 > 20975 <!-- Progress Bar --> 20976 <div v-if="loading_parent.calendar || loading_parent.ui_refresh || loading_parent.calendar_tags || loading_parent.filter" class="progress-bar"></div> 20977 </div> 20978 20979 <!-- CALENDAR VIEWS --> 20980 20981 <!-- month view --> 20982 <template v-if="view_preference == 'Month'"> 20983 <!-- days of week header --> 20984 <div 20985 class="daysOfWeek-grid relative" 20986 > 20987 <div v-for="(day, index) in daysOfWeek" :key="index" class="grid-cell dayOfWeek noBorderTop"> 20988 {{ day }} 20989 </div> 20990 </div> 20991 20992 <!-- calendar grid --> 20993 <div 20994 class="calendar-grid month-view" 20995 :style="{ '--calendar-row-height': calendarRowHeight() }" 20996 > 20997 <template v-for="(day, index) in days_array" :key="index"> 20998 <booking-calendar-cell 20999 :date="day.date" 21000 :day="day.day" 21001 :date_components="day.date_components" 21002 :day_of_week="day.day_of_week" 21003 :events="day.events" 21004 :slots="day.slots" 21005 :timezone="timezone" 21006 :days_array="days_array" 21007 :is_current_month="day.date.month() === current_date.month()" 21008 @clicked_event="clicked_event" 21009 @clicked_show_more_events="clicked_show_more_events" 21010 @add_event="add_event" 21011 @zoom_in_to_date="zoom_in_to_date" 21012 /> 21013 </template> 21014 </div> 21015 21016 </template> 21017 21018 <!-- week view --> 21019 <template v-else-if="view_preference === 'Week'" > 21020 21021 <!-- multi/all day events section --> 21022 <div class="flexNoWrap"> 21023 <div style="width:60px;" class="flexWrap align-items-center"> 21024 <div class="time-label">All day</div> 21025 </div> 21026 <div class="flexGrowOne"> 21027 <!-- days of week header --> 21028 <div 21029 class="daysOfWeek-grid relative" 21030 > 21031 <div v-for="(day, index) in daysOfWeek" :key="index" class="grid-cell dayOfWeek noBorderTop"> 21032 {{ day }} 21033 <br>
21034 <span class="day-date-label">{{ current_date.clone().startOf("week").add(index, "day").date() }}</span> 21035 </div> 21036 </div> 21037 <div 21038 class="all-day-week-wrap" 21039 > 21040 <div v-for="(day, index) in days_array" :key="index" class="grid-cell noBorderTop"> 21041 <div class="events"> 21042 <!-- handle all day events --> 21043 <!-- render content for each slot --> 21044 <template 21045 v-for="(slot, slot_index) in day.slots" 21046 :key="slot_index" 21047 > 21048 <!-- ok, we have an event for this slot--> 21049 <template v-if="slot.event"> 21050 <div 21051 class="event" 21052 :class="[{'all_day': slot.event.isAllDay}, {'partial-day': ! slot.event.isAllDay}]" 21053 @click.stop="clicked_event( slot.event )" 21054 :style="return_multi_day_event_style( slot.event, day.date )" 21055 style="position:relative;z-index:1;" 21056 > 21057 <div 21058 class="event-title-v2 flexNoWrap align-items-center" 21059 style="font-size:12.5px;" 21060 > 21061 <!-- event title --> 21062 {{ slot.event.title }} 21063 </div> 21064 </div> 21065 </template> 21066 <template v-else-if="should_hold_slot(slot, slot_index, day)"> 21067 <div 21068 class="event" 21069 > 21070 <!-- add a placeholder if there's an event in this slot but it doesn't start on this day --> 21071 <div 21072 class="event-title-v2 flexNoWrap align-items-center easeSuperFast" 21073 style="font-size:12.5px; visibility: hidden;" 21074 > </div><!-- adds a hidden, non-breaking space --> 21075 </div> 21076 </template> 21077 21078 <!-- no event for this slot --> 21079 <template v-else> 21080 <div 21081 class="event partial-day" 21082 ></div> 21083 </template> 21084 21085 </template> 21086 21087 </div> 21088 </div> 21089 </div> 21090 </div> 21091 </div> 21092 21093 <!-- main content --> 21094 <div 21095 class="flexNoWrap" 21096 > 21097 <!-- times --> 21098 <div 21099 class="time-label-wrap" 21100 style="width: 60px;" 21101 > 21102 <div v-for="hour in 24" :key="hour" class="time-slot"> 21103 <div 21104 class="time-label" 21105 >{{ formatHour( hour - 1 ) }}</div> 21106 </div> 21107 </div> 21108 21109 <!-- main content --> 21110 <div class="flexGrowOne"> 21111 21112 <!-- day columns --> 21113 <div class="flexNoWrap"> 21114 <template 21115 v-for="(day, index) in days_array" 21116 :key="index" 21117 > 21118 <booking-calendar-day-column 21119 :events="day.events" 21120 :date_components="day.date_components" 21121 :timezone="timezone" 21122 :view_preference="view_preference" 21123 @clicked_event="clicked_event" 21124 @add_event="add_event" 21125 ></booking-calendar-day-column> 21126 </template> 21127 </div> 21128 21129 </div> 21130 </div> 21131 </template> 21132 21133 <!-- day view --> 21134 <template v-else-if="view_preference === 'Day'"> 21135 <div class="showOnMobile"> 21136 <icon-button 21137 :label="'Back to month'" 21138 icon_class="fa-regular fa-chevron-left" 21139 @clicked="back_to_month" 21140 :style="[{ marginLeft: '8px' }, calendar_type !== 'web_component' ? { maxHeight: '28px' } : {}]" 21141 ></icon-button> 21142 </div> 21143 <div 21144 class="flexNoWrap tinyPaddingTop" 21145 > 21146 <booking-calendar-day-column 21147 :events="days_array[0].events" 21148 :date_components="days_array[0].date_components" 21149 :view_preference="view_preference" 21150 :timezone="timezone" 21151 @clicked_event="clicked_event" 21152 @add_event="add_event" 21153 /> 21154 </div> 21155 </template> 21156 </div> 21157 </div> 21158 <!-- start of new component --> 21159 <booking-calendar-filters 21160 v-if="!disable_filtering"
21161 :show_calendar_tags="show_calendar_tags" 21162 :default_calendar_tags="default_calendar_tags" 21163 :default_calendar_tags_secondary="default_calendar_tags_secondary" 21164 :show_catch_all_custom_tags_option="show_catch_all_custom_tags_option" 21165 :catch_all_custom_tag_selected="catch_all_custom_tag_selected" 21166 :ui_catch_all_custom_tag_selected="ui_catch_all_custom_tag_selected" 21167 :ui_tag_ids_default_secondary="ui_tag_ids_default_secondary" 21168 :view_preference="view_preference" 21169 :calendar_tags="calendar_tags" 21170 :selected_tags="selected_tags" 21171 :ui_tag_ids_default="ui_tag_ids_default" 21172 :ui_tag_ids_custom="ui_tag_ids_custom" 21173 :collapsible_menus_open="collapsible_menus_open" 21174 :loading_parent="loading_parent" 21175 :calendar_type="calendar_type" 21176 :view_only="view_only" 21177 @clicked_default_tag="clicked_default_tag" 21178 @clicked_default_tag_secondary="clicked_default_tag_secondary" 21179 @clicked_calendar_tag="clicked_calendar_tag" 21180 @clicked_catch_all_custom_tag="clicked_catch_all_custom_tag" 21181 @clicked_add_calendar_tag="clicked_add_calendar_tag" 21182 @clicked_remove_tag="clicked_remove_tag" 21183 @isolate_default_tag="isolate_default_tag" 21184 @isolate_default_tag_secondary="isolate_default_tag_secondary" 21185 @select_all_default_tags="select_all_default_tags" 21186 @select_all_default_tags_secondary="select_all_default_tags_secondary" 21187 @select_visible_default_tags_secondary="select_visible_default_tags_secondary" 21188 @clear_all_default_tags="clear_all_default_tags" 21189 @clear_all_default_tags_secondary="clear_all_default_tags_secondary" 21190 @clear_visible_default_tags_secondary="clear_visible_default_tags_secondary" 21191 @select_visible_default_tags="select_visible_default_tags" 21192 @clear_visible_default_tags="clear_visible_default_tags" 21193 @isolate_calendar_tag="isolate_calendar_tag" 21194 @isolate_catch_all_custom="isolate_catch_all_custom" 21195 @select_all_custom_tags="select_all_custom_tags" 21196 @clear_all_custom_tags="clear_all_custom_tags" 21197 @select_visible_custom_tags="select_visible_custom_tags" 21198 @clear_visible_custom_tags="clear_visible_custom_tags" 21199 @add_event="add_event" 21200 @change_view_preference="changeViewPreference" 21201 @change_show_calendar_tags="change_show_calendar_tags" 21202 @close="show_calendar_tags = false" 21203 ></booking-calendar-filters> 21204 </div> 21205 </div> 21206 </div> 21207 </template> 21208 21209 <style scoped> 21210 .cs-calendar-menu-wrap{ 21211 position: sticky; 21212 top: 0px; 21213 background-color: #fff; 21214 z-index: 3; 21215 } 21216 .calendar-list.menu-icon{ 21217 min-width: 38px !important; 21218 max-width: 38px !important; 21219 width: 38px !important; 21220 min-height: 38px !important; 21221 max-height: 38px !important; 21222 height: 38px !important; 21223 padding: 0; 21224 display: flex; 21225 justify-content: center; 21226 align-items: center; 21227 cursor: pointer; 21228 } 21229 21230 .day-date-label{ 21231 font-size: 20px; 21232 color: #081333; 21233 } 21234 21235 .cs-calendar-date-range-title { 21236 font-size: 18px !important; 21237 } 21238 21239 .calendar-menu-v2 .cs-calendar-date-picker-input{ 21240 border-radius: 30px; 21241 overflow: hidden !important; 21242 padding-bottom: 8px; 21243 padding-top: 8px; 21244 } 21245 21246 .cs-calendar-button-next, 21247 .cs-calendar-button-previous{ 21248 min-width: 38px !important; 21249 max-width: 38px !important; 21250 width: 38px !important; 21251 min-height: 38px !important; 21252 max-height: 38px !important; 21253 height: 38px !important; 21254 } 21255
21256 .daysOfWeek-grid{ 21257 grid-auto-rows: initial; 21258 } 21259 21260 .all-day-week-wrap{ 21261 display: grid; 21262 grid-template-columns: repeat(7, minmax(10%, 1fr)); 21263 border-bottom: 1px solid #e7ecf0; 21264 } 21265 21266 .progress-bar { 21267 position: absolute; 21268 top: 0; 21269 left: 0; 21270 height: 2px; /* Thin progress bar */ 21271 background-color: #5774f5; /* Primary color */ 21272 z-index: 10; /* Ensure it appears above the calendar */ 21273 animation: progress-bar-animation 0.5s linear infinite; /* Animation */ 21274 } 21275 21276 /* Animation Keyframes */ 21277 @keyframes progress-bar-animation { 21278 0% { 21279 left: -100%; /* Start outside of the viewport */ 21280 width: 30%; /* Start with a small bar */ 21281 } 21282 50% { 21283 left: 25%; /* Move towards the center */ 21284 width: 50%; /* Expand the bar */ 21285 } 21286 100% { 21287 left: 100%; /* End outside of the viewport */ 21288 width: 30%; /* Shrink the bar */ 21289 } 21290 } 21291 21292 .calendar-grid.month-view{ 21293 grid-auto-rows: var(--calendar-row-height, 130px); 21294 } 21295 21296 .grid-cell.dayOfWeek{ 21297 border-radius: 5px; 21298 } 21299 21300 .time-content { 21301 position: relative; 21302 height: 100%; /* Full height of the hour */ 21303 flex-grow: 1; 21304 border-left: 1px solid #e7ecf0; 21305 } 21306 21307 /* Day Header */ 21308 .day-header { 21309 background-color: white; 21310 padding: 16px; 21311 border-bottom: 1px solid #e7ecf0; 21312 } 21313 21314 .day-title { 21315 margin: 0; 21316 font-size: 24px; 21317 font-weight: bold; 21318 color: #202124; 21319 } 21320 21321 /* All Day Section */ 21322 .all-day-section { 21323 padding: 12px; 21324 border-bottom: 1px solid #e7ecf0; 21325 width: 100%; 21326 } 21327 21328 .all-day-section h3 { 21329 font-size: 16px; 21330 font-weight: bold; 21331 color: #5f6368; 21332 margin-bottom: 8px; 21333 } 21334 21335 .all-day-events { 21336 display: flex; 21337 flex-wrap: wrap; 21338 gap: 8px; 21339 padding-left: 12px; 21340 padding-right: 12px; 21341 } 21342 21343 .no-events { 21344 color: #9e9e9e; 21345 font-size: 12px; 21346 } 21347 21348 /* Time Slots */ 21349 .time-slots { 21350 flex-grow: 1; 21351 overflow-y: auto; 21352 background-color: white; 21353 width:100%; 21354 } 21355 21356 .time-slot { 21357 display: flex; 21358 border-bottom: 1px solid #e7ecf0; 21359 height: 60px; /* One slot per hour */ 21360 } 21361 21362 .time-label { 21363 width: 60px; 21364 text-align: center; 21365 font-size: 12px; 21366 padding-top: 8px; 21367 color: #5f6368; 21368 } 21369 21370 </style> 21371 21372
21372<script> 21373 app_content.component("calendar-booking-component", { 21374 template: "#template-booking-calendar", 21375 data() { 21376 return { 21377 show_calendar_tags: true, 21378 v_model_view_preference: this.view_preference, 21379 daysOfWeek: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], 21380 loading: { 21381 calendar_tags: true, 21382 }, 21383 catch_all_custom_tag_selected: true, 21384 ui_catch_all_custom_tag_selected: true, 21385 collapsible_menus_open: { 21386 default_primary: true, 21387 default_secondary: true, 21388 custom: true, 21389 }, 21390 }; 21391 }, 21392 mounted(){ 21393 this.check_if_mobile(); 21394 }, 21395 props: { 21396 filter_logic: { 21397 type: String, 21398 default: "or" //and vs. or 21399 }, 21400 height: String, //optional 21401 timezone: { 21402 type: String, 21403 default: moment.tz.guess(), 21404 }, 21405 loading_parent: Object, //object with several loading attributes 21406 view_preference: String, 21407 current_date: Date, 21408 events: Array, 21409 is_membership_cal: Boolean, 21410 calendar_type: String, 21411 default_calendar_tags: Object, 21412 default_calendar_tags_secondary: Object, 21413 calendar_tags: Array, 21414 selected_tags: Object, 21415 ui_tag_ids_default: Object, 21416 ui_tag_ids_default_secondary: Object, 21417 ui_tag_ids_custom: Array, 21418 is_admin: Boolean, 21419 view_only: { 21420 type: Boolean, 21421 default: false 21422 }, 21423 disable_filtering: { 21424 type: Boolean, 21425 default: false 21426 } 21427 }, 21428 methods: { 21429 back_to_month(){ 21430 this.$emit("change_view_preference", "Month"); 21431 }, 21432 check_if_mobile(){ 21433 if(this.disable_filtering){ 21434 this.show_calendar_tags = false; 21435 return; 21436 } 21437 var is_mobile = window.innerWidth < 720; 21438 if(is_mobile || this.calendar_type === 'web_component'){ 21439 this.show_calendar_tags = false; 21440 }else{ 21441 this.show_calendar_tags = true; 21442 } 21443 }, 21444 clicked_default_tag(tag) { 21445 this.$emit('clicked_default_tag', tag); 21446 }, 21447 clicked_default_tag_secondary(tag) { 21448 this.$emit('clicked_default_tag_secondary', tag); 21449 }, 21450 isolate_default_tag_secondary(tag) { 21451 this.$emit('isolate_default_tag_secondary', tag); 21452 }, 21453 select_all_default_tags_secondary() { 21454 this.$emit('select_all_default_tags_secondary'); 21455 }, 21456 select_visible_default_tags_secondary(visibleIds) { 21457 this.$emit('select_visible_default_tags_secondary', visibleIds); 21458 }, 21459 clear_all_default_tags_secondary() { 21460 this.$emit('clear_all_default_tags_secondary'); 21461 }, 21462 clear_visible_default_tags_secondary(visibleIds) { 21463 this.$emit('clear_visible_default_tags_secondary', visibleIds); 21464 }, 21465 clicked_calendar_tag(tag) { 21466 this.$emit('clicked_calendar_tag', tag); 21467 }, 21468 clicked_catch_all_custom_tag(){ 21469 this.ui_catch_all_custom_tag_selected = !this.ui_catch_all_custom_tag_selected; 21470 requestAnimationFrame(() => { 21471 // second RAF helps ensure weâre past layout/paint on Chrome/Safari 21472 requestAnimationFrame(() => { 21473 this.catch_all_custom_tag_selected = !this.catch_all_custom_tag_selected; 21474 }); 21475 }); 21476 }, 21477 remaining_days_in_event(event, date) { 21478 const cellDate = moment.tz(date, this.timezone).startOf("day"); 21479 const eventEnd = moment.tz(event.end_ts, this.timezone).startOf("day"); 21480 if (cellDate.isAfter(eventEnd)) { 21481 //already ended 21482 return 0; 21483 } 21484 // Calculate the remaining days, including the current day 21485 return eventEnd.diff(cellDate, "days") + 1; 21486 }, 21487 remaining_visible_days_in_event(event, date) { 21488 const cellDate = moment.tz(date, this.timezone).startOf("day"); 21489 const eventEnd = moment.tz(event.end_ts, this.timezone).endOf("day"); 21490 const eventStart = moment.tz(event.ts, this.timezone).startOf("day"); 21491 const startOfWeek = this.current_date.clone().startOf("week"); 21492 const endOfWeek = this.current_date.clone().endOf("week"); 21493 21494 // Determine the latest starting point between cellDate and eventStart 21495 const visibleStart = moment.max(cellDate, eventStart, startOfWeek); 21496 21497 // Determine the earliest end point between eventEnd and endOfWeek 21498 const visibleEnd = moment.min(eventEnd, endOfWeek); 21499 21500 // If the visible start is after the visible end, the event isn't visible 21501 if (visibleStart.isAfter(visibleEnd)) { 21502 return 0; 21503 } 21504 21505 // Calculate number of visible days the event should span, inclusive 21506 return visibleEnd.diff(visibleStart, "days") + 1; 21507 }, 21508 return_multi_day_event_style( event, date ){ 21509 if( ! this.is_multi_day_event( event ) ){ 21510 return { 21511 "background": event.bgColors && event.bgColors.length > 0 21512 ? `repeating-linear-gradient( 21513 -45deg, 21514 ${event.bgColors.map((color, i) => `${color} ${i * 20}px, ${color} ${(i + 1) * 20}px`).join(",")} 21515 )` 21516 : event.bgColor || "", 21517 }; 21518 } 21519 let day_of_week = moment.tz(date, this.timezone).day(); 21520 let days_until_end_of_week = 7 - day_of_week; 21521 let number_of_days = this.remaining_visible_days_in_event(event, date); 21522 let is_first_day = this.is_first_day_of_event( event, date ); 21523 if( is_first_day ){ 21524 //figure out how many days it spans 21525 let days_to_span = number_of_days; 21526 if( days_to_span > days_until_end_of_week ){ 21527 days_to_span = days_until_end_of_week; 21528 } 21529 return {
21530 "width": `calc(${days_to_span * 100}% - 4px)`, 21531 "zIndex": 1, 21532 "background": event.bgColors && event.bgColors.length > 0 21533 ? `repeating-linear-gradient( 21534 -45deg, 21535 ${event.bgColors.map((color, i) => `${color} ${i * 20}px, ${color} ${(i + 1) * 20}px`).join(",")} 21536 )` 21537 : event.bgColor || "", 21538 } 21539 }else if( day_of_week == 0 ){ 21540 //not the first day of the event 21541 //.. but see it's the first day of a new week 21542 //... figure out which day of the event this is, and how many days are left (including this day) 21543 let remaining_days_in_event = this.remaining_days_in_event( event, date ); 21544 let days_to_span = remaining_days_in_event; 21545 if( days_to_span > days_until_end_of_week ){ 21546 days_to_span = days_until_end_of_week; 21547 } 21548 return { 21549 "width": `calc(${days_to_span * 100}% - 4px)`, 21550 "zIndex": 1, 21551 "background": event.bgColors && event.bgColors.length > 0 21552 ? `repeating-linear-gradient( 21553 -45deg, 21554 ${event.bgColors.map((color, i) => `${color} ${i * 20}px, ${color} ${(i + 1) * 20}px`).join(",")} 21555 )` 21556 : event.bgColor || "", 21557 } 21558 }else{ 21559 return { 21560 "visibility":"hidden" 21561 } 21562 } 21563 }, 21564 zoom_in_to_date( date ){ 21565 this.$emit("zoom_in_to_date", date); 21566 }, 21567 clicked_event( event ){ 21568 this.$emit('clicked_event', event); 21569 }, 21570 clicked_show_more_events( date, day_of_week, events ){ 21571 this.$emit('clicked_show_more_events', date, day_of_week, events); 21572 }, 21573 add_event( date_components ){ 21574 this.$emit('add_event', date_components); 21575 }, 21576 formatHour(hour) { 21577 const baseDate = moment().startOf('day').hour(hour); 21578 return baseDate.format("h:mm A"); 21579 }, 21580 changeViewPreference( event ){ 21581 this.$emit("change_view_preference", event.target.value); 21582 }, 21583 return_number_of_days_for_event( event ){ 21584 let start = moment.tz(event.ts, this.timezone); 21585 let end = moment.tz(event.end_ts, this.timezone); 21586 //continue 21587 return end.diff(start, 'days') + 1; 21588 }, 21589 is_first_day_of_event( event, date ) { 21590 // Compare the event start date with the cell's date 21591 const eventStart = moment.tz(event.ts, this.timezone).startOf("day"); 21592 const cellDate = moment.tz(date, this.timezone).startOf("day"); 21593 return eventStart.isSame(cellDate, "day"); 21594 }, 21595 sort_events_for_day(events){ 21596 //multi day and all day events first, then by start time 21597 return events.sort((a, b) => { 21598 const aAllDay = a.isAllDay; 21599 const bAllDay = b.isAllDay; 21600 21601 const aMulti = this.is_multi_day_event(a); 21602 const bMulti = this.is_multi_day_event(b); 21603 21604 // Sort all-day events first 21605 if (aAllDay && !bAllDay) return -1; 21606 if (!aAllDay && bAllDay) return 1; 21607 21608 // Then multi-day events 21609 if (aMulti && !bMulti) return -1; 21610 if (!aMulti && bMulti) return 1; 21611 21612 // Otherwise, by start time 21613 return moment.tz(a.ts, this.timezone).isBefore(moment.tz(b.ts, this.timezone)) ? -1 : 1; 21614 }); 21615 }, 21616 is_multi_day_event( event ){ 21617 return moment.tz(event.ts, this.timezone).isBefore(moment.tz(event.end_ts, this.timezone), 'day'); 21618 }, 21619 handleButtonClick(option, new_date) { 21620 if (option === "today") { 21621 //vReplace with a new moment object 21622 var new_date = moment.tz( this.timezone ).startOf("day"); 21623 } else if (option === "next") { 21624 // Add time and replace with a new object 21625 var new_date = moment(this.current_date).add(1, this.view_preference.toLowerCase()); 21626 } else if (option === "previous") { 21627 // Subtract time and replace with a new object 21628 var new_date = moment(this.current_date).subtract(1, this.view_preference.toLowerCase()); 21629 }else if( option === 'date-picker' ){ 21630 var new_date = moment.tz( new_date, this.timezone ).startOf("day"); 21631 } 21632 if( new_date ){ 21633 this.$emit("change_date", new_date); 21634 } 21635 }, 21636 generateWrapStyle( type ){ 21637 if (type === 'cs-calendar-wrap'){ 21638 return {'width': '100%'} 21639 } else if (type === 'options' && this.is_membership_cal){ 21640 return { 21641 'padding-top': '15px', 21642 'padding-right': '15px', 21643 'padding-left': '15px', 21644 'padding-bottom': '5px', 21645 'border-width': '0 1.5px 0 1.5px', 21646 'border-color': '#e2e8e8', 21647 'border-style': 'solid', 21648 'overflow': 'auto', 21649 } 21650 } else if (type === 'cs-calendar-menu-wrap' && this.is_membership_cal){ 21651 return { 21652 'border-width': '0 1.5px 0 1.5px', 21653 'border-color': '#e2e8e8', 21654 'border-style': 'solid' 21655 } 21656 } 21657 }, 21658 // tapped_add_to_cal(){ 21659 // var params = {}; 21660 // params["title"] = "Create calendar item"; 21661 // params["hide_on_selection"] = true; 21662 // params["retrieve_sections_fn"] = this.return_sections_for_handle_common_grid; 21663 // handle_common_grid( params ); 21664 // }, 21665 // return_sections_for_handle_common_grid(){ 21666 // var sections = []; 21667 // var options = [ 21668 // { 21669 // text: "Event", 21670 // fn: this.add_event, 21671 // icon_class: "fa-regular fa-calendar-plus", 21672 // }, 21673 // { 21674 // text: "Calendar tag", 21675 // fn: this.clicked_add_calendar_tag, 21676 // icon_class: "fa-regular fa-tags", 21677 // }, 21678 // ] 21679 // sections.push({ 21680 // options: options 21681 // }); 21682 // return sections; 21683 // }, 21684 clicked_add_calendar_tag(){ 21685 store.components.tag_event.type_text = "this calendar"; 21686 store.components.tag_event.handle_selected_event_tag = this.handle_selected_event_tag; 21687 store.components.tag_event.event_tag_input = ''; 21688 store.components.tag_event.event_source = this.calendar_type; 21689 store.overlays.tag_event = true; 21690 }, 21691 handle_selected_event_tag(tag_data, event_tags){ 21692 //emit 21693 this.$emit('added_calendar_tag', tag_data, event_tags); 21694 },
21695 refresh_calendar(){ 21696 this.$emit('refresh_calendar'); 21697 }, 21698 clicked_remove_tag(tag){ 21699 this.$emit('remove_calendar_tag', tag); 21700 }, 21701 isolate_calendar_tag(tag){ 21702 this.ui_catch_all_custom_tag_selected = false; 21703 this.$emit('isolate_calendar_tag', tag); 21704 }, 21705 isolate_default_tag(tag){ 21706 this.$emit('isolate_default_tag', tag); 21707 }, 21708 select_all_custom_tags(){ 21709 this.ui_catch_all_custom_tag_selected = true; 21710 this.$emit('select_all_custom_tags'); 21711 }, 21712 select_all_default_tags(){ 21713 this.$emit('select_all_default_tags'); 21714 }, 21715 clear_all_default_tags(){ 21716 this.$emit('clear_all_default_tags'); 21717 }, 21718 select_visible_default_tags(visibleIds){ 21719 this.$emit('select_visible_default_tags', visibleIds); 21720 }, 21721 clear_visible_default_tags(visibleIds){ 21722 this.$emit('clear_visible_default_tags', visibleIds); 21723 }, 21724 clear_all_custom_tags(){ 21725 this.$emit('clear_all_custom_tags'); 21726 }, 21727 select_visible_custom_tags(visibleIds){ 21728 this.$emit('select_visible_custom_tags', visibleIds); 21729 }, 21730 clear_visible_custom_tags(visibleIds){ 21731 this.$emit('clear_visible_custom_tags', visibleIds); 21732 }, 21733 isolate_catch_all_custom(){ 21734 this.$emit('unselect_all_custom_tags'); 21735 this.ui_catch_all_custom_tag_selected = true; 21736 }, 21737 calendarRowHeight() { 21738 let maxSlots = 0; 21739 this.days_array.forEach((d) => { 21740 var used_slots = 0; 21741 if (Array.isArray(d.slots)) { 21742 used_slots = d.slots.filter(slot => slot.event).length; 21743 } 21744 maxSlots = Math.max(maxSlots, used_slots); 21745 }); 21746 const baseHeight = 100; // Base height for the calendar row 21747 var slotHeight = 10; // Height for each slot 21748 if(maxSlots >= 6){ 21749 slotHeight = 20; //extra height for more slots 21750 } 21751 return `${baseHeight + (maxSlots * slotHeight)}px`; 21752 }, 21753 should_hold_slot(slot, index, day) { 21754 var date = day.date; 21755 const usedSlots = this.return_slots_in_use_for_week(slot, index, date); 21756 return usedSlots.includes(index); 21757 }, 21758 return_slots_in_use_for_week(slot, index, date) { 21759 const startOfWeek = moment.tz(date, this.timezone).startOf("week"); 21760 const endOfWeek = moment.tz(date, this.timezone).endOf("week"); 21761 21762 const slotsUsed = new Set(); 21763 21764 this.days_array.forEach(day => { 21765 const dayMoment = moment.tz(day.date, this.timezone).startOf("day"); 21766 21767 // Only consider days in the current week 21768 if (dayMoment.isBetween(startOfWeek, endOfWeek, null, '[]')) { 21769 if (Array.isArray(day.slots)) { 21770 day.slots.forEach(slotObj => { 21771 if (slotObj.event) { 21772 slotsUsed.add(slotObj.slot); 21773 } 21774 }); 21775 } 21776 } 21777 }); 21778 21779 return Array.from(slotsUsed); 21780 }, 21781 change_show_calendar_tags( ){ 21782 this.show_calendar_tags = !this.show_calendar_tags; 21783 }, 21784 // default_tag_selected(tag) { 21785 // return this.ui_tag_ids_default && this.ui_tag_ids_default.includes(tag.id); 21786 // }, 21787 }, 21788 computed: { 21789 show_catch_all_custom_tags_option() { 21790 //if we have AND logic, we want to show an option that's a catch-all for all custom tags 21791 return this.filter_logic == 'and'; 21792 }, 21793 selected_tags_set() { 21794 let selected_tags_set = new Set( this.selected_tags.custom ); 21795 return selected_tags_set; 21796 }, 21797 selected_filter_set() { 21798 let selected_filter_set = new Set( this.selected_tags.default ); 21799 return selected_filter_set; 21800 }, 21801 selected_filter_set_secondary() { 21802 return new Set( this.selected_tags.default_secondary || [] ); 21803 }, 21804 dates_for_days_array(){ 21805 //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 21806 //create an object with the following structure for every day in the given date range 21807 /** 21808 { 21809 "date": "2025-07-27T07:00:00.000Z", 21810 "date_components": { 21811 "year": 2025, 21812 "month": 6, 21813 "day": 27 21814 }, 21815 "day": 27, 21816 "day_of_week": "Sunday", 21817 "slots": [ 21818 { 21819 "slot": 0, 21820 "event": null 21821 }, 21822 { 21823 "slot": 1, 21824 "event": null 21825 }, 21826 { 21827 "slot": 2, 21828 "event": null 21829 }, 21830 { 21831 "slot": 3, 21832 "event": null 21833 }, 21834 { 21835 "slot": 4, 21836 "event": null 21837 }, 21838 { 21839 "slot": 5, 21840 "event": null 21841 }, 21842 { 21843 "slot": 6, 21844 "event": null 21845 }, 21846 { 21847 "slot": 7, 21848 "event": null 21849 }, 21850 { 21851 "slot": 8, 21852 "event": null 21853 }, 21854 { 21855 "slot": 9, 21856 "event": null 21857 } 21858 ], 21859 "events": [] 21860 } **/ 21861 21862 const start = this.current_date.clone().startOf("month").startOf("week"); 21863 const end = this.current_date.clone().endOf("month").endOf("week"); 21864 let current = start.clone(); 21865 let days_array = []; 21866 while (current.isBefore(end) || current.isSame(end, "day")) { 21867 //create the day object 21868 let day = { 21869 date: current.clone(), 21870 date_components: { 21871 year: current.year(), 21872 month: current.month(), 21873 day: current.date(), 21874 }, 21875 day: current.date(), 21876 day_of_week: current.format("dddd"), 21877 slots: Array.from({ length: 10 }, (_, i) => ({ 21878 slot: i, 21879 event: null, // Initially no event 21880 })), 21881 events: [] 21882 }; 21883 //add the day object to the array 21884 days_array.push(day); 21885 current.add(1, "day"); 21886 } 21887 return days_array; 21888 }, 21889 days_array() { 21890 //leverage days_array_dates 21891 //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 21892 if (this.view_preference === "Month" || this.view_preference == "Week") { 21893 //always start at the beginning/end of the month, EVEN IN WEEK VIEW 21894 //.. so that we can generate slots effectively 21895 //... Then, we only push the day to the array in week view if it's in the current week 21896 21897 const start = this.current_date.clone().startOf("month").startOf("week"); 21898 const end = this.current_date.clone().endOf("month").endOf("week"); 21899 21900 21901 let days_array = []; 21902 let visible_slots = 10; 21903 if( this.view_preference == "Week" ){ 21904 visible_slots = 100; 21905 } 21906 let reserved_slots = {}; 21907 let highest_reserved_slot_in_week = 0; 21908 /* 21909 //example of reserved_slots 21910 { 21911 0: { 21912 event: Object, 21913 days_remaining: 3 21914 }, 21915 1: { 21916 event: Object, 21917 days_remaining: 1 21918 }, 21919 2: { 21920 event: Object, 21921 days_remaining: 4 21922 }, 21923 } 21924 */ 21925 21926 var dates = this.dates_for_days_array;
21927 dates.forEach(day => { 21928 let current = day.date.clone(); 21929 let day_is_in_week = current.isSame( this.current_date, "week" ); 21930 let events_for_day = this.visible_events_by_day[current.format("YYYY-MM-DD")] || []; 21931 let handled_events = []; //events we have already managed to slot in 21932 //generate slots for the day 21933 let slots = []; 21934 for (let i = 0; i <= visible_slots - 1; i++) { 21935 //check for a reserved slot already 21936 if( reserved_slots[ i ] ){ 21937 //slot is reserved 21938 slots.push({ 21939 slot: i, 21940 event: reserved_slots[ i ].event 21941 }) 21942 //update it 21943 reserved_slots[ i ].days_remaining -= 1; 21944 }else{ 21945 //slot is open 21946 //.. check for an event 21947 //.. if there is an event, reserve the slot 21948 let event_to_use = null; 21949 for (let e = 0; e < events_for_day.length; e++) { 21950 const this_event = events_for_day[ e ]; 21951 if( this.view_preference == "Week" && ! this_event.isAllDay ){ 21952 //we only slot all day events for the week view 21953 continue; 21954 } 21955 //keep going 21956 if( ! handled_events.includes( this_event.id ) ){ 21957 //haven't used it yet 21958 if( ! this.is_multi_day_event( this_event ) ){ 21959 //it's a single-day event 21960 //.. slot it right in! 21961 event_to_use = this_event; 21962 break; 21963 }else{ 21964 //check if it's the first visible day of the month calendar 21965 var is_first_of_month = current.isSame(this.current_date.clone().startOf("month").startOf("week"), 'day'); 21966 //ok, it's a multi-day event 21967 //.. see if it's the first day of a new event 21968 var remaining_days = this.remaining_days_in_event( this_event, current ); 21969 if(is_first_of_month){ 21970 //check for events that started before the month started but are still ongoing 21971 //.. and slot them 21972 if( remaining_days > 0 ){ 21973 //it's an ongoing event that started before the month started 21974 event_to_use = this_event; 21975 reserved_slots[ i ] = { 21976 event: this_event, 21977 days_remaining: Math.max(0, this.remaining_days_in_event(this_event, current) - 1) 21978 } 21979 break; 21980 } 21981 }else if( this.is_first_day_of_event( this_event, current ) ){ 21982 //it's the first day of a multi-day event 21983 //.. reserve the slot 21984 event_to_use = this_event; 21985 reserved_slots[ i ] = { 21986 event: this_event, 21987 days_remaining: Math.max(0, this.remaining_days_in_event(this_event, current) - 1) 21988 } 21989 break; 21990 } 21991 } 21992 } 21993 } 21994 if( event_to_use ){ 21995 slots.push({ 21996 slot: i, 21997 event: event_to_use 21998 }) 21999 handled_events.push( event_to_use.id ); 22000 if( day_is_in_week && i > highest_reserved_slot_in_week ){
22001 highest_reserved_slot_in_week = i; 22002 } 22003 }else{ 22004 //push it, even if we don't have an event 22005 slots.push({ 22006 slot: i, 22007 event: null 22008 }) 22009 } 22010 } 22011 if( reserved_slots[ i ] && reserved_slots[ i ].days_remaining === 0 ){ 22012 //remove the slot 22013 delete reserved_slots[ i ]; 22014 } 22015 } 22016 //end slots 22017 day.slots = slots; //visible events for the day 22018 day.events = events_for_day; //all events for the day 22019 if( this.view_preference == "Month" || day_is_in_week){ 22020 days_array.push(day); 22021 } 22022 }); 22023 22024 /** 22025 let current = start.clone(); 22026 while (current.isBefore(end) || current.isSame(end, "day")) { 22027 let day_is_in_week = current.isSame( this.current_date, "week" ); 22028 //find all events for this day 22029 let events_for_day = this.visible_events_by_day[current.format("YYYY-MM-DD")] || []; 22030 let handled_events = []; //events we have already managed to slot in 22031 //generate slots for the day 22032 let slots = []; 22033 for (let i = 0; i <= visible_slots - 1; i++) { 22034 //check for a reserved slot already 22035 if( reserved_slots[ i ] ){ 22036 //slot is reserved 22037 slots.push({ 22038 slot: i, 22039 event: reserved_slots[ i ].event 22040 }) 22041 //update it 22042 reserved_slots[ i ].days_remaining -= 1; 22043 }else{ 22044 //slot is open 22045 //.. check for an event 22046 //.. if there is an event, reserve the slot 22047 let event_to_use = null; 22048 for (let e = 0; e < events_for_day.length; e++) { 22049 const this_event = events_for_day[ e ]; 22050 if( this.view_preference == "Week" && ! this_event.isAllDay ){ 22051 //we only slot all day events for the week view 22052 continue; 22053 } 22054 //keep going 22055 if( ! handled_events.includes( this_event.id ) ){ 22056 //haven't used it yet 22057 if( ! this.is_multi_day_event( this_event ) ){ 22058 //it's a single-day event 22059 //.. slot it right in! 22060 event_to_use = this_event; 22061 break; 22062 }else{ 22063 //check if it's the first visible day of the month calendar 22064 var is_first_of_month = current.isSame(this.current_date.clone().startOf("month").startOf("week"), 'day'); 22065 //ok, it's a multi-day event 22066 //.. see if it's the first day of a new event 22067 var remaining_days = this.remaining_days_in_event( this_event, current ); 22068 if(is_first_of_month){ 22069 //check for events that started before the month
22069started but are still ongoing 22070 //.. and slot them 22071 if( remaining_days > 0 ){ 22072 //it's an ongoing event that started before the month started 22073 event_to_use = this_event; 22074 reserved_slots[ i ] = { 22075 event: this_event, 22076 days_remaining: remaining_days 22077 } 22078 break; 22079 } 22080 }else if( this.is_first_day_of_event( this_event, current ) ){ 22081 //it's the first day of a multi-day event 22082 //.. reserve the slot 22083 event_to_use = this_event; 22084 reserved_slots[ i ] = { 22085 event: this_event, 22086 days_remaining: this.return_number_of_days_for_event( this_event ) 22087 } 22088 break; 22089 } 22090 } 22091 } 22092 } 22093 if( event_to_use ){ 22094 slots.push({ 22095 slot: i, 22096 event: event_to_use 22097 }) 22098 handled_events.push( event_to_use.id ); 22099 if( day_is_in_week && i > highest_reserved_slot_in_week ){ 22100 highest_reserved_slot_in_week = i; 22101 } 22102 }else{ 22103 //push it, even if we don't have an event 22104 slots.push({ 22105 slot: i, 22106 event: null 22107 }) 22108 } 22109 } 22110 if( reserved_slots[ i ] && reserved_slots[ i ].days_remaining === 0 ){ 22111 //remove the slot 22112 delete reserved_slots[ i ]; 22113 } 22114 } 22115 //end slots 22116 //see if we should push to the days array 22117 if( this.view_preference == "Month" || day_is_in_week){ 22118 days_array.push({ 22119 date: current.clone(), 22120 date_components: { 22121 year: current.year(), 22122 month: current.month(), 22123 day: current.date(), 22124 }, 22125 day: current.date(), 22126 day_of_week: current.format("dddd"), 22127 slots: slots, //visible events for the day 22128 events: events_for_day, //all events for the day 22129 }); 22130 } 22131 //increment it 22132 current.add(1, "day"); 22133 } 22134 22135 */ 22136 //remove slots that are greater than highest actually used slot 22137 if (this.view_preference === "Week") { 22138 let highest_used_slot_index = 0; 22139 days_array.forEach(day => {
22140 day.slots.forEach(slot => { 22141 if (slot.event && slot.slot > highest_used_slot_index) { 22142 highest_used_slot_index = slot.slot; 22143 } 22144 }); 22145 }); 22146 days_array.forEach(day => { 22147 day.slots = day.slots.filter(slot => { 22148 return slot.slot <= highest_used_slot_index; 22149 }); 22150 }); 22151 } 22152 return days_array; 22153 } else if (this.view_preference === "Day") { 22154 // Generate only the current day 22155 return [ 22156 { 22157 date: this.current_date.clone(), 22158 date_components: { 22159 year: this.current_date.year(), 22160 month: this.current_date.month(), 22161 day: this.current_date.date(), 22162 }, 22163 day: this.current_date.date(), 22164 day_of_week: this.current_date.format("dddd"), 22165 events: this.visible_events_by_day[this.current_date.format("YYYY-MM-DD")] || [], 22166 }, 22167 ]; 22168 } 22169 return []; 22170 }, 22171 calendarTitle() { 22172 if (this.view_preference === "Month") { 22173 return this.current_date.format("MMMM YYYY"); 22174 } else if (this.view_preference === "Week") { 22175 // Set the title to the range of the week 22176 const start = this.current_date.clone().startOf("week"); 22177 const end = this.current_date.clone().endOf("week"); 22178 return `${start.format("MMM D")} - ${end.format("MMM D")}`; 22179 } else if (this.view_preference === "Day") { 22180 return this.current_date.format("MMMM D, YYYY"); 22181 } 22182 return null; 22183 }, 22184 calendarTitle_noYear(){ 22185 if (this.view_preference === "Month") { 22186 return this.current_date.format("MMMM"); 22187 } else if (this.view_preference === "Week") { 22188 // Set the title to the range of the week 22189 const start = this.current_date.clone().startOf("week"); 22190 const end = this.current_date.clone().endOf("week"); 22191 return `${start.format("MMM D")} - ${end.format("MMM D")}`; 22192 } else if (this.view_preference === "Day") { 22193 return this.current_date.format("MMMM D"); 22194 } 22195 return null; 22196 }, 22197 events_by_day(){ 22198 //create an object with keys as days and values as arrays of events 22199 let events_by_day = {}; 22200 this.events.forEach(event => { 22201 //get the start and end dates of the event 22202 const start = moment.tz(event.ts, this.timezone).startOf("day"); 22203 const end = moment.tz(event.end_ts, this.timezone).endOf("day"); 22204 //iterate through the days from start to end 22205 let current = start.clone(); 22206 while (current.isSameOrBefore(end)) { 22207 const date_key = current.format("YYYY-MM-DD"); 22208 if (!events_by_day[date_key]) { 22209 events_by_day[date_key] = []; 22210 } 22211 events_by_day[date_key].push(event); 22212 current.add(1, "day"); 22213 } 22214 }); 22215 //sort array for each day 22216 for (let date in events_by_day) { 22217 if(date && events_by_day[date] && events_by_day[date].length > 0){ 22218 //sort the events for the day 22219 events_by_day[date] = this.sort_events_for_day(events_by_day[date]); 22220 } 22221 } 22222 return events_by_day; 22223 }, 22224 visible_events_by_day() { 22225 const visible_events_by_day = {}; 22226 const defaultFilterIDs = this.selected_tags?.default || []; 22227 const selectedCalendarTagIDs = this.selected_tags?.custom || []; 22228 22229 for (const [dayKey, events] of Object.entries(this.events_by_day)) { 22230 visible_events_by_day[dayKey] = events.filter(event => { 22231 if(event.type !== 'booking'){ 22232 22233 var hasMatchingDefault = false;
22234 var hasMatchingDefaultSecondary = true; 22235 // Two default groups (e.g. lodging requests: room type + room with AND) 22236 if (this.default_calendar_tags_secondary) { 22237 var room_type_ids = event.room_type_ids || []; 22238 var room_ids = event.room_ids || []; 22239 if (room_type_ids.length || room_ids.length) { 22240 var is_day_trip_request_filter = this.calendar_type === 'lodging_requests' && room_type_ids.indexOf('day_trip') !== -1; 22241 if (this.default_calendar_tags && room_type_ids.length) { 22242 for (var ri = 0; ri < room_type_ids.length; ri++) { 22243 if (this.selected_filter_set.has(room_type_ids[ri])) { 22244 hasMatchingDefault = true; 22245 break; 22246 } 22247 } 22248 } else if (!this.default_calendar_tags || (this.selected_filter_set && this.selected_filter_set.size === 0)) { 22249 hasMatchingDefault = true; 22250 } 22251 if (is_day_trip_request_filter) { 22252 hasMatchingDefaultSecondary = true; 22253 } else { 22254 var secSet = this.selected_filter_set_secondary; 22255 if (secSet && secSet.size > 0) { 22256 hasMatchingDefaultSecondary = false; 22257 if (secSet.has('no_room') && room_ids.length === 0) { 22258 hasMatchingDefaultSecondary = true; 22259 } 22260 for (var rj = 0; rj < room_ids.length; rj++) { 22261 if (secSet.has(room_ids[rj])) { 22262 hasMatchingDefaultSecondary = true; 22263 break; 22264 } 22265 } 22266 } 22267 } 22268 } else { 22269 hasMatchingDefault = true; 22270 hasMatchingDefaultSecondary = true; 22271 } 22272 } else if (this.default_calendar_tags) { 22273 // 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) 22274 if ( 22275 (this.calendar_type === 'lodging' || (this.calendar_type === 'reservations' && event.type === "lodging_reservation")) && 22276 event.filter_ids && 22277 event.filter_ids.length > 0 22278 ) { 22279 const filter_ids = event.filter_ids || []; 22280 for (const filter of filter_ids) { 22281 if (this.selected_filter_set.has(filter)) { 22282 hasMatchingDefault = true; 22283 if( this.filter_logic == "or" && hasMatchingDefault ){ 22284 return true; 22285 } 22286 } 22287 } 22288 } else { 22289 const filter_ids = [event.filter_id]; 22290 for (const filter of filter_ids) { 22291 if (this.selected_filter_set.has(filter)) { 22292 hasMatchingDefault = true; 22293 if( this.filter_logic == "or" && hasMatchingDefault ){ 22294 return true; 22295 } 22296 } 22297 } 22298 } 22299 } 22300 if (this.default_calendar_tags_secondary && (!hasMatchingDefault || !hasMatchingDefaultSecondary)) { 22301 return false;
22302 } 22303 //see if we should exit early, based on default 22304 if( this.filter_logic == "or" && hasMatchingDefault ){ 22305 return true; 22306 } 22307 //now check if one of the events tags are selected 22308 var hasMatchingCalendar = false; 22309 const event_tag_ids = event.event_tag_ids || []; 22310 if( 22311 this.show_catch_all_custom_tags_option && 22312 this.catch_all_custom_tag_selected && 22313 event_tag_ids.length === 0 22314 ){ 22315 //ok, they have the default (no tags) option selected 22316 //.. AND this event isn't tagged 22317 //... it's a match 22318 hasMatchingCalendar = true; 22319 }else{ 22320 //ok, do a full check on the tags 22321 if( this.calendar_tags && this.calendar_tags.length >= 1 ){ 22322 //ok, we have tags 22323 //.. it actually needs to match one of the tags 22324 if( selectedCalendarTagIDs.length > 0 && selectedCalendarTagIDs.some(tagId => event_tag_ids.includes(tagId)) ){ 22325 hasMatchingCalendar = true; 22326 } 22327 }else if( this.filter_logic == "and" ){ 22328 //if no tags, it's effectively a match 22329 hasMatchingCalendar = true; 22330 } 22331 } 22332 //check logic 22333 if( this.filter_logic == "and" ){ 22334 //AND logic 22335 return hasMatchingDefault && hasMatchingCalendar; 22336 }else{ 22337 //OR logic 22338 return hasMatchingDefault || hasMatchingCalendar; 22339 } 22340 } else if (this.calendar_tags && this.calendar_tags.length > 0) { 22341 22342 const event_tag_ids = event.event_tag_ids || []; 22343 for (const tag_id of event_tag_ids) { 22344 if (this.selected_tags_set.has(tag_id)) { 22345 return true; // exit early 22346 } 22347 } 22348 return false; 22349 22350 } 22351 return false; 22352 }); 22353 } 22354 return visible_events_by_day; 22355 }, 22356 22357 }, 22358 watch: { 22359 view_preference(newVal) { 22360 this.v_model_view_preference = newVal; 22361 }, 22362 } 22363 }); 22364 </script>
22364
22364<script type="text/html" id="template-booking-calendar-filters"> 22365 <!-- DESKTOP VERSION --> 22366 <div 22367 v-if="show_calendar_tags" 22368 class="hideOnMobile calendar-tag-picker lightBorderLeft scrollable native-tab-bar-padding-bottom" 22369 style="width: 240px;" 22370 :class="{'flexible-height-version scrollable': calendar_type === 'web_component'}" 22371 > 22372 <div class="relative flexNoWrapLeftAlign" style="align-items: baseline;"> 22373 22374 <div style="position: sticky;top: 0;background-color: #fff;z-index: 10;" > 22375 <div v-if="can_edit_tags" class="flexWrapLeftAlign align-items-center tinyPaddingBottom" style="padding-top: 10px;padding-bottom: 6px; margin-right: 10px;"> 22376 22377 <div class="smallPaddingLeft"> 22378 <icon-button 22379 label="Add event" 22380 icon_class="fa-regular fa-plus" 22381 @clicked="add_event()" 22382 style="max-height: fit-content;" 22383 ></icon-button> 22384 </div> 22385 <div class="flexGrowOne"></div> 22386 <div class="flexWrap align-items-center calendar-menu-v2 tinyMarginTop"> 22387 <h3 22388 class="calendar-list menu-icon pointer" 22389 @click="change_show_calendar_tags()" 22390 > 22391 <i 22392 class="fa-regular fa-bars pointer" 22393 ></i> 22394 </h3> 22395 </div> 22396 </div> 22397 <h3 22398 class="flexWrapLeftAlign align-items-center" 22399 :class="{'lightBorderBottom': calendar_type !== 'web_component'}" 22400 style="padding-top: 10px;"> 22401 Calendars 22402 <template v-if="!can_edit_tags"> 22403 <div class="flexGrowOne"></div> 22404 <div class="flexWrap align-items-center calendar-menu-v2 tinyMarginTop"> 22405 <h3 22406 class="calendar-list menu-icon pointer" 22407 style="margin-right: 10px;" 22408 @click="change_show_calendar_tags()" 22409 > 22410 <i 22411 class="fa-regular fa-bars pointer" 22412 ></i> 22413 </h3> 22414 </div> 22415 </template> 22416 </h3> 22417 </div> 22418 22419 <div class="calendar-tag-picker-input bigPaddingLeftRight scrollable paddingBottom"> 22420 <div v-if="default_calendar_tags && default_calendar_tags.title" class="smallMarginTop"> 22421 <h4 class="smallBlockLetters leftText tinyMarginBottom pointer" @click="collapsible_menus_open.default_primary = !collapsible_menus_open.default_primary"> 22422 {{ default_calendar_tags.title }} 22423 <i 22424 class="fa fa-chevron-down rotate-chevron" 22425 :class="{ down: collapsible_menus_open.default_primary }" 22426 style="margin-left: 6px;" 22427 ></i> 22428 </h4> 22429 <div v-if="collapsible_menus_open.default_primary && loading_parent.filter" class="coming-soon">xxx</div> 22430 <div v-if="collapsible_menus_open.default_primary && !loading_parent.filter" class="calendar-filter-search-wrap tinyMarginBottom"> 22431 <div 22432 class="calendar-filter-search-container relative" 22433 :class="{ 'active': filter_search_default_focused }" 22434 > 22435 <i class="fa-light fa-magnifying-glass calendar-filter-search-icon" style="z-index: 8;"></i> 22436 <input 22437 class="calendar-filter-search-input" 22438 :placeholder="default_search_placeholder" 22439 v-model="filter_search_default" 22440 @focus="filter_search_default_focused = true" 22441 @blur="filter_search_default_focused = false" 22442 autocomplete="off" 22443 autocorrect="off" 22444 autocapitalize="none" 22445 spellcheck="false" 22446 > 22447 </div> 22448 </div> 22449 <div class="flexWrapLeftAlign align-items-center"> 22450 <p 22451 v-if="collapsible_menus_open.default_primary && !loading_parent.filter" 22452 @click="select_default_tags()" 22453 class="smallBlockLetters tinyMarginBottom" 22454 style="margin-right: 12px; font-size: 12px;" 22455 > 22456 <a> 22457 <i class="fa-regular fa-check" style="margin-right: 4px;"></i>{{ default_select_all_label }} 22458 </a> 22459 </p> 22460 <p 22461 v-if="collapsible_menus_open.default_primary && (has_default_search ? true : has_any_default_filters_selected)" 22462 @click="clear_default_tags()" 22463 class="smallBlockLetters tinyMarginBottom" 22464 style="margin-right: 12px; font-size: 12px;" 22465 > 22466 <a> 22467 <i class="fa-regular fa-xmark" style="margin-right: 4px;"></i>{{ default_clear_label }} 22468 </a> 22469 </p> 22470 </div> 22471 <template 22472 v-if="collapsible_menus_open.default_primary && filtered_default_options?.length > 0" 22473 v-for="tag in filtered_default_options" 22474 :key="tag.id" 22475 > 22476 <div 22477 @click="clicked_default_tag(tag)" 22478 class="calendar-tag-item flexWrapLeftAlign align-items-center tinyPaddingTop relative pointer" 22479 style="display: flex;" 22480 > 22481 <label class="calendar-tag-item flexWrapLeftAlign align-items-center" style="display: flex; align-items: center; flex: 1;"> 22482 <div 22483 class="custom-checkbox" 22484 :style="{ 22485 backgroundColor: ui_tag_ids_default && ui_tag_ids_default[tag.id] 22486 ? (tag.color || '#5774f5') 22487 : '', 22488 border: '2px solid ' + (tag.color || '#5774f5') 22489 }" 22490 > 22491 <i class="fa-regular fa-check" 22492 style="color: white; position: absolute; top: 2px; left: 2px; font-size: 12px;" 22493 ></i> 22494 </div>
22495 <span class="calendar-tag-name" style="flex: 1 1 auto; word-break: break-word;"> 22496 {{ tag.label }} 22497 </span> 22498 </label> 22499 <!-- Default tag context menu trigger --> 22500 <div 22501 class="calendar-tag-remove-icon" 22502 style="margin-left: 4px; flex-shrink: 0; padding-left: 4px; padding-right: 4px; z-index: 3;" 22503 @click.stop="toggleTagMenu(tag, $event)" 22504 > 22505 <i class="fa-regular fa-ellipsis-vertical"></i> 22506 </div> 22507 <!-- Default tag context menu dropdown (only isolate option) --> 22508 <div 22509 v-if="open_menu_for_tag_id === tag.id" 22510 class="tag-context-menu" 22511 @click.stop 22512 > 22513 <button 22514 type="button" 22515 class="tag-context-menu-item" 22516 @click.stop="closeTagMenu(); isolate_default_tag(tag)" 22517 > 22518 Display this only 22519 </button> 22520 </div> 22521 </div> 22522 </template> 22523 </div> 22524 <!-- Secondary default group (e.g. Room for lodging requests: room type + room with AND) --> 22525 <div v-if="default_calendar_tags_secondary && default_calendar_tags_secondary.title" class="smallMarginTop"> 22526 <h4 class="smallBlockLetters leftText tinyMarginBottom pointer" @click="collapsible_menus_open.default_secondary = !collapsible_menus_open.default_secondary"> 22527 {{ default_calendar_tags_secondary.title }} 22528 <i class="fa fa-chevron-down rotate-chevron" :class="{ down: collapsible_menus_open.default_secondary }" style="margin-left: 6px;"></i> 22529 </h4> 22530 <div v-if="collapsible_menus_open.default_secondary" class="calendar-filter-search-wrap tinyMarginBottom"> 22531 <div class="calendar-filter-search-container relative" :class="{ 'active': filter_search_default_secondary_focused }"> 22532 <i class="fa-light fa-magnifying-glass calendar-filter-search-icon" style="z-index: 8;"></i> 22533 <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"> 22534 </div> 22535 </div> 22536 <div class="flexWrapLeftAlign align-items-center"> 22537 <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> 22538 <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> 22539 <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> 22540 </div> 22541 <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"> 22542 <div @click="clicked_default_tag_secondary(tag)" class="calendar-tag-item flexWrapLeftAlign align-items-center tinyPaddingTop relative pointer" style="display: flex;"> 22543 <label class="calendar-tag-item flexWrapLeftAlign align-items-center" style="display: flex; align-items: center; flex: 1;"> 22544 <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') }"> 22545 <i class="fa-regular fa-check" style="color: white; position: absolute; top: 2px; left: 2px; font-size: 12px;"></i> 22546 </div>
22547 <span class="calendar-tag-name" style="flex: 1 1 auto; word-break: break-word;">{{ tag.label }}</span> 22548 </label> 22549 <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)"> 22550 <i class="fa-regular fa-ellipsis-vertical"></i> 22551 </div> 22552 <div v-if="open_menu_for_tag_id === tag.id" class="tag-context-menu" @click.stop> 22553 <button type="button" class="tag-context-menu-item" @click.stop="closeTagMenu(); isolate_default_tag_secondary(tag)">Display this only</button> 22554 </div> 22555 </div> 22556 </template> 22557 </div> 22558 <!-- Other custom calendars (event tags) --> 22559 <div v-if="show_other_filters" class="marginTop"> 22560 <h4 class="smallBlockLetters leftText tinyMarginBottom pointer noWrap" @click="collapsible_menus_open.custom = !collapsible_menus_open.custom"> 22561 {{ calendar_tag_label }} 22562 <i 22563 v-if="can_edit_tags" 22564 class="fa-regular fa-plus pointer" 22565 style="margin-left: 6px; margin-right: 3px;" 22566 @click.stop="clicked_add_calendar_tag()" 22567 ></i> 22568 <i 22569 class="fa fa-chevron-down rotate-chevron" 22570 :class="{ down: collapsible_menus_open.custom }" 22571 style="margin-left: 6px;" 22572 ></i> 22573 </h4> 22574 <div v-if="collapsible_menus_open.custom && loading_parent.calendar_tags" class="coming-soon">xxx</div> 22575 <div v-if="collapsible_menus_open.custom && !loading_parent.calendar_tags" class="calendar-filter-search-wrap tinyMarginBottom"> 22576 <div 22577 class="calendar-filter-search-container relative" 22578 :class="{ 'active': filter_search_custom_focused }" 22579 > 22580 <i class="fa-light fa-magnifying-glass calendar-filter-search-icon" style="z-index: 8;"></i> 22581 <input 22582 class="calendar-filter-search-input" 22583 :placeholder="'Search tags'" 22584 v-model="filter_search_custom" 22585 @focus="filter_search_custom_focused = true" 22586 @blur="filter_search_custom_focused = false" 22587 autocomplete="off" 22588 autocorrect="off" 22589 autocapitalize="none" 22590 spellcheck="false" 22591 > 22592 </div> 22593 </div> 22594 <div v-if="collapsible_menus_open.custom && !loading_parent.calendar_tags" class="flexWrapLeftAlign align-items-center tinyMarginBottom"> 22595 <p @click="select_custom_tags()" class="smallBlockLetters tinyMarginBottom" style="margin-right: 12px; font-size: 12px;"> 22596 <a><i class="fa-regular fa-check" style="margin-right: 4px;"></i>{{ custom_select_all_label }}</a> 22597 </p> 22598 <p 22599 v-if="has_custom_search || has_any_custom_filters_selected" 22600 @click="clear_custom_tags()" 22601 class="smallBlockLetters tinyMarginBottom" 22602 style="margin-right: 12px; font-size: 12px;" 22603 > 22604 <a><i class="fa-regular fa-xmark" style="margin-right: 4px;"></i>{{ custom_clear_label }}</a> 22605 </p> 22606 </div> 22607 22608 <!-- default (no tags) option --> 22609 <div 22610 v-if="show_catch_all_custom_tags_option && collapsible_menus_open.custom && show_no_tags_in_custom" 22611 @click="clicked_catch_all_custom_tag()" 22612 class="calendar-tag-item flexWrapLeftAlign align-items-center tinyPaddingTop relative pointer" 22613 style="display: flex;" 22614 > 22615 <label class="calendar-tag-item flexWrapLeftAlign align-items-center" style="display: flex; align-items: center; flex: 1;"> 22616 <div 22617 class="custom-checkbox" 22618 :style="{ 22619 backgroundColor: ui_catch_all_custom_tag_selected 22620 ? '#5774f5' 22621 : '', 22622 border: '2px solid #5774f5' 22623 }" 22624 > 22625 <i class="fa-regular fa-check" 22626 v-if="ui_catch_all_custom_tag_selected" 22627 style="color: white; position: absolute; top: 2px; left: 2px; font-size: 12px;" 22628 ></i> 22629 </div>
22630 <span class="calendar-tag-name" style="flex: 1 1 auto; word-break: break-word;"> 22631 No tags 22632 </span> 22633 </label> 22634 <!-- Tag context menu trigger --> 22635 <div 22636 class="calendar-tag-remove-icon" 22637 style="margin-left: 4px; flex-shrink: 0; padding-left: 4px; padding-right: 4px; z-index: 3;" 22638 @click.stop="toggleTagMenu({id: 'catch_all_custom'}, $event)" 22639 > 22640 <i class="fa-regular fa-ellipsis-vertical"></i> 22641 </div> 22642 <!-- Tag context menu dropdown --> 22643 <div 22644 v-if="open_menu_for_tag_id === 'catch_all_custom'" 22645 class="tag-context-menu" 22646 @click.stop 22647 > 22648 <button 22649 type="button" 22650 class="tag-context-menu-item" 22651 @click.stop="closeTagMenu(); isolate_catch_all_custom()" 22652 > 22653 Display this only 22654 </button> 22655 </div> 22656 </div> 22657 <!-- end default (no tags) option --> 22658 22659 <template 22660 v-if="collapsible_menus_open.custom && !loading_parent.calendar_tags && filtered_custom_tags && filtered_custom_tags.length > 0" 22661 v-for="tag in filtered_custom_tags" 22662 :key="tag.id" 22663 > 22664 <div 22665 v-if="tag" 22666 @click="clicked_calendar_tag(tag)" 22667 class="calendar-tag-item flexWrapLeftAlign align-items-center tinyPaddingTop relative pointer" 22668 style="display: flex;" 22669 > 22670 <label class="calendar-tag-item flexWrapLeftAlign align-items-center" style="display: flex; align-items: center; flex: 1;"> 22671 <div 22672 class="custom-checkbox" 22673 :style="{ 22674 backgroundColor: ui_tag_ids_custom && ui_tag_ids_custom[tag.id] 22675 ? (tag.get('hex_value') || '#5774f5') 22676 : '', 22677 border: '2px solid ' + (tag.get('hex_value') || '#5774f5') 22678 }" 22679 > 22680 <i class="fa-regular fa-check" 22681 v-if="ui_tag_ids_custom && ui_tag_ids_custom[tag.id]" 22682 style="color: white; position: absolute; top: 2px; left: 2px; font-size: 12px;" 22683 ></i> 22684 </div> 22685 <span class="calendar-tag-name" style="flex: 1 1 auto; word-break: break-word;"> 22686 {{ tag.get('name') }} 22687 </span> 22688 </label> 22689 <!-- Tag context menu trigger --> 22690 <div 22691 class="calendar-tag-remove-icon" 22692 style="margin-left: 4px; flex-shrink: 0; padding-left: 4px; padding-right: 4px; z-index: 3;" 22693 @click.stop="toggleTagMenu(tag, $event)" 22694 > 22695 <i class="fa-regular fa-ellipsis-vertical"></i> 22696 </div> 22697 <!-- Tag context menu dropdown --> 22698 <div 22699 v-if="open_menu_for_tag_id === tag.id" 22700 class="tag-context-menu" 22701 :class="{'tag-context-menu--above': isLastTag(tag)}" 22702 @click.stop 22703 > 22704 <button 22705 type="button" 22706 class="tag-context-menu-item" 22707 @click.stop="closeTagMenu(); isolate_calendar_tag(tag)" 22708 > 22709 Display this only 22710 </button> 22711 <button 22712 v-if="can_edit_tags" 22713 type="button" 22714 class="tag-context-menu-item tag-context-menu-item-danger" 22715 @click.stop="closeTagMenu(); clicked_remove_tag(tag)" 22716 > 22717 Remove tag 22718 </button> 22719 </div> 22720 </div> 22721 </template> 22722 </div> 22723 </div> 22724 </div> 22725 </div> 22726 <!-- MOBILE VERSION --> 22727 <!-- Teleport the modal to body --> 22728 <teleport to="#v-overlays"> 22729 <div v-if="show_calendar_tags" class="showOnMobile"> 22730 <transition name="slide-in-right">
22731 <aside class="mobile-filter-overlay scrollable" role="dialog" aria-modal="true" aria-labelledby="calendar-filters-title"> 22732 <div class="calendar-tag-picker marginBottom"> 22733 <div class="relative flexNoWrapLeftAlign" style="align-items: baseline;"> 22734 <div 22735 style="position: sticky;top: 0;background-color: #fff;z-index: 10;" 22736 class="lightBorderBottom smallPaddingTop smallPaddingBottom bigPaddingLeftRight" 22737 > 22738 <div 22739 v-if="can_edit_tags" 22740 class="tinyPaddingBottom" 22741 > 22742 <icon-button 22743 label="Add event" 22744 icon_class="fa-regular fa-plus" 22745 @clicked="add_event()" 22746 style="max-height: fit-content;" 22747 ></icon-button> 22748 </div> 22749 <div 22750 class="card-header-button-v3 pointer noWrap centeredText relative" 22751 > 22752 {{ v_model_view_preference }} 22753 <i class="fa-light fa-chevron-down" style="padding-left: 3px;"></i> 22754 <select 22755 v-model="v_model_view_preference" 22756 @change="changeViewPreference($event)" 22757 class="pointer" 22758 style="position:absolute;left:0px;top:0px;width:100%;height:100%;opacity:0;" 22759 > 22760 <option value="Month">Month</option> 22761 <option value="Week">Week</option> 22762 <option value="Day">Day</option> 22763 </select> 22764 </div> 22765 <h3 id="calendar-filters-title" class="flexWrapLeftAlign align-items-center smallMarginTop" style="padding-top: 0;"> 22766 Calendars 22767 <div class="flexGrowOne"></div> 22768 <!-- <i class="fa-regular fa-xmark pointer" @click="$emit('close')" style="margin-right: 10px;"></i> --> 22769 </h3> 22770 </div> 22771 <div class="calendar-tag-picker-input bigPaddingLeftRight scrollable paddingBottom"> 22772 <div v-if="default_calendar_tags && default_calendar_tags.title" class="smallMarginTop"> 22773 <h4 class="smallBlockLetters leftText tinyMarginBottom pointer" @click="collapsible_menus_open.default_primary = !collapsible_menus_open.default_primary"> 22774 {{ default_calendar_tags.title }} 22775 <i 22776 class="fa fa-chevron-down rotate-chevron" 22777 :class="{ down: collapsible_menus_open.default_primary }" 22778 style="margin-left: 6px;" 22779 ></i> 22780 </h4> 22781 <div v-if="collapsible_menus_open.default_primary && loading_parent.filter" class="coming-soon">xxx</div> 22782 <div v-if="collapsible_menus_open.default_primary && !loading_parent.filter" class="calendar-filter-search-wrap tinyMarginBottom"> 22783 <div 22784 class="calendar-filter-search-container relative" 22785 :class="{ 'active': filter_search_default_focused }" 22786 > 22787 <i class="fa-light fa-magnifying-glass calendar-filter-search-icon" style="z-index: 10;"></i> 22788 <input 22789 class="calendar-filter-search-input" 22790 :placeholder="default_search_placeholder" 22791 v-model="filter_search_default" 22792 @focus="filter_search_default_focused = true" 22793 @blur="filter_search_default_focused = false" 22794 autocomplete="off" 22795 autocorrect="off" 22796 autocapitalize="none" 22797 spellcheck="false" 22798 > 22799 </div> 22800 </div> 22801 <div class="flexWrapLeftAlign align-items-center"> 22802 <p 22803 v-if="collapsible_menus_open.default_primary" 22804 @click="select_default_tags()" 22805 class="smallBlockLetters tinyMarginBottom" 22806 style="margin-right: 12px; font-size: 12px;" 22807 > 22808 <a> 22809 <i class="fa-regular fa-check" style="margin-right: 4px;"></i>{{ default_select_all_label }} 22810 </a> 22811 </p> 22812 <p 22813 v-if="collapsible_menus_open.default_primary && (has_default_search ? true : has_any_default_filters_selected)" 22814 @click="clear_default_tags()" 22815 class="smallBlockLetters tinyMarginBottom" 22816 style="margin-right: 12px; font-size: 12px;" 22817 > 22818 <a> 22819 <i class="fa-regular fa-xmark" style="margin-right: 4px;"></i>{{ default_clear_label }} 22820 </a> 22821 </p> 22822 </div> 22823 <template 22824 v-if="collapsible_menus_open.default_primary && filtered_default_options?.length > 0" 22825 v-for="tag in filtered_default_options" 22826 :key="tag.id" 22827 > 22828 <div 22829 @click="clicked_default_tag(tag)" 22830 class="calendar-tag-item flexWrapLeftAlign align-items-center relative pointer" 22831 style="display: flex; padding-top: 16px;" 22832 > 22833 <label class="calendar-tag-item flexWrapLeftAlign align-items-center" style="display: flex; align-items: center; flex: 1;"> 22834 <div 22835 class="custom-checkbox" 22836 :style="{ 22837 backgroundColor: ui_tag_ids_default && ui_tag_ids_default[tag.id] 22838 ? (tag.color || '#5774f5') 22839 : '', 22840 border: '2px solid ' + (tag.color || '#5774f5') 22841 }" 22842 > 22843 <i class="fa-regular fa-check" 22844 style="color: white; position: absolute; top: 2px; left: 2px; font-size: 12px;" 22845 ></i> 22846 </div>
22847 <span class="calendar-tag-name" style="flex: 1 1 auto; word-break: break-word;"> 22848 {{ tag.label }} 22849 </span> 22850 </label> 22851 <!-- Default tag context menu trigger (mobile) --> 22852 <div 22853 class="calendar-tag-more mobile-version" 22854 style="margin-left: 4px; flex-shrink: 0; padding-left: 4px; padding-right: 4px; z-index: 3;" 22855 @click.stop="toggleTagMenu(tag, $event)" 22856 > 22857 <i class="fa-regular fa-ellipsis-vertical"></i> 22858 </div> 22859 <!-- Default tag context menu dropdown (only isolate option) --> 22860 <div 22861 v-if="open_menu_for_tag_id === tag.id" 22862 class="tag-context-menu" 22863 @click.stop 22864 > 22865 <button 22866 type="button" 22867 class="tag-context-menu-item" 22868 @click.stop="closeTagMenu(); isolate_default_tag(tag)" 22869 > 22870 Display this only 22871 </button> 22872 </div> 22873 </div> 22874 </template> 22875 </div> 22876 <!-- Secondary default group (mobile) --> 22877 <div v-if="default_calendar_tags_secondary && default_calendar_tags_secondary.title" class="marginTop"> 22878 <h4 class="smallBlockLetters leftText tinyMarginBottom pointer" @click="collapsible_menus_open.default_secondary = !collapsible_menus_open.default_secondary"> 22879 {{ default_calendar_tags_secondary.title }} 22880 <i class="fa fa-chevron-down rotate-chevron" :class="{ down: collapsible_menus_open.default_secondary }" style="margin-left: 6px;"></i> 22881 </h4> 22882 <div v-if="collapsible_menus_open.default_secondary" class="flexWrapLeftAlign align-items-center tinyMarginBottom"> 22883 <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> 22884 <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> 22885 </div> 22886 <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"> 22887 <div @click="clicked_default_tag_secondary(tag)" class="calendar-tag-item flexWrapLeftAlign align-items-center relative pointer" style="display: flex; padding-top: 16px;"> 22888 <label class="calendar-tag-item flexWrapLeftAlign align-items-center" style="display: flex; align-items: center; flex: 1;"> 22889 <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') }"> 22890 <i class="fa-regular fa-check" style="color: white; position: absolute; top: 2px; left: 2px; font-size: 12px;"></i> 22891 </div>
22892 <span class="calendar-tag-name" style="flex: 1 1 auto; word-break: break-word;">{{ tag.label }}</span> 22893 </label> 22894 <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)"> 22895 <i class="fa-regular fa-ellipsis-vertical"></i> 22896 </div> 22897 <div v-if="open_menu_for_tag_id === tag.id" class="tag-context-menu" @click.stop> 22898 <button type="button" class="tag-context-menu-item" @click.stop="closeTagMenu(); isolate_default_tag_secondary(tag)">Display this only</button> 22899 </div> 22900 </div> 22901 </template> 22902 </div> 22903 <!-- Other custom calendars (event tags) --> 22904 <div v-if="has_other_calendar_filters" class="marginTop"> 22905 <h4 class="smallBlockLetters leftText tinyMarginBottom pointer noWrap" @click="collapsible_menus_open.custom = !collapsible_menus_open.custom"> 22906 {{ calendar_tag_label }} 22907 <i 22908 v-if="can_edit_tags" 22909 class="fa-regular fa-plus pointer" 22910 style="margin-left: 6px; margin-right: 3px;" 22911 @click.stop="clicked_add_calendar_tag()" 22912 ></i> 22913 <i 22914 class="fa fa-chevron-down rotate-chevron" 22915 :class="{ down: collapsible_menus_open.custom }" 22916 style="margin-left: 6px;" 22917 ></i> 22918 </h4> 22919 <div v-if="collapsible_menus_open.custom && loading_parent.calendar_tags" class="coming-soon">xxx</div> 22920 <div v-if="collapsible_menus_open.custom && !loading_parent.calendar_tags" class="calendar-filter-search-wrap tinyMarginBottom"> 22921 <div 22922 class="calendar-filter-search-container relative" 22923 :class="{ 'active': filter_search_custom_focused }" 22924 > 22925 <i class="fa-light fa-magnifying-glass calendar-filter-search-icon" style="z-index: 10;"></i> 22926 <input 22927 class="calendar-filter-search-input" 22928 :placeholder="'Search tags'" 22929 v-model="filter_search_custom" 22930 @focus="filter_search_custom_focused = true" 22931 @blur="filter_search_custom_focused = false" 22932 autocomplete="off" 22933 autocorrect="off" 22934 autocapitalize="none" 22935 spellcheck="false" 22936 > 22937 </div> 22938 </div> 22939 <div v-if="collapsible_menus_open.custom && !loading_parent.calendar_tags" class="flexWrapLeftAlign align-items-center tinyMarginBottom"> 22940 <p @click="select_custom_tags()" class="smallBlockLetters tinyMarginBottom" style="margin-right: 12px; font-size: 12px;"> 22941 <a><i class="fa-regular fa-check" style="margin-right: 4px;"></i>{{ custom_select_all_label }}</a> 22942 </p> 22943 <p 22944 v-if="has_custom_search || has_any_custom_filters_selected" 22945 @click="clear_custom_tags()" 22946 class="smallBlockLetters tinyMarginBottom" 22947 style="margin-right: 12px; font-size: 12px;" 22948 > 22949 <a><i class="fa-regular fa-xmark" style="margin-right: 4px;"></i>{{ custom_clear_label }}</a> 22950 </p> 22951 </div> 22952 22953 <!-- default (no tags) option --> 22954 <div 22955 v-if="show_catch_all_custom_tags_option && collapsible_menus_open.custom && show_no_tags_in_custom" 22956 @click="clicked_catch_all_custom_tag()" 22957 class="calendar-tag-item flexWrapLeftAlign align-items-center tinyPaddingTop relative pointer" 22958 style="display: flex;" 22959 > 22960 <label class="calendar-tag-item flexWrapLeftAlign align-items-center" style="display: flex; align-items: center; flex: 1;"> 22961 <div 22962 class="custom-checkbox" 22963 :style="{ 22964 backgroundColor: ui_catch_all_custom_tag_selected 22965 ? '#5774f5' 22966 : '', 22967 border: '2px solid #5774f5' 22968 }" 22969 > 22970 <i class="fa-regular fa-check" 22971 v-if="ui_catch_all_custom_tag_selected" 22972 style="color: white; position: absolute; top: 2px; left: 2px; font-size: 12px;" 22973 ></i> 22974 </div>
22975 <span class="calendar-tag-name" style="flex: 1 1 auto; word-break: break-word;"> 22976 No tags 22977 </span> 22978 </label> 22979 <!-- Tag context menu trigger --> 22980 <div 22981 class="calendar-tag-catch-all mobile-version" 22982 style="margin-left: 4px; flex-shrink: 0; padding-left: 4px; padding-right: 4px; z-index: 3;" 22983 @click.stop="toggleTagMenu({id: 'catch_all_custom'}, $event)" 22984 > 22985 <i class="fa-regular fa-ellipsis-vertical"></i> 22986 </div> 22987 <!-- Tag context menu dropdown --> 22988 <div 22989 v-if="open_menu_for_tag_id === 'catch_all_custom'" 22990 class="tag-context-menu" 22991 @click.stop 22992 > 22993 <button 22994 type="button" 22995 class="tag-context-menu-item" 22996 @click.stop="closeTagMenu(); isolate_catch_all_custom()" 22997 > 22998 Display this only 22999 </button> 23000 </div> 23001 </div> 23002 <!-- end default (no tags) option --> 23003 23004 <template 23005 v-if="collapsible_menus_open.custom && !loading_parent.calendar_tags && filtered_custom_tags && filtered_custom_tags.length > 0" 23006 v-for="tag in filtered_custom_tags" 23007 :key="tag.id" 23008 > 23009 <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"> 23010 <label class="calendar-tag-item flexWrapLeftAlign align-items-center" style="display: flex; align-items: center; flex: 1;"> 23011 <div 23012 class="custom-checkbox" 23013 :style="{ 23014 backgroundColor: ui_tag_ids_custom && ui_tag_ids_custom[tag.id] 23015 ? (tag.get('hex_value') || '#5774f5') 23016 : '', 23017 border: '2px solid ' + (tag.get('hex_value') || '#5774f5') 23018 }" 23019 > 23020 <i class="fa-regular fa-check" 23021 v-if="ui_tag_ids_custom && ui_tag_ids_custom[tag.id]" 23022 style="color: white; position: absolute; top: 2px; left: 2px; font-size: 12px;" 23023 ></i> 23024 </div>
23025 <span class="calendar-tag-name" style="flex: 1 1 auto; word-break: break-word;"> 23026 {{ tag.get('name') }} 23027 </span> 23028 </label> 23029 <!-- Tag context menu trigger (mobile)--> 23030 <div 23031 class="calendar-tag-more mobile-version" 23032 style="margin-left: 4px; flex-shrink: 0; padding-left: 4px; padding-right: 4px; z-index: 3;" 23033 @click.stop="toggleTagMenu(tag, $event)" 23034 > 23035 <i class="fa-regular fa-ellipsis-vertical"></i> 23036 </div> 23037 <!-- Tag context menu dropdown --> 23038 <div 23039 v-if="open_menu_for_tag_id === tag.id" 23040 class="tag-context-menu" 23041 :class="{'tag-context-menu--above': isLastTag(tag)}" 23042 @click.stop 23043 > 23044 <button 23045 type="button" 23046 class="tag-context-menu-item" 23047 @click.stop="closeTagMenu(); isolate_calendar_tag(tag)" 23048 > 23049 Display this only 23050 </button> 23051 <button 23052 v-if="can_edit_tags" 23053 type="button" 23054 class="tag-context-menu-item tag-context-menu-item-danger" 23055 @click.stop="closeTagMenu(); clicked_remove_tag(tag)" 23056 > 23057 Remove tag 23058 </button> 23059 </div> 23060 </div> 23061 </template> 23062 23063 </div> 23064 </div> 23065 </div> 23066 </div> 23067 </aside> 23068 </transition> 23069 23070 <div v-if="show_calendar_tags" class="overlay-backdrop showOnMobile" @click="$emit('close')"></div> 23071 </div> 23072 </teleport> 23073</script>
23073 23074
23075<script> 23076 app_content.component("booking-calendar-filters", { 23077 template: "#template-booking-calendar-filters", 23078 data(){ 23079 return { 23080 collapsible_menus_open: { 23081 default_primary: true, 23082 default_secondary: true, 23083 custom: true, 23084 }, 23085 v_model_view_preference: this.view_preference || 'Month', 23086 open_menu_for_tag_id: null, 23087 filter_search_default: '', 23088 filter_search_custom: '', 23089 filter_search_default_secondary: '', 23090 filter_search_default_focused: false, 23091 filter_search_custom_focused: false, 23092 filter_search_default_secondary_focused: false, 23093 } 23094 }, 23095 props: { 23096 show_calendar_tags: Boolean, 23097 default_calendar_tags: Object, 23098 calendar_tags: Array, 23099 selected_tags: Object, 23100 catch_all_custom_tag_selected: Boolean, 23101 ui_tag_ids_default: Object, 23102 ui_tag_ids_custom: Array, 23103 ui_catch_all_custom_tag_selected: Boolean, 23104 show_catch_all_custom_tags_option: { 23105 type: Boolean, 23106 default: false, 23107 }, 23108 default_calendar_tags_secondary: Object, 23109 ui_tag_ids_default_secondary: Object, 23110 collapsible_menus_open: Object, 23111 loading_parent: Object, 23112 calendar_type: String, 23113 view_only: Boolean, 23114 view_preference: String, 23115 }, 23116 mounted(){ 23117 this.v_model_view_preference = this.view_preference || 'Month'; 23118 document.addEventListener('click', this.handleClickOutside); 23119 }, 23120 beforeDestroy(){ 23121 document.removeEventListener('click', this.handleClickOutside); 23122 }, 23123 methods: { 23124 add_event(){ 23125 this.$emit('add_event'); 23126 }, 23127 isLastTag(tag){ 23128 if (!this.calendar_tags || this.calendar_tags.length === 0 || !tag) { 23129 return false; 23130 } 23131 const len = this.calendar_tags.length; 23132 const last = this.calendar_tags[len - 1]; 23133 const secondLast = this.calendar_tags[len - 2]; 23134 return (last && last.id === tag.id) || (secondLast && secondLast.id === tag.id); 23135 }, 23136 toggleTagMenu(tag, event){ 23137 if (event && event.stopPropagation) { 23138 event.stopPropagation(); 23139 } 23140 // Toggle the menu for this tag; close if it's already open 23141 this.open_menu_for_tag_id = this.open_menu_for_tag_id === tag.id ? null : tag.id; 23142 }, 23143 closeTagMenu(){ 23144 this.open_menu_for_tag_id = null; 23145 }, 23146 handleClickOutside(){ 23147 // Close any open menu when clicking outside 23148 if (this.open_menu_for_tag_id !== null) { 23149 this.open_menu_for_tag_id = null; 23150 } 23151 }, 23152 clicked_default_tag(tag) { 23153 this.$emit('clicked_default_tag', tag); 23154 }, 23155 clicked_catch_all_custom_tag(){ 23156 this.$emit('clicked_catch_all_custom_tag'); 23157 }, 23158 clicked_calendar_tag(tag) { 23159 this.$emit('clicked_calendar_tag', tag); 23160 }, 23161 clicked_add_calendar_tag(){ 23162 this.$emit('clicked_add_calendar_tag'); 23163 }, 23164 clicked_remove_tag(tag) { 23165 this.$emit('clicked_remove_tag', tag); 23166 }, 23167 changeViewPreference(event){ 23168 this.$emit('change_view_preference', event ); 23169 }, 23170 change_show_calendar_tags(){ 23171 this.$emit('change_show_calendar_tags'); 23172 }, 23173 isolate_default_tag(tag){ 23174 this.$emit('isolate_default_tag', tag); 23175 }, 23176 clicked_default_tag_secondary(tag) { 23177 this.$emit('clicked_default_tag_secondary', tag); 23178 }, 23179 select_default_tags_secondary() { 23180 var q = (this.filter_search_default_secondary || '').trim(); 23181 if (q && this.filtered_default_secondary_options && this.filtered_default_secondary_options.length) { 23182 this.$emit('select_visible_default_tags_secondary', this.filtered_default_secondary_options.map((t)=>{ return t.id; })); 23183 } else { 23184 this.$emit('select_all_default_tags_secondary'); 23185 } 23186 }, 23187 clear_default_tags_secondary() { 23188 var q = (this.filter_search_default_secondary || '').trim(); 23189 if (q && this.filtered_default_secondary_options && this.filtered_default_secondary_options.length) { 23190 this.$emit('clear_visible_default_tags_secondary', this.filtered_default_secondary_options.map((t)=>{ return t.id; })); 23191 } else { 23192 this.$emit('clear_all_default_tags_secondary'); 23193 } 23194 }, 23195 clear_visible_default_tags_secondary() { 23196 var ids = (this.filtered_default_secondary_options || []).map((t)=>{ return t.id; }); 23197 this.$emit('clear_visible_default_tags_secondary', ids); 23198 }, 23199 isolate_default_tag_secondary(tag) { 23200 this.$emit('isolate_default_tag_secondary', tag); 23201 }, 23202 isolate_calendar_tag(tag){ 23203 this.$emit('isolate_calendar_tag', tag); 23204 }, 23205 select_all_custom_tags(){ 23206 this.$emit('select_all_custom_tags'); 23207 }, 23208 select_default_tags(){ 23209 var q = (this.filter_search_default || '').trim(); 23210 if (q && this.filtered_default_options && this.filtered_default_options.length) { 23211 var visibleIds = this.filtered_default_options.map((t)=>{ return t.id; }); 23212 this.$emit('select_visible_default_tags', visibleIds); 23213 } else { 23214 this.$emit('select_all_default_tags'); 23215 } 23216 }, 23217 clear_default_tags(){ 23218 var q = (this.filter_search_default || '').trim(); 23219 if (q && this.filtered_default_options && this.filtered_default_options.length) { 23220 var visibleIds = this.filtered_default_options.map((t)=>{ return t.id; }); 23221 this.$emit('clear_visible_default_tags', visibleIds); 23222 } else { 23223 this.$emit('clear_all_default_tags'); 23224 } 23225 }, 23226 select_custom_tags(){ 23227 var q = (this.filter_search_custom || '').trim(); 23228 if (q) { 23229 var visibleIds = (this.filtered_custom_tags || []).map((t)=>{ return t.id; }); 23230 if (this.show_no_tags_in_custom) { visibleIds.push('catch_all_custom'); } 23231 this.$emit('select_visible_custom_tags', visibleIds); 23232 } else { 23233 this.$emit('select_all_custom_tags'); 23234 } 23235 }, 23236 clear_custom_tags(){ 23237 var q = (this.filter_search_custom || '').trim(); 23238 if (q) { 23239 var visibleIds = (this.filtered_custom_tags || []).map((t)=>{ return t.id; }); 23240 if (this.show_no_tags_in_custom) { visibleIds.push('catch_all_custom'); } 23241 this.$emit('clear_visible_custom_tags', visibleIds); 23242 } else { 23243 this.$emit('clear_all_custom_tags'); 23244 } 23245 }, 23246 select_all_default_tags(){ 23247 this.$emit('select_all_default_tags'); 23248 }, 23249 clear_all_default_tags(){ 23250 this.$emit('clear_all_default_tags'); 23251 }, 23252 isolate_catch_all_custom(){ 23253 this.$emit('isolate_catch_all_custom'); 23254 } 23255 }, 23256 computed: { 23257 show_other_filters(){ 23258 // member facing calendars only show if we have tags 23259 if(this.calendar_type && (this.calendar_type == 'member_portal' || this.calendar_type == 'web_component' )){ 23260 //only show if we have tags
23261 return this.has_other_calendar_filters; 23262 } 23263 //admin calendars always show 23264 //...since they need to be able to add tags 23265 return true; 23266 }, 23267 has_other_calendar_filters(){ 23268 if(!this.calendar_tags || this.calendar_tags.length == 0){ 23269 return false; 23270 } 23271 return true; 23272 }, 23273 can_edit_tags(){ 23274 if(this.view_only){ 23275 return false; 23276 } 23277 if(this.calendar_type && ( this.calendar_type == 'member_portal' || this.calendar_type == 'web_component' || this.calendar_type == 'events_primary' )){ 23278 //member facing calendars 23279 // also the primary events calendar since it shows ALL event tags 23280 return false; 23281 } 23282 return true; 23283 }, 23284 calendar_tag_label(){ 23285 if(this.calendar_type && (this.calendar_type == 'member_portal' || this.calendar_type == 'web_component' || this.calendar_type == 'events_primary' )){ 23286 return "Event tags"; 23287 } 23288 return "Other calendars"; 23289 }, 23290 default_search_placeholder(){ 23291 var title = this.default_calendar_tags?.title || 'event types'; 23292 if(title.toLowerCase().trim() === 'type'){ 23293 return 'Search event types'; 23294 }else{ 23295 return 'Search ' + title.toLowerCase(); 23296 } 23297 }, 23298 has_any_default_filters_selected(){ 23299 var defaultSelected = (this.ui_tag_ids_default && typeof this.ui_tag_ids_default === 'object') ? Object.keys(this.ui_tag_ids_default).length : 0; 23300 return defaultSelected > 0; 23301 }, 23302 has_any_custom_filters_selected(){ 23303 var customSelected = (this.ui_tag_ids_custom && typeof this.ui_tag_ids_custom === 'object') ? Object.keys(this.ui_tag_ids_custom).length : 0; 23304 if (customSelected > 0) { return true; } 23305 if (this.ui_catch_all_custom_tag_selected) { return true; } 23306 return false; 23307 }, 23308 has_default_search(){ 23309 return (this.filter_search_default || '').trim().length > 0; 23310 }, 23311 has_custom_search(){ 23312 return (this.filter_search_custom || '').trim().length > 0; 23313 }, 23314 filtered_default_options(){ 23315 var options = this.default_calendar_tags?.options || []; 23316 var q = (this.filter_search_default || '').trim().toLowerCase(); 23317 if (!q) { return options; } 23318 if (typeof fuzzysort !== 'undefined') { 23319 var matches = fuzzysort.go(q, options, { keys: ['label'], threshold: -1000, limit: 100, allowTypo: false }); 23320 return matches ? matches.map((m)=>{ return m.obj; }) : []; 23321 } 23322 return options.filter((opt)=>{ return (opt.label || '').toLowerCase().indexOf(q) !== -1; }); 23323 }, 23324 filtered_default_secondary_options(){ 23325 var options = this.default_calendar_tags_secondary?.options || []; 23326 var q = (this.filter_search_default_secondary || '').trim().toLowerCase(); 23327 if (!q) { return options; } 23328 if (typeof fuzzysort !== 'undefined') { 23329 var matches = fuzzysort.go(q, options, { keys: ['label'], threshold: -1000, limit: 100, allowTypo: false }); 23330 return matches ? matches.map((m)=>{ return m.obj; }) : []; 23331 } 23332 return options.filter((opt)=>{ return (opt.label || '').toLowerCase().indexOf(q) !== -1; }); 23333 }, 23334 has_any_default_secondary_filters_selected(){ 23335 var o = this.ui_tag_ids_default_secondary && typeof this.ui_tag_ids_default_secondary === 'object' ? this.ui_tag_ids_default_secondary : {}; 23336 return Object.keys(o).filter((k)=>{ return o[k]; }).length > 0; 23337 }, 23338 default_select_all_label(){ 23339 if (!this.has_default_search || !this.filtered_default_options) { return 'Select all'; } 23340 var n = this.filtered_default_options.length; 23341 return n ? 'Select all (' + n + ')' : 'Select all'; 23342 }, 23343 default_clear_label(){ 23344 if (!this.has_default_search || !this.filtered_default_options) { return 'Clear'; } 23345 var n = this.filtered_default_options.length; 23346 return n ? 'Clear (' + n + ')' : 'Clear'; 23347 }, 23348 _custom_searchables(){ 23349 var list = []; 23350 if (this.calendar_tags && this.calendar_tags.length) {
23351 this.calendar_tags.forEach((tag)=>{ 23352 list.push({ name: (tag.get ? tag.get('name') : (tag.name || '') || ''), tag: tag }); 23353 }); 23354 } 23355 if (this.show_catch_all_custom_tags_option) { 23356 list.push({ name: 'No tags', tag: null }); 23357 } 23358 return list; 23359 }, 23360 filtered_custom_tags(){ 23361 var searchables = this._custom_searchables; 23362 var q = (this.filter_search_custom || '').trim().toLowerCase(); 23363 if (!q) { 23364 return this.calendar_tags || []; 23365 } 23366 if (typeof fuzzysort !== 'undefined') { 23367 var matches = fuzzysort.go(q, searchables, { keys: ['name'], threshold: -1000, limit: 100, allowTypo: false }); 23368 if (!matches || !matches.length) { return []; } 23369 return matches.map((m)=>{ return m.obj.tag; }).filter(Boolean); 23370 } 23371 return (this.calendar_tags || []).filter((tag)=>{ 23372 var name = (tag.get ? tag.get('name') : (tag.name || '')) || '' || ''; 23373 return name.toLowerCase().indexOf(q) !== -1; 23374 }); 23375 }, 23376 filtered_custom_display_count(){ 23377 var n = (this.filtered_custom_tags || []).length; 23378 if (this.show_no_tags_in_custom) { n += 1; } 23379 return n; 23380 }, 23381 custom_select_all_label(){ 23382 if (!this.has_custom_search) { return 'Select all'; } 23383 var n = this.filtered_custom_display_count; 23384 return n ? 'Select all (' + n + ')' : 'Select all'; 23385 }, 23386 custom_clear_label(){ 23387 if (!this.has_custom_search) { return 'Clear'; } 23388 var n = this.filtered_custom_display_count; 23389 return n ? 'Clear (' + n + ')' : 'Clear'; 23390 }, 23391 show_no_tags_in_custom(){ 23392 if (!this.show_catch_all_custom_tags_option) { return false; } 23393 var q = (this.filter_search_custom || '').trim().toLowerCase(); 23394 if (!q) { return true; } 23395 if (typeof fuzzysort !== 'undefined') { 23396 var matches = fuzzysort.go(q, [{ name: 'No tags', tag: null }], { keys: ['name'], threshold: -1000, allowTypo: false }); 23397 return matches && matches.length > 0; 23398 } 23399 return 'no tags'.indexOf(q) !== -1 || q === 'no'; 23400 }, 23401 }, 23402 watch: { 23403 view_preference(newVal) { 23404 this.v_model_view_preference = newVal; 23405 23406 } 23407 } 23408 23409 23410 }); 23411</script>
23411 23412 23413<style scoped> 23414 /* Rotating chevrons */ 23415 .rotate-chevron.down { 23416 transform: rotate(180deg); 23417 } 23418 23419 .calendar-filter-search-wrap { 23420 margin-top: 6px; 23421 } 23422 .calendar-filter-search-container { 23423 display: flex; 23424 align-items: center; 23425 border-radius: 5px; 23426 border: 1px solid #e5eaec; 23427 overflow: hidden; 23428 margin-bottom: 2px; 23429 } 23430 .calendar-filter-search-container.active { 23431 border: 1px solid #4a8be6; 23432 } 23433 .calendar-filter-search-input { 23434 flex: 1; 23435 padding: 8px 8px 8px 30px; 23436 border: none; 23437 outline: none; 23438 } 23439 .calendar-filter-search-icon { 23440 font-size: 12px; 23441 position: absolute; 23442 left: 10px; 23443 top: 50%; 23444 transform: translateY(-50%); 23445 color: #52596f; 23446 } 23447 23448 .calendar-tag-picker.flexible-height-version{ 23449 max-height: 100vh; 23450 } 23451 23452 .custom-checkbox { 23453 width: 18px; 23454 height: 18px; 23455 display: inline-block; 23456 position: relative; 23457 align-items: center; 23458 border-radius: 4px; 23459 margin-right: 8px; 23460 box-sizing: border-box; 23461 vertical-align: middle; 23462 cursor: pointer; 23463 } 23464 23465 /* Improved calendar tag item for wrapping and left alignment */ 23466 .calendar-tag-item { 23467 /* align-items: flex-start !important; */ 23468 flex-direction: row; 23469 flex-wrap: wrap; 23470 white-space: normal; 23471 text-align: left; 23472 line-height: 1.4; 23473 } 23474 23475 .calendar-tag-name { 23476 display: inline-block; 23477 word-break: break-word; 23478 max-width: 80%; 23479 } 23480 23481 .calendar-tag-item:hover .calendar-tag-remove-icon { 23482 visibility: visible; 23483 } 23484 23485 .calendar-tag-remove-icon { 23486 visibility: hidden; 23487 cursor: pointer; 23488 color:#788097; 23489 } 23490 23491 /* MOBILE */ 23492 .mobile-filter-overlay{ 23493 position: fixed; 23494 top: 0; 23495 right: 0; 23496 height: 100vh; 23497 width: 65vw; 23498 min-width: 250px; 23499 max-width: 480px; 23500 background: #fff; 23501 z-index: 1002; 23502 box-shadow: 0 0 20px rgba(0,0,0,0.2); 23503 display: flex; 23504 flex-direction: column; 23505 border-radius: 5px 0 0 5px; 23506 } 23507 .overlay-backdrop{ 23508 position: fixed; 23509 inset: 0; 23510 background: rgba(0,0,0,0.4); 23511 z-index: 1001; 23512 } 23513 /* Slide-in-from-right transition */ 23514 .slide-in-right-enter-active, .slide-in-right-leave-active{ 23515 transition: transform 200ms ease-out; 23516 } 23517 .slide-in-right-enter-from, .slide-in-right-leave-to{ 23518 transform: translateX(100%); 23519 } 23520 23521 .tag-context-menu{ 23522 position: absolute; 23523 top: 100%; 23524 right: 0; 23525 margin-top: 4px; 23526 background: #fff; 23527 border: 1px solid #e0e4ee; 23528 border-radius: 6px; 23529 box-shadow: 0 4px 12px rgba(0,0,0,0.08); 23530 min-width: 160px; 23531 z-index: 10; 23532 padding: 4px 0; 23533 } 23534 23535 .tag-context-menu--above{ 23536 top: auto; 23537 bottom: 100%; 23538 margin-top: 0; 23539 margin-bottom: -5px; 23540 } 23541 23542 .tag-context-menu-item{ 23543 width: 100%; 23544 padding: 6px 10px; 23545 background: transparent; 23546 border: none; 23547 text-align: left; 23548 font-size: 16px; 23549 cursor: pointer; 23550 color: #333; 23551 white-space: nowrap; 23552 } 23553 23554 .tag-context-menu-item:hover{ 23555 background: #f3f5fb; 23556 } 23557 23558 .tag-context-menu-item-danger{ 23559 color: #c0392b; 23560 } 23561</style> 23562
23562<script type="text/html" id="template-booking-calendar-cell"> 23563 <div 23564 class="calendar-cell grid-cell pointer booking-calendar" 23565 :class="{'notCurrentMonth': ! is_current_month, 'today': is_today}" 23566 @click="add_event" 23567 > 23568 <div class="day-info flexWrap"> 23569 <div @click.stop="zoom_in_to_date"> 23570 <div class="cell-day calendarObj">{{ day }}</div> 23571 <div class="day-name">{{ dayOfWeek }}</div> 23572 </div> 23573 </div> 23574 <div class="events"> 23575 <!-- render content for each slot --> 23576 <template 23577 v-for="(slot, index) in slots" 23578 :key="index" 23579 > 23580 <!-- ok, we have an event for this slot--> 23581 <template v-if="slot.event"> 23582 <div 23583 class="event" 23584 :class="[{'all_day': slot.event.isAllDay}, {'partial-day': ! slot.event.isAllDay}]" 23585 @click.stop="clicked_event( slot.event )" 23586 :style="return_multi_day_event_style( slot.event )" 23587 > 23588 <div 23589 class="event-title-v2 flexNoWrap align-items-center easeSuperFast" 23590 style="font-size:12.5px;" 23591 > 23592 <template 23593 v-if="! slot.event.isAllDay" 23594 > 23595 <!-- dot --> 23596 <i 23597 style="padding-right:6px;padding-left:2px;font-size:8px;" 23598 class="fa-solid fa-circle hideOnMobile" 23599 :style="{color: slot.event.color}" 23600 ></i> 23601 <!-- start time --> 23602 <span v-if="! slot.event.isAllDay" class="event-time-v2 hideOnMobile">{{ formatEventTime( slot.event.ts ) }}</span> 23603 </template> 23604 <!-- event title --> 23605 {{ slot.event.title }} 23606 </div> 23607 </div> 23608 </template> 23609 <template v-else-if="should_hold_slot(slot, index)"> 23610 <!-- Leave it blank if this slot is used later in the week --> 23611 <div 23612 class="event placeholder" 23613 > 23614 <!-- add a placeholder if there's an event in this slot but it doesn't start on this day --> 23615 <div 23616 class="event-title-v2 flexNoWrap align-items-center easeSuperFast" 23617 style="font-size:12.5px; visibility: hidden;" 23618 > </div> 23619 <!-- adds a hidden, non-breaking space --> 23620 </div> 23621 </template> 23622 23623 <!-- no event for this slot --> 23624 <template v-else> 23625 <div 23626 class="event partial-day" 23627 ></div> 23628 </template> 23629 23630 </template> 23631 23632 <!-- "+ x more" Label for Overflow --> 23633 <div 23634 v-if="overflowCount > 0" 23635 class="more-events-label" 23636 @click.stop="clicked_show_more_events" 23637 > 23638 + {{ overflowCount }} more 23639 </div> 23640 </div> 23641 </div> 23642</script>
23642 23643 23644<style scoped> 23645 23646 .event-time-v2{ 23647 font-size: 11px; 23648 padding-right: 4px;; 23649 } 23650 23651 .more-events-label { 23652 font-size: 12px; 23653 cursor: pointer; 23654 padding-left: 6px; 23655 padding-right: 6px; 23656 text-align: left; 23657 padding-top: 2px; 23658 padding-bottom: 2px; 23659 cursor: pointer; 23660 border-radius: 5px; 23661 } 23662 23663 .event-title-v2{ 23664 font-size: 12.5px; 23665 white-space: nowrap; 23666 padding-top: 2px; 23667 padding-bottom: 2px;; 23668 padding-left: 6px; 23669 padding-right: 6px; 23670 cursor: pointer; 23671 border-radius: 5px; 23672 } 23673 23674 .more-events-label:hover, 23675 .partial-day .event-title-v2:hover{ 23676 background-color: #e4ebf1; 23677 } 23678 .booking-calendar .event.partial-day { 23679 /* color: #fff; */ 23680 border-radius: 8px; 23681 } 23682 23683 .cell-day.calendarObj{ 23684 min-width: 22px; 23685 max-width: 22px; 23686 min-height: 22px; 23687 max-height: 22px; 23688 margin: 6px !important; 23689 font-size: 12px; 23690 } 23691 .grid-cell.today .cell-day{ 23692 background-color: var(--custom-mp-accent, #4a56f8); 23693 color: rgb(255, 255, 255); 23694 } 23695 23696 23697 /* Event List Styling */ 23698 .events { 23699 display: flex; 23700 flex-direction: column; 23701 gap: 4px; /* Space between events */ 23702 } 23703 23704 .event{ 23705 font-size: 12px; 23706 font-weight: 400; 23707 overflow: hidden; 23708 white-space: nowrap; 23709 text-align: left; 23710 } 23711 23712 /* Individual Event Styling */ 23713 .event.all_day { 23714 background-color: var(--custom-mp-accent, #4a56f8); 23715 color: #fff; 23716 border-radius: 8px; 23717 width: calc(100% - 4px); 23718 } 23719 23720</style> 23721
23722<script> 23723 23724 /* ----- COMPONENT ----- */ 23725 app_content.component("booking-calendar-cell", { 23726 template: "#template-booking-calendar-cell", 23727 data() { 23728 return {} 23729 }, 23730 props: { 23731 date: Object, 23732 day: Number, 23733 day_of_week: String, 23734 date_components: Object, 23735 events: Array, 23736 slots: Array, 23737 timezone: String, 23738 is_current_month: Boolean, 23739 days_array: Array, 23740 calendar_type: String 23741 }, 23742 mounted(){ 23743 }, 23744 methods: { 23745 add_event(){ 23746 var date_components = return_date_components_v2({ 23747 date: this.date, 23748 timezone: this.timezone 23749 }) 23750 this.$emit("add_event", date_components); 23751 }, 23752 zoom_in_to_date(){ 23753 this.$emit("zoom_in_to_date", this.date); 23754 }, 23755 formatEventTime(timestamp) { 23756 const time = moment.tz(timestamp, this.timezone); 23757 if (time.minute() === 0) { 23758 return time.format("ha"); 23759 } 23760 return time.format("h:mma"); 23761 }, 23762 is_multi_day_event( event ){ 23763 return moment.tz(event.ts, this.timezone).isBefore(moment.tz(event.end_ts, this.timezone), 'day'); 23764 }, 23765 // Updated to fix last day rendering & timezone issues for multi-day events 23766 return_multi_day_event_style(event) { 23767 const cellDate = moment.tz(this.date, this.timezone).startOf('day'); 23768 const eventStart = moment.tz(event.ts, this.timezone).startOf('day'); 23769 const eventEnd = moment.tz(event.end_ts, this.timezone).startOf('day'); 23770 23771 // Skip styling if the event doesn't span this day 23772 if (cellDate.isBefore(eventStart) || cellDate.isAfter(eventEnd)) { 23773 return { display: 'none' }; 23774 } 23775 23776 const isFirstDay = cellDate.isSame(eventStart, 'day'); 23777 const isStartOfWeek = cellDate.day() === 0; 23778 const textColor = this.adaptiveTextColorFromColors(event.bgColors); 23779 23780 if (isFirstDay || isStartOfWeek) { 23781 const remainingDays = eventEnd.diff(cellDate, 'days') + 1; 23782 const span = Math.min(remainingDays, 7 - cellDate.day()); 23783 23784 return { 23785 width: `calc(${span * 100}% - 4px)`, 23786 zIndex: 1, 23787 color: textColor, 23788 background: event.bgColors?.length 23789 ? `repeating-linear-gradient( 23790 -45deg, 23791 ${event.bgColors.map((color, i) => `${color} ${i * 20}px, ${color} ${(i + 1) * 20}px`).join(",")} 23792 )` 23793 : event.bgColor || "#4a56f8" 23794 }; 23795 } 23796 23797 return { 23798 width: 'calc(100% - 4px)', 23799 background: 'transparent', 23800 visibility: 'hidden' 23801 }; 23802 }, 23803 remaining_days_in_event(event) { 23804 const cellDate = moment.tz(this.date, this.timezone).startOf("day"); 23805 const eventEnd = moment.tz(event.end_ts, this.timezone).startOf("day"); 23806 if (cellDate.isAfter(eventEnd)) { 23807 return 0; 23808 } 23809 return eventEnd.diff(cellDate, "days") + 1; 23810 }, 23811 return_number_of_days_for_event( event ){ 23812 let start = moment.tz(event.ts, this.timezone); 23813 let end = moment.tz(event.end_ts, this.timezone); 23814 //continue 23815 return end.diff(start, 'days') + 1; 23816 }, 23817 is_first_day_of_event(event) { 23818 // Compare the event start date with the cell's date 23819 const eventStart = moment.tz(event.ts, this.timezone).startOf("day"); 23820 const cellDate = moment.tz(this.date, this.timezone).startOf("day"); 23821 return eventStart.isSame(cellDate, "day"); 23822 }, 23823 clicked_event(event) { 23824 // Handle event click 23825 this.$emit("clicked_event", event); 23826 }, 23827 clicked_show_more_events() { 23828 // Handle "+ x more" click (e.g., show a modal or expand view
23828) 23829 this.$emit("clicked_show_more_events", this.date, this.day_of_week, this.events); 23830 }, 23831 should_hold_slot(slot, index) { 23832 const usedSlots = this.return_slots_in_use_for_week(slot, index); 23833 return usedSlots.includes(index); 23834 }, 23835 return_slots_in_use_for_week(slot, index) { 23836 const startOfWeek = moment.tz(this.date, this.timezone).startOf("week"); 23837 const endOfWeek = moment.tz(this.date, this.timezone).endOf("week"); 23838 23839 const slotsUsed = new Set(); 23840 23841 this.days_array.forEach(day => { 23842 const dayMoment = moment.tz(day.date, this.timezone).startOf("day"); 23843 23844 // Only consider days in the current week 23845 if (dayMoment.isBetween(startOfWeek, endOfWeek, null, '[]')) { 23846 if (Array.isArray(day.slots)) { 23847 day.slots.forEach(slotObj => { 23848 if (slotObj.event) { 23849 slotsUsed.add(slotObj.slot); 23850 } 23851 }); 23852 } 23853 } 23854 }); 23855 return Array.from(slotsUsed); 23856 }, 23857 // --- text color helpers --- 23858 computeLuminance(hex) { 23859 if (!hex) return 1; // treat as light 23860 const m = String(hex).trim(); 23861 let r, g, b; 23862 if (/^#?[0-9a-fA-F]{3}$/.test(m)) { 23863 const s = m.replace('#',''); 23864 r = parseInt(s[0] + s[0], 16); 23865 g = parseInt(s[1] + s[1], 16); 23866 b = parseInt(s[2] + s[2], 16); 23867 } else { 23868 const s = m.replace('#','').slice(0,6); 23869 r = parseInt(s.substr(0,2), 16); 23870 g = parseInt(s.substr(2,2), 16); 23871 b = parseInt(s.substr(4,2), 16); 23872 } 23873 const toLin = (c) => { 23874 c /= 255; 23875 return c <= 0.03928 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4); 23876 }; 23877 return 0.2126*toLin(r) + 0.7152*toLin(g) + 0.0722*toLin(b); // 0=dark, 1=light 23878 }, 23879 adaptiveTextColorFromColors(colors, lightText = '#ffffff', darkText = '#2c2e30') { 23880 const arr = (colors && colors.length) ? colors : []; 23881 if (!arr.length) { 23882 const defaultBg = '#4a56f8'; 23883 return this.computeLuminance(defaultBg) < 0.5 ? lightText : darkText; 23884 } 23885 let darkCount = 0; 23886 for (let i = 0; i < arr.length; i++) { 23887 const L = this.computeLuminance(arr[i]); 23888 if (!isNaN(L) && L < 0.5) darkCount++; 23889 } 23890 const mostlyDark = darkCount >= Math.ceil(arr.length / 2); 23891 return mostlyDark ? lightText : darkText; 23892 }, 23893 }, 23894 computed:{ 23895 is_today(){ 23896 return moment.tz(new Date(), this.timezone).isSame(moment.tz(this.date, this.timezone), 'day'); 23897 }, 23898 overflowCount() { 23899 // Calculate the number of hidden events 23900 let visible_events = 0; 23901 this.slots.forEach(slot => { 23902 if( slot.event ){ 23903 visible_events ++; 23904 } 23905 }); 23906 return this.events.length - visible_events; 23907 }, 23908 days_until_end_of_week(){ 23909 let start = moment.tz(this.date, this.timezone); 23910 let days_until_end_of_week = 7 - start.day(); 23911 return days_until_end_of_week; 23912 }, 23913 is_first_day_of_week(){ 23914 return moment.tz(this.date, this.timezone).day() === 0; 23915 }, 23916 }, 23917 }) 23918</script>
23918
23918<script type="text/html" id="template-booking-calendar-day-column"> 23919 23920 <div class="flexGrowOne"> 23921 <!-- Time Slots Section --> 23922 <div class="time-slots"> 23923 <!-- all day --> 23924 <div 23925 v-if="view_preference == 'Day'" 23926 class="time-slot" 23927 style="height:auto;" 23928 > 23929 <div class="time-label">All day</div> 23930 <div 23931 class="time-content" 23932 style="display: flex;flex-direction: column;gap: 4px; padding: 8px;" 23933 > 23934 <!-- Filter and position events for this hour --> 23935 23936 <div v-for="(event, index) in all_day_events" :key="index" class="event all_day"> 23937 <div 23938 class="event-title-v2 flexNoWrap align-items-center easeSuperFast" 23939 :style="all_day_event_style(event)" 23940 @click="clicked_event(event)" 23941 > 23942 {{ event.title }} 23943 </div> 23944 </div> 23945 23946 </div> 23947 </div> 23948 <!-- hours --> 23949 <div 23950 v-for="hour in 24" 23951 :key="hour" 23952 class="time-slot pointer" 23953 @click="add_event( hour - 1 )" 23954 > 23955 <div 23956 class="time-label" 23957 v-if="view_preference == 'Day'" 23958 >{{ formatHour( hour - 1 ) }}</div> 23959 <div class="time-content"> 23960 <!-- Filter and position events for this hour --> 23961 <div 23962 v-for="(event, index) in filterEventsForHour(hour - 1)" 23963 :key="index" 23964 class="event time-slot-event" 23965 :style="calculateEventStyle(event, index)" 23966 @click.stop="clicked_event( event )" 23967 > 23968 <div class="event-title-v2" style="white-space: initial;">{{ event.title }}</div> 23969 </div> 23970 </div> 23971 </div> 23972 </div> 23973 </div> 23974 23975</script>
23975 23976 23977<style scoped> 23978 23979 .event.time-slot-event { 23980 position: absolute; 23981 left: 5px; /* Align slightly inside the slot */ 23982 right: 5px; 23983 color: white; 23984 border-radius: 4px; 23985 font-size: 12px; 23986 font-weight: 500; 23987 overflow: hidden; 23988 white-space: nowrap; 23989 text-overflow: ellipsis; 23990 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); 23991 z-index: 1; 23992 padding-top: 0px; 23993 padding-bottom: 0px; 23994 padding-left: 8px; 23995 padding-right: 8px; 23996 cursor: pointer; 23997 background-color: #5774f5; /* Primary color */ 23998 } 23999 24000</style> 24001
24002<script> 24003 24004 /* ----- COMPONENT ----- */ 24005 app_content.component("booking-calendar-day-column", { 24006 template: "#template-booking-calendar-day-column", 24007 data() { 24008 return {} 24009 }, 24010 props: { 24011 date_components: Object, 24012 events: Array, 24013 timezone: String, 24014 view_preference: String 24015 }, 24016 mounted(){ 24017 }, 24018 methods: { 24019 add_event( hour ){ 24020 let date_components = {... this.date_components}; 24021 date_components.hour = hour; 24022 date_components.date = this.date_components.day; 24023 this.$emit('add_event', date_components); 24024 }, 24025 clicked_event( event ){ 24026 this.$emit('clicked_event', event); 24027 }, 24028 formatHour(hour) { 24029 const baseDate = moment().startOf('day').hour(hour); 24030 return baseDate.format("h:mm A"); 24031 }, 24032 filterEventsForHour(hour) { 24033 const filtered = this.events.filter((event) => { 24034 if (event.isAllDay) return false; 24035 const eventStart = moment(event.ts).tz(this.timezone); 24036 return eventStart.hour() === hour; 24037 }); 24038 const count = filtered.length; 24039 return filtered.map(event => ({ 24040 ...event, 24041 countForHour: count 24042 })); 24043 }, 24044 all_day_event_style(event){ 24045 return { 24046 "background": event.bgColors && event.bgColors.length > 0 24047 ? `repeating-linear-gradient( 24048 -45deg, 24049 ${event.bgColors.map((color, i) => `${color} ${i * 20}px, ${color} ${(i + 1) * 20}px`).join(",")} 24050 )` 24051 : event.bgColor || "", 24052 }; 24053 }, 24054 calculateEventStyle(event, i) { 24055 const eventStart = moment( event.ts ).tz(this.timezone); 24056 const eventEnd = moment(event.end_ts).tz(this.timezone); 24057 const count = event.countForHour; 24058 24059 // Calculate start and end as a percentage of the hour 24060 const startMinutes = eventStart.minutes(); 24061 const endMinutes = eventEnd.minutes(); 24062 const duration = eventEnd.diff(eventStart, "minutes"); 24063 24064 return { 24065 top: `${(startMinutes / 60) * 100}%`, // Start position 24066 height: `calc(${(duration / 60) * 100}% - 3px)`, // Subtracts 3px for padding 24067 width: `calc(${(1/count) * 100}% - 3px)`, // Subtracts 3px for padding 24068 'margin-left': `calc(${(i/count) * 100}%)`, 24069 'padding-left': '0px', 24070 'padding-right': '0px', 24071 "background": event.bgColors && event.bgColors.length > 0 24072 ? `repeating-linear-gradient( 24073 -45deg, 24074 ${event.bgColors.map((color, i) => `${color} ${i * 20}px, ${color} ${(i + 1) * 20}px`).join(",")} 24075 )` 24076 : event.bgColor || "", 24077 }; 24078 }, 24079 }, 24080 computed:{ 24081 all_day_events(){ 24082 let all_day = this.events.filter( event => event.isAllDay === true ); 24083 return all_day; 24084 }, 24085 }, 24086 }) 24087</script>
24087 24088 24089 24090 24091 24092 24093 24094 <!-- native tab bar --> 24095 <!DOCTYPE html> 24096<html lang="en"> 24097
24097<script type="text/html" id="template-bottom-sheet-overlay"> 24098 24099 <div 24100 class="v-overlay scrollable hugePaddingBottom active" 24101 @touchstart.self="touched_overlay()" 24102 @mousedown.self="touched_overlay()" 24103 @click.self="clicked_overlay()" 24104 :class="[{'noPointer': prevent_close}, {'bottom-sheet-blur': blur_background}]" 24105 > 24106 <div 24107 class="standardOverlayCard centeredBlock bottom-sheet" 24108 :class="[ 24109 {'short': height == 'short' || is_short}, 24110 {'narrow': narrow}, 24111 {'noMaxWidth': full_width} 24112 ]" 24113 > 24114 24115 <!-- generic header content --> 24116 <div class="bottom-sheet-overlay-header"> 24117 24118 <div 24119 v-if="! prevent_close" 24120 class="relative" 24121 > 24122 <div 24123 class="overlay-header-handle-wrap centeredBlock" 24124 @click.stop="toggle_height()" 24125 > 24126 <div class="overlay-header-handle"></div> 24127 </div> 24128 <i 24129 class="overlay-x-button fa-regular fa-xmark pointer" 24130 @click.stop="dismiss_overlay()" 24131 ></i> 24132 </div> 24133 24134 <!-- header content injected here --> 24135 <slot name="header"></slot> 24136 24137 </div> 24138 24139 <!-- body content injected here --> 24140 <slot name="body"></slot> 24141 24142 <!-- footer content injected here --> 24143 <slot name="footer"></slot> 24144 24145 </div> 24146 </div> 24147 24148</script>
24148 24149 24150<style> 24151 24152 .standardOverlayCard.bottom-sheet.narrow{ 24153 max-width: 600px; 24154 } 24155 24156 .standardOverlayCard.bottom-sheet.medium{ 24157 max-width: 800px; 24158 } 24159 24160 .standardOverlayCard.bottom-sheet{ 24161 position: fixed; 24162 bottom: 0px; 24163 top: 50px; 24164 left: 0px; 24165 right: 0px; 24166 z-index: 1001; 24167 background-color: #fff; 24168 box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); 24169 transition: bottom 0.3s cubic-bezier(0.25, 0.1, 0.25, 1); 24170 border-top-left-radius: 10px; 24171 border-top-right-radius: 10px; 24172 padding-bottom: 10px; 24173 padding-top: 10px; 24174 max-width: 1100px; 24175 margin-top: 0px; 24176 border-bottom-left-radius: 0px; 24177 border-bottom-right-radius: 0px; 24178 } 24179 24180 .standardOverlayCard.bottom-sheet.short{ 24181 top: 50%; 24182 } 24183 24184 @media (max-height: 600px) { 24185 24186 .standardOverlayCard.bottom-sheet.short{ 24187 top: 150px; 24188 } 24189 24190 } 24191 24192 .standardOverlayCard.bottom-sheet h3{ 24193 padding-top:0px; 24194 } 24195 24196 .standardOverlayCard.bottom-sheet{ 24197 display: flex; 24198 flex-direction: column; 24199 padding-bottom:0px; 24200 padding-top: 0px; 24201 } 24202 24203 .standardOverlayCard.bottom-sheet .bottom-sheet-overlay-card-header 24204 { 24205 flex: 0 0 auto; /* Fixed height, doesn't grow or shrink */ 24206 } 24207 24208 .standardOverlayCard.bottom-sheet .standardCardFooter{ 24209 position: absolute; 24210 bottom: 0px; 24211 padding-bottom: var(--cs-safe-area-bottom, 10px); 24212 left: 0px; 24213 right: 0px; 24214 background-color: #fff; 24215 border-top: 1px solid #e4ebf1; 24216 z-index: 10; 24217 } 24218 24219 .standardOverlayCard.bottom-sheet .standardCardBody{ 24220 flex: 1 1 auto; /* Flexible height, can grow and shrink */ 24221 padding-bottom: 90px !important; 24222 } 24223 24224 .overlay-header-handle-wrap{ 24225 padding: 10px; 24226 cursor: pointer; 24227 max-width: 120px; 24228 } 24229 .overlay-header-handle{ 24230 width: 70px; 24231 height: 3px; 24232 border-radius: 10px; 24233 background-color: #acb3ba; 24234 margin-left: auto; 24235 margin-right: auto; 24236 margin-top: 8px; 24237 margin-bottom: 8px; 24238 } 24239 .overlay-x-button{ 24240 position: absolute; 24241 right: 14px; 24242 color: #535e6c; 24243 font-size: 20px; 24244 top: 12px; 24245 } 24246</style> 24247
24248<script> 24249 24250 //define the component 24251 app_overlays.component("bottom-sheet-overlay", { 24252 template: "#template-bottom-sheet-overlay", 24253 data() { 24254 return { 24255 height: "tall" 24256 } 24257 }, 24258 props: { 24259 blur_background: { 24260 type: Boolean, 24261 default: false 24262 }, 24263 narrow: { 24264 type: Boolean, 24265 default: false 24266 }, 24267 prevent_close: { 24268 type: Boolean, 24269 default: false 24270 }, 24271 full_width: { 24272 type: Boolean, 24273 default: false 24274 }, 24275 is_short: { 24276 type: Boolean, 24277 default: false 24278 } 24279 }, 24280 mounted(){ 24281 const universalEditOverlay = document.getElementById('overlay_universal-edit'); 24282 if (universalEditOverlay) { 24283 universalEditOverlay.style.zIndex = "1000000"; 24284 } 24285 const membershipValidationOverlay = document.getElementById('overlay_verifyMembership'); 24286 if (membershipValidationOverlay) { 24287 membershipValidationOverlay.style.zIndex = "1000000"; 24288 } 24289 }, 24290 beforeUnmount() { 24291 //reset the z-index 24292 const universalEditOverlay = document.getElementById('overlay_universal-edit'); 24293 if (universalEditOverlay) { 24294 universalEditOverlay.style.zIndex = ""; // Revert to default 24295 } 24296 const membershipValidationOverlay = document.getElementById('overlay_verifyMembership'); 24297 if (membershipValidationOverlay) { 24298 membershipValidationOverlay.style.zIndex = ""; // Revert to default 24299 } 24300 }, 24301 methods: { 24302 dismiss_overlay(){ 24303 this.$emit('dismiss_overlay'); 24304 }, 24305 toggle_height(){ 24306 if( this.height === "tall" ){ 24307 this.height = "short"; 24308 }else{ 24309 this.height = "tall"; 24310 } 24311 }, 24312 clicked_overlay(){ 24313 if( this.prevent_close ){ 24314 return; 24315 } 24316 if (should_close_overlay(this.last_touch)) { 24317 this.dismiss_overlay(); 24318 } 24319 }, 24320 touched_overlay(){ 24321 this.last_touch = new Date().getTime(); 24322 }, 24323 }, 24324 }); 24325 24326 24327</script>
24327 24328 24329<style> 24330 .bottom-sheet-blur{ 24331 backdrop-filter: blur(6px); 24332 } 24333 24334 @keyframes bottom-sheet-fade-in { 24335 from { opacity: 0; } 24336 to { opacity: 1; } 24337 } 24338</style>
24338<script type="text/html" id="template-show-push-notification"> 24339 24340 <bottom-sheet-overlay 24341 @dismiss_overlay="dismiss_overlay()" 24342 > 24343 24344 <!-- header content --> 24345 <template #header> 24346 <div class="flexNoWrap"> 24347 <h3 24348 class="flexGrowOne" 24349 style="padding-bottom:0px;" 24350 > 24351 <p>{{ title }}</p> 24352 <template v-if="has_date"> 24353 <p 24354 class="card-header-subtext pointer" 24355 >{{ date_string }}</p> 24356 </template> 24357 </h3> 24358 </div> 24359 </template> 24360 24361 <!-- body content --> 24362 <template #body> 24363 <div 24364 class="standardCardBody fff scrollable" 24365 > 24366 <p class="p-1" style="margin: 0px 8px;">{{ notification.get('data').alert }}</p> 24367 </div> 24368 </template> 24369 24370 <!-- footer content --> 24371 <template 24372 #footer 24373 > 24374 <div 24375 class="standardCardFooter flexNoWrap" 24376 > 24377 <div class="flexGrowOne"></div> 24378 <button class="marketing-header-button blue easeSuperFast flexWrapLeftAlign align-items-center" @click="dismiss_overlay()" >Done</button> 24379 </div> 24380 </template> 24381 24382 </bottom-sheet-overlay> 24383 24384</script>
24384 24385
24386<script> 24387 24388 function show_push_notification( params ){ 24389 store.components.show_push_notification.notification = params.notification; 24390 store.overlays.show_push_notification = true; 24391 } 24392 24393 //add a value to our global store, specific to this component 24394 store.components["show_push_notification"] = { 24395 notification: null 24396 } 24397 24398 //define the component 24399 app_overlays.component("show-push-notification", { 24400 template: "#template-show-push-notification", 24401 data() { 24402 return store.components["show_push_notification"] 24403 }, 24404 methods: { 24405 dismiss_overlay(){ 24406 store.overlays.show_push_notification = false; 24407 } 24408 }, 24409 computed: { 24410 title(){ 24411 let title = this.notification.get('data').title; 24412 if (title && title.length > 0){ 24413 return title; 24414 } 24415 return "Notification"; 24416 }, 24417 has_date(){ 24418 return this.notification.createdAt !== undefined; 24419 }, 24420 date_string(){ 24421 const createdAt = this.notification.createdAt; 24422 24423 if (! createdAt){ 24424 return createdAt; 24425 } 24426 24427 if ( is_utc_date_today( createdAt ) ){ 24428 return format_utc_date_v2({ 24429 "utcDate": createdAt, 24430 "formatString": "[Today at] h:mm A" 24431 }); 24432 } else { 24433 return format_utc_date_v2({ 24434 "utcDate": createdAt, 24435 "formatString": "ddd M/D h:mm A" 24436 }); 24437 } 24438 } 24439 } 24440 }); 24441 24442</script>
24442
24442<script type="text/html" id="template-native-tab-bar"> 24443 24444 <!-- first, make sure we're in the react native member app --> 24445 <template v-if="is_member_portal || is_react_native_member_app || is_admin_preview"> 24446 24447 <!-- ââ Floating glass tab bar âââââââââââ --> 24448 <div 24449 v-if="show_tab_bar && use_floating_tab_bar" 24450 class="mp-float-tab-bar" 24451 :class="{ 24452 'mp-float-tab-bar--preview': is_admin_preview, 24453 'mp-float-tab-bar--settings-preview': settings_preview_mode 24454 }" 24455 > 24456 <!-- Single sliding pill â moves between tabs via CSS transition --> 24457 <div class="mp-float-tab-pill" :style="float_pill_style"></div> 24458 24459 <template v-if="visible_tabs"> 24460 <div 24461 v-for="tab in visible_tabs" 24462 :key="tab.key" 24463 class="mp-float-tab-item" 24464 :class="{ 'mp-float-tab-item--active': tab_is_active(tab) }" 24465 @touchstart.prevent="tapped_tab_bar_item(tab)" 24466 @click="tapped_tab_bar_item(tab)" 24467 > 24468 <template v-if="show_member_details_avatar(tab)"> 24469 <span 24470 class="mp-float-tab-avatar" 24471 :class="{ 'mp-float-tab-avatar--active': tab_is_active(tab) }" 24472 :style="{ backgroundImage: 'url(' + member_tab_avatar_url + ')' }" 24473 ></span> 24474 </template> 24475 <template v-else> 24476 <i class="mp-float-tab-icon" :class="float_icon_class(tab, tab_is_active(tab))"></i> 24477 </template> 24478 </div> 24479 <!-- More item --> 24480 <div 24481 class="mp-float-tab-item" 24482 :class="{ 'mp-float-tab-item--active': more_is_active }" 24483 @touchstart.prevent="tapped_more()" 24484 @click="tapped_more()" 24485 > 24486 <i :class="(more_is_active ? 'fa-solid' : 'fa-regular') + ' fa-ellipsis mp-float-tab-icon'"></i> 24487 </div> 24488 </template> 24489 </div> 24490 24491 <!-- Classic tab bar âââââââââââââââ --> 24492 <div 24493 v-else-if="show_tab_bar" 24494 class="native-tab-bar-outer flexNoWrap" 24495 :style="[{ 24496 backgroundColor: tab_bar_colors.background || 'rgba(249, 251, 251, 1)' 24497 }, { 24498 backdropFilter: tab_bar_colors.blur ? 'blur(' + tab_bar_colors.blur + ')' : 'none' 24499 }]" 24500 :class="{ 'noBorder': tab_bar_colors.background, 'preview': is_admin_preview }" 24501 > 24502 <template v-if="visible_tabs"> 24503 <div 24504 v-for="tab in visible_tabs" 24505 :key="tab.key" 24506 class="native-tab-bar-item" 24507 :class="{ active : tab_is_active( tab ) }" 24508 :style=" tab_is_active( tab ) ? { color: tab_bar_colors.active || '#4a56f8' } : { color: tab_bar_colors.text || '#697684' } " 24509 @touchstart.prevent="tapped_tab_bar_item( tab )" 24510 @click="tapped_tab_bar_item( tab )" 24511 > 24512 <div class="centeredText centeredBlock"> 24513 <p> 24514 <template v-if="show_member_details_avatar(tab)"> 24515 <span 24516 class="native-tab-bar-avatar" 24517 :style="{ backgroundImage: 'url(' + member_tab_avatar_url + ')' }" 24518 ></span> 24519 </template> 24520 <template v-else> 24521 <i class="native-tab-bar-icon" :class="tab.icon_class"></i> 24522 </template> 24523 </p> 24524 <p class="native-tab-bar-label">{{ tab.label }}</p> 24525 </div> 24526 </div> 24527 <div 24528 class="native-tab-bar-item" 24529 @touchstart.prevent="tapped_more()" 24530 @click="tapped_more()" 24531 :class="{ 'active': more_is_active }" 24532 :style=" more_is_active ? { color: tab_bar_colors.active || '#4a56f8' } : { color: tab_bar_colors.text || '#697684' } " 24533 > 24534 <div class="centeredText centeredBlock"> 24535 <p><i class="fa-regular fa-ellipsis native-tab-bar-icon"></i></p> 24536 <p class="native-tab-bar-label">More</p> 24537 </div> 24538 </div> 24539 </template> 24540 </div> 24541 24542 <!-- compact more-section tab bar (floating) --> 24543 <div 24544 v-else-if="show_more_section_tab_bar && use_floating_tab_bar" 24545 class="mp-float-tab-bar" 24546 > 24547 <div class="mp-float-tab-pill" :style="compact_float_pill_style"></div> 24548 <div 24549 class="mp-float-tab-item"
24550 @touchstart.prevent="tapped_back_immediate()" 24551 @click="tapped_back_immediate()" 24552 > 24553 <i class="fa-regular fa-chevron-left mp-float-tab-icon"></i> 24554 </div> 24555 <div 24556 class="mp-float-tab-item" 24557 @touchstart.prevent="tapped_home()" 24558 @click="tapped_home()" 24559 > 24560 <i class="mp-float-tab-icon" :class="compact_home_icon_class"></i> 24561 </div> 24562 <div 24563 class="mp-float-tab-item mp-float-tab-item--active" 24564 @touchstart.prevent="tapped_tab_bar_item(compact_selected_tab)" 24565 @click="tapped_tab_bar_item(compact_selected_tab)" 24566 > 24567 <template v-if="show_member_details_avatar(compact_selected_tab)"> 24568 <span 24569 class="mp-float-tab-avatar mp-float-tab-avatar--active" 24570 :style="{ backgroundImage: 'url(' + member_tab_avatar_url + ')' }" 24571 ></span> 24572 </template> 24573 <template v-else> 24574 <i class="mp-float-tab-icon" :class="compact_selected_tab_icon_class"></i> 24575 </template> 24576 </div> 24577 <div 24578 class="mp-float-tab-item" 24579 @touchstart.prevent="tapped_more()" 24580 @click="tapped_more()" 24581 > 24582 <i class="fa-regular fa-ellipsis mp-float-tab-icon"></i> 24583 </div> 24584 </div> 24585 24586 <!-- compact more-section tab bar (classic) --> 24587 <div 24588 v-else-if="show_more_section_tab_bar" 24589 class="native-tab-bar-outer flexNoWrap" 24590 :style="[{ 24591 backgroundColor: tab_bar_colors.background || 'rgba(249, 251, 251, 1)' 24592 }, { 24593 backdropFilter: tab_bar_colors.blur ? 'blur(' + tab_bar_colors.blur + ')' : 'none' 24594 }]" 24595 :class="{ 'noBorder': tab_bar_colors.background, 'preview': is_admin_preview }" 24596 > 24597 <div 24598 class="native-tab-bar-item" 24599 :style="{ color: tab_bar_colors.text || '#697684' }" 24600 @touchstart.prevent="tapped_back_immediate()" 24601 @click="tapped_back_immediate()" 24602 > 24603 <div class="centeredText centeredBlock"> 24604 <p><i class="fa-regular fa-chevron-left native-tab-bar-icon"></i></p> 24605 <p class="native-tab-bar-label">Back</p> 24606 </div> 24607 </div> 24608 <div 24609 class="native-tab-bar-item" 24610 :style="{ color: tab_bar_colors.text || '#697684' }" 24611 @touchstart.prevent="tapped_home()" 24612 @click="tapped_home()" 24613 > 24614 <div class="centeredText centeredBlock"> 24615 <p><i class="native-tab-bar-icon" :class="compact_home_icon_class"></i></p> 24616 <p class="native-tab-bar-label">{{ compact_home_tab_label }}</p> 24617 </div> 24618 </div> 24619 <div 24620 class="native-tab-bar-item active" 24621 :style="{ color: tab_bar_colors.active || '#4a56f8' }" 24622 @touchstart.prevent="tapped_tab_bar_item(compact_selected_tab)" 24623 @click="tapped_tab_bar_item(compact_selected_tab)" 24624 > 24625 <div class="centeredText centeredBlock"> 24626 <p> 24627 <template v-if="show_member_details_avatar(compact_selected_tab)"> 24628 <span 24629 class="native-tab-bar-avatar" 24630 :style="{ backgroundImage: 'url(' + member_tab_avatar_url + ')' }" 24631 ></span> 24632 </template> 24633 <template v-else> 24634 <i class="native-tab-bar-icon" :class="compact_selected_tab_icon_class"></i> 24635 </template> 24636 </p> 24637 <p class="native-tab-bar-label">{{ compact_selected_tab.label }}</p> 24638 </div> 24639 </div> 24640 <div 24641 class="native-tab-bar-item" 24642 :style="{ color: tab_bar_colors.text || '#697684' }" 24643 @touchstart.prevent="tapped_more()" 24644 @click="tapped_more()" 24645 > 24646 <div class="centeredText centeredBlock"> 24647 <p><i class="fa-regular fa-ellipsis native-tab-bar-icon"></i></p> 24648 <p class="native-tab-bar-label">More</p> 24649 </div> 24650 </div> 24651 </div> 24652 24653 <!-- back button (global) --> 24654 <div v-else-if="show_back_button"> 24655 24656 <!-- overlay --> 24657 <div 24658 class="native-tab-bar-back-button-overlay pointer" 24659 @touchstart.self.prevent="back_button_expanded = false" 24660 @click.self="back_button_expanded = false" 24661 :class="{ 'active': back_button_expanded }" 24662 ></div> 24663 24664 <!-- home button --> 24665 <div 24666 class="native-tab-bar-floating-home-button boxShadowBottom moreBlur pointer easeFast" 24667 @click="tapped_home()" 24668 :class="{ 'active': back_button_expanded }" 24669 > 24670 <div class="centeredText centeredBlock"> 24671 <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> 24672 </div> 24673 </div> 24674 24675 <!-- back button --> 24676 <div 24677 class="native-tab-bar-floating-back-button boxShadowBottom moreBlur pointer easeFast" 24678 @click="tapped_back()" 24679 :class="{ 'expanded': back_button_expanded }" 24680 > 24681 <div class="centeredText centeredBlock"> 24682 <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> 24683 </div> 24684 </div> 24685 24686 </div> 24687 24688 </template> 24689 24690</script>
24690 24691 24692 24693<style> 24694 24695 .native-tab-bar-outer{ 24696 position: fixed; 24697 bottom: 0px; 24698 padding-bottom: max(0px, calc(var(--cs-safe-area-bottom, 0px) - 10px)); 24699 left: 0px; 24700 right: 0px; 24701 background-color: rgba(249, 251, 251, 1); 24702 border-top: 1px solid #e4ebf1; 24703 z-index: 1000; 24704 transition: 0.15s cubic-bezier(0.25, 0.1, 0.25, 1); 24705 } 24706 24707 .native-tab-bar-padding-bottom { 24708 padding-bottom: var(--native-tab-bar-padding-bottom, 0px); 24709 } 24710 24711 /* In admin preview mode this is just a sample, so keep z-index low and let real UI layers sit above it. */ 24712 .native-tab-bar-outer.preview{ 24713 position: relative; 24714 z-index: 1; 24715 } 24716 24717 .native-tab-bar-item{ 24718 padding: 16px 11px; 24719 display: flex; 24720 flex: 1 1 0; /* flex-grow: 1; flex-shrink: 1; flex-basis: 0; */ 24721 color: #697684; 24722 cursor: pointer; 24723 } 24724 .native-tab-bar-item.active{ 24725 color: #4a56f8; 24726 } 24727 .native-tab-bar-label{ 24728 font-size: 12px; 24729 padding-top: 4px; 24730 white-space: nowrap; 24731 } 24732 .native-tab-bar-icon{ 24733 font-size: 22px; 24734 } 24735 .native-tab-bar-avatar{ 24736 width: 23px; 24737 height: 23px; 24738 border-radius: 100%; 24739 background-size: cover; 24740 background-position: center center; 24741 background-repeat: no-repeat; 24742 display: inline-block; 24743 vertical-align: middle; 24744 } 24745 24746 /*------ back button -------*/ 24747 24748 .native-tab-bar-back-button-overlay{ 24749 position: fixed; 24750 top: 0; 24751 left: 0; 24752 width: 100%; 24753 height: 100%; 24754 /* 24755 background-color: rgba(111,117,122,0.5); 24756 */ 24757 background-color: rgba(249, 251, 251, 0.8); 24758 opacity: 0; 24759 visibility: hidden; 24760 transition: opacity 0.15s ease; 24761 z-index: 999; 24762 } 24763 24764 .native-tab-bar-back-button-overlay.active{ 24765 opacity: 1; 24766 visibility: visible; 24767 } 24768 24769 .native-tab-bar-floating-back-button{ 24770 position: fixed; 24771 bottom: 22px; 24772 left: 22px; 24773 right: auto; 24774 z-index: 1000; 24775 transition: 0.15s cubic-bezier(0.25, 0.1, 0.25, 1); 24776 border-radius: 100%; 24777 width: 50px; 24778 height: 50px; 24779 background-color: rgba(255, 255, 255, 0.28); 24780 backdrop-filter: blur(15px); 24781 -webkit-backdrop-filter: blur(15px); 24782 line-height: 50px; 24783 font-size: 16px; 24784 white-space: nowrap; 24785 min-width: 50px; 24786 } 24787 24788 .native-tab-bar-floating-back-button.expanded{ 24789 border-radius: 25px; 24790 padding-left: 16px; 24791 padding-right: 16px; 24792 width: auto; 24793 } 24794 24795 /*------ home button -------*/ 24796 24797 .native-tab-bar-floating-home-button.active{ 24798 border-radius: 25px; 24799 padding-left: 16px; 24800 padding-right: 16px; 24801 width: auto; 24802 visibility: visible; 24803 opacity: 1; 24804 animation: expand 0.15s cubic-bezier(0.25, 0.1, 0.25, 1) forwards; 24805 background-color: #4a56f8; 24806 color: #fff; 24807 } 24808 24809 .native-tab-bar-floating-home-button{ 24810 position: fixed; 24811 bottom: 82px; 24812 left: 22px; 24813 right: auto; 24814 visibility: hidden; 24815 opacity: 0; 24816 background-color: rgba(249, 251, 251, 1); 24817 /* border-top: 1px solid #e4ebf1; */ 24818 z-index: 1000; 24819 transition: 0.15s cubic-bezier(0.25, 0.1, 0.25, 1); 24820 border-radius: 20px; 24821 width: auto; 24822 height: 50px; 24823 background-color: #fff; 24824 line-height: 50px; 24825 font-size: 16px; 24826 white-space: nowrap; 24827 24828 /* Initial state for animation */ 24829 transform: scale(0); 24830 animation: none; 24831 } 24832 24833 /* Keyframes for the expansion effect */ 24834 @keyframes expand { 24835 from { 24836 transform: scale(0); 24837 opacity: 0; 24838 } 24839 to { 24840 transform: scale(1); 24841 opacity: 1; 24842 } 24843 } 24844 24845 /* ââ Floating glass tab bar (club XVgOrNASDY only) âââââââââââââââââââ */ 24846 24847 .mp-float-tab-bar { 24848 position: fixed; 24849 /* sit above the safe-area bottom edge with a small gap */ 24850 bottom: max(12px, var(--cs-safe-area-bottom, 12px)); 24851 /* center the pill horizontally; max-width keeps it compact on tablets */ 24852 left: 0; 24853 right: 0; 24854 margin-left: auto; 24855 margin-right: auto; 24856 max-width: 360px; 24857 width: calc(100% - 48px); 24858 /* light frosted glass pill */ 24859 border-radius: 30px; 24860 background: rgba(255, 255, 255, 0.28); 24861 backdrop-filter: blur(15px); 24862 -webkit-backdrop-filter: blur(15px); 24863 border: 1px solid rgba(0, 0, 0, 0.06); 24864 box-shadow: 0 4px 28px rgba(0, 0, 0, 0.12); 24865 /* layout */ 24866 display: flex; 24867 align-items: center; 24868 z-index: 1000; 24869 overflow: hidden; 24870 } 24871 24872 .mp-float-tab-bar--preview { 24873 position: relative; 24874 bottom: auto; 24875 margin: 12px auto; 24876 /* In preview mode we do not want the production-level z-index to overlap the left menu peek. */ 24877 z-index: 1; 24878 } 24879 24880 .mp-float-tab-bar--settings-preview { 24881 margin: 0; 24882 } 24883 24884 .mp-float-tab-item { 24885 flex: 1 1 0; 24886 display: flex; 24887 align-items: center; 24888 justify-content: center; 24889 position: relative; 24890 padding: 18px 0; 24891 cursor: pointer; 24892 /* tap highlight off */ 24893 -webkit-tap-highlight-color: transparent; 24894 } 24895 24896 /* Single sliding active pill â left and width are driven by float_pill_style computed */ 24897 .mp-float-tab-pill { 24898 position: absolute; 24899 top: 7px; 24900 bottom: 7px; 24901 /* left + width set inline; transition slides the pill between tabs */ 24902 border-radius: 22px; 24903 background: rgba(0, 0, 0, 0.08); 24904 pointer-events: none; 24905 transition: left 0.28s cubic-bezier(0.25, 0.1, 0.25, 1), 24906 width 0.28s cubic-bezier(0.25, 0.1, 0.25, 1); 24907 } 24908 24909 .mp-float-tab-icon { 24910 font-size: 22px; 24911 color: rgba(0, 0, 0, 0.7); 24912 position: relative; /* above the pill */ 24913 z-index: 1; 24914 } 24915 24916 .mp-float-tab-avatar { 24917 width: 26px;
24918 height: 26px; 24919 border-radius: 50%; 24920 background-size: cover; 24921 background-position: center; 24922 background-repeat: no-repeat; 24923 display: inline-block; 24924 position: relative; 24925 z-index: 1; 24926 opacity: 1; 24927 box-shadow: 0 0 0 2px transparent; 24928 transition: box-shadow 0.2s ease; 24929 } 24930 24931 .mp-float-tab-avatar--active { 24932 box-shadow: 0 0 0 2px #fff; 24933 } 24934 24935</style> 24936
24937<script> 24938 24939 let native_tab_bar_config = { 24940 template: "#template-native-tab-bar", 24941 data() { 24942 return { 24943 back_button_expanded: false 24944 } 24945 }, 24946 props: { 24947 primary_view: String, 24948 tabs: Array, 24949 hide_back_button: Boolean, 24950 is_admin_preview: { 24951 type: Boolean, 24952 default: false 24953 }, 24954 preview_colors: { 24955 type: Object, 24956 default: null 24957 }, 24958 preview_tab_bar_style: { 24959 type: String, 24960 default: null 24961 }, 24962 member_portal_config: { 24963 type: Object, 24964 default: null 24965 }, 24966 settings_preview_mode: { 24967 type: Boolean, 24968 default: false 24969 }, 24970 is_member_portal: { 24971 type: Boolean, 24972 default: false 24973 } 24974 }, 24975 mounted(){ 24976 console.log("Mounted native tab bar"); 24977 }, 24978 methods: { 24979 tab_is_active( tab ){ 24980 if( this.primary_view ){ 24981 return tab.key === this.primary_view; 24982 } 24983 return false; 24984 }, 24985 tapped_home(){ 24986 if(this.is_admin_preview){ 24987 return; 24988 } 24989 if( this.is_member_portal ){ 24990 this.back_button_expanded = false; 24991 updateURL('home'); 24992 displayViewForUrlPath(); 24993 }else{ 24994 window.location.href = '/account/home'; 24995 } 24996 }, 24997 tapped_more(){ 24998 if(this.is_admin_preview){ 24999 return; 25000 } 25001 toggle_left_menu(); 25002 }, 25003 tapped_tab_bar_item( tab ){ 25004 if(this.is_admin_preview){ 25005 return; 25006 } 25007 if( tab && tab.type === 'custom' && tab.link ){ 25008 window.location.href = tab.link; 25009 return; 25010 } 25011 updateURL( tab.key ); 25012 displayViewForUrlPath(); 25013 }, 25014 tapped_back(){ 25015 if(this.is_admin_preview){ 25016 return; 25017 } 25018 if( this.back_button_expanded ){ 25019 this.back_button_expanded = false; 25020 window.history.back(); 25021 } else { 25022 this.back_button_expanded = true; 25023 } 25024 }, 25025 tapped_back_immediate(){ 25026 if(this.is_admin_preview){ 25027 return; 25028 } 25029 window.history.back(); 25030 }, 25031 float_icon_class( tab, is_active ){ 25032 if (!tab || !tab.icon_class) { 25033 return ''; 25034 } 25035 if (is_active) { 25036 return tab.icon_class.replace('fa-regular', 'fa-solid'); 25037 } 25038 return tab.icon_class; 25039 }, 25040 show_member_details_avatar( tab ){ 25041 if( ! tab ){ 25042 return false; 25043 } 25044 //only show if its the account tab AND we have an avatar url 25045 return tab.key === "settings" && !!this.member_tab_avatar_url; 25046 }, 25047 }, 25048 computed: { 25049 member_tab_avatar_url(){ 25050 const current_user = Parse.User.current(); 25051 if( ! current_user ){ 25052 return null; 25053 } 25054 const member_object = current_user.get('memberObject'); 25055 if( member_object && member_object.get('profPicURL') ){ 25056 return member_object.get('profPicURL'); 25057 } 25058 if( current_user.get('profPicURL') ){ 25059 return current_user.get('profPicURL'); 25060 } 25061 return null; 25062 }, 25063 tab_bar_colors(){ 25064 if( this.preview_colors ){ 25065 return this.preview_colors; 25066 } 25067 //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) 25068 if( ! store.native_app_props || ! store.native_app_props.safeAreaInsets ){ 25069 if(!this.is_admin_preview){ 25070 return {}; 25071 } 25072 } 25073 //ok, it's a newer version of the app! 25074 if( store.clubObject && store.clubObject.get('web_settings').get('tab_bar_colors') ){ 25075 //validate the config 25076 let tab_bar_colors = store.clubObject.get('web_settings').get('tab_bar_colors'); 25077 if( 25078 tab_bar_colors.background && 25079 tab_bar_colors.text && 25080 tab_bar_colors.active 25081 ){ 25082 return tab_bar_colors; 25083 } 25084 } 25085 return {}; 25086 }, 25087 //we only show it when we're on one of the relevant primary views (contained in the tab bar) 25088 //.. otherwise we should display the back button bar 25089 tab_bar_keys(){ 25090 25091 /* 25092 var keys = []; 25093 if( this.visible_tabs ){
25094 this.visible_tabs.forEach(tab => { 25095 keys.push( tab.key ); 25096 }); 25097 } 25098 return keys; 25099 */ 25100 25101 let keys = []; 25102 if( this.tabs ){ 25103 this.tabs.forEach(tab => { 25104 keys.push( tab.key ); 25105 }); 25106 } 25107 return keys; 25108 25109 }, 25110 visible_tab_keys(){ 25111 let keys = []; 25112 if( this.visible_tabs ){ 25113 this.visible_tabs.forEach(tab => { 25114 keys.push( tab.key ); 25115 }); 25116 } 25117 return keys; 25118 }, 25119 more_is_active(){ 25120 if( ! this.tab_bar_keys.includes( this.primary_view ) ){ 25121 return false; 25122 } 25123 //it's active if not in the visible tabs 25124 if( this.visible_tab_keys.includes( this.primary_view ) ){ 25125 return false; 25126 } 25127 return true; 25128 }, 25129 show_more_section_tab_bar(){ 25130 if( ! this.is_member_portal ){ 25131 return false; 25132 } 25133 if( this.primary_view === 'booking' && this.is_inside_booking_flow ){ 25134 return false; 25135 } 25136 if( ! this.tab_bar_keys.includes( this.primary_view ) ){ 25137 return false; 25138 } 25139 if( this.visible_tab_keys.includes( this.primary_view ) ){ 25140 return false; 25141 } 25142 return true; 25143 }, 25144 show_tab_bar(){ 25145 //1) hide the tab bar if a member has drilled into one ofthe in-portal booking flows (golf, courts, simulators, 25146 if( this.primary_view === 'booking' && this.is_inside_booking_flow ){ 25147 return false; 25148 } 25149 //2) see if it's a visible tab, else don't show 25150 if( this.visible_tab_keys.includes( this.primary_view ) ){ 25151 return true; 25152 } 25153 return false; 25154 25155 25156 /* 25157 // hide the tab bar once a member has drilled into one of 25158 // the in-portal booking flows (golf, courts, simulators, 25159 // group-classes, dining, staff-services, lodging). The hub 25160 // landing page itself has secondary_view == 'default', so 25161 // we keep the tab bar visible there. 25162 if( this.primary_view === 'booking' && this.is_inside_booking_flow ){ 25163 return false; 25164 } 25165 for (let t = 0; t < this.tab_bar_keys.length; t++) { 25166 if( this.tab_bar_keys[t] === this.primary_view ){ 25167 return true 25168 } 25169 } 25170 return false; 25171 */ 25172 }, 25173 is_inside_booking_flow(){ 25174 let secondary = store.secondary_view; 25175 if( ! secondary || secondary === 'default' ){ 25176 return false; 25177 } 25178 return true; 25179 }, 25180 visible_tabs(){ 25181 if( this.tabs ){ 25182 return this.tabs.slice(0,4) 25183 } 25184 return []; 25185 }, 25186 show_back_button(){ 25187 if( this.hide_back_button ){ 25188 return false; 25189 } 25190 if( this.show_more_section_tab_bar ){ 25191 return false; 25192 } 25193 //if we're on the home screen, show the back button 25194 if( ! this.show_tab_bar && window.history.length >= 1 ){ 25195 return true; 25196 } 25197 return false; 25198 }, 25199 is_react_native_member_app(){ 25200 return store.is_react_native_member_app; 25201 }, 25202 use_floating_tab_bar(){ 25203 // In the admin preview, respect the dropdown selection immediately. 25204 if( this.is_admin_preview ){ 25205 return this.preview_tab_bar_style === 'modern'; 25206 } 25207 //end preview case 25208 if( this.member_portal_config ){ 25209 return this.member_portal_config.tab_bar_style === 'modern'; 25210 } 25211 return false;
25212 }, 25213 float_active_tab_index(){ 25214 if (!this.visible_tabs) { 25215 return 0; 25216 } 25217 for (let idx = 0; idx < this.visible_tabs.length; idx++) { 25218 if (this.tab_is_active(this.visible_tabs[idx])) { 25219 return idx; 25220 } 25221 } 25222 // "More" is the last slot 25223 return this.visible_tabs.length; 25224 }, 25225 float_pill_style(){ 25226 let total = this.visible_tabs ? this.visible_tabs.length + 1 : 5; 25227 let idx = this.float_active_tab_index; 25228 // Each slot is (100% / total) wide. The pill is inset 8px on each side. 25229 return { 25230 left: 'calc(' + idx + ' * (100% / ' + total + ') + 8px)', 25231 width: 'calc(100% / ' + total + ' - 16px)', 25232 }; 25233 }, 25234 compact_float_pill_style(){ 25235 // compact more-section bar is always 4 slots and selected tab is slot index 2 25236 let total = 4; 25237 let selected_tab_index = 2; 25238 return { 25239 left: 'calc(' + selected_tab_index + ' * (100% / ' + total + ') + 8px)', 25240 width: 'calc(100% / ' + total + ' - 16px)', 25241 }; 25242 }, 25243 compact_home_icon_class(){ 25244 let home_tab = this.tabs.find(tab => tab.key === 'home'); 25245 if( home_tab && home_tab.icon_class ){ 25246 return home_tab.icon_class; 25247 } 25248 return 'fa-regular fa-house'; 25249 }, 25250 compact_home_tab_label(){ 25251 let home_tab = this.tabs.find(tab => tab.key === 'home'); 25252 if( home_tab && home_tab.label ){ 25253 return home_tab.label; 25254 } 25255 return 'Home'; 25256 }, 25257 compact_selected_tab(){ 25258 let current_tab = this.tabs.find(tab => tab.key === this.primary_view); 25259 if( current_tab ){ 25260 return current_tab; 25261 } 25262 return { 25263 icon_class: 'fa-regular fa-circle', 25264 label: 'Tab', 25265 }; 25266 }, 25267 compact_selected_tab_icon_class(){ 25268 if( this.use_floating_tab_bar ){ 25269 return this.float_icon_class(this.compact_selected_tab, true); 25270 } 25271 if( this.compact_selected_tab && this.compact_selected_tab.icon_class ){ 25272 return this.compact_selected_tab.icon_class; 25273 } 25274 return 'fa-regular fa-circle'; 25275 }, 25276 }, 25277 } 25278 app_content.component("native-tab-bar-component", native_tab_bar_config ); 25279 app_overlays.component("native-tab-bar-component", native_tab_bar_config ); 25280 25281</script>
25281</html> 25282
25282<script> 25283 //after we've included all the relevant views, mount the vue apps 25284 app_content.mount('#v-content'); 25285 app_overlays.mount('#v-overlays'); 25286 25287 </script>
25287 25288 25289 25290 25291 25292</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.