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-20261007t203508-251d55ab8c"; 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: #eaefef; 337 color: #181b3e; 338 } 339 340 .wb-content .color_scheme_dark .background, 341 .wb-content .color_scheme_dark.background 342 { 343 background-color: #181b3e; 344 color: #fff; 345 } 346 347 .wb-content .color_scheme_black .background, 348 .wb-content .color_scheme_black.background 349 { 350 background-color: #000; 351 color: #fff; 352 } 353 354 .wb-content .color_scheme_accent_light .background, 355 .wb-content .color_scheme_accent_light.background 356 { 357 background-color: #fff; 358 color: #236651; 359 } 360 361 .wb-content .color_scheme_accent_dark .background, 362 .wb-content .color_scheme_accent_dark.background 363 { 364 background-color: #236651; 365 color: #fff; 366 } 367 368 /*----- color schemes for header ------ */ 369 370 /* Keep gradient channels tied to the same custom palette as the solid header. */ 371 .cs-header.color_scheme_light{ --cs-header-background-rgb: 234, 239, 239 !important; } 372 .cs-header.color_scheme_dark{ --cs-header-background-rgb: 24, 27, 62 !important; } 373 .cs-header.color_scheme_accent_dark{ --cs-header-background-rgb: 35, 102, 81 !important; } 374 .cs-header.cs-header-background-color-white{ --cs-header-background-rgb: 255, 255, 255 !important; } 375 .cs-header.cs-header-background-color-light{ --cs-header-background-rgb: 234, 239, 239 !important; } 376 .cs-header.cs-header-background-color-dark{ --cs-header-background-rgb: 24, 27, 62 !important; } 377 .cs-header.cs-header-background-color-black{ --cs-header-background-rgb: 0, 0, 0 !important; } 378 .cs-header.cs-header-background-color-accent_light{ --cs-header-background-rgb: 255, 255, 255 !important; } 379 .cs-header.cs-header-background-color-accent_dark{ --cs-header-background-rgb: 35, 102, 81 !important; } 380 381 .color_scheme_white .cs-header-link-wrap > p, 382 .color_scheme_white .cs-burgee-wrap > p{ 383 color: #181b3e !important; 384 } 385 386 .color_scheme_white .cs-header-link-wrap::after{ 387 background: #181b3e; 388 } 389 390 .color_scheme_light .cs-header-background{ 391 background-color: #eaefef; 392 } 393 394 .color_scheme_light .cs-header-link-wrap > p, 395 .color_scheme_light .cs-burgee-wrap > p{ 396 color: #181b3e !important; 397 } 398 399 .color_scheme_light .cs-header-link-wrap::after{ 400 background: #181b3e; 401 } 402 403 .color_scheme_dark .cs-header-background{ 404 background-color: #181b3e; 405 } 406 407 .color_scheme_dark .cs-header-link-wrap > p, 408 .color_scheme_dark .cs-burgee-wrap > p{ 409 color: #fff; 410 } 411 412 .color_scheme_dark .cs-header-link-wrap::after{ 413 background: #fff; 414 } 415 416 .color_scheme_black .cs-header-background{ 417 background-color: #000; 418 } 419 420 .color_scheme_black .cs-header-link-wrap > p, 421 .color_scheme_black .cs-burgee-wrap > p{ 422 color: #fff; 423 } 424 425 .color_scheme_black .cs-header-link-wrap::after{ 426 background: #fff; 427 } 428 429 .color_scheme_accent_light .cs-header-background{ 430 background-color: #fff; 431 } 432 433 .color_scheme_accent_light .cs-header-link-wrap > p, 434 .color_scheme_accent_light .cs-burgee-wrap > p{ 435 color: #236651; 436 } 437 438 .color_scheme_accent_light .cs-header-link-wrap::after{ 439 background: #236651; 440 } 441 442 .color_scheme_accent_dark .cs-header-background{ 443 background-color: #236651; 444 } 445 446 .color_scheme_accent_dark .cs-header-link-wrap > p, 447 .color_scheme_accent_dark .cs-burgee-wrap > p{ 448 color: #fff; 449 } 450 451 .color_scheme_accent_dark .cs-header-link-wrap::after{ 452 background: #fff; 453 } 454 455 /* Animated sub-menu text follows the same accent color as the nav links. */ 456 .cs-header-submenu-style_animated_buttons.color_scheme_accent_light .cs-header-sublink-label, 457 .cs-header-submenu-style_animated_flat.color_scheme_accent_light .cs-header-sublink-label{ 458 color: #236651; 459 } 460 461 .cs-header-submenu-style_animated_buttons.color_scheme_accent_light .cs-header-sublink-label:hover, 462 .cs-header-submenu-style_animated_flat.color_scheme_accent_light .cs-header-sublink-label:hover{ 463 color: #236651; 464 opacity: .7; 465 } 466 467 /* Edge-fade / color-token overlays follow club palette. Do not set 468 background-color on bare .cs-image-overlay â classic opacity-only 469 layers must keep the hardcoded #141a21 fill from hosted.css. */ 470 .cs-image-overlay.cs-overlay-color-light, 471 .cs-header-section-gradient.cs-overlay-color-light{ 472 --cs-overlay-rgb: 234, 239, 239; 473 } 474 .cs-image-overlay.cs-overlay-color-dark, 475 .cs-header-section-gradient.cs-overlay-color-dark{ 476 --cs-overlay-rgb: 24, 27, 62; 477 } 478 .cs-image-overlay.cs-overlay-color-top-light{ --cs-overlay-rgb-top: 234, 239, 239; } 479 .cs-image-overlay.cs-overlay-color-top-dark{ --cs-overlay-rgb-top: 24, 27, 62; } 480 .cs-image-overlay.cs-overlay-color-bottom-light{ --cs-overlay-rgb-bottom: 234, 239, 239; } 481 .cs-image-overlay.cs-overlay-color-bottom-dark{ --cs-overlay-rgb-bottom: 24, 27, 62; } 482 .cs-image-overlay.cs-overlay-top, 483 .cs-image-overlay.cs-overlay-bottom, 484 .cs-image-overlay.cs-overlay-both, 485 .cs-image-overlay.cs-overlay-gradient_top, 486 .cs-image-overlay.cs-overlay-gradient_bottom{ 487 background-color: transparent; 488 } 489 .cs-fade-color-top-light{ 490 --cs-fade-color-top: #eaefef; 491 } 492 .cs-fade-color-top-dark{ 493 --cs-fade-color-top: #181b3e; 494 } 495 .cs-fade-color-bottom-light{ 496 --cs-fade-color-bottom: #eaefef; 497 } 498 .cs-fade-color-bottom-dark{ 499 --cs-fade-color-bottom: #181b3e; 500 } 501 502 /* Overlap fade-through color follows club palette when set. */ 503 .cs-overlap-through-light, 504 :not(.event-page-callout):has(+ .cs-overlap-through-light), 505 .componentPreview:has(+ .componentPreview .cs-overlap-through-light), 506 .wb-diff-section:has(+ .wb-diff-section .cs-overlap-through-light){ 507 --cs-overlap-through-rgb: 234, 239, 239; 508 } 509 .cs-overlap-through-dark, 510 :not(.event-page-callout):has(+ .cs-overlap-through-dark), 511 .componentPreview:has(+ .componentPreview .cs-overlap-through-dark), 512 .wb-diff-section:has(+ .wb-diff-section .cs-overlap-through-dark){ 513 --cs-overlap-through-rgb: 24, 27, 62; 514 } 515 516 517</style> <!-- favicon --> 518 519 <link rel="shortcut icon" href="https://d282wvk2qi4wzk.cloudfront.net/1672259638195_3165"> 520 <link rel="icon" sizes="16x16 32x32 64x64" href="https://d282wvk2qi4wzk.cloudfront.net/1672259638195_3165"> 521 522 <!-- touch icon (mobile web) --> 523
524 <!-- web app title (mobile safari) --> 525 526 <meta name="apple-mobile-web-app-title" content="Lake Lanier Sailing Club"> 527 528 <!-- app_manifest (android) --> 529 530 531 532 533 534 535 536 <title>Home - Lake Lanier Sailing Club</title> 537 538 539 <link rel="canonical" href="https://llsc.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/1695242947599_2295"> 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='uWizD4z84G' 573 data-club-name='Lake Lanier Sailing Club' 574 data-timezone='America/New_York' 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 <!-- start standard content --> 590 <div id="v-content" class="hidden relative leftText relative easeFast paddingTopHeader"> 591 <!-- header --> 592 593 <custom-page-header 594 v-if="! is_react_native_member_app" 595 ></custom-page-header> 596 597 <!-- page components --> 598 599 600 601 602 603 <!-- check the config --> 604 605 606 607<!-- clean them first, to weed out null values --> 608 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<!-- now generate markup --> 682 683 <hero-banner-component 684 :client_id="'2S4MFL7tHNz_1675123838'" 685 :formatting="'{"section_height":"67","section_width":"70","align_items":"center","background_size":"cover","overlay_opacity":"0"}'" 686 :class_string="' color_scheme_dark orientation-left number_of_columns-3 cs-first-page-section caption_style-show_on_hover'" 687 :style_string="'min-height:67vh;align-items:center;'" 688 :element_style_string="'max-width:70%;'" 689 :component_type="'hero-banner'" 690 :elements_string="'%5B%7B%22type%22%3A%22h1-center%22%2C%22content%22%3A%22%3Ch1%20style%3D%5C%22text-align%3Acenter%5C%22%3E%3Cbr%20%2F%3E%3C%2Fh1%3E%22%7D%5D'" 691 :images_string="'["https://d282wvk2qi4wzk.cloudfront.net/1675124290994_6795","https://d282wvk2qi4wzk.cloudfront.net/AaQbUtDZKrgG_2S4MFL7tHNz_1675123838_1_video_1774712288862","https://d282wvk2qi4wzk.cloudfront.net/1675124306798_4534","https://d282wvk2qi4wzk.cloudfront.net/1675124359559_7962","https://d282wvk2qi4wzk.cloudfront.net/fmsmClsJnRPW_2S4MFL7tHNz_1675123838_4_image_1774712155990"]'" 692 ></hero-banner-component> 693 694 695 696 697 698 <!-- check the config --> 699 700 701 702<!-- clean them first, to weed out null values --> 703 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<!-- now generate markup --> 767 768 <event-list-component 769 :class_string="' color_scheme_white orientation-left number_of_columns-3 caption_style-show_on_hover'" 770 :style_string="'min-height:1vh;'" 771 :element_style_string="'max-width:100%;'" 772 :formatting="'{"section_height":"1","section_width":"100","align_items":"flex-start","card_spacing":"22"}'" 773 :filter_types="''" 774 :filter_tag_ids="''" 775 :date_filter="'upcoming'" 776 :component_type="'event-list'" 777 :client_id="'e6w2dCsaCxi_1740857084'" 778 ></event-list-component> 779 780 781 782 783 784 <!-- check the config --> 785 786 787 788<!-- clean them first, to weed out null values --> 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852<!-- now generate markup --> 853 854 <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:flex-start;"> 855
856 <div class="element-wrapper flexNoWrap flexWrapOnMobile" class="'element-wrapper_' + text" style="max-width:100%;"> 857 858 <text-component 859 :number_of_columns="'3'" 860 :column_prop="'%5B%7B%22type%22%3A%22button%22%2C%22content%22%3A%22%22%2C%22client_id%22%3A%22O7mOkQ8wWydVITdV%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%5D'" 861 :column_index="'0'" 862 :section_element_animation="''" 863 ></text-component> 864 865 <text-component 866 :number_of_columns="'3'" 867 :column_prop="'%5B%7B%22type%22%3A%22h1-center%22%2C%22content%22%3A%22%3Ch3%20style%3D%5C%22text-align%3Acenter%5C%22%3E%3Cdiv%20class%3D%5C%22cs-signups-name-wrap%20ellipsis%20textLeft%5C%22%20style%3D%5C%22background-color%3Argb%2528255%2C%20255%2C%20255%2529%5C%22%3E%3Cspan%20class%3D%5C%22flexWrap%5C%22%20style%3D%5C%22justify-content%3Aflex-start%3Balign-items%3Acenter%3Bmargin-bottom%3A5px%5C%22%3E%3Cdiv%20class%3D%5C%22cs-signups-name-0%20textLeft%5C%22%20style%3D%5C%22flex-grow%3A0%3Bmargin-right%3A8px%5C%22%3E%3Cdiv%20class%3D%5C%22cs-signups-name-wrap%20ellipsis%20textLeft%5C%22%3E%3Cspan%20class%3D%5C%22flexWrap%5C%22%20style%3D%5C%22justify-content%3Aflex-start%3Balign-items%3Acenter%3Bmargin-bottom%3A5px%5C%22%3E%3Cdiv%20class%3D%5C%22cs-signups-name-0%20textLeft%5C%22%20style%3D%5C%22flex-grow%3A0%3Bmargin-right%3A8px%5C%22%3E%C2%A0%3C%2Fdiv%3E%3Cdiv%20class%3D%5C%22cs-signups-name-0%20textLeft%5C%22%20style%3D%5C%22flex-grow%3A0%3Bmargin-right%3A8px%5C%22%3E%3Cspan%20style%3D%5C%22font-size%3A20px%3Btext-wrap-mode%3Awrap%5C%22%3E%3Cb%3E%C2%A0%3C%2Fb%3E%3C%2Fspan%3E%3C%2Fdiv%3E%3C%2Fspan%3E%3C%2Fdiv%3E%3C%2Fdiv%3E%3C%2Fspan%3E%3C%2Fdiv%3E%3C%2Fh3%3E%22%2C%22client_id%22%3A%22IBuWhNAxKjMxuvBV%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%5D'" 868 :column_index="'1'" 869 :section_element_animation="''" 870 ></text-component> 871 872 <text-component 873 :number_of_columns="'3'" 874 :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%C2%A0%3C%2Fh3%3E%22%2C%22client_id%22%3A%22U6rRzB5BYO1GZtO0%22%2C%22column_count%22%3A1%2C%22column_gap%22%3A0%7D%5D'" 875 :column_index="'2'" 876 :section_element_animation="''" 877 ></text-component> 878 879 </div> 880 881 </div> 882 883 884 885 886 887 <!-- check the config --> 888 889 890 891<!-- clean them first, to weed out null values --> 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959<!-- now generate markup --> 960 961 <photo-gallery-component 962 :client_id="'0jBbnsZbXiM_1741478757'" 963 :photo_album="'%7B%22id%22%3Anull%2C%22name%22%3Anull%7D'" 964 :show_caption="false" 965 :caption_size="'16'" 966 :caption_font_weight="'400'" 967 :photos_per_row="'4'" 968 ></photo-gallery-component> 969 970 971 972 973 974 <!-- footer --> 975 976 977 978<!-- check the config --> 979 980 981<div class="cs-footer relative color_scheme_dark" style="" > 982 <div class=""> 983 <div class="cs-footer-flex-wrap-row"> 984 <div class="cs-footer-logo-wrap background"> 985 986 987 988 <div class="cs-burgee-wrap relative" style="height:250px;width:348px;"> 989 <div class="cs-burgee" style="background-image:url(https://d282wvk2qi4wzk.cloudfront.net/1675123872101_798)"></div> 990 <a class="absoluteA" href="/"></a> 991 </div> 992 993 </div> 994 <div> 995 <div class="flexWrapLeftAlign"> 996 <div class="cs-footer-link-wrap flexGrowOne noPaddingBottom"> 997 998 999 1000 1001 1002 <!-- component --> 1003 <div class="footer-link-section flexGrowOne"> 1004 1005 1006 <p class="footer-sublink-section-header smallMarginTop">CONTACT US</p> 1007 1008 <!-- handle sub-components --> 1009 1010 1011 <div class="cs-footer-sublink-outer easeFast"> 1012 1013 1014 1015 1016 1017 <div class="cs-footer-sublink-wrap relative"> 1018 1019 1020 1021 <p class="cs-footer-sublink-label">6206 Commodore Drive Flowery Branch, Georgia 30542</p> 1022 1023 1024 </div> 1025 1026 1027 1028 </div> 1029 1030 </div> 1031 1032 1033 1034 1035 1036 1037 1038 <!-- component --> 1039 <div class="footer-link-section flexGrowOne"> 1040 1041 1042 <p class="footer-sublink-section-header smallMarginTop">SOCIAL MEDIA</p> 1043 1044 <!-- handle sub-components --> 1045 1046 1047 <div class="cs-footer-sublink-outer easeFast"> 1048 1049 1050 1051 1052 1053 <div class="cs-footer-sublink-wrap relative"> 1054 1055 1056 1057 <p class="cs-footer-sublink-label"> 1058 Facebook 1059 <a class="absoluteA" href="https://www.facebook.com/lakelaniersc/"></a> 1060 </p> 1061 1062 1063 </div> 1064 1065 1066 1067 1068 1069 1070 1071 <div class="cs-footer-sublink-wrap relative"> 1072 1073 1074 1075 <p class="cs-footer-sublink-label"> 1076 Instagram 1077 <a class="absoluteA" href="https://www.instagram.com/lakelaniersailing/"></a> 1078 </p> 1079 1080 1081 </div> 1082 1083 1084 1085 1086 1087 1088 1089 <div class="cs-footer-sublink-wrap relative"> 1090 1091 1092 1093 <p class="cs-footer-sublink-label"> 1094 Twitter 1095 <a class="absoluteA" href="https://twitter.com/llsc1959"></a> 1096 </p> 1097 1098 1099 </div> 1100 1101 1102 1103 </div> 1104 1105 </div> 1106 1107 1108 1109 <!-- social icons --> 1110 1111 </div> 1112 </div> 1113 <div class="cs-footer-link-wrap noPaddingTop"> 1114 <div class="powered_by_wrap relative"> 1115 <div class="flexNoWrap align-items-center"> 1116 <p class="powered_by"><i class="fa-solid fa-shield-check" style="padding-right:3px;"></i> Powered by</p> 1117 <p class="modern pointer powered_by_text relative" style="font-weight:bold;">Clubspot <a class="absoluteA" href="https://theclubspot.com"></a></p> 1118 </div> 1119 1120 <p style="display:flex;font-family:roboto !important;" class="cs-footer-sublink-label roboto copyright_text noPointer">© 2026 Lake Lanier Sailing Club, All rights reserved.</p> 1121 </div> 1122 </div> 1123 </div> 1124 </div> 1125 </div> 1126</div> 1127 1128 <!-- react native tab bar --> 1129 <native-tab-bar-component></native-tab-bar-component> 1130 1131 </div> 1132 <div id="v-overlays" class="hidden"> 1133 <!-- common --> 1134 <template v-if="overlays.show_push_notification"> 1135 <show-push-notification></show-push-notification> 1136 </template> 1137 <template v-if="overlays.overflow_overlay"> 1138 <overflow-overlay></overflow-overlay> 1139 </template> 1140 1141 <template v-if="overlays.view_all_events"> 1142 <view-all-events-overlay></view-all-events-overlay> 1143 </template> 1144 1145 <template v-if="overlays.view_photo_carousel"> 1146 <view-photo-carousel></view-photo-carousel> 1147 </template> 1148 <template v-if="overlays.show_event_details_in_member_cal"> 1149 <show-event-details-in-member-cal-overlay></show-event-details-in-member-cal-overlay> 1150 </template> 1151 <template v-if="overlays.member_directory_profile"> 1152 <member-directory-profile></member-directory-profile> 1153 </template> 1154 <template v-if="overlays.directory_vehicle_profile"> 1155 <directory-vehicle-profile></directory-vehicle-profile> 1156 </template> 1157 1158 </div> 1159 <div id="overlay_event-description-popup" class="standardOverlay scrollable hugePaddingBottom" style="display:none;opacity:0;"> 1160 <div class="standardOverlayCard centeredBlock eightyThenNinety" style="max-width:850px;"> 1161 <h3></h3> 1162 <div class="standardCardBody noPadding fff"> 1163 <div class="centeredBlock eightyThenNinety bigPaddingBottom marginTop"> 1164 <p class="inline-label smallerMarginBottom">Date</p> 1165 <p class="metric marginBottom leftText tinyMarginTop metric_start_date" style="margin-bottom:20px;"></p> 1166 <p class="inline-label smallerMarginBottom">Time</p> 1167 <p class="metric marginBottom leftText tinyMarginTop metric_start_date_time" style="margin-bottom:20px;"></p> 1168 <p class="inline-label smallerMarginBottom">Event Details</p> 1169 <div class="marginBottom leftText tinyMarginTop metric_description" style="line-height:1.5;"></div> 1170 </div> 1171 </div> 1172 <div class="standardCardFooter flexNoWrap"> 1173 <div class="flexGrowOne"></div> 1174 <button class="cardFooterButton easeFast" onclick="fadeOut('#overlay_event-description-popup');">Done</button> 1175 </div> 1176 </div> 1177</div> 1178 </body> 1179
1179<script> 1180 1181 //add the timezone to the global store 1182 store.timezone = $('body').data('timezone'); 1183 1184 //add to global store 1185 store.eventBus = ref({ 1186 listeners: {}, 1187 $on(event, callback) { 1188 if (!this.listeners[event]) { 1189 this.listeners[event] = []; 1190 } 1191 this.listeners[event].push(callback); 1192 }, 1193 $emit(event, ...args) { 1194 if (this.listeners[event]) { 1195 for (const callback of this.listeners[event]) { 1196 callback(...args); 1197 } 1198 } 1199 }, 1200 $off(event, callback) { 1201 if (!this.listeners[event]) { 1202 return; 1203 } 1204 if (!callback) { 1205 delete this.listeners[event]; 1206 return; 1207 } 1208 this.listeners[event] = this.listeners[event].filter(listener => listener !== callback); 1209 }, 1210 }); 1211 1212 //create the app 1213 var app_content = createApp({ 1214 data() { 1215 return store 1216 }, 1217 mounted() { 1218 console.log("mounted vue app: main content"); 1219 $('#v-content').removeClass('hidden'); 1220 var clubId = $('body').data('clubId'); 1221 if(clubId) { 1222 var query = new Parse.Query('clubs'); 1223 query.include('web_settings'); 1224 query.include('membership_settings'); 1225 return query.get(clubId).then((foundClub) => { 1226 store.clubObject = foundClub; 1227 }) 1228 } 1229 }, 1230 methods: { 1231 1232 }, 1233 computed: { 1234 is_react_native_member_app(){ 1235 return store?.is_react_native_member_app || false; 1236 } 1237 } 1238 }); 1239 1240 //create the app 1241 var app_overlays = createApp({ 1242 data() { 1243 return store 1244 }, 1245 mounted() { 1246 console.log("mounted vue app: overlays"); 1247 $('#v-overlays').removeClass('hidden'); 1248 } 1249 }); 1250 1251 </script>
1251 1252 1253 <!-- define/register vue components --> 1254
1254<script type="text/html" id="template-custom-page-header"> 1255 1256 1257 <!-- globals --> 1258 1259 1260 1261 <!-- check the config --> 1262 1263 1264 1265 1266 1267 1268 1269 1270 <!-- color scheme --> 1271 1272 1273 1274 1275 1276 <!-- opacity --> 1277 1278 1279 1280 <!-- font-size --> 1281 1282 1283 1284 1285 <!-- font-weight --> 1286 1287 1288 1289 1290 <!-- header style (eg stacked vs inline vs centered) --> 1291 1292 1293 1294 1295 <!-- header position (eg relative vs absolute) --> 1296 1297 1298 1299 1300 <!-- sub-menu style (eg simple vs animated slide-down) --> 1301 1302 1303 1304 1305 1306 1307 1308 <!-- header background image, eg. for inverness yc --> 1309 1310 1311 <!-- burgee url --> 1312 1313 1314 <!-- ok, start header markup --> 1315 <div 1316 ref="header" 1317 class="cs-header easeFast absolute-header color_scheme_light cs-font-size-16 header-400 header-style_inline header-position_relative" 1318 style="" 1319 1320 1321 > 1322 1323 <div class="cs-header-background background" style="opacity: 100%;"></div> 1324 1325 <div class="innerHeader align-items-middle flexNoWrap"> 1326 <div class="hamburgerWrap marketingMenu"> 1327 <div style="position: fixed; height: 1px; width: 1px; opacity: .01;"></div> 1328 <div class="hamburger hideOnDesktop"> 1329 1330 <div id="headerLineTop" class="ease"></div> 1331 <div id="headerLineMiddle" class="ease"></div> 1332 <div id="headerLineBottom" class="ease"></div> 1333 1334 </div> 1335 1336 <p class="burger-menu-text hideOnDesktop hideOnMobile">Menu</p> 1337 1338 </div> 1339 1340 1341 1342 1343 1344 <div class="cs-burgee-wrap hideOnMobile" style="height:71px;width:231px;"> 1345 <div class="cs-burgee" style="background-image:url(https://d282wvk2qi4wzk.cloudfront.net/1675123872101_798);background-size:contain;"></div> 1346 <a href="/" class="absoluteA"></a> 1347 </div> 1348 1349 <div class="cs-burgee-wrap-mobile"> 1350 <div class="cs-burgee" style="background-image:url(https://d282wvk2qi4wzk.cloudfront.net/1675123872101_798);background-size:contain;"></div> 1351 <a :href="burgee_link" class="absoluteA"></a> 1352 </div> 1353 1354 1355 1356 <div class="cs-desktop-menu-wrap flexGrowOne flexWrapRightAlign hideOnMobile header-style_inline"> 1357 1358 1359 1360 1361 1362 1363 <div class="cs-header-link-wrap header-style_inline"> 1364 <p class="button_link_p" style="">MEMBERSHIP</p> 1365 <!-- link --> 1366 1367 1368 <a class="absoluteA" href="/membership"></a> 1369 1370 <!-- handle sub-components --> 1371 1372 1373 <div class="cs-header-sublink-outer easeFast"> 1374 1375 1376 1377 1378 1379 1380 <div class="cs-header-sublink-wrap relative" style="--sm-index:0;"> 1381 <p class="cs-header-sublink-label">Membership Options</p> 1382 1383 1384 1385 <a class="absoluteA" href="/membership-options"></a> 1386 1387 </div> 1388 1389 1390 1391 1392 1393 1394 1395 1396 <div class="cs-header-sublink-wrap relative" style="--sm-index:1;"> 1397 <p class="cs-header-sublink-label">How to Join</p> 1398 1399 1400 1401 <a class="absoluteA" href="/how-to-join"></a> 1402 1403 </div> 1404 1405 1406 1407 1408 1409 1410 1411 1412 <div class="cs-header-sublink-wrap relative" style="--sm-index:2;"> 1413 <p class="cs-header-sublink-label">Membership FAQs</p> 1414 1415 1416 1417 <a class="absoluteA" href="/membership-q&a"></a> 1418 1419 </div> 1420 1421 1422 1423 1424 1425 1426 1427 1428 <div class="cs-header-sublink-wrap relative" style="--sm-index:3;"> 1429 <p class="cs-header-sublink-label">LLSC Membership Inquiry</p> 1430 1431 1432 1433 <a class="absoluteA" href="/llsc-membership-inquiry"></a> 1434 1435 </div> 1436 1437 1438 1439 </div> 1440 1441 </div> 1442 1443 1444 1445 1446 1447 <div class="cs-header-link-wrap header-style_inline"> 1448 <p class="button_link_p" style="">ABOUT US</p> 1449 <!-- link --> 1450 1451 1452 <a class="absoluteA" href="/about-llsc"></a> 1453 1454 <!-- handle sub-components --> 1455 1456 1457 <div class="cs-header-sublink-outer easeFast"> 1458 1459 1460 1461 1462 1463 1464 <div class="cs-header-sublink-wrap relative" style="--sm-index:0;"> 1465 <p class="cs-header-sublink-label">Sailing Classes</p> 1466 1467 1468 1469 <a class="absoluteA" href="/sailing-classes"></a> 1470 1471 </div> 1472 1473 1474 1475 1476 1477 1478 1479 1480 <div class="cs-header-sublink-wrap relative" style="--sm-index:1;"> 1481 <p class="cs-header-sublink-label">History</p> 1482 1483 1484 1485 <a class="absoluteA" href="/history"></a> 1486 1487 </div> 1488 1489 1490 1491 1492 1493 1494 1495 1496 <div class="cs-header-sublink-wrap relative" style="--sm-index:2;"> 1497 <p class="cs-header-sublink-label">Club and Lake News</p> 1498 1499 1500 1501 <a class="absoluteA" href="/club-and-lake-news"></a> 1502 1503 </div> 1504 1505 1506 1507 1508 1509 1510 1511 1512 <div class="cs-header-sublink-wrap relative" style="--sm-index:3;"> 1513 <p class="cs-header-sublink-label">Past Commodores</p> 1514 1515 1516 1517 <a class="absoluteA" href="/past-commodores"></a> 1518 1519 </div> 1520 1521 1522 1523 1524 1525 1526 1527 1528 <div class="cs-header-sublink-wrap relative" style="--sm-index:4;"> 1529 <p class="cs-header-sublink-label">Team</p> 1530 1531 1532 1533 <a class="absoluteA" href="/team"></a> 1534 1535 </div> 1536 1537 1538 1539 1540 1541 1542 1543 1544 <div class="cs-header-sublink-wrap relative" style="--sm-index:5;"> 1545 <p class="cs-header-sublink-label">Facilities & Services</p> 1546 1547 1548 1549 <a class="absoluteA" href="/facilities-&-services"></a> 1550 1551 </div> 1552 1553 1554 1555 </div> 1556 1557 </div> 1558 1559 1560 1561 1562 1563 <div class="cs-header-link-wrap header-style_inline"> 1564 <p class="button_link_p" style="">CALENDAR</p> 1565 <!-- link --> 1566 1567 1568 <a class="absoluteA" href="/calendar"></a> 1569 1570 <!-- handle sub-components --> 1571 1572 1573 </div> 1574 1575 1576 1577 1578 1579 <div class="cs-header-link-wrap header-style_inline"> 1580 <p class="button_link_p" style="">RACING</p> 1581 <!-- link --> 1582 1583 1584 <a class="absoluteA" href="/racing-information"></a> 1585 1586 <!-- handle sub-components --> 1587 1588 1589 <div class="cs-header-sublink-outer easeFast"> 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608
1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 </div> 1620 1621 </div> 1622 1623 1624 1625 1626 1627 <div class="cs-header-link-wrap header-style_inline"> 1628 <p class="button_link_p" style="">JUNIOR SAILING</p> 1629 <!-- link --> 1630 1631 1632 <a class="absoluteA" href="/junior-sailing"></a> 1633 1634 <!-- handle sub-components --> 1635 1636 1637 <div class="cs-header-sublink-outer easeFast"> 1638 1639 1640 1641 1642 1643 1644 <div class="cs-header-sublink-wrap relative" style="--sm-index:0;"> 1645 <p class="cs-header-sublink-label">About</p> 1646 1647 1648 1649 <a class="absoluteA" href="/junior-sailing"></a> 1650 1651 </div> 1652 1653 1654 1655 1656 1657 1658 1659 1660 <div class="cs-header-sublink-wrap relative" style="--sm-index:1;"> 1661 <p class="cs-header-sublink-label">Damian Beisner Memorial Fund</p> 1662 1663 1664 1665 <a class="absoluteA" href="https://www.llscjuniorsailingfoundation.org/beisnerfund"></a> 1666 1667 </div> 1668 1669 1670 1671 1672 1673 1674 1675 1676 <div class="cs-header-sublink-wrap relative" style="--sm-index:2;"> 1677 <p class="cs-header-sublink-label">LLSC Junior Training</p> 1678 1679 1680 1681 <a class="absoluteA" href="/llsc-junior-training"></a> 1682 1683 </div> 1684 1685 1686 1687 1688 1689 1690 1691 1692 <div class="cs-header-sublink-wrap relative" style="--sm-index:3;"> 1693 <p class="cs-header-sublink-label">Junior Weeks</p> 1694 1695 1696 1697 <a class="absoluteA" href="/summer-sailing-program"></a> 1698 1699 </div> 1700 1701 1702 1703 </div> 1704 1705 </div> 1706 1707 1708 1709 1710 1711 <div class="cs-header-link-wrap header-style_inline"> 1712 <p class="button_link_p" style="">WEB CAM & WEATHER</p> 1713 <!-- link --> 1714 1715 1716 <a class="absoluteA" href="/web-cam"></a> 1717 1718 <!-- handle sub-components --> 1719 1720 1721 </div> 1722 1723 1724 1725 1726 1727 <div class="cs-header-link-wrap link-style-button header-style_inline"> 1728 <p class="button_link_p" style="background-color:#fcfcfc !important; color:#171b3e !important;">MEMBER LOGIN</p> 1729 <!-- link --> 1730 1731 1732 <a class="absoluteA" href="/login"></a> 1733 1734 <!-- handle sub-components --> 1735 1736 1737 </div> 1738 1739 1740 1741 </div> 1742 </div> 1743 </div> 1744 1745 <!-- now the mobile menu --> 1746 <div class="mobileMenuOverlay hideOnDesktop ease"></div> 1747 <div class="mobileMenu hideOnDesktop easeFast scrollable"> 1748 <div class="innerMenu"> 1749 <div class="mobile-link-wrap"> 1750 1751 1752 1753 <div class="cs-burgee-wrap" style="width:175px;height:60px;float:none;margin-top:-40px;margin-bottom:15px;"> 1754 <div class="cs-burgee" style="background-position:left;background-image:url(https://d282wvk2qi4wzk.cloudfront.net/1675123872101_798);background-size:contain;margin-bottom:15px;"></div> 1755 <a :href="burgee_link" class="absoluteA"></a> 1756 </div> 1757 1758 1759 1760 <div class="mobile-sublink-outer easeFast"> 1761 1762 1763 <div class="header-sublink-wrap relative"> 1764 <p class="header-sublink-label">My Account</p> 1765 <p class="header-sublink-explain">Click below to view your account</p> 1766 <button class="wb-button-primary easeFast boxShadowBottom smallMarginTop"> 1767 View my account 1768 <a class="absoluteA" href="/account"></a> 1769 </button> 1770 </div> 1771 1772 1773 1774 1775 1776 1777 1778 1779 1780 1781 1782 1783 1784 1785 <!-- component --> 1786 1787 1788 <p class="header-sublink-section-header smallMarginTop"> 1789 MEMBERSHIP 1790 <a class="absoluteA" href="/membership"></a> 1791 </p> 1792 1793 <!-- handle sub-components --> 1794 1795 1796 1797 1798 1799 1800 1801 1802 <div class="cs-header-sublink-wrap relative"> 1803 1804 1805 <p class="cs-header-sublink-label"> 1806 Membership Options 1807 <a class="absoluteA" href="/membership-options"></a> 1808 </p> 1809 1810 </div> 1811 1812 1813 1814 1815 1816 1817 1818 1819 <div class="cs-header-sublink-wrap relative"> 1820 1821 1822 <p class="cs-header-sublink-label"> 1823 How to Join 1824 <a class="absoluteA" href="/how-to-join"></a> 1825 </p> 1826 1827 </div> 1828 1829 1830 1831 1832 1833 1834 1835 1836 <div class="cs-header-sublink-wrap relative"> 1837 1838 1839 <p class="cs-header-sublink-label"> 1840 Membership FAQs 1841 <a class="absoluteA" href="/membership-q&a"></a> 1842 </p> 1843 1844 </div> 1845 1846 1847 1848 1849 1850 1851 1852 1853 <div class="cs-header-sublink-wrap relative"> 1854 1855 1856 <p class="cs-header-sublink-label"> 1857 LLSC Membership Inquiry 1858 <a class="absoluteA" href="/llsc-membership-inquiry"></a> 1859 </p> 1860 1861 </div> 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872 1873 1874 <!-- component --> 1875 1876 1877 <p class="header-sublink-section-header smallMarginTop"> 1878 ABOUT US 1879 <a class="absoluteA" href="/about-llsc"></a> 1880 </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 Sailing Classes 1896 <a class="absoluteA" href="/sailing-classes"></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 History 1913 <a class="absoluteA" href="/history"></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 c
1928lass="cs-header-sublink-label"> 1929 Club and Lake News 1930 <a class="absoluteA" href="/club-and-lake-news"></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 Past Commodores 1947 <a class="absoluteA" href="/past-commodores"></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 Team 1964 <a class="absoluteA" href="/team"></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 Facilities & Services 1981 <a class="absoluteA" href="/facilities-&-services"></a> 1982 </p> 1983 1984 </div> 1985 1986 1987 1988 1989 1990 1991 1992 1993 1994 1995 1996 1997 <!-- component --> 1998 1999 2000 <p class="header-sublink-section-header smallMarginTop"> 2001 CALENDAR 2002 <a class="absoluteA" href="/calendar"></a> 2003 </p> 2004 2005 <!-- handle sub-components --> 2006 2007 2008 2009 2010 2011 2012 2013 2014 2015 2016 <!-- component --> 2017 2018 2019 <p class="header-sublink-section-header smallMarginTop"> 2020 RACING 2021 <a class="absoluteA" href="/racing-information"></a> 2022 </p> 2023 2024 <!-- handle sub-components --> 2025 2026 2027 2028 2029 2030 2031 2032 2033 2034 2035 2036 2037 2038 2039 2040 2041 2042 2043 2044 2045 2046 2047 2048 2049 2050 2051 2052 2053 2054 2055 2056 2057 2058 2059 2060 2061 2062 2063 2064 2065 <!-- component --> 2066 2067 2068 <p class="header-sublink-section-header smallMarginTop"> 2069 JUNIOR SAILING 2070 <a class="absoluteA" href="/junior-sailing"></a> 2071 </p> 2072 2073 <!-- handle sub-components --> 2074 2075 2076 2077 2078 2079 2080 2081 2082 <div class="cs-header-sublink-wrap relative"> 2083 2084 2085 <p class="cs-header-sublink-label"> 2086 About 2087 <a class="absoluteA" href="/junior-sailing"></a> 2088 </p> 2089 2090 </div> 2091 2092 2093 2094 2095 2096 2097 2098 2099 <div class="cs-header-sublink-wrap relative"> 2100 2101 2102 <p class="cs-header-sublink-label"> 2103 Damian Beisner Memorial Fund 2104 <a class="absoluteA" href="https://www.llscjuniorsailingfoundation.org/beisnerfund"></a> 2105 </p> 2106 2107 </div> 2108 2109 2110 2111 2112 2113 2114 2115 2116 <div class="cs-header-sublink-wrap relative"> 2117 2118 2119 <p class="cs-header-sublink-label"> 2120 LLSC Junior Training 2121 <a class="absoluteA" href="/llsc-junior-training"></a> 2122 </p> 2123 2124 </div> 2125 2126 2127 2128 2129 2130 2131 2132 2133 <div class="cs-header-sublink-wrap relative"> 2134 2135 2136 <p class="cs-header-sublink-label"> 2137 Junior Weeks 2138 <a class="absoluteA" href="/summer-sailing-program"></a> 2139 </p> 2140 2141 </div> 2142 2143 2144 2145 2146 2147 2148 2149 2150 2151 2152 2153 2154 <!-- component --> 2155 2156 2157 <p class="header-sublink-section-header smallMarginTop"> 2158 WEB CAM & WEATHER 2159 <a class="absoluteA" href="/web-cam"></a> 2160 </p> 2161 2162 <!-- handle sub-components --> 2163 2164 2165 2166 2167 2168 2169 2170 2171 2172 2173 <!-- component --> 2174 2175 2176 <p class="header-sublink-section-header smallMarginTop"> 2177 MEMBER LOGIN 2178 <a class="absoluteA" href="/login"></a> 2179 </p> 2180 2181 <!-- handle sub-components --> 2182 2183 2184 2185 2186 2187 2188 2189 2190 2191 2192 2193 2194 2195 2196 2197 2198 2199 2200 2201 </div> 2202 </div> 2203 </div> 2204 </div> 2205 2206</script>
2206 2207 2208 2209
2210<script> 2211 2212 //define the component 2213 app_content.component("custom-page-header", { 2214 template: "#template-custom-page-header", 2215 data() { 2216 return {} 2217 }, 2218 props: {}, 2219 mounted(){ 2220 this.sync_submenu_edge_alignment(); 2221 window.addEventListener("resize", this.schedule_submenu_edge_alignment, { passive: true }); 2222 if( !this.$refs.header ){ 2223 return; 2224 } 2225 // Belt-and-suspenders: re-check right before a panel opens so a 2226 // stale measurement (from before nav content settled) can't leave 2227 // it hanging off-screen. 2228 this.$refs.header.addEventListener("mouseover", this.handle_submenu_hover_check); 2229 this.$refs.header.addEventListener("focusin", this.handle_submenu_hover_check); 2230 let sticky_header = this.$refs.header.classList.contains("cs-header-behavior_always_visible"); 2231 if( !sticky_header ){ 2232 return; 2233 } 2234 window.addEventListener("scroll", this.handle_header_scroll, { passive: true }); 2235 this.$nextTick(() => { 2236 window.requestAnimationFrame(() => { 2237 this.handle_header_scroll(); 2238 }); 2239 }); 2240 }, 2241 beforeUnmount(){ 2242 window.removeEventListener("scroll", this.handle_header_scroll); 2243 window.removeEventListener("resize", this.schedule_submenu_edge_alignment); 2244 window.clearTimeout(this._submenu_edge_alignment_timeout); 2245 if( this.$refs.header ){ 2246 this.$refs.header.removeEventListener("mouseover", this.handle_submenu_hover_check); 2247 this.$refs.header.removeEventListener("focusin", this.handle_submenu_hover_check); 2248 } 2249 }, 2250 methods: { 2251 // Left-aligned animated sub-menus can run past the right edge of the 2252 // viewport for nav items near the end of the menu - flip those to 2253 // hang from the right instead so they stay on screen. 2254 schedule_submenu_edge_alignment(){ 2255 window.clearTimeout(this._submenu_edge_alignment_timeout); 2256 this._submenu_edge_alignment_timeout = window.setTimeout(() => { 2257 this.sync_submenu_edge_alignment(); 2258 }, 150); 2259 }, 2260 handle_submenu_hover_check(event){ 2261 if( event.target.closest && event.target.closest(".cs-header-link-wrap") ){ 2262 this.sync_submenu_edge_alignment(); 2263 } 2264 }, 2265 sync_submenu_edge_alignment(){ 2266 if( !this.$refs.header ){ 2267 return; 2268 } 2269 let is_animated = this.$refs.header.classList.contains("cs-header-submenu-style_animated_buttons") 2270 || this.$refs.header.classList.contains("cs-header-submenu-style_animated_flat"); 2271 if( !is_animated ){ 2272 return; 2273 } 2274 let panels = this.$refs.header.querySelectorAll(".cs-header-sublink-outer"); 2275 let viewport_width = window.innerWidth || document.documentElement.clientWidth || 0; 2276 panels.forEach((panel) => { 2277 panel.classList.remove("cs-header-sublink-outer_align-right"); 2278 if( panel.getBoundingClientRect().right > viewport_width - 8 ){ 2279 panel.classList.add("cs-header-sublink-outer_align-right"); 2280 } 2281 }); 2282 }, 2283 handle_header_scroll(){ 2284 if( !this.$refs.header ){ 2285 return; 2286 } 2287 let scroll_y = window.scrollY || window.pageYOffset || 0; 2288 let header_rect = this.$refs.header.getBoundingClientRect(); 2289 let first_component = document.querySelector("#v-content .cs-first-page-section") 2290 || document.querySelector("#v-content .cs-custom-code-wrap.first-page-section"); 2291 let first_component_rect = first_component 2292 ? first_component.getBoundingClientRect() 2293 : null; 2294 let viewport_height = window.innerHeight || document.documentElement.clientHeight || 0; 2295 let transition_mode = this.$refs.header.getAttribute("data-header-transition-mode") 2296 || 'phased'; 2297 let was_past_handoff = !!this._header_past_handoff;
2298 let past_full_handoff = !!( 2299 first_component_rect 2300 && first_component_rect.height > 0 2301 && first_component_rect.bottom <= ( 2302 header_rect.bottom + ( was_past_handoff ? 8 : -8 ) 2303 ) 2304 ) || ( 2305 viewport_height > 0 2306 && scroll_y >= viewport_height + ( was_past_handoff ? -8 : 8 ) 2307 ); 2308 let immediate_threshold = Math.max(1, Math.round(header_rect.height / 2)); 2309 let header_past_handoff = transition_mode === 'immediate' 2310 ? scroll_y >= immediate_threshold 2311 : scroll_y > 1 && past_full_handoff; 2312 this._header_past_handoff = header_past_handoff; 2313 let early_offset = Math.max(8, Math.round(header_rect.height * 0.2)); 2314 let early_default_gradient = scroll_y >= early_offset 2315 && !header_past_handoff 2316 && transition_mode === 'phased' 2317 && this.$refs.header.classList.contains("cs-header-background-on-scroll"); 2318 this.apply_header_background_for_scroll(header_past_handoff); 2319 if( early_default_gradient ){ 2320 this.sync_header_background_rgb(); 2321 } 2322 this.$refs.header.classList.remove("cs-header-following-section-gradient"); 2323 this.$refs.header.classList.toggle( 2324 "cs-header-early-default-gradient", 2325 early_default_gradient 2326 ); 2327 }, 2328 apply_header_background_for_scroll(is_scrolled){ 2329 if( !this.$refs.header ){ 2330 return; 2331 } 2332 let top_raw = this.$refs.header.getAttribute("data-header-bg-top"); 2333 let scrolled_raw = this.$refs.header.getAttribute("data-header-bg-scrolled"); 2334 if( !top_raw ){ 2335 return; 2336 } 2337 let top = {}; 2338 let scrolled = {}; 2339 try { 2340 top = JSON.parse(top_raw) || {}; 2341 scrolled = JSON.parse(scrolled_raw || top_raw) || top; 2342 } catch (error) { 2343 return; 2344 } 2345 let active = is_scrolled ? scrolled : top; 2346 let previous_text_color = this.$refs.header.className.match(/color_scheme_[^\s]+/); 2347 if( previous_text_color ){ 2348 this.$refs.header.classList.remove(previous_text_color[0]); 2349 } 2350 this.$refs.header.classList.add( 2351 "color_scheme_" + ( active.color || 'white' ) 2352 ); 2353 let previous_mode = this.$refs.header.className.match(/cs-header-background-mode-[^\s]+/); 2354 if( previous_mode ){ 2355 this.$refs.header.classList.remove(previous_mode[0]); 2356 } 2357 let previous_blur = this.$refs.header.className.match(/cs-header-blur-(light|medium|strong)/); 2358 if( previous_blur ){ 2359 this.$refs.header.classList.remove(previous_blur[0]); 2360 } 2361 let previous_color = this.$refs.header.className.match(/cs-header-background-color-[^\s]+/); 2362 if( previous_color ){ 2363 this.$refs.header.classList.remove(previous_color[0]); 2364 } 2365 this.$refs.header.classList.add("cs-header-background-mode-" + ( active.mode || 'none' )); 2366 this.$refs.header.classList.add( 2367 "cs-header-background-color-" + ( active.color || 'white' ) 2368 ); 2369 if( active.mode === 'blur_tint' ){ 2370 this.$refs.header.classList.add("cs-header-blur-" + ( active.blur_amount || 'medium' )); 2371 } 2372 if( active.opacity !== undefined ){ 2373 this.$refs.header.style.setProperty( 2374 '--cs-header-background-opacity', 2375 String(active.opacity / 100) 2376 ); 2377 } 2378 this.$refs.header.classList.toggle( 2379 "cs-header-top-visible", 2380 !is_scrolled && active.mode !== 'none' 2381 ); 2382 this.$refs.header.classList.toggle( 2383 "cs-header-blur-active", 2384 is_scrolled && active.mode !== 'none' 2385 ); 2386 let header_visible = !( is_scrolled && active.mode === 'none' ); 2387 this.$refs.header.style.opacity = header_visible ? '' : '0'; 2388 this.$refs.header.style.visibility = header_visible ? '' : 'hidden'; 2389 this.$refs.header.style.pointerEvents = header_visible ? '' : 'none'; 2390 },
2391 // Sample the real sticky fill (incl. club custom CSS) so the interim 2392 // gradient never falls back to overlay navy / a mismatched default. 2393 sync_header_background_rgb(){ 2394 if( !this.$refs.header ){ 2395 return; 2396 } 2397 let scrolled_raw = this.$refs.header.getAttribute("data-header-bg-scrolled"); 2398 if( scrolled_raw ){ 2399 try { 2400 let scrolled = JSON.parse(scrolled_raw) || {}; 2401 let previous_color = this.$refs.header.className.match(/cs-header-background-color-[^\s]+/); 2402 if( previous_color ){ 2403 this.$refs.header.classList.remove(previous_color[0]); 2404 } 2405 this.$refs.header.classList.add( 2406 "cs-header-background-color-" + ( scrolled.color || 'white' ) 2407 ); 2408 if( scrolled.opacity !== undefined ){ 2409 this.$refs.header.style.setProperty( 2410 '--cs-header-background-opacity', 2411 String(scrolled.opacity / 100) 2412 ); 2413 } 2414 } catch (error) { 2415 return; 2416 } 2417 } 2418 let bg = this.$refs.header.querySelector('.cs-header-background'); 2419 if( !bg ){ 2420 return; 2421 } 2422 let color = window.getComputedStyle(bg).backgroundColor || ''; 2423 let match = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([0-9.]+))?/.exec(color); 2424 if( !match ){ 2425 return; 2426 } 2427 let alpha = match[4] === undefined ? 1 : Number(match[4]); 2428 if( !( alpha > 0 ) ){ 2429 return; 2430 } 2431 this.$refs.header.style.setProperty( 2432 '--cs-header-background-rgb', 2433 match[1] + ', ' + match[2] + ', ' + match[3] 2434 ); 2435 }, 2436 sync_section_gradient_height(section_height_px){ 2437 if( !this.$refs.section_gradient || !this.$refs.header ){ 2438 return; 2439 } 2440 let transfer_raw = this.$refs.header.getAttribute("data-header-top-fade-transfer"); 2441 if( !transfer_raw ){ 2442 return; 2443 } 2444 let transfer = null; 2445 try { 2446 transfer = JSON.parse(transfer_raw); 2447 } catch (error) { 2448 return; 2449 } 2450 if( !transfer || transfer.kind === 'legacy_fade' ){ 2451 return; 2452 } 2453 if( !section_height_px || section_height_px <= 0 ){ 2454 return; 2455 } 2456 this.$refs.section_gradient.style.height = 2457 ( section_height_px * ( transfer.height_percent / 100 ) ) + "px"; 2458 }, 2459 }, 2460 computed: { 2461 burgee_link(){ 2462 //starting june 18 2026 we don't show this custom page header component in the member app at all.. 2463 /* 2464 if( this.is_react_native_member_app ){ 2465 return "/account" 2466 } 2467 */ 2468 return "/" 2469 }, 2470 current_user(){ 2471 return Parse.User.current() 2472 }, 2473 }, 2474 }); 2475 2476 2477</script>
2477
2477<script type="text/html" id="template-image-grid"> 2478 <template v-if="!image_grid_layout || image_grid_layout !== 'carousel'"> 2479 <div 2480 class="image-grid-wrap centeredBlock" 2481 :class="'image-grid-wrap_' + client_id + ' images_per_row_' + images_per_row + ' ' + class_string" 2482 > 2483 <!-- draggable start for image-grid-image --> 2484 <div 2485 v-if="!show_first_row_skeleton && images?.length > 0" 2486 class="image-grid-insert flexWrapLeftAlign" 2487 :class=" is_admin ? 'draggable-container_image_grid_' + component.id : '' " 2488 > 2489 <template v-for="(image, index) in images" :key="image?.id"> 2490 <div 2491 v-if="image && image.url" 2492 class="image-grid-image relative" 2493 :class="'image-grid-image_' + image?.id" 2494 :id="image?.id" 2495 > 2496 <!-- video --> 2497 <div 2498 v-if="image.is_video" 2499 class="image-grid-image-inner" 2500 :class="{ 'image-grid-fade-media': !is_admin, 'is-loaded': image_has_loaded(image) }" 2501 style="align-items:center;display:flex;" 2502 :style="{'border-radius': image_grid_border_radius + 'px' }" 2503 > 2504 <video class="cs-background-video" autoplay playsinline loop muted :src="image.url" style="display:block;"></video> 2505 <div v-if="is_admin" class="image-grid-drag-handle-wrap draggable-handle"> 2506 <i class="fa-regular fa-grip-dots image-grid-drag-handle"></i> 2507 </div> 2508 </div> 2509 <!-- image --> 2510 <div 2511 v-else 2512 class="image-grid-image-inner" 2513 :class="{ 'image-grid-fade-media': !is_admin, 'is-loaded': image_has_loaded(image) }" 2514 :style="{'background-image': 'url(' + image.url + ')', 'border-radius': image_grid_border_radius + 'px' }" 2515 > 2516 <div v-if="is_admin" class="image-grid-drag-handle-wrap draggable-handle"> 2517 <i class="fa-regular fa-grip-dots image-grid-drag-handle"></i> 2518 </div> 2519 </div> 2520 <!-- overlay --> 2521 <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)"> 2522 <div class="image-toolbar-wrapper flexNoWrap centeredBlock" @click.stop> 2523 <div 2524 class="image-toolbar-option easeFast" 2525 data-option="change" 2526 style="border-right:1px solid #fff;" 2527 @click.stop="change_image(image)" 2528 >Change</div> 2529 <div 2530 class="image-toolbar-option easeFast" 2531 data-option="update" 2532 style="border-right:1px solid #fff;" 2533 @click.stop="prepThenDisplayImageSettings(image)" 2534 >Update</div> 2535 <div 2536 class="image-toolbar-option easeFast" 2537 data-option="remove" 2538 @click.stop="tapped_remove_image(image)" 2539 ><i class="fa-regular fa-trash" style="font-size:14px;"></i></div> 2540 </div> 2541 <div class="image-text-wrapper" :style="{ alignItems: text_position === 'start' ? 'flex-start' : text_position === 'center' ? 'center' : 'flex-end' }"> 2542 <h2 2543 v-if="image.caption" 2544 class="image-grid-image-caption" 2545 :style="{ 'font-size': caption_size + 'px', 'font-weight': caption_font_weight }" 2546 >{{ image.caption }}</h2> 2547 <p 2548 v-if="image.subtext" 2549 class="image-grid-image-caption marginTopFour" 2550 :style="{ 'font-size': subtext_size + 'px', 'font-weight': caption_font_weight }" 2551 >{{ image.subtext }}</p> 2552 <p 2553 v-if="image.link_label" 2554 class="image-grid-image-caption marginTopFour" 2555 style="text-decoration: underline;" 2556 :style="{ 'font-size': link_label_size + 'px', 'font-weight': caption_font_weight }" 2557 >{{ image.link_label }}</p> 2558 </div> 2559 </div> 2560 <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' }"> 2561 <div class="image-text-wrapper" :style="{ alignItems: text_position === 'start' ? 'flex-start' : text_position === 'center' ? 'center' : 'flex-end' }"> 2562 <h2 2563 v-if="image.caption" 2564 class="image-grid-image-caption" 2565 :style="{ 'font-size': caption_size + 'px', 'font-weight': caption_font_weight }" 2566 >{{ image.caption }}</h2> 2567 <p 2568 v-if="image.subtext" 2569 class="image-grid-image-caption marginTopFour" 2570 :style="{ 'font-size': subtext_size + 'px', 'font-weight': caption_font_weight }" 2571 >{{ image.subtext }}</p> 2572 <p 2573 v-if="image.link_label" 2574 class="image-grid-image-caption marginTopFour" 2575 style="text-decoration: underline;" 2576 :style="{ 'font-size': link_label_size + 'px', 'font-weight': caption_font_weight }" 2577 >{{ image.link_label }}</p> 2578 <a 2579 v-if="return_link_href(image.link_to)" 2580 class="absoluteA" 2581 :href="return_link_href(image.link_to)" 2582 rel="noopener noreferrer" 2583 ></a> 2584 </div> 2585 </div> 2586 </div> 2587 <div 2588 v-else-if="is_admin && image && image.placeholder" 2589 class="image-grid-image relative cs-image-slot-placeholder-host" 2590 :class="'image-grid-image_' + image?.id" 2591 :id="image?.id" 2592 @click.stop="fill_image_placeholder(image)" 2593 > 2594 <div class="image-grid-image-inner cs-image-slot-placeholder" :style="{ 'border-radius': image_grid_border_radius + 'px' }">
2595 <span class="cs-image-slot-placeholder-icon"><i class="fa-regular fa-image"></i></span> 2596 <span class="cs-image-slot-placeholder-label">Add {{ image.label || 'image' }}</span> 2597 </div> 2598 </div> 2599 </template> 2600 <!-- upload image **for admin --> 2601 <template v-if="is_admin"> 2602 <div class="image-grid-image image-grid-image_upload non-draggable"> 2603 <div class="image-grid-image-inner"> 2604 <div class="uploadImageZone relative pointer"> 2605 <input 2606 @change="imageUploadChange($event)" 2607 class="image-grid-upload-input absoluteInput" 2608 type="file" accept="image/*,video/*" 2609 > 2610 <p class="centeredBlock" style="font-family:roboto !important;">+ Upload image/video</p> 2611 <div class="uploadImageOverlay"> 2612 <div class="uploadImageSpinner centeredBlock"></div> 2613 </div> 2614 </div> 2615 </div> 2616 </div> 2617 </template> 2618 </div> 2619 <!-- draggable end for image-grid-image --> 2620 <div v-if="!show_first_row_skeleton && images?.length === 0 && is_admin" class="image-grid-insert flexWrapLeftAlign"> 2621 <div class="image-grid-image image-grid-image_upload"> 2622 <div class="image-grid-image-inner"> 2623 <div class="uploadImageZone relative pointer"> 2624 <input 2625 @change="imageUploadChange($event)" 2626 class="image-grid-upload-input absoluteInput" 2627 type="file" accept="image/*,video/*" 2628 > 2629 <p class="centeredBlock" style="font-family:roboto !important;">+ Upload image/video</p> 2630 <div class="uploadImageOverlay"> 2631 <div class="uploadImageSpinner centeredBlock"></div> 2632 </div> 2633 </div> 2634 </div> 2635 </div> 2636 </div> 2637 <div v-if="show_first_row_skeleton" class="image-grid-insert flexWrapLeftAlign"> 2638 <div 2639 v-for="n in skeleton_tile_count" 2640 :key="'image-grid-skeleton-' + n" 2641 class="image-grid-image relative" 2642 > 2643 <div 2644 class="image-grid-image-inner image-grid-skeleton-tile" 2645 :style="{ 'border-radius': image_grid_border_radius + 'px' }" 2646 ></div> 2647 </div> 2648 </div> 2649 </div> 2650 </template> 2651 <template v-else> 2652 2653 <div style="box-sizing: border-box; padding: 20px;" class="event-list-wrap centeredBlock centeredText" :class="'event-list-wrap_' + client_id"> 2654 <!-- <div 2655 class="flexNoWrap align-items-center event_list_carousel_default" 2656 style="padding: 0px 0px 15px 5px;" 2657 > 2658 <p>PLACEHOLDER</p> 2659 </div> --> 2660 <div 2661 class="image-grid-wrap centeredBlock" 2662 :class="'image-grid-wrap_' + client_id + class_string" 2663 style="padding: 20px;" 2664 > 2665 <div 2666 class="tableInsert event-list-card-wrapper" 2667 style="overflow: scroll !important; scrollbar-width: 5px !important; flex-wrap: nowrap;" 2668 :style="{ 'gap': formatting_obj.card_spacing ? Number(formatting_obj.card_spacing) + 'px' : '25px' }" 2669 > 2670 <template v-if="show_first_row_skeleton"> 2671 <div 2672 v-for="n in skeleton_tile_count" 2673 :key="'image-grid-carousel-skeleton-' + n" 2674 class="event-list-card noPadding noMargin" 2675 :class="{ 2676 'small-card-image-grid': formatting_obj.card_size == 'small', 2677 'medium-card-image-grid': !formatting_obj.card_size || formatting_obj.card_size == 'medium', 2678 'large-card-image-grid': formatting_obj.card_size == 'large', 2679 }" 2680 :style="{'border-radius': image_grid_border_radius + 'px' ||'0px' }" 2681 > 2682 <div class="image-grid-image relative"> 2683 <div 2684 class="image-grid-event-image image-grid-skeleton-tile" 2685 :style="{'border-radius': image_grid_border_radius + 'px' }" 2686 ></div> 2687 </div> 2688 <div 2689 v-if="formatting_obj.caption_align === 'under'" 2690 class="image-grid-card-content background" 2691 :style="{'border-radius': image_grid_border_radius + 'px' ||'0px' }" 2692 > 2693 <div class="image-grid-skeleton-caption image-grid-skeleton-tile"></div> 2694 </div> 2695 </div> 2696 </template> 2697 <template 2698 v-else-if="images && images.length > 0" 2699 v-for="(image, index) in images" 2700 :key="image?.id" 2701 > 2702 <div 2703 v-if="image && image.url" 2704 class="event-list-card noPadding noMargin" 2705 :class="{ 2706 'small-card-image-grid': formatting_obj.card_size == 'small', 2707 'medium-card-image-grid': !formatting_obj.card_size || formatting_obj.card_size == 'medium', 2708 'large-card-image-grid': formatting_obj.card_size == 'large', 2709 'box-shadow-light': formatting_obj.box_shadow == 'light', 2710 'box-shadow-dark': formatting_obj.box_shadow == 'dar
2710k', 2711 'pointer': !is_admin, 2712 }" 2713 :style="{'border-radius': image_grid_border_radius + 'px' ||'0px' }" 2714 @click.stop="clicked_carousel_image(image, $event)" 2715 > 2716 <div 2717 v-if="image && image.url" 2718 class="image-grid-image relative" 2719 :class="'image-grid-image_' + image?.id" 2720 :id="image?.id" 2721 > 2722 <!-- video --> 2723 <div 2724 v-if="image.is_video" 2725 class="image-grid-event-image" 2726 :class="{ 'image-grid-fade-media': !is_admin, 'is-loaded': image_has_loaded(image) }" 2727 style="align-items:center;display:flex;" 2728 :style="{'border-radius': image_grid_border_radius + 'px' }" 2729 > 2730 <video class="cs-background-video" autoplay playsinline loop muted :src="image.url" style="display:block;"></video> 2731 </div> 2732 <!-- image --> 2733 <div 2734 v-else 2735 class="image-grid-event-image" 2736 :class="{ 'image-grid-fade-media': !is_admin, 'is-loaded': image_has_loaded(image) }" 2737 :style="{'background-image': 'url(' + image.url + ')', 'border-radius': image_grid_border_radius + 'px' }" 2738 ></div> 2739 <!-- overlay --> 2740 <div 2741 @click.stop="change_image(image)" 2742 v-if="is_admin" 2743 class="image-grid-image-overlay flexWrap easeFast" 2744 style="left:0px;right:0px;bottom:0px;top:0px" 2745 :style="{ 'background-color': returnRGBAStringForImageGridImageOverlayOpacity( image_grid_opacity ), 'border-radius': image_grid_border_radius + 'px' }" 2746 > 2747 <div class="image-toolbar-wrapper flexNoWrap centeredBlock" @click.stop> 2748 <div 2749 class="image-toolbar-option easeFast" 2750 data-option="change" 2751 style="border-right:1px solid #fff;" 2752 @click.stop="change_image(image)" 2753 >Change</div> 2754 <div 2755 class="image-toolbar-option easeFast" 2756 data-option="update" 2757 style="border-right:1px solid #fff;" 2758 @click.stop="prepThenDisplayImageSettings(image)" 2759 >Update</div> 2760 <div 2761 class="image-toolbar-option easeFast" 2762 data-option="remove" 2763 @click.stop="tapped_remove_image(image)" 2764 ><i class="fa-regular fa-trash" style="font-size:14px;"></i></div> 2765 </div> 2766 <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' }"> 2767 <h2 2768 v-if="image.caption" 2769 class="image-grid-image-caption" 2770 :style="{ 'font-size': caption_size + 'px', 'font-weight': caption_font_weight }" 2771 >{{ image.caption }}</h2> 2772 <p 2773 v-if="image.subtext" 2774 class="image-grid-image-caption marginTopFour" 2775 :style="{ 'font-size': subtext_size + 'px', 'font-weight': caption_font_weight }" 2776 >{{ image.subtext }}</p> 2777 <p 2778 v-if="image.link_label" 2779 class="image-grid-image-caption marginTopFour" 2780 style="text-decoration: underline;" 2781 :style="{ 'font-size': link_label_size + 'px', 'font-weight': caption_font_weight }" 2782 >{{ image.link_label }}</p> 2783 </div> 2784 </div> 2785 <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)"> 2786 <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' }"> 2787 <h2 2788 v-if="image.caption" 2789 class="image-grid-image-caption" 2790 :style="{ 'font-size': caption_size + 'px', 'font-weight': caption_font_weight }" 2791 >{{ image.caption }}</h2> 2792 <p 2793 v-if="image.subtext" 2794 class="image-grid-image-caption marginTopFour" 2795 :style="{ 'font-size': subtext_size + 'px', 'font-weight': caption_font_weight }" 2796 >{{ image.subtext }}</p> 2797 <p 2798 v-if="image.link_label" 2799 class="image-grid-image-caption marginTopFour" 2800 style="text-decoration: underline;" 2801 :style="{ 'font-size': link_label_size + 'px', 'font-weight': caption_font_weight }" 2802 >{{ image.link_label }}</p> 2803 </div> 2804 <a 2805 v-if="return_link_href(image.link_to)" 2806 class="absoluteA" 2807 :href="return_link_href(image.link_to)" 2808 rel="noopener noreferrer" 2809 ></a> 2810 </div> 2811 </div> 2812 <div 2813 v-if="formatting_obj.caption_align === 'under'" 2814 class="image-grid-card-content background" 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 <h2 2819 v-if="image.caption" 2820 class="image-grid-image-caption visible" 2821 :style="{ 'font-size': caption_size + 'px', 'font-weight': caption_font_weight }" 2822 >{{ image.caption }}</h2> 2823 <p 2824 v-if="image.subtext" 2825 class="image-grid-image-caption visible marginTopFour" 2826 :style="{ 'font-size': subtext_size + 'px', 'font-weight': caption_font_weight }" 2827 >{{ image.subtext }}</p> 2828 <p 2829 v-if="image.link_label" 2830 class="image-grid-image-caption visible marginTopFour" 2831 style="text-decoration: underline;" 2832 :style="{ 'font-size': link_label_size + 'px', 'font-weight': caption_font_weight }" 2833 > 2834 <a 2835 v-if="return_link_href(image.link_to)" 2836 :href="return_link_href(image.link_to)" 2837 rel="noopener noreferrer" 2838 >{{ image.link_label }}</a> 2839 <template v-else>{{ image.link_label }}</template> 2840 </p> 2841 </div> 2842 </div> 2843 </div> 2844 <div 2845 v-else-if="is_admin && image && image.placeholder" 2846 class="event-list-card noPadding noMargin cs-image-slot-placeholder-host pointer" 2847 :class="{ 2848 'small-card-image-grid': formatting_obj.card_size == 'small', 2849 'medium-card-image-grid': !formatting_obj.card_size || formatting_obj.card_size == 'medium', 2850 'large-card-image-grid': formatting_obj.card_size == 'large', 2851 }" 2852 :style="{'border-radius': image_grid_border_radius + 'px' ||'0px' }" 2853 @click.stop="fill_image_placeholder(image)" 2854 > 2855 <div class="image-grid-image relative"> 2856 <div class="image-grid-event-image cs-image-slot-placeholder">
2857 <span class="cs-image-slot-placeholder-icon"><i class="fa-regular fa-image"></i></span> 2858 <span class="cs-image-slot-placeholder-label">Add {{ image.label || 'image' }}</span> 2859 </div> 2860 </div> 2861 </div> 2862 </template> 2863 <!-- upload image **for admin --> 2864 <template v-if="is_admin"> 2865 <div 2866 class="event-list-card pointer" 2867 :class="{ 2868 'small-card-image-grid': formatting_obj.card_size == 'small', 2869 'medium-card-image-grid': !formatting_obj.card_size || formatting_obj.card_size == 'medium', 2870 'large-card-image-grid': formatting_obj.card_size == 'large', 2871 }" 2872 > 2873 <div class="image-grid-image relative upload-image-grid-image-zone"> 2874 <div class="image-grid-event-image"></div> 2875 <input 2876 @change="imageUploadChange($event)" 2877 class="image-grid-upload-input absoluteInput" 2878 type="file" accept="image/*,video/*" 2879 > 2880 <p class="centeredBlock" style="font-family:roboto !important;">+ Upload image/video</p> 2881 <!-- <div class="uploadImageOverlay"> 2882 <div class="uploadImageSpinner centeredBlock"></div> 2883 </div> --> 2884 </div> 2885 <div 2886 v-if="formatting_obj.caption_align === 'under'" 2887 class="image-grid-card-content" 2888 :style="{'border-radius': image_grid_border_radius + 'px' ||'0px' }" 2889 > 2890 <div class="image-text-wrapper" :style="{ alignItems: text_position === 'start' ? 'flex-start' : text_position === 'center' ? 'center' : 'flex-end' }"> 2891 <!-- PLACEHOLDER --> 2892 </div> 2893 </div> 2894 </div> 2895 </template> 2896 </div> 2897 </div> 2898 </div> 2899 </template> 2900</script>
2900 2901 2902<style> 2903 .cs-image-slot-placeholder-host { 2904 cursor: pointer; 2905 } 2906 .cs-image-slot-placeholder { 2907 display: flex; 2908 flex-direction: column; 2909 align-items: center; 2910 justify-content: center; 2911 gap: 8px; 2912 width: 100%; 2913 height: 100%; 2914 min-height: 140px; 2915 background: #f1f4f8; 2916 border: 1.5px dashed #b7c1d1; 2917 color: #5f6f8d; 2918 text-align: center; 2919 box-sizing: border-box; 2920 } 2921 .cs-image-slot-placeholder-icon { 2922 font-size: 22px; 2923 } 2924 .cs-image-slot-placeholder-label { 2925 font-family: roboto !important; 2926 font-size: 14px; 2927 font-weight: 600; 2928 } 2929 .image-grid-image.dragging{ 2930 overflow: hidden; 2931 border-radius: 10px; 2932 } 2933 .image-grid-image.dragging .image-grid-image-inner { 2934 transition: all 0.35s ease-in-out; 2935 border: 1.2px solid #fff; 2936 border-radius: 10px; 2937 z-index: 1000; 2938 transform: scale(1.1); 2939 cursor: grab; 2940 cursor: move; 2941 } 2942 .image-grid-image.dragging .image-grid-drag-handle-wrap { 2943 display: none; 2944 position: relative; 2945 } 2946 .event-list-card { 2947 overflow: hidden; 2948 background: white; 2949 font-family: 'Inter', sans-serif; 2950 display: flex; 2951 flex-direction: column; 2952 flex: 0 0 auto; 2953 transition: transform 0.1s ease; 2954 } 2955 .event-list-card.small-card-image-grid { 2956 width: 190px; 2957 } 2958 .event-list-card.medium-card-image-grid { 2959 width: 230px; 2960 } 2961 .event-list-card.large-card-image-grid { 2962 width: 300px; 2963 } 2964 .event-list-card.box-shadow-light { 2965 box-shadow: 2px 4px 8px rgba(0, 0, 0, 0.2); 2966 } 2967 .event-list-card.box-shadow-dark { 2968 box-shadow: 6px 6px 16px rgba(0, 0, 0, 0.2); 2969 } 2970 .event-list-card .image-grid-image { 2971 width: 100%; 2972 flex-basis: auto; 2973 aspect-ratio: 1 / 1; 2974 } 2975 .event-list-card .image-grid-image:before { 2976 display: none; 2977 } 2978 .event-list-card .image-grid-event-image { 2979 display: flex; 2980 text-align: center; 2981 align-items: center; 2982 width: 100%; 2983 height: 100%; 2984 background-size: cover; 2985 background-repeat: no-repeat; 2986 background-position: center; 2987 color: #fff; 2988 position: absolute; 2989 pointer-events: none; 2990 } 2991 .event-list-card .image-grid-event-image * { 2992 pointer-events: none; 2993 } 2994 .event-list-card .image-grid-image-overlay { 2995 z-index: 1; 2996 } 2997 .event-list-card .image-grid-carousel-live-overlay { 2998 pointer-events: auto; 2999 cursor: pointer; 3000 } 3001 .image-grid-card-content { 3002 height: 100px; 3003 padding: 12px 5px; 3004 } 3005 .image-grid-skeleton-tile { 3006 position: relative; 3007 background-color: #e8edf1; 3008 overflow: hidden; 3009 margin-right: 0; 3010 } 3011 .image-grid-skeleton-tile::after { 3012 background: linear-gradient(40deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0)); 3013 display: block; 3014 content: ""; 3015 position: absolute; 3016 width: 100%; 3017 height: 100%; 3018 transform: translateX(-100%); 3019 top: 0; 3020 left: 0; 3021 animation: coming_soon 0.75s infinite; 3022 } 3023 .image-grid-skeleton-caption { 3024 height: 16px; 3025 width: 70%; 3026 border-radius: 4px; 3027 margin: 8px auto 0; 3028 } 3029 .image-grid-fade-media { 3030 opacity: 0; 3031 transition: opacity 0.4s ease; 3032 } 3033 .image-grid-fade-media.is-loaded { 3034 opacity: 1; 3035 } 3036 .upload-image-grid-image-zone{ 3037 color: #323d63; 3038 text-align: center; 3039 border-radius: 2px; 3040 background-color: #f1f5f9; 3041 position: relative; 3042 display: flex; 3043 align-items: center; 3044 } 3045</style> 3046
3047<script> 3048 3049 app_content.component("image-grid-component", { 3050 template: "#template-image-grid", 3051 data() { 3052 return { 3053 global: store, 3054 loading: false, 3055 first_row_ready: false, 3056 loaded_image_ids: {}, 3057 }; 3058 }, 3059 props: { 3060 client_id: String, 3061 class_string: String, 3062 image_grid_layout: String, 3063 images_per_row: [String, Number], 3064 caption_size: String, 3065 subtext_size: String, 3066 link_label_size: String, 3067 text_position: String, 3068 caption_font_weight: String, 3069 image_grid_opacity: String, 3070 images_string: String, 3071 image_grid_border_radius: String, 3072 // carousel 3073 formatting: String, 3074 // admin 3075 is_admin: { 3076 type: Boolean, 3077 default: false, 3078 }, 3079 data_source_type: String, 3080 images_arr: Array, 3081 component: Object, 3082 last_update: Number, 3083 }, 3084 mounted() { 3085 this.preload_first_row(); 3086 this.$nextTick(() => { 3087 if (this.is_admin && this.images_arr){ 3088 this.makeElementsSortable_image_grid(); 3089 } 3090 }) 3091 }, 3092 methods: { 3093 preload_first_row(){ 3094 if( this.is_admin ){ 3095 this.first_row_ready = true; 3096 return; 3097 } 3098 let list = this.images || []; 3099 let count = this.first_row_slot_count; 3100 let slice = []; 3101 for( let i = 0; i < list.length && slice.length < count; i++ ){ 3102 let entry = list[i]; 3103 if( entry && entry.url && !entry.placeholder && !entry.is_video ){ 3104 slice.push(entry); 3105 } 3106 } 3107 if( slice.length === 0 ){ 3108 this.first_row_ready = true; 3109 this.preload_remaining_images(); 3110 return; 3111 } 3112 let remaining = slice.length; 3113 let finish = () => { 3114 this.first_row_ready = true; 3115 this.preload_remaining_images(); 3116 }; 3117 setTimeout(finish, 2500); 3118 slice.forEach((entry) => { 3119 this.preload_image_entry(entry, () => { 3120 remaining -= 1; 3121 if( remaining <= 0 ){ 3122 finish(); 3123 } 3124 }); 3125 }); 3126 }, 3127 preload_remaining_images(){ 3128 let list = this.images || []; 3129 list.forEach((entry) => { 3130 this.preload_image_entry(entry); 3131 }); 3132 }, 3133 preload_image_entry( entry, on_done ){ 3134 if( !entry || !entry.id ){ 3135 if( on_done ){ 3136 on_done(); 3137 } 3138 return; 3139 } 3140 if( entry.is_video || !entry.url || entry.placeholder ){ 3141 this.mark_image_loaded(entry.id); 3142 if( on_done ){ 3143 on_done(); 3144 } 3145 return; 3146 } 3147 if( this.loaded_image_ids[entry.id] ){ 3148 if( on_done ){ 3149 on_done(); 3150 } 3151 return; 3152 } 3153 let img = new Image(); 3154 img.onload = () => { 3155 this.mark_image_loaded(entry.id); 3156 if( on_done ){ 3157 on_done(); 3158 } 3159 }; 3160 img.onerror = () => { 3161 this.mark_image_loaded(entry.id); 3162 if( on_done ){ 3163 on_done(); 3164 } 3165 }; 3166 img.src = entry.url; 3167 },
3168 mark_image_loaded( id ){ 3169 if( !id ){ 3170 return; 3171 } 3172 let next = Object.assign({}, this.loaded_image_ids); 3173 next[id] = true; 3174 this.loaded_image_ids = next; 3175 }, 3176 image_has_loaded( image ){ 3177 if( this.is_admin ){ 3178 return true; 3179 } 3180 if( !image || !image.id ){ 3181 return false; 3182 } 3183 if( image.is_video ){ 3184 return true; 3185 } 3186 return !!this.loaded_image_ids[image.id]; 3187 }, 3188 makeElementsSortable_image_grid(){ 3189 //now create the new instance 3190 const containerSelector = `.draggable-container_image_grid_${this.component.id}`; 3191 const containers = document.querySelectorAll(containerSelector); 3192 if (containers?.length === 0) { 3193 return false; 3194 } 3195 const sortable = new Draggable.Sortable(containers, { 3196 draggable: '.image-grid-image:not(.non-draggable)', 3197 handle: '.draggable-handle', 3198 }); 3199 //now register listeners 3200 sortable.on('sortable:start', event => { 3201 var target = event.data.dragEvent.source; 3202 target.classList.add('dragging'); 3203 }); 3204 sortable.on('sortable:stop', event => { 3205 var target = event.data.dragEvent.source; 3206 target.classList.remove('dragging'); 3207 //now handle ui updates inside a timeout due to a draggable library issue (https://github.com/Shopify/draggable/issues/92) 3208 setTimeout(() => { 3209 this.handle_sortable_stop( event ); 3210 }, 0) 3211 }); 3212 }, 3213 handle_sortable_stop( event ){ 3214 let targetId = event.data.dragEvent.source.id; 3215 let dropElement = document.getElementById(targetId); 3216 var reordered_ids = Array.from(event.newContainer.children).map(event => event.id); 3217 reordered_ids.pop(); //pop off last value which is an empty string placeholder for image upload 3218 var reordered_images = []; 3219 reordered_ids.forEach(image_id => { 3220 //find the image 3221 var this_image = null; 3222 this.images.forEach(image => { 3223 if( image.id == image_id ){ 3224 this_image = image 3225 } 3226 }); 3227 if( this_image ){ 3228 reordered_images.push( this_image ); 3229 } 3230 }); 3231 //save changes 3232 save_changes_for_component( this.component, 'images', reordered_images ); 3233 }, 3234 returnRGBAStringForImageGridImageOverlayOpacity( opacity ){ 3235 if( ! opacity && opacity !== 0 ){ 3236 opacity = 40; 3237 } 3238 return "rgba(8,8,19,"+ opacity / 100 +")"; 3239 }, 3240 clicked_carousel_image( image, event ){ 3241 if( this.is_admin ){ 3242 return; 3243 } 3244 if( event && event.target && event.target.closest && event.target.closest('a') ){ 3245 return; 3246 } 3247 // An image with a link navigates instead of opening the lightbox. 3248 let href = this.return_link_href( image && image.link_to ); 3249 if( href ){ 3250 window.location.href = href; 3251 return; 3252 } 3253 this.open_carousel_lightbox( image ); 3254 }, 3255 // link_to is saved raw from the image settings input, so: 3256 // - a bare domain needs a protocol or it resolves as a relative path 3257 // - anything that isn't a usable link returns '' so callers fall back 3258 // to the lightbox instead of navigating nowhere 3259 return_link_href( link_to ){ 3260 if( ! link_to ){ 3261 return ''; 3262 } 3263 let trimmed = String( link_to ).trim(); 3264 if( /^(https?:\/\/|mailto:|tel:)\S/i.test( trimmed ) ){ 3265 return trimmed; 3266 } 3267 if( /^\//.test( trimmed ) || /^#\S/.test( trimmed ) ){ 3268 return trimmed; 3269 } 3270 if( /^[a-z0-9][a-z0-9-]*(\.[a-z0-9-]+)+([\/?#]\S*)?$/i.test( trimmed ) ){ 3271 return 'https://' + trimmed; 3272 } 3273 return ''; 3274 }, 3275 lightbox_file_name( entry ){ 3276 let url = (entry && entry.url) || ''; 3277 let from_url = url.split('?')[0].split('/').pop(); 3278 if( entry && entry.is_video ){ 3279 if( from_url && from_url.indexOf('.') !== -1 ){ 3280 return from_url; 3281 } 3282 return 'video.mp4'; 3283 } 3284 return from_url || 'photo.jpg'; 3285 }, 3286 open_carousel_lightbox( image ){ 3287 if( !store.components || !store.components.view_photo_carousel ){ 3288 return; 3289 } 3290 let photos = (this.images || []).filter((entry) => { 3291 return entry && entry.url && !entry.placeholder; 3292 }); 3293 if( photos.length === 0 ){ 3294 return; 3295 } 3296 let current_index = photos.findIndex((entry) => { 3297 return image && entry.id === image.id; 3298 }); 3299 if( current_index < 0 ){ 3300 current_index = 0; 3301 } 3302 store.components.view_photo_carousel.json_photos = photos.map((entry) => { 3303 return { 3304 id: entry.id, 3305 url: entry.url, 3306 caption: entry.caption, 3307 file_name: this.lightbox_file_name(entry), 3308 }; 3309 }); 3310 store.components.view_photo_carousel.current_index = current_index; 3311 store.components.view_photo_carousel.json_config = { 3312 context: 'web_builder', 3313 icon_only_actions: true, 3314 allow_download: false, 3315 allow_share: true, 3316 show_caption: true, 3317 caption_size: this.caption_size || 16, 3318 caption_font_weight: this.caption_font_weight || 400, 3319 blur_background: false, 3320 }; 3321 store.overlays.view_photo_carousel = true; 3322 }, 3323 confirmed_remove_image( data ){ 3324 var image = data.image; 3325 var component = data.component; 3326 //find existing images 3327 var images = component.get('config_latest').images || []; 3328 //loop through to remove this one 3329 var new_images = []; 3330 for (var i = 0; i <= images?.length - 1; i++) { 3331 if( images[i].id !== image.id ){ 3332 new_images.push( images[i] ); 3333 } 3334 } 3335 //now save and update the preview 3336 save_changes_for_component( component, 'images', new_images ); 3337 store.overlays.are_you_sure = false;
3338 }, 3339 // Agent-placed empty slot. Open the shared image-slot picker; on a 3340 // pick, write the chosen/imported URL into this slot and clear the 3341 // placeholder flag, so it renders like any uploaded tile. 3342 fill_image_placeholder( image ){ 3343 if( typeof webbuilder_open_image_slot_picker !== 'function' ){ 3344 if( typeof displayMessage === 'function' ){ 3345 displayMessage('Image picker is not loaded. Refresh the page and try again.', true, 5000); 3346 } 3347 return; 3348 } 3349 let label = (image && image.label) ? image.label : 'image'; 3350 let slot_id = image && image.id; 3351 webbuilder_open_image_slot_picker({ 3352 label: label, 3353 mode: 'placeholder', 3354 on_pick: (url) => { 3355 if( !url || !slot_id ){ 3356 return; 3357 } 3358 let current = (this.component.get('config_latest') && this.component.get('config_latest').images) || []; 3359 let next = current.map((entry) => { 3360 if( !entry || entry.id !== slot_id ){ 3361 return entry; 3362 } 3363 let updated = Object.assign({}, entry, { url: url, is_video: false }); 3364 delete updated.placeholder; 3365 return updated; 3366 }); 3367 save_changes_for_component( this.component, 'images', next ); 3368 }, 3369 on_keep_empty: () => { 3370 if( !slot_id ){ 3371 return; 3372 } 3373 let current = (this.component.get('config_latest') && this.component.get('config_latest').images) || []; 3374 let next = current.filter((entry) => entry && entry.id !== slot_id); 3375 save_changes_for_component( this.component, 'images', next ); 3376 }, 3377 }); 3378 }, 3379 // Filled tile "Change" action â swap the image via the shared picker 3380 // (mirrors custom-code's attached-slot menu: pick/paste/copy-external/ 3381 // remove). Caption/link editing stays on the existing Update toolbar. 3382 change_image( image ){ 3383 if( typeof webbuilder_open_image_slot_picker !== 'function' ){ 3384 if( typeof displayMessage === 'function' ){ 3385 displayMessage('Image picker is not loaded. Refresh the page and try again.', true, 5000); 3386 } 3387 return; 3388 } 3389 let slot_id = image && image.id; 3390 if( !slot_id ){ 3391 return; 3392 } 3393 webbuilder_open_image_slot_picker({ 3394 label: 'image', 3395 mode: 'attached', 3396 current_url: (image && image.url) || '', 3397 on_pick: (url) => { 3398 if( !url ){ 3399 return; 3400 } 3401 let current = (this.component.get('config_latest') && this.component.get('config_latest').images) || []; 3402 let next = current.map((entry) => { 3403 if( !entry || entry.id !== slot_id ){ 3404 return entry; 3405 } 3406 return Object.assign({}, entry, { url: url, is_video: false }); 3407 }); 3408 save_changes_for_component( this.component, 'images', next ); 3409 }, 3410 on_remove: () => { 3411 this.confirmed_remove_image({ image: image, component: this.component }); 3412 }, 3413 }); 3414 }, 3415 tapped_remove_image( image ){ 3416 //ask for confirmation 3417 var params = {}; 3418 params["fn"] = this.confirmed_remove_image; 3419 params["title"] = "Remove image"; 3420 params["subtext"] = "Are you sure you want to permanently remove this tile from the image grid?"; 3421 params["button_label"] = "Remove"; 3422 params["data"] = { 3423 "image": image, 3424 "component": this.component 3425 }; 3426 are_you_sure( params ); 3427 return 3428 }, 3429 prepThenDisplayImageSettings(imageObject){ 3430 store.components.image_settings_overlay.componentObject = this.component; 3431 store.components.image_settings_overlay.imageObject = imageObject; 3432 store.components.image_settings_overlay.settings = { 3433 link_input: imageObject.link_to || '', 3434 caption_input: imageObject.caption || '', 3435 subtext_input: imageObject.subtext || '', 3436 link_label_input: imageObject.link_label || '', 3437 } 3438 store.components.image_settings_overlay.image_grid_props = { 3439 client_id: this.client_id, 3440 caption_size: this.caption_size, 3441 caption_font_weight: this.caption_font_weight, 3442 };
3443 this.global.overlays.image_settings_overlay = !this.global.overlays.image_settings_overlay; 3444 this.$nextTick(() => { 3445 this.updatePositionForImageSettings( imageObject ); 3446 }); 3447 }, 3448 updatePositionForImageSettings(imageObject){ 3449 var image_object = imageObject; 3450 if( ! image_object ){ 3451 return 3452 } 3453 var element = $(".image-grid-image_" + image_object.id); 3454 if( ! element ){ 3455 return 3456 } 3457 var offset = element.offset(); 3458 var top = offset.top; 3459 var popupHeight = $('.image-settings').outerHeight(true) || 0; 3460 var above_top = top - ( popupHeight + 10 ); 3461 var viewportTop = $(window).scrollTop(); 3462 var correct_top = above_top >= viewportTop + 10 ? above_top : top + 45; 3463 store.components.image_settings_overlay.position = { 3464 top: `${correct_top}px`, 3465 left: `${offset.left}px` 3466 }; 3467 }, 3468 imageUploadChange($event){ 3469 var file = event.target.files[0]; 3470 var componentObject = this.component; 3471 if(file && componentObject){ 3472 //update the ui 3473 $(this).parents('.uploadImageZone').addClass('loading'); 3474 //set attributes 3475 var type = 'image_grid'; 3476 var timestamp = Date.now(); 3477 //check the file type 3478 var is_video = this.returnIsVideo( file['name'] ) || false; 3479 if( is_video ){ 3480 //ok, it's a video 3481 //.. handle the video 3482 var file_name = componentObject.get('client_id')+"_grid_video_"+timestamp; 3483 return uploadFileToAWSV2( file, file_name ).then(( url )=>{ 3484 this.handleUploadedImageGridImage( url, componentObject, true ); 3485 }); 3486 }else{ 3487 //assume it's an image! 3488 var file_name = componentObject.get('client_id')+"_grid_image_"+timestamp; 3489 //handle the image 3490 loadCleanImage(file, this.handleCleanImage_image_grid, type, file_name, componentObject.get('client_id') ); 3491 } 3492 } 3493 //reset input value to detect change if same file is uploaded 3494 event.target.value = ''; 3495 }, 3496 returnIsVideo(filename) { 3497 var ext = this.getExtension(filename); 3498 switch (ext.toLowerCase()) { 3499 case 'm4v': 3500 case 'avi': 3501 case 'mpg': 3502 case 'mp4': 3503 case 'mp3': 3504 case 'mov': 3505 // etc 3506 return true; 3507 } 3508 return false; 3509 }, 3510 getExtension(filename) { 3511 var parts = filename.split('.'); 3512 return parts[parts?.length - 1]; 3513 }, 3514 completed_crop(params){ 3515 const { fileType, is_video, file_name, blob, componentObject } = params; 3516 //upload it 3517 return uploadFileToAWSV2( blob, file_name ).then(( url )=>{ 3518 this.handleUploadedImageGridImage( url, componentObject, is_video ); 3519 }); 3520 }, 3521 handleCleanImage_image_grid( canvas, type, file_name, componentID, image_index, is_video, fileType ){ 3522 $('body').data('can', canvas); 3523 let componentObject = this.component; 3524 if(!fileType) { 3525 fileType = 'image/jpeg' 3526 } 3527 var url = canvas.toDataURL(fileType); 3528 var params = {}; 3529 params["url"] = url; 3530 params["fn"] = this.completed_crop; 3531 params["componentID"] = componentID; 3532 params["fileType"] = fileType; 3533 params["is_video"] = is_video || false; 3534 params["file_name"] = file_name; 3535 params["componentObject"] = componentObject; 3536 launch_image_cropper( params ); 3537 }, 3538 handleUploadedImageGridImage(url, componentObject, is_video){ 3539 if( componentObject ){ 3540 //it's still in the dom 3541 //.. update it 3542 var images = componentObject.get('config_latest')?.images || []; 3543 images.push({ 3544 "id": generateClientID(), 3545 "url": url, 3546 "is_video": is_video 3547 }); 3548 save_changes_for_component( this.component, 'images', images ); 3549 //store.eventBus.$emit('saveChangesForComponent', this.component, option, value) 3550 //message 3551 if( is_video ){ 3552 displayMessage("Video uploaded. It may take a moment for your video to appear.", false, 5000); 3553 }else{ 3554 displayMessage("Image uploaded.", false, 3000); 3555 } 3556 } 3557 }, 3558 }, 3559 computed: { 3560 images(){ 3561 if (!this.is_admin && this.images_string){ 3562 try { 3563 const parsedObject = JSON.parse(this.images_string); 3564 // Drop unfilled agent placeholders on the public site â 3565 // they are an admin-only "Add image" affordance. 3566 return Array.isArray(parsedObject) 3567 ? parsedObject.filter((entry) => entry && entry.url && !entry.placeholder) 3568 : parsedObject; 3569 } catch (error) { 3570 console.error('Error parsing JSON: ', error) 3571 } 3572 } else if (this.is_admin && this.images_arr){ 3573 return this.images_arr || []; 3574 } 3575 }, 3576 formatting_obj(){ 3577 if (this.formatting){ 3578 return JSON.parse(this.formatting); 3579 } 3580 return {}; 3581 }, 3582 first_row_slot_count(){ 3583 if( this.image_grid_layout === 'carousel' ){ 3584 let size = this.formatting_obj.card_size; 3585 if( size === 'small' ){ 3586 return 5; 3587 } 3588 if( size === 'large' ){ 3589 return 3; 3590 } 3591 return 4; 3592 } 3593 let per_row = Number(this.images_per_row); 3594 if( !per_row || per_row < 1 ){ 3595 return 3; 3596 } 3597 return per_row; 3598 }, 3599 skeleton_tile_count(){ 3600 let images = this.images; 3601 let row = this.first_row_slot_count; 3602 if( images && images.length > 0 && images.length < row ){ 3603 return images.length; 3604 } 3605 return row; 3606 }, 3607 show_first_row_skeleton(){ 3608 if( this.is_admin ){ 3609 return false;
3610 } 3611 return !this.first_row_ready; 3612 }, 3613 }, 3614 }) 3615 3616</script>
3616
3616<script type="text/html" id="template-photo-gallery"> 3617 <div class="photo-gallery-wrap centeredBlock centeredText"> 3618 <div v-if="loading" class="photo-gallery"> 3619 <div 3620 v-for="n in skeleton_tile_count" 3621 :key="'photo-gallery-skeleton-' + n" 3622 class="photo-container" 3623 :style="calculate_container_dimensions()" 3624 > 3625 <div class="photo-cover photo-gallery-skeleton-tile"></div> 3626 </div> 3627 </div> 3628 <!-- hosted site --> 3629 <div v-if="!loading && !is_admin"> 3630 <div 3631 v-if="photo_album_id && json_photos" 3632 class="photo-gallery" 3633 > 3634 <div 3635 v-for="(photo, index) in json_photos" 3636 class="photo-container" 3637 :id="photo.id" 3638 @click="clicked_gallery_photo( index )" 3639 :style="calculate_container_dimensions()" 3640 > 3641 <video v-if="photo && returnIsVideo(photo.file_name)" class="video-cover paddingTop" autoplay muted loop playsinline> 3642 <source :src="photo.url" type="video/mp4"> 3643 </video> 3644 <div 3645 v-else-if="photo && photo.url" 3646 class="photo-cover" 3647 :class="{ 'photo-cover-fade': !is_admin, 'is-loaded': photo_has_loaded(photo) }" 3648 :style="'background-image:url('+encodeURI(photo.url)+')'" 3649 ></div> 3650 <!-- overlay --> 3651 <div class="photo-overlay"></div> 3652 </div> 3653 </div> 3654 </div> 3655 <!-- admin --> 3656 <div v-if="!loading && is_admin"> 3657 <div 3658 v-if="!photo_album_id" 3659 @click="link_photo_album()" 3660 class="noObjectsMSG centeredBlock centeredText" 3661 style="padding: 5em;" 3662 > 3663 <p style="cursor: pointer; font-family: 'Roboto', 'Arial' !important;"><i class="fa-light fa-images smallMarginRight"></i>Click to link a photo album</p> 3664 </div> 3665 <div 3666 v-else-if="photo_album_id && json_photos" 3667 class="photo-gallery" 3668 > 3669 <div 3670 v-for="(photo, index) in json_photos" 3671 class="photo-container" 3672 :id="photo.id" 3673 @click="clicked_gallery_photo( index )" 3674 :style="calculate_container_dimensions()" 3675 > 3676 <video v-if="photo && returnIsVideo(photo.file_name)" class="video-cover paddingTop" autoplay muted loop playsinline> 3677 <source :src="photo.url" type="video/mp4"> 3678 </video> 3679 <div 3680 v-else-if="photo && photo.url" 3681 class="photo-cover" 3682 :class="{ 'photo-cover-fade': !is_admin, 'is-loaded': photo_has_loaded(photo) }" 3683 :style="'background-image:url('+encodeURI(photo.url)+')'" 3684 ></div> 3685 <!-- overlay --> 3686 <div class="photo-overlay"></div> 3687 </div> 3688 </div> 3689 </div> 3690 </div> 3691</script>
3691 3692 3693<style> 3694 .photo-gallery-wrap{ 3695 height: fit-content; 3696 width: 100%; 3697 } 3698 .photo-gallery{ 3699 display: flex; 3700 flex-wrap: wrap; 3701 align-items: center; 3702 justify-content: flex-start; /* center for mobile */ 3703 } 3704 .photo-container { 3705 position: relative; 3706 box-sizing: border-box; 3707 margin: 0.3em; 3708 text-align: center; 3709 } 3710 .photo-container .video-cover { 3711 width: 100%; 3712 object-fit: cover; 3713 } 3714 .photo-container > .photo-cover { 3715 height: 100%; 3716 width: inherit; 3717 display: block; 3718 overflow: hidden; 3719 margin: 0 auto; 3720 background-size: cover; 3721 background-position: center; 3722 background-repeat: no-repeat; 3723 opacity: 1; 3724 } 3725 .photo-container > .photo-cover.photo-cover-fade { 3726 opacity: 0; 3727 transition: opacity 0.4s ease; 3728 } 3729 .photo-container > .photo-cover.photo-cover-fade.is-loaded { 3730 opacity: 1; 3731 } 3732 .photo-overlay{ 3733 position: absolute; 3734 display: flex; 3735 align-items: center; 3736 justify-content: center; 3737 top: 0; 3738 left: 0; 3739 height: 100%; 3740 width: 100%; 3741 opacity: 0; 3742 background-color: rgba(8,8,19,0.3); 3743 transition: opacity 0.2s ease-out; 3744 } 3745 .photo-container:hover .photo-overlay { 3746 cursor: pointer; 3747 opacity: 0.8 !important; 3748 } 3749 .photo-gallery-skeleton-tile { 3750 position: relative; 3751 background-color: #e8edf1; 3752 overflow: hidden; 3753 margin-right: 0; 3754 height: 100%; 3755 width: 100%; 3756 border-radius: 2px; 3757 } 3758 .photo-gallery-skeleton-tile::after { 3759 background: linear-gradient(40deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0)); 3760 display: block; 3761 content: ""; 3762 position: absolute; 3763 width: 100%; 3764 height: 100%; 3765 transform: translateX(-100%); 3766 top: 0; 3767 left: 0; 3768 animation: coming_soon 0.75s infinite; 3769 } 3770</style> 3771
3772<script> 3773 3774 app_content.component("photo-gallery-component", { 3775 template: "#template-photo-gallery", 3776 data() { 3777 return { 3778 global: store, 3779 loading: true, 3780 json_photos: [], 3781 loaded_photo_ids: {}, 3782 }; 3783 }, 3784 props: { 3785 client_id: String, 3786 is_admin: Boolean, 3787 component: Object, 3788 last_update: Number, 3789 photo_album: [String, Object], 3790 show_caption: Boolean, 3791 caption_size: Number, 3792 caption_font_weight: Number, 3793 photos_per_row: Number, 3794 }, 3795 mounted() { 3796 if( !this.photo_album_id ){ 3797 this.loading = false; 3798 return; 3799 } 3800 this.load_photo_albums( this.client_id ); 3801 }, 3802 methods: { 3803 calculate_container_dimensions(){ 3804 const percentage = 100 / this.photos_per_row; 3805 const isMobile = window.innerWidth <= 600; 3806 if ( isMobile ){ 3807 return { 3808 flex: `1 1 calc(${percentage}% - 0.6em)`, 3809 maxWidth: `calc(${percentage}% - 0.6em)`, 3810 height: `calc(${100}px * (${percentage} / 100) * 2.5)`, 3811 }; 3812 } 3813 return { 3814 flex: `1 1 calc(${percentage}% - 0.6em)`, 3815 maxWidth: `calc(${percentage}% - 0.6em)`, 3816 height: `calc(${500}px * (${percentage} / 100) * 2.5)`, 3817 }; 3818 }, 3819 load_photo_albums( client_id, overrides = {} ){ 3820 this.loading = true; 3821 var promise = Parse.Promise.as(); 3822 var thisPromise = promise.then(() => { 3823 var params = {}; 3824 params["client_id"] = client_id; 3825 params["photo_album_id"] = this.photo_album_id; 3826 params["overrides"] = overrides; 3827 params["request_url"] = window.location.href; 3828 return Parse.Cloud.run('retrieve_data_for_component', params); 3829 }).then(retrieved_photos => { 3830 this.json_photos = retrieved_photos; 3831 this.preload_gallery_photos(retrieved_photos); 3832 return Parse.Promise.as(); 3833 }).then(() => { 3834 this.loading = false; 3835 }, error => { 3836 checkParseError(error); 3837 this.loading = false; 3838 }); 3839 return thisPromise; 3840 }, 3841 clicked_gallery_photo( index ){ 3842 store.components.view_photo_carousel.json_photos = this.json_photos; 3843 store.components.view_photo_carousel.current_index = index; 3844 store.components.view_photo_carousel.json_config = { 3845 context: 'web_builder', 3846 icon_only_actions: true, 3847 allow_download: false, 3848 allow_share: true, 3849 show_caption: this.show_caption || false, 3850 caption_size: this.caption_size || 16, 3851 caption_font_weight: this.caption_font_weight || 400, 3852 blur_background: false, 3853 }; 3854 store.overlays.view_photo_carousel = true; 3855 }, 3856 link_photo_album(){ 3857 store.components.link_photo_album.component = this.component; 3858 store.components.link_photo_album.last_update = this.last_update; 3859 store.overlays.link_photo_album = true; 3860 }, 3861 returnIsVideo(filename) { 3862 var ext = this.getExtension(filename); 3863 switch (ext.toLowerCase()) { 3864 case 'm4v': 3865 case 'avi': 3866 case 'mpg': 3867 case 'mp4': 3868 case 'mp3': 3869 case 'mov': 3870 // etc 3871 return true; 3872 } 3873 return false;
3874 }, 3875 getExtension(filename) { 3876 var parts = filename.split('.'); 3877 return parts[parts.length - 1]; 3878 }, 3879 preload_gallery_photos( photos ){ 3880 if( !photos || !photos.length ){ 3881 return; 3882 } 3883 photos.forEach((photo) => { 3884 if( !photo || !photo.id ){ 3885 return; 3886 } 3887 if( this.returnIsVideo(photo.file_name) || !photo.url ){ 3888 this.mark_photo_loaded(photo.id); 3889 return; 3890 } 3891 let img = new Image(); 3892 img.onload = () => { 3893 this.mark_photo_loaded(photo.id); 3894 }; 3895 img.onerror = () => { 3896 this.mark_photo_loaded(photo.id); 3897 }; 3898 img.src = photo.url; 3899 }); 3900 }, 3901 mark_photo_loaded( id ){ 3902 if( !id ){ 3903 return; 3904 } 3905 let next = Object.assign({}, this.loaded_photo_ids); 3906 next[id] = true; 3907 this.loaded_photo_ids = next; 3908 }, 3909 photo_has_loaded( photo ){ 3910 if( this.is_admin ){ 3911 return true; 3912 } 3913 if( !photo || !photo.id ){ 3914 return false; 3915 } 3916 if( this.returnIsVideo(photo.file_name) ){ 3917 return true; 3918 } 3919 return !!this.loaded_photo_ids[photo.id]; 3920 }, 3921 }, 3922 computed: { 3923 photo_album_obj(){ 3924 if (this.photo_album && typeof this.photo_album === 'string'){ 3925 return JSON.parse(unescape(decodeURIComponent(this.photo_album))); 3926 } else if (this.photo_album){ 3927 return this.photo_album; 3928 } 3929 return; 3930 }, 3931 photo_album_id(){ 3932 let album = this.photo_album_obj; 3933 if( !album ){ 3934 return null; 3935 } 3936 return album.id || null; 3937 }, 3938 skeleton_tile_count(){ 3939 let per_row = Number(this.photos_per_row); 3940 if( !per_row || per_row < 1 ){ 3941 return 4; 3942 } 3943 return per_row; 3944 }, 3945 photo_album_name(){ 3946 return this.photo_album_obj.name || null; 3947 }, 3948 }, 3949 watch: { 3950 last_update(newVal, oldVal){ 3951 if (newVal !== oldVal){ 3952 this.load_photo_albums( this.client_id ); 3953 } 3954 } 3955 } 3956 }) 3957 3958</script>
3958
3958<script type="text/html" id="template-membership-calendar"> 3959 <!-- calendar component --> 3960 <div v-if="current_date"class="lightBorderTop hundop"> 3961 <calendar-booking-component 3962 ref="bookingCalendar" 3963 :is_admin="false" 3964 :rerender_key="rerender_key" 3965 :calendar_type="'web_component'" 3966 :default_calendar_tags="formatted_event_types" 3967 :selected_tags="selected_tags" 3968 :events="filtered_events" 3969 :view_preference="view_preference" 3970 :current_date="current_date" 3971 :calendar_tags="calendar_tags" 3972 :timezone="timezone" 3973 :height="'initial'" 3974 :filter_logic="filter_logic" 3975 :loading_parent="loading" 3976 :ui_tag_ids_default="ui_tag_ids_default" 3977 :ui_tag_ids_custom="ui_tag_ids_custom" 3978 :disable_filtering="!allow_user_filtering" 3979 @change_date="change_date" 3980 @change_view_preference="change_view_preference" 3981 @clicked_default_tag="clicked_default_tag" 3982 @clicked_calendar_tag="clicked_calendar_tag" 3983 @clicked_event="clicked_event" 3984 @clicked_show_more_events="clicked_show_more_events" 3985 @zoom_in_to_date="zoom_in_to_date" 3986 @add_event="clicked_day_cell" 3987 @isolate_default_tag="isolate_default_tag" 3988 @select_all_default_tags="select_all_default_tags" 3989 @clear_all_default_tags="clear_all_default_tags" 3990 @select_visible_default_tags="select_visible_default_tags" 3991 @clear_visible_default_tags="clear_visible_default_tags" 3992 @isolate_calendar_tag="isolate_calendar_tag" 3993 @select_all_custom_tags="select_all_custom_tags" 3994 @clear_all_custom_tags="clear_all_custom_tags" 3995 @select_visible_custom_tags="select_visible_custom_tags" 3996 @clear_visible_custom_tags="clear_visible_custom_tags" 3997 @unselect_all_custom_tags="unselect_all_custom_tags" 3998 /> 3999 </div> 4000</script>
4000 4001
4002<script> 4003 /* ----- STORE ----- */ 4004 store.components['membership_cal'] = { 4005 clubId: null, 4006 config_obj: {}, 4007 // new 4008 rerender_key: 0, 4009 current_date: null, 4010 view_preference: "Month", 4011 events: [], 4012 loading: { 4013 calendar: false, 4014 calendar_tags: false, 4015 ui_refresh: false, 4016 }, 4017 ui_tag_ids_default: {}, //used to track which tags are selected in the UI for default tags 4018 ui_tag_ids_custom: {}, 4019 selected_tags: { 4020 default: [], 4021 custom: [], 4022 }, 4023 calendar_tags: [], 4024 }; 4025 /* ----- COMPONENT ----- */ 4026 let membership_calendar_config = { 4027 template: '#template-membership-calendar', 4028 data() { 4029 return store.components['membership_cal']; 4030 }, 4031 props: { 4032 config: String, 4033 client_id: String, 4034 class_string: String, 4035 style_string: String, 4036 //admin 4037 component: Object, 4038 last_update: Number, 4039 tz: String, 4040 }, 4041 mounted() { 4042 if (this.config){ 4043 try { 4044 let raw_config = this.config; 4045 try { 4046 raw_config = decodeURIComponent(raw_config); 4047 } catch (decode_error) { 4048 // Already plain JSON from older markup. 4049 } 4050 this.config_obj = JSON.parse(raw_config); 4051 } catch (parse_error) { 4052 this.config_obj = {}; 4053 } 4054 } 4055 this.clubId = $('body').data('club-id'); 4056 // this.load_membership_cal(this.client_id); 4057 // Use component prop to determine if admin flow 4058 this.is_admin = !!this.component; 4059 // this.checkThenHandleEventTagsForCalendar(this.client_id); 4060 // //eventBus for overflow-overlay 4061 // store.eventBus.$on('clickSchedule_membership_cal', (event) => { 4062 // this.clickSchedule(event) 4063 // }) 4064 4065 //******* NEW CALENDAR BELOW *******// 4066 if(!this.current_date){ 4067 this.current_date = moment.tz( this.timezone ); 4068 } 4069 this.load_calendar(); 4070 this.set_tags(); 4071 // this.handle_filter_options(); 4072 this.load_event_tags(); 4073 this.get_timezone(); 4074 }, 4075 methods: { 4076 get_timezone(){ 4077 var promise = Parse.Promise.as().then(() => { 4078 var query = new Parse.Query("clubs"); 4079 query.select("timezone"); 4080 return query.get(this.clubId); 4081 }).then((clubObj) => { 4082 if(clubObj){ 4083 store.clubObject = clubObj; 4084 } 4085 return Parse.Promise.as(); 4086 }).catch((error) => { 4087 checkParseError(error); 4088 displayMessage("An error occurred while loading the club timezone: " + error.message, true, 5000); 4089 }); 4090 }, 4091 load_calendar(){ 4092 this.loading.calendar = true; 4093 var promise = Parse.Promise.as(); 4094 var events = []; 4095 var camps = []; 4096 var regattas = []; 4097 var group_classes = []; 4098 var recurring_events = []; 4099 var thisPromise = promise.then(() => { 4100 var promises = []; 4101 //if admin configured filter_tag_ids, we need to load all event types 4102 //so we can match events that have those tags regardless of type 4103 var should_load_all_types = this.filter_tag_ids && this.filter_tag_ids.length > 0; 4104 //social events 4105 var primary_promise = promise.then(()=>{ 4106 //see if we want these event types 4107 if( ! this.event_types.includes('socials') && ! should_load_all_types ){ 4108 //not retrieving social events 4109 return Promise.resolve({ 4110 formatted_events: [], 4111 recurring_events: [], 4112 }); 4113 } 4114 var params = {}; 4115 var { startTimestamp, endTimestamp } = this.getCalendarRange(); 4116 params["club_id"] = $('body').data('club-id'); 4117 params["start_timestamp"] = startTimestamp; 4118 params["end_timestamp"] = endTimestamp; 4119 params["flow"] = "member_portal"; 4120 params["is_admin"] = false;
4121 return Parse.Cloud.run('retrieve_social_events_for_calendar_v2', params); 4122 }).then((retrievedEvents) => { 4123 events = retrievedEvents.formatted_events || []; 4124 recurring_events = retrievedEvents.recurring_events || []; 4125 return Parse.Promise.as(); 4126 }); 4127 promises.push(primary_promise); 4128 // load camps 4129 var camps_promise = promise.then(()=>{ 4130 //see if we want these event types 4131 if( ! this.event_types.includes('camps') && ! should_load_all_types ){ 4132 //not retrieving camps 4133 return Promise.resolve([]); 4134 } 4135 var params = {}; 4136 var { startTimestamp, endTimestamp } = this.getCalendarRange(); 4137 params["club_id"] = $('body').data('club-id'); 4138 params["start_timestamp"] = startTimestamp; 4139 params["end_timestamp"] = endTimestamp; 4140 params["is_admin"] = false; 4141 return Parse.Cloud.run('retrieve_camps_for_calendar_v2', params); 4142 }).then((retrievedCamps) => { 4143 camps = retrievedCamps; 4144 return Parse.Promise.as(); 4145 }); 4146 promises.push(camps_promise); 4147 // load regattas 4148 var regattas_promise = promise.then(()=>{ 4149 //see if we want these event types 4150 if( this.hide_regattas ){ 4151 return Promise.resolve([]); 4152 } 4153 if( ! this.event_types.includes('regattas') && ! should_load_all_types ){ 4154 //not retrieving regattas 4155 return Promise.resolve([]); 4156 } 4157 var params = {}; 4158 var { startTimestamp, endTimestamp } = this.getCalendarRange(); 4159 params["club_id"] = $('body').data('club-id'); 4160 params["start_timestamp"] = startTimestamp; 4161 params["end_timestamp"] = endTimestamp; 4162 params["flow"] = "member_portal"; 4163 return Parse.Cloud.run('retrieve_regattas_for_calendar_v2', params); 4164 }).then((retrievedRegattas) => { 4165 regattas = retrievedRegattas; 4166 return Parse.Promise.as(); 4167 }); 4168 promises.push(regattas_promise); 4169 // load group classes 4170 var group_classes_promise = promise.then(()=>{ 4171 if( ! this.event_types.includes('group_classes') && ! should_load_all_types ){ 4172 return Promise.resolve([]); 4173 } 4174 var params = {}; 4175 var { startTimestamp, endTimestamp } = this.getCalendarRange(); 4176 params["club_id"] = $('body').data('club-id'); 4177 params["start_timestamp"] = startTimestamp; 4178 params["end_timestamp"] = endTimestamp; 4179 params["is_admin"] = false; 4180 return Parse.Cloud.run('retrieve_group_classes_for_calendar_v2', params); 4181 }).then((retrievedGroupClasses) => { 4182 group_classes = retrievedGroupClasses || []; 4183 return Parse.Promise.as(); 4184 }); 4185 promises.push(group_classes_promise); 4186 //return them in parallel 4187 return Promise.all(promises); 4188 }).then(() => { 4189 this.events = []; 4190 this.events = [ ...events, ...camps, ...regattas, ...group_classes, ...recurring_events ]; 4191 this.loading.calendar = false;
4192 }).catch((error) => { 4193 checkParseError(error); 4194 displayMessage("An error occurred while loading the calendar: " + error.message, true, 5000); 4195 this.loading.calendar = false; 4196 this.events = []; 4197 }); 4198 return thisPromise; 4199 }, 4200 load_event_tags(){ 4201 this.loading.calendar_tags = true; 4202 var promise = Parse.Promise.as(); 4203 var thisPromise = promise.then(() => { 4204 var query = new Parse.Query("event_tags"); 4205 query.equalTo("clubObject", { 4206 __type: "Pointer", 4207 objectId: $('body').data('club-id'), 4208 className: "clubs" 4209 }); 4210 query.equalTo("archived", false); 4211 query.notEqualTo("internal_only", true); 4212 query.limit( 1000 ); 4213 query.ascending("name"); 4214 return query.find(); 4215 }).then((retrievedTags) => { 4216 //if admin restricted to specific tags, only expose those in the user filter UI 4217 if( this.filter_tag_ids && this.filter_tag_ids.length > 0 ){ 4218 retrievedTags = retrievedTags.filter(tag => this.filter_tag_ids.indexOf(tag.id) !== -1); 4219 } 4220 this.calendar_tags = retrievedTags; 4221 //now set all of them to selected 4222 this.ui_tag_ids_custom = {};
4223 this.calendar_tags.forEach((tag) => { 4224 this.ui_tag_ids_custom[tag.id] = true; 4225 }); 4226 this.selected_tags.custom = this.calendar_tags.map(t => t.id); 4227 this.loading.calendar_tags = false; 4228 }).catch((error) => { 4229 checkParseError(error); 4230 displayMessage("An error occurred while loading calendar tags: " +error.message, true, 5000); 4231 this.loading.calendar_tags = false; 4232 this.calendar_tags = []; 4233 this.loading.calendar = false; 4234 }); 4235 return thisPromise; 4236 }, 4237 set_tags(){ 4238 //select all event types by default 4239 //options: "camp", "regatta", "social", "calendar_event" 4240 this.selected_tags.default = []; 4241 this.selected_tags.custom = []; 4242 let selected = []; 4243 //check what event types are available 4244 let event_types = this.event_types || []; 4245 if( event_types.indexOf('camps') !== -1 ) { 4246 selected.push('camp'); 4247 } 4248 if( event_types.indexOf('regattas') !== -1 ) { 4249 selected.push('regatta'); 4250 } 4251 if( event_types.indexOf('socials') !== -1 ) { 4252 selected.push('social'); 4253 selected.push('calendar_event'); 4254 } 4255 if( event_types.indexOf('group_classes') !== -1 ) { 4256 selected.push('group_class'); 4257 } 4258 this.selected_tags.default = selected; 4259 //and update the ui tags 4260 this.ui_tag_ids_default = {}; 4261 this.selected_tags.default.forEach((id) => { 4262 this.ui_tag_ids_default[id] = true; 4263 }); 4264 }, 4265 getCalendarRange() { 4266 let startTimestamp, endTimestamp; 4267 if (this.view_preference === "Month") { 4268 // Start and end of the previous and next months 4269 startTimestamp = moment(this.current_date).startOf("month").subtract(1, "month").valueOf(); 4270 endTimestamp = moment(this.current_date).endOf("month").add(1, "month").valueOf(); 4271 } else if (this.view_preference === "Week") { 4272 // Start and end of the previous and next 2 weeks 4273 startTimestamp = moment(this.current_date).startOf("isoWeek").subtract(2, "week").valueOf(); 4274 endTimestamp = moment(this.current_date).endOf("isoWeek").add(2, "week").valueOf(); 4275 } else if (this.view_preference === "Day") { 4276 // Start and end of the previous and next 2 weeks 4277 //.. important to do a wide enough range so that multi-day events are included 4278 startTimestamp = moment(this.current_date).startOf("isoWeek").subtract(2, "week").valueOf(); 4279 endTimestamp = moment(this.current_date).endOf("isoWeek").add(2, "week").valueOf(); 4280 } 4281 4282 return { startTimestamp, endTimestamp }; 4283 }, 4284 change_date( new_date ){ 4285 this.current_date = new_date; 4286 this.load_calendar(); 4287 }, 4288 change_view_preference( new_value ){ 4289 this.view_preference = new_value; 4290 this.load_calendar(); 4291 }, 4292 clicked_day_cell(date_components){ 4293 // if its month view, and we are on mobile (width < 720), go to day view 4294 if(this.view_preference === "Month" && window.innerWidth < 720){ 4295 //set the current date to the clicked date 4296 var clicked_date = moment.tz({ 4297 year: date_components.year, 4298 month: date_components.month, 4299 date: date_components.date, 4300 }, this.timezone); 4301 this.zoom_in_to_date( clicked_date ); 4302 } 4303 }, 4304 zoom_in_to_date( date ){ 4305 this.change_view_preference( "Day" ); 4306 this.current_date = date; 4307 }, 4308 clicked_default_tag(tag){ 4309 this.loading.ui_refresh = true; 4310 //update the ui 4311 if(this.ui_tag_ids_default[tag.id] === undefined){ 4312 //if the tag is not in the ui_tag_ids_default, add it 4313 this.ui_tag_ids_default[tag.id] = true; 4314 }else{ 4315 this.ui_tag_ids_default[tag.id] = !this.ui_tag_ids_default[tag.id]; 4316 } 4317 requestAnimationFrame(() => { 4318 // second RAF helps ensure weâre past layout/paint on Chrome/Safari 4319 requestAnimationFrame(() => { 4320 const list = this.selected_tags.default; 4321 const idx = list.indexOf(tag.id); 4322 // create a new array to avoid deep reactive churn 4323 this.selected_tags.default = idx !== -1 4324 ? list.filter(t => t !== tag.id) 4325 : [...list, tag.id]; 4326 this.loading.ui_refresh = false;
4327 }); 4328 }); 4329 }, 4330 clicked_calendar_tag(tag){ 4331 this.loading.ui_refresh = true; 4332 //update the ui 4333 if(this.ui_tag_ids_custom[tag.id] === undefined){ 4334 //if the tag is not in the ui_tag_ids_custom, add it 4335 this.ui_tag_ids_custom[tag.id] = true; 4336 }else{ 4337 this.ui_tag_ids_custom[tag.id] = !this.ui_tag_ids_custom[tag.id]; 4338 } 4339 requestAnimationFrame(() => { 4340 // second RAF helps ensure weâre past layout/paint on Chrome/Safari 4341 requestAnimationFrame(() => { 4342 const list = this.selected_tags.custom; 4343 const idx = list.indexOf(tag.id); 4344 // create a new array to avoid deep reactive churn 4345 this.selected_tags.custom = idx !== -1 4346 ? list.filter(t => t !== tag.id) 4347 : [...list, tag.id]; 4348 this.loading.ui_refresh = false; 4349 }); 4350 }); 4351 }, 4352 isolate_default_tag(tag){ 4353 //set the ui check boxes 4354 this.ui_tag_ids_default = {}; 4355 this.ui_tag_ids_default[tag.id] = true; 4356 this.ui_tags_ 4357 //set the selected tags to just this one 4358 this.selected_tags.default = [tag.id]; 4359 }, 4360 select_all_default_tags(){ 4361 //set the ui check boxes 4362 this.ui_tag_ids_default = {}; 4363 var event_types = ["camp", "regatta", "social", "banquet_event", "calendar_event", "group_class"]; 4364 if(store.clubObject.get('hide_regattas')){ 4365 event_types = event_types.filter(type => type !== 'regatta'); 4366 } 4367 event_types.forEach((type) => { 4368 this.ui_tag_ids_default[type] = true; 4369 }); 4370 //set the selected tags to all default tags 4371 this.selected_tags.default = event_types; 4372 }, 4373 clear_all_default_tags(){ 4374 this.ui_tag_ids_default = {}; 4375 this.selected_tags.default = []; 4376 }, 4377 select_visible_default_tags(visibleIds){ 4378 if (!visibleIds || !visibleIds.length) return; 4379 var current = this.selected_tags.default || []; 4380 var next = current.slice(); 4381 visibleIds.forEach(function(id){ if (next.indexOf(id) === -1) next.push(id); }); 4382 this.selected_tags.default = next; 4383 next.forEach(function(id){ this.ui_tag_ids_default[id] = true; }.bind(this)); 4384 }, 4385 clear_visible_default_tags(visibleIds){ 4386 if (!visibleIds || !visibleIds.length) return; 4387 var set = {}; 4388 visibleIds.forEach(function(id){ set[id] = true; }); 4389 var current = this.selected_tags.default || []; 4390 this.selected_tags.default = current.filter(function(id){ return !set[id]; }); 4391 this.ui_tag_ids_default = {}; 4392 (this.selected_tags.default || []).forEach(function(id){ this.ui_tag_ids_default[id] = true; }.bind(this)); 4393 }, 4394 isolate_calendar_tag(tag){ 4395 //set the ui check boxes 4396 this.ui_tag_ids_custom = {}; 4397 this.ui_tag_ids_custom[tag.id] = true; 4398 //set the selected tags to just this one 4399 this.selected_tags.custom = [tag.id]; 4400 }, 4401 select_all_custom_tags(){ 4402 //set the ui check boxes 4403 this.ui_tag_ids_custom = {};
4404 this.calendar_tags.forEach((tag) => { 4405 this.ui_tag_ids_custom[tag.id] = true; 4406 }); 4407 //set the selected tags to all custom tags 4408 this.selected_tags.custom = this.calendar_tags.map((tag) => tag.id); 4409 }, 4410 unselect_all_custom_tags(){ 4411 //used for the "catch all" option 4412 //set the ui check boxes 4413 this.ui_tag_ids_custom = {}; 4414 //set the selected tags to no custom tags 4415 this.selected_tags.custom = []; 4416 }, 4417 clear_all_custom_tags(){ 4418 this.ui_tag_ids_custom = {}; 4419 this.selected_tags.custom = []; 4420 }, 4421 select_visible_custom_tags(visibleIds){ 4422 if (!visibleIds || !visibleIds.length) return; 4423 var tagIds = visibleIds.filter(function(id){ return id !== 'catch_all_custom'; }); 4424 var current = this.selected_tags.custom || []; 4425 var next = current.slice(); 4426 tagIds.forEach(function(id){ if (next.indexOf(id) === -1) next.push(id); }); 4427 this.selected_tags.custom = next; 4428 next.forEach(function(id){ this.ui_tag_ids_custom[id] = true; }.bind(this)); 4429 }, 4430 clear_visible_custom_tags(visibleIds){ 4431 if (!visibleIds || !visibleIds.length) return; 4432 var set = {}; 4433 visibleIds.forEach(function(id){ set[id] = true; }); 4434 var current = this.selected_tags.custom || []; 4435 this.selected_tags.custom = current.filter(function(id){ return !set[id]; }); 4436 this.ui_tag_ids_custom = {}; 4437 (this.selected_tags.custom || []).forEach(function(id){ this.ui_tag_ids_custom[id] = true; }.bind(this)); 4438 }, 4439 clicked_event(event){ 4440 var is_recurring_event = event.is_recurring; 4441 // open detail view 4442 if(is_recurring_event) { 4443 // if(event.external_event_url) { 4444 // //its external, just pop open the window 4445 // return window.open(event.external_event_url); 4446 // } 4447 // var params = { 4448 // event_json: event, 4449 // }; 4450 show_event_details_in_member_cal({ event_json: event }); 4451 } else if(event) { 4452 if(event.regatta_subevent) { 4453 //go to microsite 4454 var regatta_id = event.parent_regatta_id; 4455 window.open(`/regatta/${ regatta_id }`); 4456 } else if(event.type == 'regatta'){ 4457 if(event.external_regatta_url){ 4458 window.open(event.external_regatta_url); 4459 } else { 4460 window.open(`/regatta/${ event.id }`); 4461 } 4462 } else if(event.type == 'camp'){ 4463 //go to registration 4464 window.open(`/register/camp/${ event.id }/class`); 4465 } else if(event.type === 'group_class'){ 4466 var timestamp = event.ts; 4467 var dateComponents = return_date_components_v2({ 4468 timezone: this.timezone, 4469 date: new Date(timestamp) 4470 }); 4471 var dateString = dateComponents.year + '-' + (dateComponents.month + 1) + '-' + dateComponents.date; 4472 let event_json = { 4473 ...event, 4474 external_event_url: '/group-classes?date_string=' + dateString, 4475 }; 4476 let params = { 4477 event_json: event_json, 4478 }; 4479 show_event_details_in_member_cal(params); 4480 } else { 4481 // if(event.external_event_url){ 4482 // window.open(event.external_event_url); 4483 // } else 4484 if(event.type === 'social') { 4485 //not calendar only ... go to the event page 4486 window.open(`/event/${ event.id }`); 4487 } else { 4488 //calendar event 4489 var params = { 4490 event_json: event, 4491 }; 4492 show_event_details_in_member_cal( params ); 4493 } 4494 } 4495 } 4496 }, 4497 }, 4498 computed: { 4499 event_types(){ 4500 if(this.is_admin) { 4501 return this.component?.get('config_latest').event_types || []; 4502 } else { 4503 return this.config_obj.event_types || []; 4504 } 4505 }, 4506 filter_tag_ids(){ 4507 if(this.is_admin) { 4508 return this.component?.get('config_latest').filter_tag_ids || []; 4509 } else { 4510 return this.config_obj.filter_tag_ids || []; 4511 } 4512 }, 4513 allow_user_filtering(){ 4514 //defaults to true for legacy components where the field is not set 4515 var raw; 4516 if(this.is_admin) { 4517 raw = this.component?.get('config_latest').allow_user_filtering; 4518 } else { 4519 raw = this.config_obj.allow_user_filtering; 4520 } 4521 return raw !== false; 4522 }, 4523 filter_logic(){ 4524 //when admin restricts the calendar to a set of event tags, an event 4525 //should be visible if it matches the type OR has a matching tag (per 4526 //admin filter rules), so we use OR logic on the user-facing filter 4527 //to avoid hiding events that admin intends to show. 4528 if( this.filter_tag_ids && this.filter_tag_ids.length > 0 ){ 4529 return 'or'; 4530 } 4531 return 'and'; 4532 }, 4533 event_type_setting_for_event(){ 4534 //map the actual event.type strings to the toggle keys used in event_types config 4535 return { 4536 'camp': 'camps', 4537 'regatta': 'regattas', 4538 'social': 'socials', 4539 'calendar_event': 'socials', 4540 'group_class': 'group_classes', 4541 }; 4542 }, 4543 filtered_events(){ 4544 var event_types = this.event_types || []; 4545 var filter_tag_ids = this.filter_tag_ids || []; 4546 //if neither types nor tags are configured, fall back to whatever was loaded 4547 if( event_types.length === 0 && filter_tag_ids.length === 0 ){ 4548 return this.events; 4549 } 4550 var type_map = this.event_type_setting_for_event; 4551 return this.events.filter(event => { 4552 var setting_key = type_map[event.type]; 4553 var type_in_selected = setting_key && event_types.indexOf(setting_key) !== -1; 4554 if( filter_tag_ids.length === 0 ){ 4555 //no tag filter configured: only show events whose type is selected 4556 return type_in_selected; 4557 } 4558 //tags are configured: an event is shown if its type is selected 4559 //(regardless of tags applied) OR it has any of the selected tags 4560 if( type_in_selected ){ 4561 return true; 4562 } 4563 var event_tag_ids = event.event_tag_ids || []; 4564 return event_tag_ids.some(id => filter_tag_ids.indexOf(id) !== -1); 4565 }); 4566 }, 4567 formatted_event_types(){ 4568 this.rerender_key; //depend on this to force recompute when it changes 4569 var event_types = this.event_types || []; 4570 // Updated vibrant, distinct colors of similar intensity to #853fc6 4571 var options = []; 4572 var camp_option = { 4573 key: "camp", 4574 label: "Camps", 4575 id: "camp", 4576 color: "#236651" 4577 }; 4578 var social_option = { 4579 key: "social", 4580 label: "Social events", 4581 id: "social", 4582 color: "#ffa69e" 4583 }; 4584 var calendar_event_option = { 4585 key: "calendar_event", 4586 label: "Other", 4587 id: "calendar_event",
4588 color: "#e1e7ea" 4589 }; 4590 if( event_types.includes('camps') ) { 4591 options.push(camp_option); 4592 } 4593 //respect hide regattas on club object 4594 if( event_types.includes('regattas') && ! this.hide_regattas ) { 4595 var regatta_option = { 4596 key: "regatta", 4597 label: "Regattas", 4598 id: "regatta", 4599 color: "#87d4f9" 4600 }; 4601 options.push(regatta_option); 4602 } 4603 if( event_types.includes('socials') ) { 4604 options.push(social_option); 4605 options.push(calendar_event_option); 4606 } 4607 if( event_types.includes('group_classes') ) { 4608 options.push({ 4609 key: "group_class", 4610 label: "Group classes", 4611 id: "group_class", 4612 color: "#5c7c99" 4613 }); 4614 } 4615 return { 4616 title: "Type", 4617 options: options 4618 }; 4619 }, 4620 hide_regattas(){ 4621 return store.clubObject && store.clubObject.get('hide_regattas'); 4622 }, 4623 timezone(){ 4624 if(this.tz){ 4625 return this.tz; 4626 } 4627 return store.clubObject ? store.clubObject.get('timezone') : moment.tz.guess(); 4628 } 4629 4630 }, 4631 watch: { 4632 last_update(newVal, oldVal){ 4633 if (newVal !== oldVal){ 4634 if (this.config){ 4635 try { 4636 let raw_config = this.config; 4637 try { 4638 raw_config = decodeURIComponent(raw_config); 4639 } catch (decode_error) { 4640 // Already plain JSON from older markup. 4641 } 4642 this.config_obj = JSON.parse(raw_config); 4643 } catch (parse_error) { 4644 this.config_obj = {}; 4645 } 4646 } 4647 this.rerender_key = generateClientID(); //force update of event types included 4648 this.clubId = $('body').data('club-id'); 4649 this.load_calendar(); 4650 //re-filter the user-facing tag list in case admin changed filter_tag_ids 4651 this.load_event_tags(); 4652 this.is_admin = !!this.component; 4653 } 4654 } 4655 }, 4656 } 4657 app_content.component('membership-calendar-component', membership_calendar_config); 4658 app_overlays.component('membership-calendar-component', membership_calendar_config); 4659</script>
4659
4659<script type="text/html" id="template-membership-directory"> 4660 <div class="cs-background-image relative" :class="class_string" :style="style_string"> 4661 <template v-if="formatting && formatting.overlay_opacity"> 4662 <div class="cs-image-overlay" :style="{ 'opacity': formatting.overlay_opacity.toString() }"></div> 4663 </template> 4664 <div class="element-wrapper" :class="'element-wrapper_' + component_type" :style="element_style_string"> 4665 <div class="membership-directory-wrap centeredBlock centeredText" :data-client-id="client_id"> 4666 <div class="ease flexNoWrap show_when_loaded"> 4667 <div class="inputWrap searchBarWrap flexGrowOne" style="min-width:100%;"> 4668 <input class="searchBar easeFast" placeholder="Search members..." v-model="search"> 4669 </div> 4670 <div class="flexGrowOne"></div> 4671 </div> 4672 <div v-if="!loading && member_tags?.length >= 1" class="scrollable posScrollbar lightBorderTop noPaddingBottom"> 4673 <div class="flexNoWrap insert_member_tags" style="padding:15px;"> 4674 <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> 4675 <template v-for="tag in member_tags" :key="tag.id"> 4676 <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> 4677 </template> 4678 </div> 4679 </div> 4680 <!-- <div class="show_when_loaded table_markup_insert"> --> 4681 <div class="lightBorderTop scrollable fff noPadding"> 4682 <table v-if="!loading && filtered_members?.length >= 1 && configurable_columns.length >= 2" class="tableInsert hundop"> 4683 <tbody> 4684 <tr class="tableHeaderRow" :class="{ 'ascending' : sortObject.order == 'ascending' }"> 4685 <template v-for="column in configurable_columns"> 4686 <th v-if="!Array.isArray(column.headerName) && !column.headerClasses.includes('sortable')" :class="column.headerClasses" :style="column.headerStyle">{{ column.headerName }}</th> 4687 <th v-if="!Array.isArray(column.headerName) && column.headerClasses.includes('sortable')" 4688 :style="column.headerStyle" 4689 :data-db-key="column['data-db-key']" 4690 @click="clicked_sort(column['data-db-key'])" 4691 :class="[column.headerClasses, { 'sorted': sortObject.key === column['data-db-key'] }]" 4692 > 4693 {{ column.headerName }} 4694 <div class="sort-arrow"></div> 4695 </th> 4696 <template v-for="headerName in column.headerName"> 4697 <th v-if="Array.isArray(column.headerName)" :class="column.headerClasses">{{ headerName }}</th> 4698 </template> 4699 </template> 4700 </tr> 4701 <!-- member rows injected here --> 4702 <template v-for="member in filtered_members" :key="member.id"> 4703 <tr 4704 v-if="check_archive_status( member )" 4705 class="tableRow pointer" 4706 :class="`member-row_${member.id || member.objectId}`" 4707 @click="clicked_member_row( member )" 4708 > 4709 <!-- photo column --> 4710 <td v-if="check_configurable_classes('column_photo')" class="lessPadding column_photo first-cell" style="padding-right:0px;"> 4711 <div v-if="member && member.get('profPicURL')" class="smallIcon noMargin noBorder" :style="{ backgroundImage: 'url(' + member.get('profPicURL') + ')' }"></div> 4712 <div v-else 4713 class="smallIcon noMargin noBorder flexWrap align-items-center" 4714 style="padding-right:0px; 4715 background-color: #f1f5fa; 4716 text-align: center; 4717 font-size: 16px;" 4718 > 4719 <i class="fa-regular fa-user"></i> 4720 </div> 4721 </td> 4722 <!-- firstName column --> 4723 <td v-if="check_configurable_classes('firstName')"> 4724 <p v-if="(member && member.get('firstName')) && !member.get('nickname')" class="capitalize">{{ member.get('firstName') }}</p> 4725 <p v-if="(member && member.get('firstName')) && member.get('nickname')" class="capitalize">{{ member.get('firstName') }} ({{ member.get('nickname') }})</p> 4726 <p v-if="member && !member.get('firstName')" class="incomplete">None</p> 4727 </td> 4728 <!-- lastName column --> 4729 <td v-if="check_configurable_classes('lastName')"> 4730 <p v-if="(member && member.get('lastName'))" class="capitalize">{{ member.get('lastName') }}</p> 4731 <p v-else class="incomplete">None</p> 4732 </td> 4733 <!-- member number column --> 4734 <td v-if="check_configurable_classes('column_member_number')" class="column_member_number"> 4735 <p v-if="(member && member.get('membershipObject')) && member.get('membershipObject').get('memberNumber')" class="uppercase">{{ member.get('membershipObject').get('memberNumber') }}</p> 4736 <p v-else class="incomplete">None</p> 4737 </td> 4738 <!-- email column --> 4739 <td v-if="check_configurable_classes('column_email')" class="column_email"> 4740 <p v-if="member && member.get('email')"><a @click.stop :href="'mailto:' + member.get('email')">{{ member.get('email')}}</a></p> 4741 <p v-else class="incomplete">None</p> 4742 </td> 4743 <!-- mobile column --> 4744 <td v-if="check_configurable_classes('column_mobile')" class="column_mobile"> 4745 <p v-if="member && member.get('mobile')"><a @click.stop :href="'tel:' + member.get('mobile')">{{ member.get('mobile')}}</a></p> 4746 <p v-else class="incomplete">None</p> 4747 </td> 4748 <!-- home phone column --> 4749 <td v-if="check_configurable_classes('column_phone_home')" class="column_phone_home"> 4750 <p v-if="member && member.get('phone_home')"><a @click.stop :href="'tel:' + member.get('phone_home')">{{ member.get('phone_home')}}</a></p> 4751 <p v-else class="incomplete">None</p> 4752 </td> 4753 <!-- member tags column --> 4754 <td v-if="check_configurable_classes('column_member_tags')" class="column_member_tags"> 4755 <div class="flexWrapLeftAlign" v-html="return_member_tags( member ) || ''"></div> 4756 </td> 4757 <!-- ghin number column --> 4758 <td v-if="check_configurable_classes('column_ghin_number')" class="column_ghin_number"> 4759 <p v-if="member && member.get('ghin_number')">{{ member.get('ghin_number') }}</p> 4760 <p v-else class="incomplete">None</p> 4761 </td> 4762 <!-- address ( street, city, state, zip) columns --> 4763 <template v-if="check_configurable_classes('column_address')"> 4764 <td class="column_address"> 4765 <p v-if="member && member.get('street')">{{ member.get('street') }}</p> 4766 <p v-else class="incomplete">None</p> 4767 </td> 4768 <td class="column_address"> 4769 <p v-if="member && member.get('city')">{{ member.get('city') }}</p> 4770 <p v-else class="incomplete">None</p> 4771 </td> 4772 <td class="column_address"> 4773 <p v-if="member && member.get('state')">{{ member.get('state') }}</p> 4774 <p v-else class="incomplete">None</p> 4775 </td> 4776 <td class="column_address"> 4777 <p v-if="member && member.get('zip')">{{ member.get('zip') }}</p> 4778 <p v-else class="incomplete">None</p> 4779 </td> 4780 </template> 4781 <!-- secondary address ( street, city, state, zip ) columns --> 4782 <template v-if="check_configurable_classes('column_address_secondary')"> 4783 <td class="column_address_secondary"> 4784 <p v-if="member && member.get('street_secondary')">{{ member.get('street_secondary') }}</p> 4785 <p v-else class="incomplete">None</p> 4786 </td> 4787 <td class="column_address_secondary"> 4788 <p v-if="member && member.get('city_secondary')">{{ member.get('city_secondary') }}</p> 4789 <p v-else class="incomplete">None</p> 4790 </td> 4791 <td class="column_address_secondary"> 4792 <p v-if="member && member.get('state_secondary')">{{ member.get('state_secondary') }}</p> 4793 <p v-else class="incomplete">None</p> 4794 </td> 4795 <td class="column_address_secondary"> 4796 <p v-if="member && member.get('zip_secondary')">{{ member.get('zip_secondary') }}</p> 4797 <p v-else class="incomplete">None</p> 4798 </td> 4799 </template> 4800 <!-- start date column --> 4801 <td v-if="check_configurable_classes('column_start_date')" class="column_start_date"> 4802 <p v-if="(member && member.get('membershipObject')) && member.get('membershipObject').get('startDate')">{{ format_utc_date( member.get('membershipObject').get('startDate') ) }}</p> 4803 <p v-else class="incomplete">None</p> 4804 </td> 4805 <!-- end date column --> 4806 <td v-if="check_configurable_classes('column_end_date')" class="column_end_date"> 4807 <p v-if="(member && member.get('membershipObject')) && member.get('membershipObject').get('endDate')">{{ format_utc_date( member.get('membershipObject').get('endDate')) }}</p> 4808 <p v-else class="incomplete">None</p> 4809 </td>
4810 <!-- renewal column --> 4811 <td v-if="check_configurable_classes('column_renewed')" class="column_renewed"> 4812 <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> 4813 <p v-else class="incomplete">None</p> 4814 </td> 4815 <!-- category column --> 4816 <td v-if="check_configurable_classes('column_category')" class="column_category"> 4817 <p v-if="(member && member.get('membershipObject')) && member.get('membershipObject').get('tierObject')">{{ member.get('membershipObject').get('tierObject').get('name') }}</p> 4818 <p v-else class="incomplete">None</p> 4819 </td> 4820 <!-- plan column --> 4821 <td v-if="check_configurable_classes('column_plan')" class="column_plan"> 4822 <p v-if="(member && member.get('membershipObject')) && member.get('membershipObject').get('planObject')">{{ member.get('membershipObject').get('planObject').get('name') }}</p> 4823 <p v-else class="incomplete">None</p> 4824 </td> 4825 </tr> 4826 </template> 4827 </tbody> 4828 </table> 4829 </div> 4830 <!-- </div> --> 4831 <div v-if="loading" class="cardBodySpinnerWrap relative active"> 4832 <div class="cardBodySpinner centeredBlock"></div> 4833 </div> 4834 <p v-if="!loading && filtered_members.length == 0" class="noObjectsMSG withPadding">{{ return_no_results_msg() }}</p> 4835 </div> 4836 </div> 4837 </div> 4838</script>
4838 4839
4840<script> 4841 4842 function prepThenDisplayMembershipDirectoryPopup(){ 4843 //directory profiles now open in the member directory overlay 4844 console.warn('prepThenDisplayMembershipDirectoryPopup is deprecated â migrated to Vue.'); 4845 } 4846 4847 function openDirectoryProfileImage(){ 4848 //profile photos now open in the member directory overlay 4849 console.warn('openDirectoryProfileImage is deprecated â migrated to Vue.'); 4850 } 4851 4852 function closeDirectoryProfileImage(){ 4853 //profile photos now open in the member directory overlay 4854 console.warn('closeDirectoryProfileImage is deprecated â migrated to Vue.'); 4855 } 4856 4857 function returnNameStringForBoat( boatObject ){ 4858 var this_name = ''; 4859 if( boatObject.get('boatName') ){ 4860 this_name += boatObject.get('boatName'); 4861 } 4862 if( boatObject.get('sailNumber') ){ 4863 if( this_name.length >= 1 ){ 4864 this_name += " - "; 4865 } 4866 this_name += boatObject.get('sailNumber'); 4867 } 4868 if( boatObject.get('class') ){ 4869 if( this_name.length >= 1 ){ 4870 this_name += " - "; 4871 } 4872 this_name += boatObject.get('class'); 4873 } 4874 if( boatObject.get('hullNumber') ){ 4875 if( this_name.length >= 1 ){ 4876 this_name += " - "; 4877 } 4878 this_name += boatObject.get('hullNumber'); 4879 } 4880 return this_name; 4881 } 4882 4883 function returnResponseForCustomField( member, custom_field_id ){ 4884 //find the response in custom_field_responses where .get('member') == ,ember and .get('custom_field').id matches custom_field_id 4885 var all_responses = store.components.membership_directory.custom_field_responses; 4886 if(!member || !custom_field_id){ 4887 return null; 4888 } 4889 var matching_response = all_responses?.find(resp => { 4890 // Match member (Parse.Object, so check .id) 4891 const member_matches = resp.get('member')?.id === member.id; 4892 4893 // Match custom field (resp.get('custom_field') is also a Parse.Object) 4894 const field_matches = resp.get('custom_field')?.id === custom_field_id; 4895 4896 return member_matches && field_matches; 4897 }); 4898 if(matching_response){ 4899 if( matching_response.get('field_type') == 'text' || matching_response.get('field_type') == 'textarea' || matching_response.get('field_type') == 'number' ){ 4900 return matching_response.get('response') 4901 }else if (matching_response.get('field_type') == 'select'){ 4902 var selected_options = matching_response.get('selected_options'); 4903 var displayString = selected_options.map(opt => opt.option_name).join(', '); 4904 return displayString; 4905 }else if( matching_response.get('field_type') == 'radio' ){ 4906 return matching_response.get('selected_options').map(option => option.option_name) 4907 }else if( matching_response.get('field_type') == 'file_upload' ){ 4908 return matching_response.get('field_url') || null 4909 } 4910 } 4911 4912 return null; 4913 } 4914 4915 /* ----- STORE ----- */ 4916 store.components["membership_directory"] = { 4917 clubId: null, 4918 clubIds: ['8pB9LjQKoE', '2oWU1chg3P'], //for mantoloking and cambridge yc 4919 allMembers: [], 4920 member_tags: [], 4921 json: [], 4922 boats_map: [], 4923 custom_fields: [], 4924 custom_field_responses: [], 4925 documents: [], //for scira 4926 selectedTag: 'all', 4927 // configurable_columns: [], 4928 count: 0, 4929 search: '', 4930 loading: true, 4931 sortObject: { 4932 order: 'ascending', 4933 key: 'lastName' 4934 }, 4935 }; 4936 4937 /* ----- COMPONENT ----- */ 4938 app_content.component("membership-directory-component", { 4939 template: "#template-membership-directory", 4940 data() { 4941 return store.components['membership_directory'] 4942 }, 4943 props: { 4944 formatting: Object, 4945 client_id: String, 4946 class_string: String, 4947 style_string: String, 4948 element_style_string: String, 4949 component_type: String, 4950 //admin 4951 last_update: Number, 4952 }, 4953 mounted() { 4954 this.clubId = $('body').data('club-id'); 4955 this.load_membership_directory(); 4956 // this.fillColumnClasses(); 4957 }, 4958 methods: { 4959 sortByFirstName_ascending( a, b ){ 4960 var aFirstName = ''; 4961 if( a.get('firstName') ){ 4962 aFirstName += a.get('firstName').trim().toLowerCase(); 4963 } 4964 var bFirstName = ''; 4965 if( b.get('firstName') ){ 4966 bFirstName += b.get('firstName').trim().toLowerCase(); 4967 } 4968 if(aFirstName < bFirstName) { return -1; } 4969 if(aFirstName > bFirstName) { return 1; } 4970 //they had the same name 4971 return 0; 4972 }, 4973 sortByFirstName_descending( a, b ){ 4974 var aFirstName = ''; 4975 if( a.get('firstName') ){ 4976 aFirstName += a.get('firstName').trim().toLowerCase(); 4977 } 4978 var bFirstName = ''; 4979 if( b.get('firstName') ){ 4980 bFirstName += b.get('firstName').trim().toLowerCase(); 4981 } 4982 if(aFirstName > bFirstName) { return -1; } 4983 if(aFirstName < bFirstName) { return 1; } 4984 return 0; 4985 }, 4986 sortByLastName_ascending( a, b ){ 4987 var aLastName = ''; 4988 if( a.get('lastName') ){ 4989 aLastName += a.get('lastName').trim().toLowerCase(); 4990 } 4991 var bLastName = ''; 4992 if( b.get('lastName') ){ 4993 bLastName += b.get('lastName').trim().toLowerCase(); 4994 } 4995 if(aLastName < bLastName) { return -1; } 4996 if(aLastName > bLastName) { return 1; } 4997 return 0; 4998 }, 4999 sortByLastName_descending( a, b ){ 5000 var aLastName = ''; 5001 if( a.get('lastName') ){ 5002 aLastName += a.get('lastName').trim().toLowerCase(); 5003 } 5004 var bLastName = ''; 5005 if( b.get('lastName') ){ 5006 bLastName += b.get('lastName').trim().toLowerCase(); 5007 } 5008 if(aLastName > bLastName) { return -1; } 5009 if(aLastName < bLastName) { return 1; } 5010 return 0; 5011 }, 5012 sortByMemberNumber_ascending( a, b ){ 5013 var aMemberNumber = ''; 5014 if( a.get('membershipObject') && a.get('membershipObject').get('memberNumber') ){ 5015 aMemberNumber += a.get('membershipObject').get('memberNumber').trim().toLowerCase(); 5016 } 5017 var bMemberNumber = ''; 5018 if( b.get('membershipObject') && b.get('membershipObject').get('memberNumber') ){ 5019 bMemberNumber += b.get('membershipObject').get('memberNumber').trim().toLowerCase(); 5020 } 5021 if(aMemberNumber < bMemberNumber) { return -1; } 5022 if(aMemberNumber > bMemberNumber) { return 1; } 5023 return 0; 5024 }, 5025 sortByMemberNumber_descending( a, b ){ 5026 var aMemberNumber = ''; 5027 if( a.get('membershipObject') && a.get('membershipObject').get('memberNumber') ){ 5028 aMemberNumber += a.get('membershipObject').get('memberNumber').trim().toLowerCase(); 5029 } 5030 var bMemberNumber = ''; 5031 if( b.get('membershipObject') && b.get('membershipObject').get('memberNumber') ){ 5032 bMemberNumber += b.get('membershipObject').get('memberNumber').trim().toLowerCase(); 5033 } 5034 if(aMemberNumber > bMemberNumber) { return -1; } 5035 if(aMemberNumber < bMemberNumber) { return 1; } 5036 return 0; 5037 }, 5038 sortByStartDate_ascending( a, b ){ 5039 var aStartDate; 5040 if( a.get('membershipObject') && a.get('membershipObject').get('startDate') ){ 5041 aStartDate = a.get('membershipObject').get('startDate'); 5042 } 5043 var bStartDate; 5044 if( b.get('membershipObject') && b.get('membershipObject').get('startDate') ){
5045 bStartDate = b.get('membershipObject').get('startDate'); 5046 } 5047 if(aStartDate < bStartDate) { return -1; } 5048 if(aStartDate > bStartDate) { return 1; } 5049 return 0; 5050 }, 5051 sortByStartDate_descending( a, b ){ 5052 var aStartDate; 5053 if( a.get('membershipObject') && a.get('membershipObject').get('startDate') ){ 5054 aStartDate = a.get('membershipObject').get('startDate'); 5055 } 5056 var bStartDate; 5057 if( b.get('membershipObject') && b.get('membershipObject').get('startDate') ){ 5058 bStartDate = b.get('membershipObject').get('startDate'); 5059 } 5060 if(aStartDate > bStartDate) { return -1; } 5061 if(aStartDate < bStartDate) { return 1; } 5062 return 0; 5063 }, 5064 sortByRenewed_ascending( a, b ){ 5065 var aRenewed; 5066 if( a.get('membershipObject') && a.get('membershipObject').get('renewed') ){ 5067 aRenewed = a.get('membershipObject').get('renewed'); 5068 }else if( a.get('membershipObject') && a.get('membershipObject').get('startDate') ){ 5069 aRenewed = a.get('membershipObject').get('startDate'); 5070 } 5071 var bRenewed; 5072 if( b.get('membershipObject') && b.get('membershipObject').get('renewed') ){ 5073 bRenewed = b.get('membershipObject').get('renewed'); 5074 }else if( b.get('membershipObject') && b.get('membershipObject').get('startDate') ){ 5075 bRenewed = b.get('membershipObject').get('startDate'); 5076 } 5077 if(aRenewed < bRenewed) { return -1; } 5078 if(aRenewed > bRenewed) { return 1; } 5079 return 0; 5080 }, 5081 sortByRenewed_descending( a, b ){ 5082 var aRenewed; 5083 if( a.get('membershipObject') && a.get('membershipObject').get('renewed') ){ 5084 aRenewed = a.get('membershipObject').get('renewed'); 5085 }else if( a.get('membershipObject') && a.get('membershipObject').get('startDate') ){ 5086 aRenewed = a.get('membershipObject').get('startDate'); 5087 } 5088 var bRenewed; 5089 if( b.get('membershipObject') && b.get('membershipObject').get('renewed') ){ 5090 bRenewed = b.get('membershipObject').get('renewed'); 5091 }else if( b.get('membershipObject') && b.get('membershipObject').get('startDate') ){ 5092 bRenewed = b.get('membershipObject').get('startDate'); 5093 } 5094 if(aRenewed > bRenewed) { return -1; } 5095 if(aRenewed < bRenewed) { return 1; } 5096 return 0; 5097 }, 5098 sortByEndDate_ascending( a, b ){ 5099 var aEndDate; 5100 if( a.get('membershipObject') && a.get('membershipObject').get('endDate') ){ 5101 aEndDate = a.get('membershipObject').get('endDate'); 5102 } 5103 var bEndDate; 5104 if( b.get('membershipObject') && b.get('membershipObject').get('endDate') ){ 5105 bEndDate = b.get('membershipObject').get('endDate'); 5106 } 5107 if(aEndDate < bEndDate) { return -1; } 5108 if(aEndDate > bEndDate) { return 1; } 5109 return 0; 5110 }, 5111 sortByEndDate_descending( a, b ){ 5112 var aEndDate; 5113 if( a.get('membershipObject') && a.get('membershipObject').get('endDate') ){ 5114 aEndDate = a.get('membershipObject').get('endDate'); 5115 } 5116 var bEndDate; 5117 if( b.get('membershipObject') && b.get('membershipObject').get('endDate') ){ 5118 bEndDate = b.get('membershipObject').get('endDate'); 5119 } 5120 if(aEndDate > bEndDate) { return -1; } 5121 if(aEndDate < bEndDate) { return 1; } 5122 return 0; 5123 }, 5124 load_membership_directory(){ 5125 //update the ui 5126 store.components.membership_directory.loading = true; 5127 var promise = Parse.Promise.as(); 5128 var thisPromise = promise.then(() => { 5129 //get new data 5130 var params = {}; 5131 params["client_id"] = $('.membership-directory-wrap').data('client-id'); 5132 params["request_url"] = window.location.href; 5133 return Parse.Cloud.run('retrieve_data_for_component', params); 5134 }).then(result => { 5135 //vue 5136 store.components.membership_directory.member_tags = result.member_tags; 5137 store.components.membership_directory.loading = false; 5138 var json = []; 5139 var retrieved_members = result.members; 5140 for (var i = 0; i <= retrieved_members.length - 1; i++) { 5141 var boats_string = ""; 5142 var number = ""; 5143 if( retrieved_members[i].get('membershipObject') ){ 5144 number = retrieved_members[i].get('membershipObject').get('memberNumber'); 5145 } 5146 var boats_map = result.boats_map; 5147 if( boats_map && retrieved_members[i].get('membershipObject') ){ 5148 var these_boats = boats_map[ retrieved_members[i].get('membershipObject').id ]; 5149 if( these_boats ){ 5150 for (var b = these_boats.length - 1; b >= 0; b--) { 5151 if( boats_string.length >= 1 ){ 5152 boats_string += " ";
5153 } 5154 boats_string += returnNameStringForBoat( these_boats[b] ); 5155 } 5156 } 5157 } 5158 retrieved_members[i].boats_string = boats_string 5159 json.push({ 5160 "objectId": retrieved_members[i].id, 5161 "name": retrieved_members[i].get('firstName') + " " + retrieved_members[i].get('lastName'), 5162 "boats_string": boats_string, 5163 "number": number 5164 }) 5165 } 5166 //refactor for search 5167 store.components.membership_directory.allMembers = retrieved_members; 5168 //vue 5169 store.components.membership_directory.json = json; 5170 store.components.membership_directory.boats_map = result.boats_map; 5171 store.components.membership_directory.custom_fields = result.custom_fields; 5172 store.components.membership_directory.custom_field_responses = result.custom_field_responses; 5173 store.components.membership_directory.documents = result.documents; //for scira 5174 }, function(error){ 5175 checkParseError( error ); 5176 }); 5177 return thisPromise; 5178 }, 5179 /* ----- common methods ----- */ 5180 clicked_sort( key ){ 5181 if( this.sortObject.key == key ){ 5182 //already sorting by this one - flip it 5183 if( this.sortObject.order == "descending" ){ 5184 this.sortObject.order = "ascending"; 5185 }else{ 5186 this.sortObject.order = "descending"; 5187 } 5188 }else{ 5189 //not sorting by this yet 5190 this.sortObject.key = key; 5191 this.sortObject.order = "descending"; 5192 } 5193 }, 5194 /* ----- table methods ----- */ 5195 handleTagSelection( tag_id ){ 5196 this.selectedTag = tag_id; 5197 }, 5198 fillColumnClasses(){ 5199 // let configureColumns = { 5200 // 'show_photo': { 5201 // order: 1, 5202 // headerClasses: 'column_photo lessPadding', 5203 // headerStyle: 'padding-left:10px;0px', 5204 // headerName: null, 5205 // }, 5206 // 'show_member_number': { 5207 // order: 4, 5208 // headerClasses: 'column_member_number sortable', 5209 // 'data-db-key': 'memberNumber', 5210 // headerName: 'Number' 5211 // }, 5212 // 'show_email': { 5213 // order: 5, 5214 // headerClasses: 'column_email', 5215 // headerName: 'Email' 5216 // }, 5217 // 'show_mobile': { 5218 // order: 6, 5219 // headerClasses: 'column_mobile', 5220 // headerName: 'Mobile' 5221 // }, 5222 // 'show_phone_home': { 5223 // order: 7, 5224 // headerClasses: 'column_phone_home', 5225 // headerName: 'Home Phone' 5226 // }, 5227 // 'show_member_tags': { 5228 // order: 8, 5229 // headerClasses: 'column_member_tags', 5230 // headerName: 'Tags' 5231 // }, 5232 // 'show_ghin_number': { 5233 // order: 9, 5234 // headerClasses: 'column_ghin_number', 5235 // headerName: 'GHIN Number' 5236 // }, 5237 // 'show_address': { 5238 // order: 10, 5239 // headerClasses: 'column_address', 5240 // headerName: ['Street', 'City', 'State', 'Postal Code'] 5241 // }, 5242 // 'show_address_secondary': { 5243 // order: 11, 5244 // headerClasses: 'column_address_secondary', 5245 // headerName: ['Street (Secondary)', 'City (Secondary)', 'State (Secondary)', 'Postal Code (Secondary)'] 5246 // }, 5247 // 'show_start_date': { 5248 // order: 12, 5249 // headerClasses: 'sortable column_start_date', 5250 // 'data-db-key': 'startDate', 5251 // headerName: 'Start Date' 5252 // }, 5253 // 'show_end_date': { 5254 // order: 13, 5255 // headerClasses: 'sortable column_end_date', 5256 // 'data-db-key': 'endDate', 5257 // headerName: 'End Date' 5258 // }, 5259 // 'show_renewed': { 5260 // order: 14, 5261 // headerClasses: 'sortable column_renewed', 5262 // 'data-db-key': 'renewed', 5263 // headerName: 'Renewed' 5264 // }, 5265 // 'show_category': { 5266 // order: 15, 5267 // headerClasses: 'column_category', 5268 // headerName: 'Category' 5269 // }, 5270 // 'show_plan': { 5271 // order: 16, 5272 // headerClasses: 'column_plan', 5273 // headerName: 'Plan' 5274 // }, 5275 // } 5276 // let classStringArr = this.class_string.split(' '); 5277 // this.configurable_columns = classStringArr.reduce((acc, currentClass) => { 5278 // if (configureColumns[currentClass] !== undefined){ 5279 // acc.push(configureColumns[currentClass]) 5280 // } 5281 // return acc; 5282 // }, [ 5283 // { 5284 // order: 2, 5285 // headerClasses: 'sortable firstName', 5286 // 'data-db-key': 'firstName', 5287 // required: true, 5288 // headerName: 'First Name' 5289 // }, 5290 // { 5291 // order: 3, 5292 // headerClasses: 'sortable lastName', 5293 // 'data-db-key': 'lastName', 5294 // required: true, 5295 // headerName: 'Last Name' 5296 // }, 5297 // ]).sort((a, b) => { 5298 // return a.order - b.order; 5299 // }) 5300 }, 5301 check_archive_status( memberObject ){ 5302 var membershipObject = memberObject.get('membershipObject'); 5303 if(membershipObject && ! membershipObject.get('archived') && membershipObject.get('status') != 'canceled' ){ 5304 return true; 5305 } 5306 return false;
5307 }, 5308 directory_profile_config() { 5309 let class_names = (this.class_string || '').split(' '); 5310 let fields = ['photo', 'email', 'mobile', 'address', 'address_secondary', 'boats']; 5311 let column_fields = { 5312 show_member_number: 'member_number', 5313 show_email: 'email', 5314 show_mobile: 'mobile', 5315 show_phone_home: 'phone_home', 5316 show_member_tags: 'member_tags', 5317 show_ghin_number: 'ghin_number', 5318 show_address: 'address', 5319 show_address_secondary: 'address_secondary', 5320 show_start_date: 'start_date', 5321 show_end_date: 'end_date', 5322 show_renewed: 'renewed', 5323 show_category: 'category', 5324 show_plan: 'plan', 5325 }; 5326 Object.keys(column_fields).forEach(class_name => { 5327 let field_name = column_fields[class_name]; 5328 if (class_names.indexOf(class_name) !== -1 && fields.indexOf(field_name) === -1) { 5329 fields.push(field_name); 5330 } 5331 }); 5332 this.custom_fields.forEach(custom_field => { 5333 fields.push('custom_field:' + custom_field.id); 5334 }); 5335 return { 5336 member_directory_fields: fields, 5337 }; 5338 }, 5339 clicked_member_row(memberObject) { 5340 let params = { 5341 member: memberObject, 5342 all_members: this.allMembers, 5343 boats_map: this.boats_map || {}, 5344 custom_fields: this.custom_fields || [], 5345 custom_field_responses: this.custom_field_responses || [], 5346 documents: this.documents || [], 5347 member_portal_config: this.directory_profile_config(), 5348 }; 5349 open_member_directory_profile(params); 5350 }, 5351 check_configurable_classes( className ){ 5352 for (let i = 0; i < this.configurable_columns.length; i++){ 5353 if (this.configurable_columns[i].headerClasses.includes(className)){ 5354 return true; 5355 break; 5356 } 5357 } 5358 return false; 5359 }, 5360 return_member_tags( memberObject ){ 5361 var member_tags = memberObject.get('member_tags') || [];
5362 var tag_string = ""; 5363 var tagsSituation = ''; 5364 if( member_tags.length >= 1 ){ 5365 for (var i = 0; i <= member_tags.length - 1; i++) { 5366 if( member_tags[i].className == "member_tags" ){ 5367 if( ! member_tags[i].get('archived') && ! member_tags[i].get('hidden') ){ 5368 if( tag_string.length >= 1 ){ 5369 tag_string += ','; 5370 } 5371 tag_string += member_tags[i].id; 5372 if( member_tags[i].get('hex_value') ){ 5373 //custom color 5374 tagsSituation += '<p class="table-row-tag" style="background-color:#'+ member_tags[i].get('hex_value') +';">' + member_tags[i].get('name') + '</p>'; 5375 }else{ 5376 //default color 5377 tagsSituation += '<p class="table-row-tag grey">' + member_tags[i].get('name') + '</p>'; 5378 } 5379 } 5380 } 5381 } 5382 } 5383 if( tagsSituation.length >= 1 ){ 5384 return tagsSituation; 5385 }else{ 5386 return '<p class="incomplete">None</p>' 5387 } 5388 }, 5389 format_utc_date( memberObject ){ 5390 return formatUTCDate( memberObject, true, false, true ) 5391 }, 5392 return_no_results_msg(){ 5393 if (this.clubIds.includes(this.clubId)){ 5394 return 'Search a member to get started.' //for mantoloking, cambridge yc, northport yc 5395 } else { 5396 return 'No members found.'; 5397 } 5398 }, 5399 sortMembershipDirectory( members ){ 5400 var sortObject = store.components.membership_directory.sortObject; 5401 if( ! sortObject ){ 5402 sortObject = {} 5403 } 5404 if( ! sortObject.key ){ 5405 sortObject.key = "startDate"; 5406 } 5407 if( ! sortObject.order ){ 5408 sortObject.order = "descending" 5409 } 5410 if( sortObject.key == "firstName" ){ 5411 if( sortObject.order == "ascending" ){ 5412 members = members.sort( this.sortByFirstName_ascending ); 5413 }else{ 5414 members = members.sort( this.sortByFirstName_descending ); 5415 } 5416 }else if( sortObject.key == "lastName" ){ 5417 if( sortObject.order == "ascending" ){ 5418 members = members.sort( this.sortByLastName_ascending ); 5419 }else{ 5420 members = members.sort( this.sortByLastName_descending ); 5421 } 5422 }else if( sortObject.key == "memberNumber" ){ 5423 if( sortObject.order == "ascending" ){ 5424 members = members.sort( this.sortByMemberNumber_ascending ); 5425 }else{ 5426 members = members.sort( this.sortByMemberNumber_descending ); 5427 } 5428 }else if( sortObject.key == "startDate" ){ 5429 if( sortObject.order == "ascending" ){ 5430 members = members.sort( this.sortByStartDate_ascending ); 5431 }else{ 5432 members = members.sort( this.sortByStartDate_descending ); 5433 } 5434 }else if( sortObject.key == "endDate" ){ 5435 if( sortObject.order == "ascending" ){ 5436 members = members.sort( this.sortByEndDate_ascending ); 5437 }else{ 5438 members = members.sort( this.sortByEndDate_descending ); 5439 } 5440 }else if( sortObject.key == "renewed" ){ 5441 if( sortObject.order == "ascending" ){ 5442 members = members.sort( this.sortByRenewed_ascending ); 5443 }else{ 5444 members = members.sort( this.sortByRenewed_de
5444scending ); 5445 } 5446 } 5447 return members; 5448 } 5449 }, 5450 computed: { 5451 members_json(){ 5452 let json = []; 5453 this.allMembers.forEach(member => { 5454 let this_search = ''; 5455 if( member.get('firstName') ){ 5456 this_search += ' ' + member.get('firstName').toLowerCase(); 5457 } 5458 if( member.get('lastName') ){ 5459 this_search += ' ' + member.get('lastName').toLowerCase(); 5460 } 5461 if( member['boats_string'] ){ 5462 this_search += ' ' + member['boats_string'].toLowerCase(); 5463 } 5464 if( member?.get('membershipObject')?.get('memberNumber') ){ 5465 this_search += ' ' + member?.get('membershipObject')?.get('memberNumber').toLowerCase(); 5466 } 5467 json.push({ 5468 "memberObject": member, 5469 "search": this_search, 5470 }) 5471 }); 5472 return json; 5473 }, 5474 members_matching_search(){ 5475 let val = this.search.toLowerCase(); 5476 var keys = [ 'search' ]; 5477 //sort them 5478 var these_matches = fuzzysort.go(val, this.members_json, { 5479 threshold: -10000, // Don't return matches worse than this (higher is faster) 5480 limit: 20, // Don't return more results than this (lower is faster) 5481 allowTypo: false, // Allows a snigle transpoes (false is faster) 5482 key: null, // For when targets are objects (see its example usage) 5483 keys: keys, // For when targets are objects (see its example usage) 5484 scoreFn: null, // For use with `keys` (see its example usage) 5485 }); 5486 return these_matches.map(match => match.obj.memberObject); 5487 }, 5488 filtered_members(){ 5489 let val = this.search.toLowerCase(); 5490 let filteredMembers; 5491 if (this.clubIds.includes(this.clubId)){ 5492 filteredMembers = []; //for mantoloking, cambridge yc, northport yc 5493 } else { 5494 filteredMembers = this.allMembers; 5495 } 5496 // handle search results using all members 5497 if (this.search.length > 0){ 5498 //use the new method 5499 filteredMembers = this.members_matching_search; 5500 } 5501 // handle member tag selection using filtered search results 5502 if (this.selectedTag !== 'all'){ 5503 filteredMembers = filteredMembers.filter(member => { 5504 let memberTags = member.get('member_tags') || []; 5505 for (let i = 0; i < memberTags.length; i++){ 5506 if (memberTags[i].id === this.selectedTag){ 5507 return member; 5508 break; 5509 } 5510 } 5511 }) 5512 } 5513 if (this.clubIds.includes(this.clubId) && filteredMembers.length > 20){ 5514 return this.sortMembershipDirectory( filteredMembers.slice(0, 20) ); //for mantoloking, cambridge yc, northport yc 5515 } else { 5516 return this.sortMembershipDirectory( filteredMembers ); 5517 } 5518 }, 5519 configurable_columns(){ 5520 let configureColumns = { 5521 'show_photo': { 5522 order: 1, 5523 headerClasses: 'column_photo lessPadding', 5524 headerStyle: 'padding-left:10px;0px', 5525 headerName: null, 5526 }, 5527 'show_member_number': { 5528 order: 4, 5529 headerClasses: 'column_member_number sortable', 5530 'data-db-key': 'memberNumber', 5531 headerName: 'Number' 5532 }, 5533 'show_email': { 5534 order: 5, 5535 headerClasses: 'column_email', 5536 headerName: 'Email' 5537 }, 5538 'show_mobile': { 5539 order: 6, 5540 headerClasses: 'column_mobile', 5541 headerName: 'Mobile' 5542 }, 5543 'show_phone_home': { 5544 order: 7, 5545 headerClasses: 'column_phone_home', 5546 headerName: 'Home Phone' 5547 }, 5548 'show_member_tags': { 5549 order: 8, 5550 headerClasses: 'column_member_tags', 5551 headerName: 'Tags' 5552 }, 5553 'show_ghin_number': { 5554 order: 9, 5555 headerClasses: 'column_ghin_number', 5556 headerName: 'GHIN Number' 5557 }, 5558 'show_address': { 5559 order: 10, 5560 headerClasses: 'column_address', 5561 headerName: ['Street', 'City', 'State', 'Postal Code'] 5562 }, 5563 'show_address_secondary': { 5564 order: 11, 5565 headerClasses: 'column_address_secondary', 5566 headerName: ['Street (Secondary)', 'City (Secondary)', 'State (Secondary)', 'Postal Code (Secondary)'] 5567 }, 5568 'show_start_date': { 5569 order: 12, 5570 headerClasses: 'sortable column_start_date', 5571 'data-db-key': 'startDate', 5572 headerName: 'Start Date' 5573 }, 5574 'show_end_date': { 5575 order: 13, 5576 headerClasses: 'sortable column_end_date', 5577 'data-db-key': 'endDate', 5578 headerName: 'End Date' 5579 }, 5580 'show_renewed': { 5581 order: 14, 5582 headerClasses: 'sortable column_renewed', 5583 'data-db-key': 'renewed', 5584 headerName: 'Renewed' 5585 }, 5586 'show_category': { 5587 order: 15, 5588 headerClasses: 'column_category', 5589 headerName: 'Category' 5590 }, 5591 'show_plan': { 5592 order: 16, 5593 headerClasses: 'column_plan', 5594 headerName: 'Plan' 5595 }, 5596 } 5597 let classStringArr = this.class_string.split(' '); 5598 return classStringArr.reduce((acc, currentClass) => { 5599 if (configureColumns[currentClass] !== undefined){ 5600 acc.push(configureColumns[currentClass]) 5601 } 5602 return acc; 5603 }, [ 5604 { 5605 order: 2, 5606 headerClasses: 'sortable firstName', 5607 'data-db-key': 'firstName', 5608 required: true, 5609 headerName: 'First Name' 5610 }, 5611 { 5612 order: 3, 5613 headerClasses: 'sortable lastName', 5614 'data-db-key': 'lastName', 5615 required: true, 5616 headerName: 'Last Name' 5617 }, 5618 ]).sort((a, b) => { 5619 return a.order - b.order; 5620 }) 5621 }, 5622 } 5623 }) 5624</script>
5624
5624<script type="text/html" id="template-event-list-card"> 5625 <div 5626 @click.stop="tapped_event_list_event()" 5627 class="event-row overlay-event-row flexEventOne noBorderBottom" 5628 :class="'event-row_' + event.id" 5629 :data-event-id="event.id" 5630 > 5631 <div 5632 class="event-list-card" 5633 :class="{ 5634 'small-card': formatting.card_size == 'small', 5635 'large-card': formatting.card_size == 'large', 5636 'box-shadow-light': formatting.box_shadow == 'light', 5637 'box-shadow-dark': formatting.box_shadow == 'dark', 5638 }" 5639 :style="{ 'border-radius': formatting.border_radius + 'px' || '0px' }" 5640 > 5641 <div 5642 v-if="image_data.url" 5643 class="event-image" 5644 :class="{ 5645 'small-card': formatting.card_size == 'small', 5646 'large-card': formatting.card_size == 'large', 5647 'use-contain': image_data.crop === 'contain' 5648 }" 5649 :style="{ 5650 '--event-image-url': `url(${image_data.url})`, 5651 backgroundImage: 'var(--event-image-url)', 5652 backgroundSize: image_data.crop || 'cover' 5653 }" 5654 > 5655 </div> 5656 <div 5657 v-else 5658 class="event-image" 5659 :class="{ 5660 'small-card': formatting.card_size == 'small', 5661 'large-card': formatting.card_size == 'large' 5662 }" 5663 > 5664 <p class="no-background">{{ event.get('name').charAt(0).toUpperCase() }}</p> 5665 </div> 5666 5667 <div 5668 class="card-content" 5669 :style="{ 'border-radius': formatting.border_radius + 'px' || '0px' }" 5670 > 5671 <p class="event-type">{{ event_type }}</p> 5672 <p 5673 class="event-title" 5674 :style="event_title_style" 5675 >{{ event_title }}</p> 5676 <div class="event-date-inline"> 5677 <i class="fa-regular fa-calendar" style="font-size: 0.8em;"></i> 5678 <span>{{ short_month }} {{ days }}</span> 5679 </div> 5680 <div v-if="show_time" class="event-time"> 5681 <i class="fa-regular fa-clock" style="font-size: 0.8em;"></i> 5682 <span>{{ event_time }}</span> 5683 </div> 5684 <button 5685 @click.stop 5686 v-if="button_label" 5687 class="event-button exclude-from-custom-button-styles relative background" 5688 v-html="button_label" 5689 ></button> 5690 </div> 5691 </div> 5692 </div> 5693</script>
5693 5694 5695<style> 5696 .event-list-card { 5697 width: 190px; 5698 overflow: hidden; 5699 background: white; 5700 font-family: 'Inter', sans-serif; 5701 display: flex; 5702 flex-direction: column; 5703 transition: transform 0.1s ease, background-color 0.15s ease; 5704 } 5705 .event-list-card.small-card { 5706 width: 190px; 5707 } 5708 .event-list-card.large-card { 5709 width: 230px; 5710 } 5711 .event-list-card.box-shadow-light { 5712 box-shadow: 2px 4px 8px rgba(0, 0, 0, 0.2); 5713 } 5714 .event-list-card.box-shadow-dark { 5715 box-shadow: 6px 6px 16px rgba(0, 0, 0, 0.2); 5716 } 5717 .event-list-card .event-image { 5718 display: flex; 5719 text-align: center; 5720 align-items: center; 5721 width: 100%; 5722 height: 170px; 5723 /* background-size now controlled via inline style / CSS class */ 5724 background-repeat: no-repeat; 5725 background-position: center; 5726 background-color: #77818f; 5727 color: #fff; 5728 position: relative; 5729 overflow: hidden; /* hide blurred overflow */ 5730 } 5731 /* When the image uses "contain", add a blurred cover background behind it */ 5732 .event-list-card .event-image.use-contain::before { 5733 content: ''; 5734 position: absolute; 5735 inset: 0; 5736 background-image: var(--event-image-url); 5737 background-size: cover; 5738 background-position: center; 5739 filter: blur(12px); 5740 transform: scale(1.1); /* avoid sharp edges from blur */ 5741 z-index: 0; 5742 pointer-events: none; 5743 } 5744 /* Foreground, non-blurred "contain" image layer */ 5745 .event-list-card .event-image.use-contain::after { 5746 content: ''; 5747 position: absolute; 5748 inset: 0; 5749 background-image: var(--event-image-url); 5750 background-size: contain; 5751 background-position: center; 5752 background-repeat: no-repeat; 5753 z-index: 1; 5754 pointer-events: none; 5755 } 5756 /* Ensure foreground content (initials) sits above the image layers */ 5757 .event-list-card .event-image .no-background { 5758 position: relative; 5759 z-index: 2; 5760 } 5761 .event-list-card .event-image.small-card{ 5762 height: 170px; 5763 } 5764 .event-list-card .event-image.large-card{ 5765 height: 220px; 5766 } 5767 .event-list-card .event-image .no-background { 5768 width: 100%; 5769 font-weight: 500; 5770 font-size: 5em; 5771 } 5772 .card-content { 5773 height: 140px; 5774 padding: 14px 12px; 5775 background-color: #f9f9f9; 5776 display: flex; 5777 flex-direction: column; 5778 transition: background-color 0.15s ease; 5779 } 5780 .event-row:hover .card-content { 5781 background-color: #f0f0f0; 5782 } 5783 .event-date-inline { 5784 display: flex; 5785 align-items: center; 5786 font-size: 14px; 5787 color: #000; 5788 margin-bottom: 2px; 5789 } 5790 .event-date-inline i { 5791 margin-right: 6px; 5792 } 5793 .event-list-card .event-type { 5794 font-size: 12px; 5795 text-transform: uppercase; 5796 color: #000; 5797 margin-bottom: 2px; 5798 } 5799 .event-list-card .event-title { 5800 font-size: 18px; 5801 text-transform: uppercase; 5802 margin-bottom: 8px; 5803 color: #000; 5804 font-family: serif; 5805 letter-spacing: 0.2px; 5806 } 5807 .event-list-card .event-time { 5808 display: flex; 5809 align-items: center; 5810 font-size: 14px; 5811 color: #000; 5812 margin-bottom: 12px; 5813 } 5814 .event-time i { 5815 margin-right: 6px; 5816 } 5817 .event-list-card .event-button { 5818 width: 100%; 5819 display: flex; 5820 align-items: center; 5821 justify-content: center; 5822 position: relative; 5823 z-index: 2; 5824 margin-top: auto; 5825 letter-spacing: 1px; 5826 line-height: 16px; 5827 cursor: pointer; 5828 font-size: 14px; 5829 font-weight: normal; 5830 padding: 6px 12px; 5831 text-transform: uppercase; 5832 transition: all 0.2s ease; 5833 border: 1.5px solid #000; 5834 border-radius: 2px; 5835 } 5836 .event-list-card .event-button:hover{ 5837 background-color: #000; 5838 color: white; 5839 } 5840 .res-callout{ 5841 display: flex; 5842 align-items: center; 5843 white-space: nowrap; 5844 height: fit-content; 5845 width: fit-content; 5846 padding: 3.5px; 5847 background-color: #fff; 5848 border: 1px solid #e4ebf1; 5849 border-radius: 5px; 5850 font-size: 10px; 5851 text-transform: uppercase; 5852 color: #5c6d7a; 5853 } 5854</style> 5855
5856<script> 5857 5858 var config = defineComponent({ 5859 template: "#template-event-list-card", 5860 data() { 5861 return {} 5862 }, 5863 props: { 5864 event_data: { 5865 type: Object, 5866 required: true, 5867 }, 5868 event_props: { 5869 type: Object, 5870 required: true, 5871 }, 5872 formatting: Object, 5873 }, 5874 methods: { 5875 tapped_event_list_event(){ 5876 this.$emit('tapped-event-list-event', this.event_data); 5877 }, 5878 }, 5879 computed: { 5880 short_month(){ 5881 if( this.start_date ){ 5882 return format_utc_date_v2({ 5883 "utcDate": this.start_date, 5884 "timezone": store.timezone, 5885 "formatString": "MMM" 5886 }); 5887 } 5888 return null; 5889 }, 5890 days(){ 5891 let string = ''; 5892 if( this.start_date ){ 5893 let start_components = return_date_components_v2({ 5894 "date": this.start_date, 5895 "timezone": store.timezone 5896 }); 5897 string += start_components.date; 5898 if( this.end_date && ! this.event.get('all_day') ){ 5899 let end_components = return_date_components_v2({ 5900 "date": this.end_date, 5901 "timezone": store.timezone 5902 }); 5903 if( start_components.date !== end_components.date ){ 5904 string += ' - ' + end_components.date; 5905 } 5906 } 5907 } 5908 return string; 5909 }, 5910 show_register_button(){ 5911 return this.event_props.show_register_button; 5912 }, 5913 show_time(){ 5914 return this.event_props.show_time; 5915 }, 5916 image_data(){ 5917 return this.event_props.image_data; 5918 }, 5919 start_date(){ 5920 return this.event_props.start_date; 5921 }, 5922 end_date(){ 5923 return this.event_props.end_date; 5924 }, 5925 event_title() { 5926 return this.event.get('name') || ''; 5927 }, 5928 event_title_style() { 5929 let style = {}; 5930 if( this.formatting.event_title_overflow === 'wrap' ){ 5931 style['white-space'] = 'break-spaces'; 5932 } 5933 if( this.formatting.event_title_font_size ){ 5934 style['font-size'] = this.formatting.event_title_font_size + 'px'; 5935 } 5936 return style; 5937 }, 5938 event_type(){ 5939 if( this.event.className == 'camps' ){ 5940 return 'camp'; 5941 }else if( this.event.className == 'regattas' ){ 5942 return 'regatta'; 5943 }else{ 5944 return 'event'; 5945 } 5946 }, 5947 button_label(){ 5948 let eventObject = this.event; 5949 let href_base = ''; 5950 let clubObject = store.clubObject; 5951 let web_settings = null; 5952 if( clubObject ){ 5953 web_settings = clubObject.get('web_settings'); 5954 } 5955 if( web_settings ){ 5956 if( web_settings.get('event_links') == "use_clubspot_regatta_links" && ( eventObject.className == "regattas" || eventObject.className == "subevents" ) ){ 5957 //force clubspot urls for regattas 5958 href_base += 'https://theclubspot.com'; 5959 } 5960 } 5961 var content; 5962 var show_register_button = this.show_register_button; 5963 if( eventObject.className == "regattas" || eventObject.className == "subevents" ){ 5964 if( eventObject.className == "subevents" ){ 5965 eventObject = eventObject.get('regattaObject'); 5966 } 5967 //check for external events 5968 if( eventObject.get('regatta_external') ){ 5969 //it's an external regatta - only render the external button if a URL is set; never fall back to register/results 5970 if( eventObject.get('external_regatta_url') ){ 5971 content = 'View event<a class="absoluteA" target="_blank" href="'+ eventObject.get('external_regatta_url') +'"></a>'; 5972 } 5973 }else{ 5974 //handle button status 5975 if( show_register_button ){ 5976 //register 5977 content = 'Register<a class="absoluteA" href="'+href_base+'/regatta/'+ eventObject.id +'"></a>'; 5978 }else{ 5979 //results 5980 content = 'View Results<a class="absoluteA" href="'+href_base+'/regatta/'+ eventObject.id +'/results"></a>'; 5981 } 5982 } 5983 }else if( eventObject.className == "events" ){ 5984 var registration_link; 5985 var details_link; 5986 if( eventObject.get('calendarOnly') ){ 5987 if( eventObject.get('external_event') ){ 5988 details_link = this.event_props.resolved_external_url || eventObject.get('external_event_url'); 5989 } 5990 }else{ 5991 if( eventObject.get('events_v2') ){ 5992 registration_link = '/event/' + eventObject.id; 5993 }else if( eventObject.get('external_event') && eventObject.get('external_register_link') ){ 5994 registration_link = eventObject.get('external_register_link'); 5995 }else if( eventObject.get('event_product') ){ 5996 if( eventObject.get('event_product').get('availability') && eventObject.get('event_product').get('availability').startDate ){ 5997 registration_link = "/reserve/" + eventObject.get('event_product').id + "?date=" + eventObject.get('event_product').get('availability').startDate; 5998 } 5999 } 6000 } 6001 if( registration_link && show_register_button ){ 6002 content = 'RSVP<a class="absoluteA" href="'+href_base+''+registration_link+'"></a>'; 6003 } else if( details_link && details_link.length > 0 ){ 6004 let button_label = eventObject.get('external_event_url_type') === 'dining' ? 'Reserve' : 'View details'; 6005 content = button_label + '<a class="absoluteA" target="_blank" href="'+ details_link +'"></a>'; 6006 } 6007 }else if( eventObject.className == "camps" ){ 6008 //handle button status 6009 if( show_register_button ){ 6010 //register 6011 content = 'Register<a href="'+href_base+'/register/camp/'+ eventObject.id +'/class" class="absoluteA"></a>'; 6012 } 6013 } 6014 return content; 6015 }, 6016 event(){ 6017 return this.event_data.event; 6018 }, 6019 clubObject(){ 6020 return store.clubObject; 6021 }, 6022 event_time() { 6023 if( this.show_time ){ 6024 return format_utc_date_v2({ 6025 "utcDate": this.start_date, 6026 "timezone": store.timezone, 6027 "formatString": "h:mm A" 6028 }); 6029 } 6030 return ""; 6031 }, 6032 } 6033 }) 6034 //define the component 6035 app_overlays.component("event-list-card-component", config); 6036 app_content.component("event-list-card-component", config); 6037 6038</script>
6038
6038<script type="text/html" id="template-event-list-event"> 6039 <div 6040 @click.stop="tapped_event_list_event()" 6041 class="event-row overlay-event-row flexEventOne" 6042 :class="'event-row_' + event.id" 6043 :data-event-id="event.id" 6044 > 6045 <!-- date and image wrap --> 6046 <div class="cs-signups-row-left flexNoWrapCenterAlign"> 6047 <div class="cs-signups-date-wrap relative" style="width: 55px;"> 6048 <div class="centeredBlock relative"> 6049 <div> 6050 <p class="cs-signups-date-month">{{ short_month }}</p> 6051 <p class="cs-signups-date-day">{{ days }}</p> 6052 </div> 6053 </div> 6054 </div> 6055 <div 6056 v-if="image_data.url" 6057 class="cs-signups-row-image flexWrap" 6058 :class="{ 'background-contain' : image_data.crop === 'contain', 'background-cover' : image_data.crop === 'cover'}" 6059 :style="{ backgroundImage: 'url(' + image_data.url + ')' }" 6060 > 6061 <div v-if="is_different_year" class="header-callout">{{ full_year }}</div> 6062 </div> 6063 <div 6064 v-else 6065 class="cs-signups-row-image flexWrap" 6066 > 6067 <div v-if="is_different_year" class="header-callout">{{ full_year }}</div> 6068 {{ event.get('name').charAt(0).toUpperCase() }} 6069 </div> 6070 </div> 6071 <!-- event info, links, and host wrap --> 6072 <div class="cs-signups-row-inner flexWrapLeftAlign ellipsis align-items-center"> 6073 <!-- event info --> 6074 <div class="cs-signups-name-wrap ellipsis textLeft"> 6075 <span class="flexWrap" style="display: flex; align-items: center; justify-content: flex-start; margin-bottom: 5px;"> 6076 <div class="cs-signups-name-0 textLeft" style="flex-grow: 0; margin-right: 8px;">{{ event.get('name') }}</div> 6077 </span> 6078 <div class="event-date-wrap-v2">{{ niceDate }}</div> 6079 <div class="event-date-wrap-v2 tiniestPaddingTop"> 6080 <p>{{ clubObject.get('name') }}</p> 6081 <p>{{ return_markup( event, 'event-host-2' )}}</p> 6082 </div> 6083 </div> 6084 <!-- event links --> 6085 <div @click.stop class="cs-signups-links-wrap flexGrowOne"> 6086 <div class="cs-signups-links-wrap-inner textLeft"> 6087 <div class="event-nav-1" v-html="return_markup( event, 'links' )"></div> 6088 <div class="event-nav-2" v-html="return_markup( event, 'button' )"></div> 6089 </div> 6090 </div> 6091 </div> 6092 </div> 6093</script>
6093 6094
6095<script> 6096 6097 var config = defineComponent({ 6098 template: "#template-event-list-event", 6099 data() { 6100 return {} 6101 }, 6102 props: { 6103 event_data: { 6104 type: Object, 6105 required: true, 6106 }, 6107 event_props: { 6108 type: Object, 6109 required: true, 6110 }, 6111 }, 6112 methods: { 6113 tapped_event_list_event(){ 6114 this.$emit('tapped-event-list-event', this.event_data); 6115 }, 6116 return_markup( eventObject, type ){ 6117 var href_base = ''; 6118 var clubObject = this.clubObject; 6119 var web_settings = clubObject.get('web_settings'); 6120 if( web_settings ){ 6121 if( web_settings.get('event_links') == "use_clubspot_regatta_links" && ( eventObject.className == "regattas" || eventObject.className == "subevents" ) ){ 6122 //force clubspot urls for regattas 6123 href_base += 'https://theclubspot.com'; 6124 } 6125 } 6126 // returns here 6127 if ( type === 'event-host-2' ){ 6128 var club_string = ''; 6129 var location_params = { 6130 eventObject: eventObject, 6131 hideCountry: true, 6132 hideZip: true, 6133 hideStreet: true, 6134 } 6135 var club_location_params = { 6136 eventObject: clubObject, 6137 hideCountry: true, 6138 hideZip: true, 6139 hideStreet: true, 6140 } 6141 if(formatLocation(location_params) ){ 6142 if( club_string.length >= 1 ){ 6143 club_string += ', '; 6144 } 6145 club_string += formatLocation(location_params); 6146 }else if( clubObject && formatLocation(club_location_params) ){ 6147 if( club_string.length >= 1 ){ 6148 club_string += ', '; 6149 } 6150 club_string += formatLocation(club_location_params); 6151 } 6152 return club_string; 6153 } else if ( type === 'links' ){ 6154 var content; 6155 if( eventObject.className == "regattas" || eventObject.className == "subevents" ){ 6156 //check for external events 6157 if( eventObject.get('regatta_external') ){ 6158 //it's an external regatta - only render the external link if a URL is set; never fall back to internal links 6159 if( eventObject.get('external_regatta_url') ){ 6160 content = '<p class="tiniestPaddingTop"><a target="_blank" href="'+ eventObject.get('external_regatta_url') +'">Event page</a></p>'; 6161 } 6162 }else{ 6163 if( eventObject.className == "subevents" ){ 6164 eventObject = eventObject.get('regattaObject'); 6165 } 6166 //event page 6167 content = '<p class="tiniestPaddingTop"><a href="'+href_base+'/regatta/'+ eventObject.id +'">Event page</a></p>'; 6168 //entries 6169 content += '<p class="tiniestPaddingTop"><a href="'+href_base+'/regatta/'+ eventObject.id +'/#entry-list">Entries</a></p>'; 6170 //race docs 6171 content += '<p class="tiniestPaddingTop"><a href="'+href_base+'/regatta/'+ eventObject.id +'/#notice-board">Notice board</a></p>'; 6172 } 6173 }else if( eventObject.className == "events" ){ 6174 //check for external events 6175 if( ! eventObject.get('calendarOnly') ){ 6176 if( eventObject.get('external_event') && eventObject.get('external_event_url') ){ 6177 //it's an external event 6178 content = '<p class="tiniestPaddingTop"><a target="_blank" href="'+ eventObject.get('external_event_url') +'">Event page</a></p>'; 6179 }else{ 6180 //event page 6181 content = '<p class="tiniestPaddingTop"><a href="'+href_base+'/event/'+ eventObject.id +'">Event page</a></p>'; 6182 } 6183 }else{ 6184 //calendar only event: use resolved URL per instance (recurring dining) or stored URL 6185 let external_url = this.event_props.resolved_external_url || eventObject.get('external_event_url'); 6186 if( 6187 eventObject.get('external_event') 6188 && eventObject.get('external_event_url_type') !== 'dining' 6189 && external_url 6190 ){ 6191 content = '<p class="tiniestPaddingTop"><a target="_blank" href="'+ external_url +'">View details</a></p>'; 6192 } 6193 } 6194 }else if( eventObject.className == "camps" ){ 6195 //it's a camp 6196 content = '<p class="tiniestPaddingTop"><a href="'+href_base+'/camp-registrations/'+ eventObject.id +'">Entry list</a></p>'; 6197 } 6198 return content; 6199 } else if ( type === 'button' ){ 6200 var content; 6201 var show_register_button = this.show_register_button; 6202 if( eventObject.className == "regattas" || eventObject.className == "subevents" ){ 6203 if( eventObject.className == "subevents" ){ 6204 eventObject = eventObject.get('regattaObject'); 6205 } 6206 //check for external events 6207 if( eventObject.get('regatta_external') ){ 6208 //it's an external regatta - only render the external button if a URL is set; never fall back to register/results 6209 if( eventObject.get('external_regatta_url') ){ 6210 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>'; 6211 } 6212 }else{ 6213 //handle button status 6214 if( show_register_button ){ 6215 //register 6216 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>'; 6217 }else{ 6218 //results 6219 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>'; 6220 } 6221 } 6222 }else if( eventObject.className == "events" ){ 6223 var registration_link; 6224 var details_link; 6225 if( eventObject.get('calendarOnly') ){ 6226 if( eventObject.get('external_event') ){ 6227 details_link = this.event_props.resolved_external_url || eventObject.get('external_event_url'); 6228 } 6229 }else{ 6230 if( eventObject.get('events_v2') ){ 6231 registration_link = '/event/' + eventObject.id; 6232 }else if( eventObject.get('external_event') && eventObject.get('external_register_link') ){ 6233 registration_link = eventObject.get('external_register_link'); 6234 } 6235 } 6236 if( registration_link && show_register_button ){ 6237 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>'; 6238 }else if( details_link ){ 6239 if( eventObject.get('external_event_url_type') === 'dining' ){ 6240 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>'; 6241 }else{ 6242 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>'; 6243 } 6244 } 6245 }else if( eventObject.className == "camps" ){ 6246 //handle button status 6247 if( show_register_button ){ 6248 //register 6249 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>'; 6250 } 6251 } 6252 return content; 6253 } 6254 }, 6255 }, 6256 computed: { 6257 show_register_button(){ 6258 return this.event_props.show_register_button; 6259 }, 6260 show_time(){ 6261 return this.event_props.show_time; 6262 }, 6263 image_data(){ 6264 return this.event_props.image_data; 6265 }, 6266 start_date(){ 6267 return this.event_props.start_date; 6268 }, 6269 end_date(){ 6270 return this.event_props.end_date; 6271 }, 6272 niceDate(){ 6273 if( this.start_date ){ 6274 if( this.show_time ){ 6275 //show the time 6276 return format_utc_date_v2({ 6277 "utcDate": this.start_date, 6278 "timezone": store.timezone, 6279 "formatString": "MMM D, YYYY, h:mm A" 6280 }); 6281 }else{ 6282 //show the date only 6283 return format_utc_date_v2({ 6284 "utcDate": this.start_date, 6285 "timezone": store.timezone, 6286 "formatString": "MMM D, YYYY" 6287 }); 6288 } 6289 } 6290 return null; 6291 }, 6292 full_year(){ 6293 if( this.start_date ){ 6294 return format_utc_date_v2({ 6295 "utcDate": this.start_date, 6296 "timezone": store.timezone, 6297 "formatString": "YYYY" 6298 }); 6299 } 6300 return null; 6301 }, 6302 days(){ 6303 let string = ''; 6304 if( this.start_date ){ 6305 let start_components = return_date_components_v2({ 6306 "date": this.start_date, 6307 "timezone": store.timezone 6308 });
6309 string += start_components.date; 6310 if( this.end_date && ! this.event.get('all_day') ){ 6311 let end_components = return_date_components_v2({ 6312 "date": this.end_date, 6313 "timezone": store.timezone 6314 }); 6315 if( start_components.date !== end_components.date ){ 6316 string += ' - ' + end_components.date; 6317 } 6318 } 6319 } 6320 return string; 6321 }, 6322 short_month(){ 6323 if( this.start_date ){ 6324 return format_utc_date_v2({ 6325 "utcDate": this.start_date, 6326 "timezone": store.timezone, 6327 "formatString": "MMM" 6328 }); 6329 } 6330 return null; 6331 }, 6332 is_different_year(){ 6333 if( this.start_date ){ 6334 let start_components = return_date_components_v2({ 6335 "date": this.start_date, 6336 "timezone": store.timezone 6337 }); 6338 let now_components = return_date_components_v2({ 6339 "date": new Date(), 6340 "timezone": store.timezone 6341 }); 6342 return start_components.year !== now_components.year; 6343 } 6344 return false; 6345 }, 6346 event(){ 6347 return this.event_data.event; 6348 }, 6349 clubObject(){ 6350 return store.clubObject; 6351 }, 6352 event_time() { 6353 if( this.show_time ){ 6354 return format_utc_date_v2({ 6355 "utcDate": this.start_date, 6356 "timezone": store.timezone, 6357 "formatString": "h:mm A" 6358 }); 6359 } 6360 return ""; 6361 }, 6362 }, 6363 }) 6364 //define the component 6365 app_overlays.component("event-list-event-component", config); 6366 app_content.component("event-list-event-component", config); 6367 6368</script>
6368 6369 6370<style> 6371 .res-callout{ 6372 display: flex; /* Add this to make the container a flexbox */ 6373 align-items: center; /* Center items vertically */ 6374 white-space: nowrap; /* Prevent wrapping */ 6375 height: fit-content; 6376 width: fit-content; 6377 padding: 3.5px; 6378 background-color: #fff; 6379 border: 1px solid #e4ebf1; 6380 border-radius: 5px; 6381 font-size: 10px; 6382 text-transform: uppercase; 6383 color: #5c6d7a; 6384 } 6385</style>
6385<script type="text/html" id="template-event-list"> 6386 <div class="cs-background-image relative" :class="class_string" :style="style_string"> 6387 <div class="element-wrapper" 6388 :class="'element-wrapper_' + component_type" 6389 :style="element_style_string" 6390 style="max-width:100% !important;min-width:90%;" 6391 > 6392 <!-- list view --> 6393 <template v-if="formatting_obj.layout === 'list' || !formatting_obj.layout"> 6394 <div class="event-list-wrap centeredBlock centeredText" :class="'event-list-wrap_' + client_id" :data-client_id="client_id"> 6395 <div 6396 class="flexNoWrap align-items-center" 6397 style="padding-bottom:5px;" 6398 > 6399 <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> 6400 <div 6401 class="view-all-events-wrapper tinyMarginLeft" 6402 @click="popViewAllEventsOverlay()" 6403 > 6404 <div :style="{ 'font-size': '14px !important', 'font-weight': '400' }">View all</div> 6405 <div class="smallDrilldownHeaderBackButton flip"></div> 6406 </div> 6407 </div> 6408 <div class="standardCardBody noBorderBottom fff noPadding overflowHidden"> 6409 <div v-if="!loading && sorted_event_data.length >= 1" class="tableInsert hundop flexWrapEvent"> 6410 <template v-for="(event_data, index) in sorted_event_data" :key="event_key(event_data)"> 6411 <p 6412 v-if="is_first_visible_event_of_month(event_data)" 6413 class="cs-section-header" 6414 >{{ month_string( event_data ) }}</p> 6415 <event-list-event-component 6416 :event_data="event_data" 6417 :event_props="event_props( event_data )" 6418 @tapped-event-list-event="tapped_event_list_event" 6419 ></event-list-event-component> 6420 </template> 6421 </div> 6422 <div v-if="loading" class="cardBodySpinnerWrap active relative"> 6423 <div class="cardBodySpinner centeredBlock"></div> 6424 </div> 6425 <no-objects-found 6426 v-if="!loading && sorted_event_data.length == 0" 6427 subtext="No events found" 6428 icon_class="fa-light fa-calendar-days" 6429 ></no-objects-found> 6430 </div> 6431 </div> 6432 </template> 6433 6434 <!-- carousel view --> 6435 <template v-if="formatting_obj.layout === 'carousel'"> 6436 <div class="event-list-wrap centeredBlock centeredText" :class="'event-list-wrap_' + client_id" :data-client_id="client_id"> 6437 <div 6438 class="flexNoWrap align-items-center event_list_carousel_default" 6439 style="padding: 0px 0px 15px 5px;" 6440 > 6441 <h3 6442 class="event_list_carousel_header" 6443 >{{ date_filter || 'upcoming' }} events</h3> 6444 </div> 6445 <span 6446 @mouseenter="start_auto_scroll('left')" 6447 @mouseleave="stop_auto_scroll" 6448 class="event-list-auto-scroll left" 6449 ></span> 6450 <div class="standardCardBody noBorderTop noBorderBottom fff noPadding"> 6451 <div 6452 ref="event_list_card_wrapper" 6453 v-if="!loading && sorted_event_data.length >= 1" 6454 class="tableInsert hundop event-list-card-wrapper" 6455 :style="{ gap: formatting_obj.card_spacing + 'px' || '25px' }" 6456 > 6457 <template v-for="(event_data, index) in sorted_event_data" :key="event_key(event_data)"> 6458 <event-list-card-component 6459 :event_data="event_data" 6460 :formatting="formatting_obj" 6461 :event_props="event_props( event_data )" 6462 @tapped-event-list-event="tapped_event_list_event" 6463 ></event-list-card-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 <span 6476 @mouseenter="start_auto_scroll('right')" 6477 @mouseleave="stop_auto_scroll" 6478 class="event-list-auto-scroll right" 6479 ></span> 6480 </div> 6481 </template> 6482 </div> 6483 </div> 6484</script>
6484 6485 6486<style> 6487 .event_list_carousel_default { 6488 --event-list-carousel-font-family: 'Cormorant Garamond'; 6489 --event-list-carousel-font-size: 16px; 6490 --event-list-carousel-font-weight: 600; 6491 --event-list-carousel-text-transform: uppercase; 6492 --event-list-carousel-letter-spacing: 1.2px; 6493 --event-list-carousel-color: #000; 6494 } 6495 .event_list_carousel_header { 6496 /* font-family: var(--event-list-carousel-font-family) !important; */ 6497 font-size: var(--event-list-carousel-font-size) !important; 6498 font-weight: var(--event-list-carousel-font-weight) !important; 6499 text-transform: var(--event-list-carousel-text-transform) !important; 6500 letter-spacing: var(--event-list-carousel-letter-spacing) !important; 6501 color: var(--event-list-carousel-color) !important; 6502 } 6503 .event_list_carousel_header.select_filter { 6504 width: 100px !important; 6505 text-overflow: ellipsis !important; 6506 border: none !important; 6507 border-bottom: 0.5px solid black !important; 6508 appearance: none !important; 6509 -webkit-appearance: none !important; 6510 -moz-appearance: none !important; 6511 background: none !important; 6512 padding: 0px !important; 6513 box-shadow: none !important; 6514 display: inline-block !important; 6515 background-image: none !important; 6516 } 6517 .event-list-card-wrapper { 6518 position: relative; 6519 display: flex; 6520 justify-content: flex-start; 6521 align-items: center; 6522 padding: 10px; 6523 overflow: scroll !important; 6524 scrollbar-width: none !important; 6525 } 6526 .event-list-auto-scroll { 6527 position: absolute; 6528 top: 0; 6529 height: 100%; 6530 width: 20px; 6531 } 6532 .event-list-auto-scroll.left { 6533 left: 0; 6534 } 6535 .event-list-auto-scroll.right { 6536 right: 0; 6537 } 6538</style> 6539
6540<script> 6541 6542 app_content.component('event-list-component', { 6543 template: '#template-event-list', 6544 data() { 6545 return { 6546 event_data: [], 6547 loading: true, 6548 sortObject: { 6549 order: 'ascending', 6550 key: 'startDate' 6551 }, 6552 scroll_animation_frame: null, 6553 } 6554 }, 6555 props: { 6556 class_string: String, 6557 style_string: String, 6558 element_style_string: String, 6559 formatting: String, 6560 filter_types: String, 6561 filter_tag_ids: String, 6562 date_filter: { 6563 type: String, 6564 default: 'upcoming', 6565 }, 6566 component_type: String, 6567 client_id: String, 6568 //admin 6569 component: Object, 6570 last_update: Number, 6571 }, 6572 mounted() { 6573 if( this.date_filter == "past" ){ 6574 this.sortObject = { 6575 order: "descending", 6576 key: "startDate" 6577 }; 6578 } 6579 this.loadEventList(this.client_id); 6580 }, 6581 methods: { 6582 is_first_visible_event_of_month( event_data ){ 6583 return this.first_visible_events.includes( this.event_key(event_data) ); 6584 }, 6585 event_key( event_data ){ 6586 return event_data.event.id + '_' + event_data.ts; 6587 }, 6588 month_string( event_data ){ 6589 let start_date = new Date(event_data.ts); 6590 return format_utc_date_v2({ 6591 "utcDate": start_date, 6592 "timezone": store.timezone, 6593 "formatString": "MMMM YYYY" 6594 }); 6595 }, 6596 event_props( event_data ){ 6597 return { 6598 show_register_button: this.show_register_button( event_data ), 6599 show_time: this.show_time( event_data.event ), 6600 image_data: this.image_data( event_data.event ), 6601 start_date: this.start_date( event_data ), 6602 end_date: this.end_date( event_data ), 6603 resolved_external_url: this.resolve_external_url_for_instance( event_data ), 6604 }; 6605 }, 6606 resolve_external_url_for_instance( event_data ){ 6607 let event = event_data.event; 6608 if ( !event || !event.get('calendarOnly') ) { 6609 return null; 6610 } 6611 if ( event.get('external_event_url_type') === 'dining' ) { 6612 if ( event_data.external_event_url ) { 6613 return event_data.external_event_url; 6614 } 6615 let startDate = event.get('startDate'); 6616 let components = return_date_components_v2({ 6617 "date": startDate, 6618 "timezone": 'UTC' 6619 }); 6620 let date_string = components.year + '-' + ( components.month + 1 ) + '-' + components.date; 6621 let dining_url = '/dining?date_string=' + date_string; 6622 if ( event.get('startTime') ) { 6623 dining_url += '&time=' + event.get('startTime'); 6624 } 6625 return dining_url; 6626 } 6627 if ( event.get('external_event_url') ) { 6628 let url = event.get('external_event_url'); 6629 if ( url && !/^https?:\/\//i.test(url) ) { 6630 url = 'https://' + url; 6631 } 6632 return url; 6633 } 6634 return null; 6635 }, 6636 start_date( event_data ){ 6637 if( event_data.ts ){ 6638 return new Date(event_data.ts); 6639 } 6640 return null; 6641 }, 6642 end_date( event_data ){ 6643 if( event_data.end_ts ){ 6644 return new Date(event_data.end_ts); 6645 } 6646 return null; 6647 }, 6648 tapped_event_list_event( event_data ){ 6649 let event = event_data.event; 6650 if(event && event.className == 'events') { 6651 $('#overlay_event-description-popup h3').text(event.get('name')); 6652 let start_date = this.start_date( event_data ); 6653 let nice_date = format_utc_date_v2({ 6654 "utcDate": start_date, 6655 "timezone": store.timezone, 6656 "formatString": "MMM D, YYYY" 6657 }); 6658 $('#overlay_event-description-popup .metric_start_date').text( nice_date ); 6659 if( this.show_time( event ) ){ 6660 let event_time = format_utc_date_v2({ 6661 "utcDate": start_date, 6662 "timezone": store.timezone, 6663 "formatString": "h:mm A" 6664 }); 6665 $('#overlay_event-description-popup .metric_start_date_time').text( event_time ).removeClass('incomplete'); 6666 }else{ 6667 $('#overlay_event-description-popup .metric_start_date_time').text('Not provided').addClass('incomplete'); 6668 } 6669 if(event.get('description')) { 6670 $('#overlay_event-description-popup .metric_description').html(event.get('description')).removeClass('incomplete'); 6671 } else { 6672 $('#overlay_event-description-popup .metric_description').text('Not provided').addClass('incomplete'); 6673 } 6674 fadeIn('#overlay_event-description-popup'); 6675 } 6676 }, 6677 show_register_button( event_data ){ 6678 var now = new Date(); 6679 var ended = false;
6680 let eventObject = event_data.event; 6681 var registration_closed = eventObject.get('registration_closed'); 6682 var lastChanceDate = eventObject.get('lastChanceDate'); 6683 var startDate = new Date(event_data.ts); 6684 if( event_data.end_ts ){ 6685 var endDate = new Date(event_data.end_ts); 6686 } 6687 if( ! endDate && startDate ){ 6688 endDate = startDate; 6689 } 6690 var endDate_plus_one; 6691 if( endDate ){ 6692 endDate_plus_one = new Date( endDate.getTime() ); 6693 endDate_plus_one.setDate( endDate.getDate() + 1 ); 6694 } 6695 if( now > endDate_plus_one && endDate_plus_one ){ 6696 ended = true; 6697 } 6698 if( now > lastChanceDate && lastChanceDate ){ 6699 registration_closed = true; 6700 } 6701 if( ended || registration_closed || eventObject.get('calendarOnly') ){ 6702 return false; 6703 } else { 6704 return true; 6705 } 6706 }, 6707 show_time( event ){ 6708 if( 6709 event.get('calendarOnly') && event.get('all_day') 6710 ){ 6711 return false; 6712 }else if( 6713 event.className == "camps" || 6714 event.className == "regattas" || 6715 event.className == "subevents" 6716 ){ 6717 return false; 6718 } 6719 return true; 6720 }, 6721 image_data( event ){ 6722 if( event.className == "subevents" && event.get('regattaObject') ){ 6723 event = event.get('regattaObject'); 6724 } 6725 return { 6726 url: event.get('imageURL'), 6727 crop: event.get('imageCrop') 6728 } 6729 }, 6730 popViewAllEventsOverlay(){ 6731 view_all_events({ 6732 "class_string": this.class_string, 6733 "style_string": this.style_string, 6734 "element_style_string": this.element_style_string, 6735 "component_type": this.component_type, 6736 "client_id": this.client_id, 6737 "event_data": this.event_data, 6738 "props_generator": this.event_props, 6739 }); 6740 }, 6741 start_auto_scroll(direction) { 6742 const wrapper = this.$refs.event_list_card_wrapper; 6743 const amount = direction === 'right' ? 10 : -10; //scroll speed 6744 const scrollStep = () => { 6745 wrapper.scrollBy({ left: amount, behavior: 'auto' }); 6746 this.scroll_animation_frame = requestAnimationFrame(scrollStep); 6747 }; 6748 scrollStep(); 6749 }, 6750 stop_auto_scroll() { 6751 cancelAnimationFrame(this.scroll_animation_frame); 6752 this.scroll_animation_frame = null; 6753 }, 6754 loadEventList( client_id, overrides = {} ){ 6755 var promise = Parse.Promise.as(); 6756 var thisPromise = promise.then(() => { 6757 var params = {}; 6758 params["client_id"] = client_id; 6759 params["overrides"] = overrides; 6760 params["request_url"] = window.location.href; 6761 params["date_filter"] = this.date_filter; 6762 return Parse.Cloud.run('retrieve_data_for_component', params); 6763 }).then(event_data => { 6764 this.event_data = event_data; 6765 return Promise.resolve(); 6766 }).then(() => { 6767 this.loading = false; 6768 }, error => { 6769 checkParseError(error); 6770 this.loading = false; 6771 }); 6772 return thisPromise; 6773 }, 6774 sortEventList( event_data, sortObject ){ 6775 event_data = [... event_data]; 6776 if( ! sortObject ){ 6777 sortObject = {} 6778 } 6779 if( ! sortObject.key ){ 6780 sortObject.key = "startDate"; 6781 } 6782 if( ! sortObject.order ){ 6783 sortObject.order = "ascending" 6784 } 6785 if( sortObject.key == "name" ){ 6786 if( sortObject.order == "ascending" ){ 6787 return event_data.sort( this.sortByEventName_ascending ); 6788 }else{ 6789 return event_data.sort( this.sortByEventName_descending ); 6790 } 6791 }else if( sortObject.key == "startDate" ){ 6792 if( sortObject.order == "ascending" ){ 6793 return event_data.sort( this.sortByStartDate_event_ascending ); 6794 }else{ 6795 return event_data.sort( this.sortByStartDate_event_descending ); 6796 } 6797 }else if( sortObject.key == "endDate" ){ 6798 if( sortObject.order == "ascending" ){ 6799 return event_data.sort( this.sortByEndDate_event_ascending ); 6800 }else{ 6801 return event_data.sort( this.sortByEndDate_event_descending ); 6802 } 6803 } 6804 return event_data; 6805 }, 6806 sortByEventName_ascending( a, b ){ 6807 let event_a = a.event; 6808 let event_b = b.event; 6809 var aName = ''; 6810 if( event_a.get('name') ){ 6811 aName += event_a.get('name').trim().toLowerCase(); 6812 } 6813 var bName = ''; 6814 if( event_b.get('name') ){ 6815 bName += event_b.get('name').trim().toLowerCase(); 6816 } 6817 if(aName < bName) { return -1; } 6818 if(aName > bName) { return 1; } 6819 //they had the same name 6820 return 0; 6821 }, 6822 sortByEventName_descending( a, b ){ 6823 let event_a = a.event; 6824 let event_b = b.event; 6825 var aName = ''; 6826 if( event_a.get('name') ){ 6827 aName += event_a.get('name').trim().toLowerCase(); 6828 } 6829 var bName = ''; 6830 if( event_b.get('name') ){ 6831 bName += event_b.get('name').trim().toLowerCase(); 6832 } 6833 if(aName > bName) { return -1; } 6834 if(aName < bName) { return 1; } 6835 //they had the same name 6836 return 0; 6837 }, 6838 sortByStartDate_event_ascending( a, b ){ 6839 let a_ts = a.ts; 6840 let b_ts = b.ts; 6841 if(a_ts < b_ts) { return -1; } 6842 if(a_ts > b_ts) { return 1; } 6843 return 0; 6844 }, 6845 sortByStartDate_event_descending( a, b ){ 6846 let a_ts = a.ts; 6847 let b_ts = b.ts; 6848 if(a_ts > b_ts) { return -1; } 6849 if(a_ts < b_ts) { return 1; } 6850 return 0; 6851 }, 6852 sortByEndDate_event_ascending( a, b ){ 6853 let a_end_ts = a.end_ts; 6854 let b_end_ts = b.end_ts; 6855 if(a_end_ts < b_end_ts) { return -1; } 6856 if(a_end_ts > b_end_ts) { return 1; } 6857 return 0; 6858 }, 6859 sortByEndDate_event_descending( a, b ){ 6860 let a_end_ts = a.end_ts; 6861 let b_end_ts = b.end_ts; 6862 if(a_end_ts < b_end_ts) { return 1; } 6863 if(a_end_ts > b_end_ts) { return -1; } 6864 return 0; 6865 }, 6866 }, 6867 computed: { 6868 pre_filtered_event_data(){ 6869 //filter by date first 6870 let pre_filtered_event_data = [...this.event_data]; 6871 var timestamp = new Date().getTime(); 6872 if ( this.date_filter !== 'all' ){ 6873 pre_filtered_event_data = pre_filtered_event_data.filter(event_data => { 6874 let start = event_data.ts; 6875 let end = event_data.end_ts; 6876 if (this.date_filter === 'past'){ 6877 if( start < timestamp && ( end < timestamp || ! end || end == "undefined" ) ){ 6878 return true; 6879 } 6880 } else if (this.date_filter === 'upcoming'){ 6881 if ( start > timestamp || end > timestamp ){ 6882 return true; 6883 } 6884 // Single-day events: ts and end_ts are both midnight, so they get filtered out 6885 // when the event is today. Include if the event's date is today or later. 6886 if ( start === end || !end ){ 6887 let startOfEventDay = new Date(start); 6888 startOfEventDay.setUTCHours(0, 0, 0, 0); 6889 let startOfToday = new Date(); 6890 startOfToday.setUTCHours(0, 0, 0, 0); 6891 if ( startOfEventDay.getTime() >= startOfToday.getTime() ){ 6892 return true; 6893 } 6894 } 6895 } 6896 return false;
6897 }) 6898 } 6899 return pre_filtered_event_data; 6900 }, 6901 first_visible_events(){ 6902 let first_visible_events = []; 6903 let handled_keys = []; 6904 //loop through the event_data 6905 this.sorted_event_data.forEach(event_data => { 6906 let start_date = new Date(event_data.ts); 6907 let start_components = return_date_components_v2({ 6908 "date": start_date, 6909 "timezone": store.timezone 6910 }); 6911 let key = start_components.year + '_' + start_components.month; 6912 if( ! handled_keys.includes(key) ){ 6913 first_visible_events.push( this.event_key(event_data) ); 6914 handled_keys.push(key); 6915 } 6916 }); 6917 return first_visible_events; 6918 }, 6919 filtered_event_data(){ 6920 let filtered_event_data = this.pre_filtered_event_data; 6921 //filter by selected tag IDs (configured in admin settings panel) 6922 if( this.filter_tag_ids_arr && this.filter_tag_ids_arr.length > 0 ){ 6923 filtered_event_data = filtered_event_data.filter(event_data => { 6924 let event_tag_ids = event_data.event_tag_ids; 6925 return event_tag_ids && this.filter_tag_ids_arr.some(id => event_tag_ids.includes(id)); 6926 }); 6927 } 6928 //return them 6929 return filtered_event_data; 6930 }, 6931 sorted_event_data(){ 6932 let sorted = this.sortEventList( this.filtered_event_data, this.sortObject ); 6933 if( this.formatting_obj.layout === 'carousel' ){ 6934 return sorted.splice(0, 20); 6935 }else{ 6936 return sorted.splice(0, 5); 6937 } 6938 }, 6939 formatting_obj(){ 6940 if (this.formatting){ 6941 return JSON.parse(this.formatting); 6942 } 6943 return {}; 6944 }, 6945 filter_tag_ids_arr(){ 6946 if (this.filter_tag_ids){ 6947 return JSON.parse(this.filter_tag_ids); 6948 } 6949 return []; 6950 }, 6951 filter_types_arr(){ 6952 if (this.filter_types){ 6953 return JSON.parse(this.filter_types); 6954 } 6955 return []; 6956 }, 6957 }, 6958 watch: { 6959 last_update(newVal, oldVal){ 6960 if (newVal !== oldVal){ 6961 let event_types = this.component.get('config_latest')?.event_types || []; 6962 this.loadEventList( this.client_id, { event_types } ); 6963 } 6964 } 6965 }, 6966 }); 6967</script>
6967
6967<script type="text/html" id="template-iframe"> 6968 <div v-if="!loading && !is_admin" class="cs-iframe-wrap" :class="class_string" :style="style_string"> 6969 <iframe class="cs-iframe" :src="iframe_src"></iframe> 6970 </div> 6971 <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 + '%' }]"> 6972 <template v-if="iframe_src" > 6973 <iframe class="cs-iframe" sandbox="allow-forms allow-scripts" :src="iframe_src"></iframe> 6974 </template> 6975 <template v-if="!iframe_src"> 6976 <p class="noObjectsMSG centeredBlock centeredText">Click on edit to configure your iframe.</p> 6977 </template> 6978 </div> 6979</script>
6979 6980 6981<style> 6982 /* Draft-editor only floor (the admin wrap carries cs-min-section-width; 6983 the live wrap does not). Without it an iframe with no configured 6984 section_height â or an empty/unconfigured embed â collapses shorter 6985 than the component toolbar + inline "add section" button, so those 6986 controls overlap. A configured section_height (inline min-height) 6987 still wins since it's taller. */ 6988 .cs-iframe-wrap.cs-min-section-width { 6989 min-height: 130px; 6990 } 6991</style> 6992
6993<script> 6994 /* ----- COMPONENT ----- */ 6995 app_content.component('iframe-component', { 6996 template: '#template-iframe', 6997 data() { 6998 return { 6999 global: store, 7000 loading: true, 7001 is_admin: false, 7002 } 7003 }, 7004 props: { 7005 class_string: String, 7006 style_string: String, 7007 iframe_src: String, 7008 //admin 7009 component: Object, 7010 formatting_string: String, 7011 last_update: Number 7012 }, 7013 mounted() { 7014 this.is_admin = window.location.href.indexOf('website-builder') !== -1; 7015 this.loading = false; 7016 }, 7017 computed: { 7018 formatting_obj(){ 7019 if (this.formatting_string){ 7020 return JSON.parse(this.formatting_string); 7021 } 7022 return {}; 7023 }, 7024 }, 7025 }); 7026</script>
7026
7026<script type="text/html" id="template-camera-feed"> 7027 <div v-if="!loading && !is_admin" class="cs-iframe-wrap" :class="class_string" :style="style_string"> 7028 <div v-if="!errored" style="position:relative;padding:8px;width:100%;box-sizing:border-box;"> 7029 <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> 7030 <img v-show="image_loaded" :src="img_src" style="width:100%;height:100%;object-fit:contain;display:block;border-radius:12px;"> 7031 <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;"> 7032 <span style="width:7px;height:7px;border-radius:50%;background:#ff3b30;display:inline-block;"></span>LIVE 7033 </span> 7034 <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> 7035 </div> 7036 <p v-if="errored" class="noObjectsMSG centeredBlock centeredText">The live camera is temporarily unavailable.</p> 7037 </div> 7038 <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 + '%' }]"> 7039 <template v-if="has_camera"> 7040 <div style="position:relative;padding:8px;width:100%;box-sizing:border-box;"> 7041 <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> 7042 <img v-show="image_loaded" :src="img_src" style="width:100%;height:100%;object-fit:contain;display:block;border-radius:12px;"> 7043 <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;"> 7044 <span style="width:7px;height:7px;border-radius:50%;background:#ff3b30;display:inline-block;"></span>LIVE 7045 </span> 7046 <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> 7047 </div> 7048 </template> 7049 <template v-if="!has_camera"> 7050 <p class="noObjectsMSG centeredBlock centeredText">Click on edit to configure your camera.</p> 7051 </template> 7052 </div> 7053</script>
7053 7054
7055<script> 7056 app_content.component('camera-feed-component', { 7057 template: '#template-camera-feed', 7058 data() { 7059 return { 7060 global: store, 7061 loading: true, 7062 is_admin: false, 7063 img_src: '', 7064 errored: false, 7065 refresh_timer: null, 7066 loader: null, 7067 last_image_time: '', 7068 image_loaded: false, 7069 } 7070 }, 7071 props: { 7072 class_string: String, 7073 style_string: String, 7074 client_id: String, 7075 camera_feed_id: String, 7076 refresh_interval_ms: [Number, String], 7077 //admin 7078 component: Object, 7079 formatting_string: String, 7080 last_update: Number, 7081 }, 7082 mounted() { 7083 this.is_admin = window.location.href.indexOf('website-builder') !== -1; 7084 this.loading = false; 7085 if (this.has_camera) { 7086 this.start_refresh(); 7087 } 7088 }, 7089 beforeUnmount() { 7090 this.stop_refresh(); 7091 }, 7092 methods: { 7093 snapshot_base() { 7094 let base = `/camera-feed/${this.client_id}/snapshot.jpg`; 7095 //editor has no subdomain rewrite, so pass the club id explicitly 7096 if (this.is_admin && store.clubObject) { 7097 base += `?club_id=${store.clubObject.id}`; 7098 } 7099 return base; 7100 }, 7101 return_refresh_interval() { 7102 let interval = Number(this.refresh_interval_ms); 7103 if (!interval || isNaN(interval) || interval < 500) { 7104 interval = 2000; 7105 } 7106 return interval; 7107 }, 7108 return_next_snapshot_url() { 7109 let base = this.snapshot_base(); 7110 let separator = base.indexOf('?') === -1 ? '?' : '&'; 7111 return `${base}${separator}t=${Date.now()}`; 7112 }, 7113 start_refresh() { 7114 this.stop_refresh(); 7115 this.load_next_frame(); 7116 }, 7117 //fetch the next frame into an off-DOM Image so the visible <img> 7118 //is only swapped once bytes are fully loaded. we never reassign a 7119 //pending src, so the browser never cancels an in-flight request. 7120 load_next_frame() { 7121 let started = Date.now(); 7122 let url = this.return_next_snapshot_url(); 7123 let loader = new Image(); 7124 loader.onload = () => { 7125 //loaded bytes are in cache, so this swap paints instantly 7126 this.img_src = url; 7127 this.image_loaded = true; 7128 this.errored = false; 7129 this.last_image_time = this.format_time(new Date()); 7130 this.schedule_next_frame(started); 7131 }; 7132 loader.onerror = () => { 7133 this.errored = true; 7134 this.schedule_next_frame(started); 7135 }; 7136 loader.src = url; 7137 this.loader = loader; 7138 }, 7139 //only advance after the current frame finishes, subtracting elapsed 7140 //so cadence tracks the target interval (never firing early) 7141 schedule_next_frame(started) { 7142 let interval = this.return_refresh_interval(); 7143 let elapsed = Date.now() - started; 7144 let delay = Math.max(0, interval - elapsed); 7145 this.refresh_timer = setTimeout(() => { 7146 this.load_next_frame(); 7147 }, delay); 7148 }, 7149 stop_refresh() { 7150 if (this.refresh_timer) { 7151 clearTimeout(this.refresh_timer); 7152 this.refresh_timer = null; 7153 } 7154 if (this.loader) { 7155 this.loader.onload = null; 7156 this.loader.onerror = null; 7157 this.loader = null; 7158 } 7159 }, 7160 format_time(date) { 7161 let hours = date.getHours(); 7162 let minutes = date.getMinutes().toString().padStart(2, '0'); 7163 let seconds = date.getSeconds().toString().padStart(2, '0'); 7164 let period = hours >= 12 ? 'PM' : 'AM'; 7165 hours = hours % 12; 7166 if (hours === 0) { 7167 hours = 12; 7168 } 7169 return `${hours}:${minutes}:${seconds} ${period}`; 7170 }, 7171 }, 7172 computed: { 7173 has_camera() { 7174 if (!this.client_id) { 7175 return false;
7176 } 7177 if (this.camera_feed_id) { 7178 return true; 7179 } 7180 return false; 7181 }, 7182 formatting_obj() { 7183 if (this.formatting_string) { 7184 return JSON.parse(this.formatting_string); 7185 } 7186 return {}; 7187 }, 7188 }, 7189 watch: { 7190 camera_feed_id() { 7191 if (this.has_camera) { 7192 this.start_refresh(); 7193 } else { 7194 this.stop_refresh(); 7195 } 7196 }, 7197 refresh_interval_ms() { 7198 if (this.has_camera) { 7199 this.start_refresh(); 7200 } 7201 }, 7202 }, 7203 }); 7204</script>
7204 7205
7205<script type="text/html" id="template-blog-list"> 7206 <div class="relative" :class="class_string" :style="style_string"> 7207 <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}%` }]"> 7208 <div class="blog-list-wrap centeredBlock centeredText" :class="'blog-list-wrap_' + client_id" :data-client_id="client_id"> 7209 <div class="ease flexNoWrap show_when_loaded" style="justify-content: center; align-items: center;"> 7210 <!-- template type - list --> 7211 <template v-if="!formatting_obj.template || formatting_obj.template === 'list'"> 7212 <div class="inputWrap searchBarWrap flexGrowOne" style="min-width:100%;"> 7213 <input class="searchBar searchBar_blog-list easeFast" placeholder="Search posts..." v-model="search"> 7214 </div> 7215 <div class="flexGrowOne"></div> 7216 </template> 7217 <!-- template type - bulletin --> 7218 <template v-if="formatting_obj.template === 'bulletin'"> 7219 <div class="searchBarWrap_v2" style="width: 100%; 7220 display: flex; 7221 flex-direction: row; 7222 align-items: center; 7223 justify-content: center; 7224 margin-right: 8px; 7225 margin-left: 8px; 7226 margin-bottom: 10px;"> 7227 <input id="searchBar_blog-list_v2" type="text" placeholder="Search posts..." v-model="search" /> 7228 <button id="searchBar_blog-list_submit" type="submit"> 7229 <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> 7230 </button> 7231 </div> 7232 </template> 7233 </div> 7234 <div class="fff noPadding"> 7235 <!-- template type - list --> 7236 <template v-if="!formatting_obj.template || formatting_obj.template === 'list'"> 7237 <div v-if="!loading && truncated_posts.length >= 1" class="tableInsert hundop"> 7238 <template v-for="(post, index) in truncated_posts" :key="post.id"> 7239 <div 7240 :style="{ 'background-color': formatting_obj.background_color || '#fff', 'border-radius': '4px' }" 7241 class="blog-row flexWrapLeftAlign relative pointer blog-row-border" 7242 :class="'blog-row_' + post.id" 7243 :data-post-id="post.id" 7244 > 7245 <div v-if="post && post.get('thumbnail')" class="blog-post-thumbnail" :style="'background-image:url(' + post.get('thumbnail') + ')'"></div> 7246 <div v-if="post && !post.get('thumbnail')" class="blog-post-thumbnail default-image-icon"></div> 7247 <div class="blog-row-inner flexGrowOne"> 7248 <div class="blog-name-wrap-column"> 7249 <p 7250 class="blog-title" 7251 :style="{ 7252 'color': formatting_obj.title_color || '#000', 7253 'font-weight': formatting_obj.title_styling && formatting_obj.title_styling.bold ? '600 !important' : '400 !important', 7254 'font-style': formatting_obj.title_styling && formatting_obj.title_styling.italic ? 'italic' : 'normal', 7255 'text-decoration': formatting_obj.title_styling && formatting_obj.title_styling.underline ? 'underline' : 'none', 7256 }" 7257 >{{ post.get('title') }}</p> 7258 <p 7259 v-if="post && post.get('subtitle')" 7260 class="blog-subtitle" 7261 :style="{ 7262 'color': formatting_obj.subtitle_color || '#000', 7263 'font-weight': formatting_obj.subtitle_styling && formatting_obj.subtitle_styling.bold ? '600 !important' : '400 !important', 7264 'font-style': formatting_obj.subtitle_styling && formatting_obj.subtitle_styling.italic ? 'italic' : 'normal', 7265 'text-decoration': formatting_obj.subtitle_styling && formatting_obj.subtitle_styling.underline ? 'underline' : 'none', 7266 }" 7267 >{{ post.get('subtitle') }}</p> 7268 <p class="blog-date" :style="{ 'color': formatting_obj.date_color || '#000' }">{{ niceDate(post) }}</p> 7269 <p><a class="absoluteA" :href="'/blog-post/' + post.id" style="text-transform: none !important; border-bottom: none !important;"></a> 7270 <a 7271 :href="'/blog-post/' + post.id" 7272 :style="{ 7273 'font-variant': 'small-caps', 7274 'opacity': '0.9', 7275 'color': formatting_obj.date_color || '#000', 7276 'border-bottom': 'none !important', 7277 'text-decoration': 'underline !important', 7278 'text-decoration-color': (formatting_obj.date_color || '#000') + ' !important' 7279 }" 7280 >Read more</a> 7281 </p> 7282 </div> 7283 </div> 7284 <div v-if="post && post.get('pinned') === true"> 7285 <i class="fa-solid fa-thumbtack thumbtack-icon" style="height: 1em; transform: rotate(45deg);"></i> 7286 </div> 7287 </div> 7288 </template> 7289 <div 7290 v-if="has_more_posts" 7291 class="view_more_button flexNoWrapCenter align-items-center" 7292 @click="view_all = true" 7293 style="margin-bottom:0px;margin-left:0px;margin-right:0px;" 7294 > 7295 <div> 7296 <p>View more</p> 7297 </div> 7298 <i class="fa-regular fa-arrow-right easeFast"></i> 7299 </div> 7300 </div> 7301 </template> 7302 <!-- template type - bulletin --> 7303 <template v-if="formatting_obj.template === 'bulletin'"> 7304 <div 7305 v-if="!loading && truncated_posts.length >= 1" 7306 :class="class_string" 7307 > 7308 <div class="blog-list-container"> 7309 <section 7310 class="blog-list-cards-v2" 7311 > 7312 <div v-for="(post, index) in truncated_posts" :key="post.id"> 7313 <article 7314 :style="{ 'background-color': formatting_obj.background_color || '#fff' }" 7315 style="display: flex; flex-direction: column; align-items: center; justify-content: flex-start; position: relative;" 7316 class="blog_post_card_v2" 7317 @click="go_to_post( post )" 7318 > 7319 <div class="blog-list-image-container"> 7320 <img v-if="post && post.get('thumbnail')" class="blog_post_thumbnail" :src="post.get('thumbnail')"> 7321 <div v-if="post && !post.get('thumbnail')" style="display: flex; align-items: center; justify-content: center;"> 7322 <i class="fa-duotone fa-solid fa-newspaper" style="font-size: 10em;"></i> 7323 </div> 7324 </div> 7325 <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> 7326 <div class="card__content"> 7327 <div 7328 v-if="post && post.get('title')" 7329 class="card__title_v2" 7330 :style="{ 7331 'color': formatting_obj.title_color || '#000', 7332 'font-weight': formatting_obj.title_styling && formatting_obj.title_styling.bold ? '600 !important' : '400 !important', 7333 'font-style': formatting_obj.title_styling && formatting_obj.title_styling.italic ? 'italic' : 'normal', 7334 'text-decoration': formatting_obj.title_styling && formatting_obj.title_styling.underline ? 'underline' : 'none', 7335 }" 7336 > 7337 {{ post.get('title') }} 7338 </div> 7339 <div 7340 v-if="post && post.get('subtitle')" 7341 class="card__subtitle tinyMarginTop" 7342 :style="{ 7343 'color': formatting_obj.subtitle_color || '#000', 7344 'font-weight': formatting_obj.subtitle_styling && formatting_obj.subtitle_styling.bold ? '600 !important' : '400 !important', 7345 'font-style': formatting_obj.subtitle_styling && formatting_obj.subtitle_styling.italic ? 'italic' : 'normal', 7346 'text-decoration': formatting_obj.subtitle_styling && formatting_obj.subtitle_styling.underline ? 'underline' : 'none', 7347 }" 7348 > 7349 {{ post.get('subtitle') }} 7350 </div> 7351 <div class="card__date" :style="{ 'color': formatting_obj.date_color || '#000' }">{{ niceDate(post) }}</div> 7352 <div class="card__excerpt"> 7353 <a 7354 @click.stop 7355 :href="'/blog-post/' + post.id" 7356 :style="{ 7357 'font-variant': 'small-caps', 7358 'opacity': '0.9', 7359 'color': formatting_obj.date_color || '#000', 7360 'border-bottom': 'none !important', 7361 'text-decoration': 'underline !important', 7362 'text-decoration-color': (formatting_obj.date_color || '#000') + ' !important' 7363 }" 7364 > 7365 read more 7366 </a> 7367 </div> 7368 </div> 7369 </article> 7370 </div> 7371 </section> 7372 </div> 7373 <div 7374 v-if="has_more_posts" 7375 class="view_more_button flexNoWrapCenter align-items-center" 7376 :class="{'larchmont': is_larchmont || is_larchmont === 'true'}" 7377 @click="view_all = true" 7378 style="margin-bottom:0px;" 7379 > 7380 <div> 7381 <p>View more</p> 7382 </div> 7383 <i class="fa-regular fa-arrow-right easeFast"></i> 7384 </div> 7385 </div> 7386 </template> 7387 <div v-if="!loading && filtered_posts.length == 0" class="tableInsert hundop blog-list-contain"> 7388 <p class="noObjectsMSG withPadding textLeft blog-row flexWrapLeftAlign relative pointer">No posts found.</p> 7389 </div> 7390 </div> 7391 <div v-if="loading" class="cardBodySpinnerWrap active relative"> 7392 <div class="cardBodySpinner centeredBlock"></div> 7393 </div> 7394 </div> 7395 </div> 7396 </div> 7397</script>
7397 7398 7399<style> 7400 7401 /*------------------------ view more buttons --------------------- */ 7402 7403 .view_more_button.card-layout{ 7404 margin-top:0px; 7405 } 7406 .view_more_button{ 7407 color: #6a7482; 7408 margin: 8px; 7409 border: 1px solid #e5eaec; 7410 border-top: 1px solid #e5eaec; 7411 border-radius: 5px; 7412 cursor: pointer; 7413 font-size: 16px; 7414 padding: 16px; 7415 margin-top: 8px; 7416 } 7417 .view_more_button:hover{ 7418 color: #081333; 7419 background-color: #f8f9fb; 7420 } 7421 .view_more_button.larchmont{ 7422 color: #fff; 7423 margin: 8px; 7424 border: 1px solid #fff; 7425 border-top: 1px solid #fff; 7426 border-radius: 5px; 7427 cursor: pointer; 7428 font-size: 16px; 7429 padding: 16px; 7430 margin-top: 8px; 7431 } 7432 .view_more_button.larchmont:hover{ 7433 background-color: #002D62; 7434 color: #fff; 7435 } 7436 .view_more_button i{ 7437 padding-left: 8px; 7438 } 7439 .view_more_button:hover i{ 7440 color: #4a56f8; 7441 transform: translateX(4px); 7442 } 7443 7444 /* -- end view more button --*/ 7445 7446 section.blog-list-cards-v2 { 7447 margin: 0 auto; 7448 display: grid; 7449 grid-template-columns: repeat(3, 1fr); 7450 grid-auto-rows: auto; 7451 grid-gap: 16px; 7452 align-content: center; 7453 justify-content: center; 7454 padding: 8px; 7455 width: 100%; 7456 box-sizing: border-box; 7457 } 7458 .blog-list-image-container { 7459 width: 100%; 7460 height: 300px; 7461 display: flex; 7462 align-items: center; 7463 justify-content: center; 7464 overflow: hidden; 7465 border-top-left-radius: 6px; 7466 border-top-right-radius: 6px; 7467 border-bottom-left-radius: 0px; 7468 border-bottom-right-radius: 0px; 7469 background-color: #f2f2f2; 7470 } 7471 .blog_post_thumbnail { 7472 width: 100%; 7473 height: 100%; 7474 object-fit: cover; 7475 display: block; 7476 } 7477 .default_image_icon { 7478 background-image: url('https://s3-us-west-2.amazonaws.com/myclubspot/upload-photo-icon.jpg'); 7479 background-size: contain; 7480 background-position: center; 7481 background-repeat: no-repeat; 7482 width: 100%; 7483 height: 100%; 7484 } 7485 @media (max-width: 768px) { 7486 section.blog-list-cards-v2 { 7487 grid-template-columns: repeat(1, 1fr); 7488 } 7489 } 7490</style> 7491
7492<script> 7493 7494 store.components["blog_list"] = { 7495 allPosts: [], 7496 search: '', 7497 loading: true, 7498 view_all: false, 7499 post_limit: 6 //how many to show on initial load (before clicking "see more") 7500 }; 7501 7502 app_content.component("blog-list-component", { 7503 template: "#template-blog-list", 7504 data() { 7505 return store.components['blog_list'] 7506 }, 7507 props: { 7508 client_id: String, 7509 class_string: String, 7510 style_string: String, 7511 element_style_string: String, 7512 formatting: String, 7513 component_type: String, 7514 is_larchmont: Boolean, String, 7515 //admin 7516 component: Object, 7517 last_update: Number, 7518 }, 7519 mounted() { 7520 this.loadBlogList( this.client_id ) 7521 }, 7522 methods: { 7523 go_to_post( post ){ 7524 window.location.href = "blog-post/" + post.id; 7525 }, 7526 loadBlogList( client_id, overrides = {} ){ 7527 var promise = Parse.Promise.as(); 7528 var thisPromise = promise.then(() => { 7529 //get new data 7530 var params = {}; 7531 params["client_id"] = client_id; 7532 params["overrides"] = overrides; 7533 params["request_url"] = window.location.href; 7534 return Parse.Cloud.run('retrieve_data_for_component', params); 7535 }).then(retrieved_posts => { 7536 // sort with 'pinned' posts on top 7537 this.allPosts = retrieved_posts.sort((a, b) => (a.get('pinned') === b.get('pinned') ? 0 : a.get('pinned') ? -1 : 1)); 7538 this.loading = false; 7539 //and return 7540 return Parse.Promise.as(); 7541 }, error => { 7542 checkParseError( error ); 7543 }); 7544 return thisPromise; 7545 }, 7546 niceDate( postObject ){ 7547 return dayjs(postObject.get('post_date')).format('MMM DD, YYYY'); 7548 }, 7549 return_grid_column_css(){ 7550 if (window.innerWidth <= 990) { 7551 //for mobile 7552 return 'repeat(1, 256px)'; 7553 } else { 7554 return 'repeat(4, 256px)'; 7555 } 7556 }, 7557 }, 7558 computed: { 7559 has_more_posts(){ 7560 if( this.view_all ){ 7561 return false; 7562 } 7563 if( this.filtered_posts.length > this.post_limit ){ 7564 return true; 7565 } 7566 return false; 7567 }, 7568 truncated_posts(){ 7569 if( this.view_all ){ 7570 return this.filtered_posts; 7571 }else{ 7572 return this.filtered_posts.slice(0, this.post_limit); 7573 } 7574 }, 7575 filtered_posts(){ 7576 let val = this.search.toLowerCase(); 7577 let filteredPosts = this.allPosts; 7578 // handle search results using all posts 7579 if (this.search.length > 0){ 7580 filteredPosts = this.allPosts.filter(post => { 7581 let title = post.get('title').toLowerCase(); 7582 if ( title.includes(val) ){ 7583 return post; 7584 } 7585 }) 7586 } 7587 return filteredPosts; 7588 }, 7589 formatting_obj(){ 7590 if (this.formatting){ 7591 return JSON.parse(this.formatting); 7592 } 7593 return {}; 7594 }, 7595 } 7596 }) 7597</script>
7597
7597<script type="text/html" id="template-custom-code"> 7598 <template v-if="preview_element"> 7599 <div 7600 class="cs-custom-code-wrap" 7601 :class="[class_string, { 7602 'preview-mode': active_tab === 'preview', 7603 'first-page-section': is_first, 7604 'active-ai-thread-target': ai_panel_is_open_for_this_component, 7605 'custom-code-preview-blank': !custom_code_is_empty && custom_code_preview_is_blank, 7606 }]" 7607 :style="ai_loading_wrap_style" 7608 @mouseenter="activate_ai_builder_target_from_presence" 7609 > 7610 <!-- Tab bar --> 7611 <div class="custom-code-tab-bar"> 7612 <div 7613 class="custom-code-tab" 7614 :class="{ active: active_tab === 'edit' }" 7615 @click="active_tab = 'edit'" 7616 >Code</div> 7617 <div 7618 class="custom-code-tab" 7619 :class="{ active: active_tab === 'preview' }" 7620 @click="active_tab = 'preview'" 7621 >Preview</div> 7622 </div> 7623 <!-- Code pane --> 7624 <div v-show="active_tab === 'edit'" class="custom-code-pane"> 7625 <div ref="cm_editor" class="custom-code-editor"></div> 7626 </div> 7627 <!-- Preview pane --> 7628 <!-- Hide the welcome page during the pre-first-delta state. 7629 The loading pane below owns that window completely so 7630 the preview area does not show both the composer and 7631 "thinking" UI at the same time. --> 7632 <div 7633 v-show="active_tab === 'preview' && custom_code_is_empty && !has_ai_thread_for_component && !ai_streaming && !ai_streaming_pending && !ai_welcome_generating" 7634 class="custom-code-pane custom-code-preview-pane custom-code-welcome-pane" 7635 :class="{ 'is-welcome-drop-active': welcome_drop_active }" 7636 style="display: flex; align-items: center; justify-content: center; min-height: 420px; padding: 48px 20px; background: #fbfcff;" 7637 @dragenter="handle_welcome_dragenter" 7638 @dragover.prevent="handle_welcome_dragover" 7639 @dragleave="handle_welcome_dragleave" 7640 @drop.prevent="handle_welcome_drop" 7641 > 7642 <!-- Drop overlay (welcome composer) â same pattern as 7643 the chat panel's drop overlay; mounts only while 7644 a file drag is hovering this pane. --> 7645 <div 7646 v-if="welcome_file_upload_enabled && welcome_drop_active" 7647 aria-hidden="true" 7648 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;" 7649 > 7650 <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);"> 7651 <i class="fa-regular fa-image" style="font-size: 22px;"></i> 7652 <p class="noMargin">Drop images to attach</p> 7653 </div> 7654 </div> 7655 <div style="width: min(680px, 92%); margin: 0 auto; text-align: center;"> 7656 <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;"> 7657 <i class="fa-regular fa-wand-magic-sparkles" style="font-size: 22px;"></i> 7658 </div> 7659 <h3 class="noMargin" style="font-size: 24px; line-height: 32px; color: #081333; font-weight: 700;">Build a custom section</h3> 7660 <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> 7661 <div class="flexWrap" style="gap: 8px; justify-content: center; margin: 22px auto 14px auto;"> 7662 <button 7663 v-for="example in ai_prompt_examples" 7664 :key="example" 7665 type="button" 7666 class="ai-composer-pill pointer exclude-from-custom-button-styles" 7667 @click.stop.prevent="set_ai_prompt_example(example)" 7668 >{{ example }}</button> 7669 </div> 7670 <!-- Attached image thumbnails â read-only mirror of 7671 the chat panel's mcp_attachments. Files queued 7672 here flow into the chat panel's state via the 7673 global ai_mcp_panel_attach_files helper, so 7674 submit ships them alongside the prompt without 7675 this composer needing its own prep pipeline. --> 7676 <div 7677 v-if="welcome_file_upload_enabled && welcome_attachments && welcome_attachments.length" 7678 style="display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin-bottom: 10px;" 7679 > 7680 <div 7681 v-for="(att, ai) in welcome_attachments" 7682 :key="'welcome-att-' + att.id" 7683 :title="att.name" 7684 style="position: relative; width: 56px; height: 56px; border-radius: 8px; overflow: hidden; background: #eef0f4; border: 1px solid #d9dfeb; flex: 0 0 auto;" 7685 :style="att.status === 'error' ? 'border-color: #e
768507b6f;' : ''" 7686 > 7687 <img 7688 v-if="att.preview_url" 7689 :src="att.preview_url" 7690 :alt="att.name" 7691 style="width: 100%; height: 100%; object-fit: cover; display: block;" 7692 :style="att.status === 'pending' ? 'opacity: 0.55;' : ''" 7693 /> 7694 <div 7695 v-if="att.status === 'pending'" 7696 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;" 7697 aria-hidden="true" 7698 ></div> 7699 <button 7700 type="button" 7701 class="pointer exclude-from-custom-button-styles" 7702 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;" 7703 :title="'Remove ' + (att.name || 'image')" 7704 @click.stop.prevent="welcome_remove_attachment(ai)" 7705 > 7706 <i class="fa-regular fa-xmark"></i> 7707 </button> 7708 </div> 7709 </div> 7710 <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;"> 7711 <div style="flex: 1 1 auto; min-width: 0;"> 7712 <p class="noMargin" style="font-size: 12px; line-height: 16px; color: #4a56f8; font-weight: 700; margin-bottom: 8px !important;"> 7713 <i class="fa-regular fa-sparkles" style="padding-right: 4px;"></i>Design with AI 7714 </p> 7715 <textarea 7716 ref="ai_prompt_textarea" 7717 v-model="ai_prompt" 7718 rows="1" 7719 class="ai-prompt-textarea" 7720 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;" 7721 placeholder="Describe the section you want to build..." 7722 :disabled="ai_prompt_loading" 7723 @input="auto_resize_ai_prompt" 7724 @paste="handle_welcome_paste" 7725 @keydown.enter.exact.prevent="submit_ai_prompt" 7726 ></textarea> 7727 </div> 7728 <input 7729 v-if="welcome_file_upload_enabled" 7730 ref="ai_prompt_file_input" 7731 type="file" 7732 accept="image/*" 7733 multiple 7734 style="display: none;" 7735 @change="handle_welcome_file_input_change" 7736 /> 7737 <button 7738 v-if="welcome_file_upload_enabled" 7739 type="button" 7740 class="pointer exclude-from-custom-button-styles" 7741 :disabled="ai_prompt_loading" 7742 title="Attach image" 7743 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;" 7744 @click.stop.prevent="open_welcome_file_picker" 7745 > 7746 <i class="fa-regular fa-paperclip"></i> 7747 </button> 7748 <button 7749 type="button" 7750 class="ai-submit-button easeFast exclude-from-custom-button-styles" 7751 :class="{ 7752 inactive: !ai_prompt_can_submit, 7753 loading: ai_prompt_loading 7754 }" 7755 :disabled="!ai_prompt_can_submit" 7756 title="Send" 7757 @click.stop.prevent="submit_ai_prompt" 7758 > 7759 <i class="fa-regular fa-paper-plane"></i> 7760 </button> 7761 </div> 7762 </div> 7763 </div> 7764 <!-- Welcome "Generatingâ¦" state â shown after the blank welcome 7765 composer hands the prompt to the page agent, until the first 7766 result lands (content arrives) or the safety timer fires. --> 7767 <div 7768 v-show="active_tab === 'preview' && custom_code_is_empty && ai_welcome_generating" 7769 class="custom-code-pane custom-code-preview-pane custom-code-welcome-generating-pane" 7770 style="display: flex; align-items: center; justify-content: center; min-height: 420px; padding: 48px 20px; background: #fbfcff;"
7771 aria-live="polite" 7772 > 7773 <div style="display: flex; flex-direction: column; align-items: center; gap: 14px; text-align: center;"> 7774 <span class="custom-code-welcome-generating-spinner" aria-hidden="true"></span> 7775 <p class="noMargin" style="font-size: 15px; line-height: 22px; color: #4a56f8; font-weight: 600;">Generatingâ¦</p> 7776 </div> 7777 </div> 7778 <div 7779 v-show="active_tab === 'preview' && custom_code_is_empty && has_ai_thread_for_component && !ai_streaming && !ai_streaming_pending && !ai_welcome_generating" 7780 class="custom-code-pane custom-code-preview-pane custom-code-empty-state-pane" 7781 > 7782 <div class="custom-code-empty-state-copy"> 7783 <i class="fa-regular fa-code"></i> 7784 <span>No content</span> 7785 </div> 7786 </div> 7787 <!-- Preview strips scripts / non-visual tags, so a section that 7788 only has those would otherwise collapse to 0 height. --> 7789 <div 7790 v-show="active_tab === 'preview' && !custom_code_is_empty && custom_code_preview_is_blank && (!ai_streaming || ai_streaming_pending || !ai_generation_active)" 7791 class="custom-code-pane custom-code-preview-pane custom-code-empty-state-pane custom-code-nonvisual-pane" 7792 > 7793 <div class="custom-code-empty-state-copy"> 7794 <i class="fa-regular fa-code"></i> 7795 <span>This section has code that isn't visible in preview</span> 7796 </div> 7797 </div> 7798 <!-- Static preview pane: HTML is mounted into an open shadow 7799 root (see ensure_isolated_custom_code_host) so host theme 7800 CSS cannot restyle the section â same isolation as live. --> 7801 <div 7802 v-show="active_tab === 'preview' && !custom_code_is_empty && !custom_code_preview_is_blank && (!ai_streaming || ai_streaming_pending || !ai_generation_active)" 7803 ref="custom_code_preview" 7804 class="custom-code-pane custom-code-preview-pane" 7805 :style="ai_loading_pane_style" 7806 ></div> 7807 <!-- Pre-first-delta loading. Empty sections get a full pane; 7808 edits to existing content use the same large dots as an 7809 overlay so the current section remains visible while the 7810 updated HTML is being prepared. --> 7811 <div 7812 v-show="active_tab === 'preview' && ai_streaming_pending" 7813 class="custom-code-pane custom-code-preview-pane custom-code-loading-pane" 7814 :class="{ 'is-overlay': !custom_code_is_empty }" 7815 :style="ai_loading_pane_style" 7816 aria-live="polite" 7817 > 7818 <div class="custom-code-pending-dots" aria-label="Generating"> 7819 <span></span><span></span><span></span> 7820 </div> 7821 </div> 7822 <!-- Streaming preview pane: managed by flush_streaming_preview_pane 7823 during a live SSE codegen stream. We bypass v-html so a 7824 tiny morph diff can append-or-replace top-level children 7825 by index â keeps already-rendered sections stable as new 7826 ones stream in below them. Snapped back to the v-html 7827 pane on stream `done`. --> 7828 <div 7829 v-show="active_tab === 'preview' && ai_streaming && !ai_streaming_pending && ai_generation_active" 7830 ref="custom_code_stream_preview" 7831 class="custom-code-pane custom-code-preview-pane" 7832 :style="ai_loading_pane_style" 7833 ></div> 7834 7835 <!-- AI builder launcher â bottom-right of the component. 7836 Clicking sets this component as the active target for 7837 the shared right-side AI panel (defined in 7838 ai-mcp-panel.html) and expands it. The panel's webbuilder 7839 mode then reads `current_code` server-side from
7840 pageComponents.config_latest.custom_code and runs in a 7841 thread pinned to this component instance. --> 7842 <button 7843 type="button" 7844 class="custom-code-ai-launcher pointer exclude-from-custom-button-styles" 7845 :class="{ 'has-active-stream': ai_generation_active }" 7846 @mousedown.stop.prevent 7847 @mouseup.stop 7848 @touchstart.stop 7849 @click.stop.prevent="open_ai_builder" 7850 :title="ai_launcher_title" 7851 v-if="ai_launcher_visible" 7852 > 7853 <i class="fa-regular fa-sparkles"></i> 7854 <span class="custom-code-ai-launcher-label"> 7855 <template v-if="effective_ai_streaming_status === 'queued'">Queuedâ¦</template> 7856 <template v-else-if="ai_generation_active">Generatingâ¦</template> 7857 <template v-else>Edit with AI</template> 7858 </span> 7859 <span 7860 v-if="ai_generation_active" 7861 class="custom-code-ai-launcher-spinner" 7862 aria-hidden="true" 7863 ></span> 7864 </button> 7865 </div> 7866 </template> 7867 <template v-if="!preview_element"> 7868 <!-- Live host: same shadow isolation as builder preview. --> 7869 <span v-if="!loading" ref="custom_code_static" class="cs-custom-code-live-host"></span> 7870 </template> 7871</script>
7871 7872
7873<script> 7874 // Reset + inheritance for custom-code shadow roots. Host theme element 7875 // selectors (h1/a/details/â¦) cannot pierce the shadow; :host inherits 7876 // color/font from the light-DOM wrapper so club theme still flows in. 7877 var CS_CUSTOM_CODE_ISOLATION_CSS = [ 7878 ':host { display: block; width: 100%; color: inherit; font-family: inherit; font-size: inherit; line-height: inherit; }', 7879 '.cs-cc-root { display: block; width: 100%; color: inherit; font: inherit; box-sizing: border-box; }', 7880 '.cs-cc-root *, .cs-cc-root *::before, .cs-cc-root *::after { box-sizing: border-box; }', 7881 '.cs-cc-root summary { list-style: none; cursor: pointer; }', 7882 '.cs-cc-root summary::-webkit-details-marker, .cs-cc-root summary::marker { display: none; }', 7883 '.cs-cc-root img { max-width: 100%; }', 7884 ].join('\n'); 7885 7886 // Builder-only overlays live inside the shadow tree. Document CSS 7887 // cannot pierce it, so these rules must ship with the isolation sheet 7888 // or Add/Change chips render unstyled and get clipped by overflow. 7889 var CS_CUSTOM_CODE_PREVIEW_CHROME_CSS = [ 7890 '.cs-asset-placeholder-host, .cs-asset-attached-host { position: relative; }', 7891 '.cs-asset-background-placeholder-host:hover { outline: 2px dashed rgba(74, 86, 248, 0.55) !important; outline-offset: -4px !important; }', 7892 '.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; }', 7893 '.cs-asset-background-placeholder-chip:hover { background: #ffffff !important; border-color: rgba(74, 86, 248, 0.85) !important; }', 7894 '.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; }', 7895 '.cs-asset-placeholder-overlay:hover { background: rgba(74, 86, 248, 0.16) !important; border-color: rgba(74, 86, 248, 0.85) !important; }', 7896 '.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; }', 7897 '.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; }', 7898 '.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; }', 7899 '.cs-asset-change-button:hover, .cs-asset-change-button:focus { opacity: 1 !important; background: rgba(20, 24, 45, 0.95) !important; }', 7900 '.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; }', 7901 '.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; }', 7902 ].join('\n'); 7903 7904 function createDebounce() { 7905 let timeout = null; 7906 return function (fnc, delayMs) { 7907 clearTimeout(timeout); 7908 timeout = setTimeout(() => { 7909 fnc(); 7910 }, delayMs || 800); 7911 }; 7912 } 7913
7914 // âââ External image detection (shared) ââââââââââââââââââââââââââââââ 7915 // 7916 // Shared by the custom-code component and section-actions overlay. 7917 var WEBBUILDER_CLUBSPOT_ASSET_HOSTS = [ 7918 'd282wvk2qi4wzk.cloudfront.net', 7919 'myclubspot.s3-us-west-2.amazonaws.com', 7920 'myclubspot.s3.us-west-2.amazonaws.com', 7921 ]; 7922 7923 function webbuilder_is_external_image_url(url) { 7924 if (typeof url !== 'string' || !url.trim()) { 7925 return false; 7926 } 7927 let parsed; 7928 try { 7929 parsed = new URL(url.trim()); 7930 } catch (err) { 7931 return false; 7932 } 7933 if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { 7934 return false; 7935 } 7936 return WEBBUILDER_CLUBSPOT_ASSET_HOSTS.indexOf((parsed.hostname || '').toLowerCase()) === -1; 7937 } 7938 7939 // Parse an HTML string and return the unique external image URLs it 7940 // references â both <img src> and inline `background-image: url(...)`. 7941 function webbuilder_collect_external_image_urls(source_html) { 7942 let source = (typeof source_html === 'string') ? source_html : ''; 7943 if (!source.trim()) { 7944 return []; 7945 } 7946 let found = []; 7947 let seen = {}; 7948 let push_if_external = (raw_url) => { 7949 let candidate = (typeof raw_url === 'string') ? raw_url.trim() : ''; 7950 if (!candidate || seen[candidate]) { 7951 return; 7952 } 7953 if (!webbuilder_is_external_image_url(candidate)) { 7954 return; 7955 } 7956 seen[candidate] = true; 7957 found.push(candidate); 7958 }; 7959 let template = document.createElement('template'); 7960 template.innerHTML = source; 7961 let root = template.content; 7962 let imgs = Array.from(root.querySelectorAll('img[src]')); 7963 for (let img of imgs) { 7964 push_if_external(img.getAttribute('src')); 7965 } 7966 let styled = Array.from(root.querySelectorAll('[style*="background-image"]')); 7967 for (let el of styled) { 7968 let style = el.getAttribute('style') || ''; 7969 let bg_pattern = /background-image\s*:\s*url\(\s*(['"]?)([^'"\)]+)\1\s*\)/gi; 7970 let match; 7971 while ((match = bg_pattern.exec(style)) !== null) { 7972 push_if_external(match[2]); 7973 } 7974 } 7975 return found; 7976 } 7977 7978 // âââ Text editing âââââââââââââââââââââââââââââââââââââââââââââââââââ 7979 // 7980 // In PREVIEW mode we walk the rendered DOM looking for "text leaf" 7981 // elements (headings, paragraphs, buttons, links, list items, etc.) 7982 // and decorate each one with a click-to-edit affordance. Clicking 7983 // opens a small inline popover with a textarea; on save we mutate 7984 // the persisted custom_code source and persist via 7985 // save_changes_for_component, mirroring the placeholder fill flow. 7986 // 7987 // Unlike images, there's no "placeholder" concept for text â the AI 7988 // writes plausible draft copy directly into the section and admins 7989 // tweak it inline. Round-tripping (preview path â source path) 7990 // works because the preview's DOM is parsed from the same source 7991 // string we mutate, with the only structural divergence being 7992 //
7992<script> tags removed by sanitize_custom_code_html (which we 7993 // skip on the source side when walking). 7994 // 7995 // A "text leaf" is any element whose element children are all 7996 // inline tags (or none). That treats `<h1>Welcome <em>back</em></h1>` 7997 // as a single editable string â emphasis collapses on save, by 7998 // design. Admins can use the Code tab for mixed inline formatting. 7999 8000 var TEXT_INLINE_TAGS = new Set([ 8001 'a', 'abbr', 'b', 'bdi', 'bdo', 'br', 'cite', 'code', 'em', 'i', 8002 'kbd', 'mark', 'q', 's', 'samp', 'small', 'span', 'strong', 8003 'sub', 'sup', 'time', 'u', 'var', 'wbr', 8004 ]); 8005 8006 var TEXT_SKIP_TAGS = new Set([ 8007 'script', 'style', 'template', 'noscript', 'iframe', 'svg', 8008 'video', 'audio', 'canvas', 'img', 'picture', 'source', 'track', 8009 'input', 'textarea', 'select', 'option', 'optgroup', 'meta', 8010 'link', 'br', 'hr', 'col', 'param', 'embed', 'object', 8011 ]); 8012 8013 // Tags that never produce a layout box in the sanitized preview. 8014 var CUSTOM_CODE_PREVIEW_SKIP_TAGS = new Set([ 8015 'script', 'style', 'template', 'noscript', 'meta', 'link', 'head', 8016 ]); 8017 // Replaced / form controls that take space even without text. 8018 var CUSTOM_CODE_PREVIEW_VISUAL_TAGS = new Set([ 8019 'img', 'iframe', 'video', 'audio', 'canvas', 'svg', 'object', 'embed', 8020 'picture', 'input', 'textarea', 'select', 'hr', 'br', 8021 ]); 8022 8023 function custom_code_element_has_explicit_preview_box(element) { 8024 let style = element.getAttribute('style'); 8025 if (!style || typeof style !== 'string') { 8026 return false; 8027 } 8028 if (/background-image\s*:\s*url\s*\(/i.test(style)) { 8029 return true; 8030 } 8031 let height_match = style.match(/(?:^|;)\s*(?:min-)?height\s*:\s*([^;]+)/i); 8032 if (!height_match) { 8033 return false; 8034 } 8035 let value = height_match[1].trim().toLowerCase(); 8036 if (!value || value === '0' || value === '0px' || value === '0%' || value === 'auto') { 8037 return false; 8038 } 8039 return true; 8040 } 8041 8042 function custom_code_node_has_visible_preview_content(node) { 8043 if (!node) { 8044 return false; 8045 } 8046 if (node.nodeType === 8) { 8047 return false; 8048 } 8049 if (node.nodeType === 3) { 8050 return !!(node.nodeValue && node.nodeValue.trim()); 8051 } 8052 if (node.nodeType !== 1) { 8053 let children = node.childNodes || []; 8054 for (let i = 0; i < children.length; i++) { 8055 if (custom_code_node_has_visible_preview_content(children[i])) { 8056 return true; 8057 } 8058 } 8059 return false; 8060 } 8061 let tag = (node.tagName || '').toLowerCase(); 8062 if (CUSTOM_CODE_PREVIEW_SKIP_TAGS.has(tag)) { 8063 return false; 8064 } 8065 if (CUSTOM_CODE_PREVIEW_VISUAL_TAGS.has(tag)) { 8066 return true; 8067 } 8068 if (custom_code_element_has_explicit_preview_box(node)) { 8069 return true; 8070 } 8071 for (let i = 0; i < node.childNodes.length; i++) { 8072 if (custom_code_node_has_visible_preview_content(node.childNodes[i])) { 8073 return true; 8074 } 8075 } 8076 return false; 8077 } 8078 8079 function custom_code_html_has_visible_preview_content(html) { 8080 if (!html || typeof html !== 'string' || !html.trim()) { 8081 return false; 8082 } 8083 let template = document.createElement('template'); 8084 template.innerHTML = html; 8085 return custom_code_node_has_visible_preview_content(template.content); 8086 } 8087 8088 app_content.component('custom-code-component', { 8089 template: '#template-custom-code', 8090 data() { 8091 return { 8092 loading: true, 8093 debounce: createDebounce(), 8094 custom_code_value: '', 8095 active_tab: 'preview', 8096 // True while the section-actions "save external images" 8097 // batch is in flight, so duplicate overlay triggers no-op. 8098 importing_external_images: false, 8099 ai_prompt: '', 8100 ai_prompt_examples: [ 8101 'A modern hero section for our club homepage', 8102 'A membership benefits section with three cards', 8103 'An event promo section with image placeholders', 8104 ], 8105 ai_launcher_title: 'Edit with AI', 8106 // âââ Streaming state âââââââââââââââââââââââââââââââ 8107 // Flipped to true by begin_streaming_code() and back 8108 // to false by finish_streaming_code(). Controls the 8109 // template's preview-pane selection (v-html static vs. 8110 // morph-managed div) and the overlay visibility. 8111 ai_streaming: false, 8112 ai_streaming_status: null, // 'streaming' | 'queued' | 'aborted_local' | 'error' | 'done' 8113 // Snapshot of the editor contents at stream begin â 8114 // used by apply_generated_code as the return value 8115 // (Undo target) when the panel's done handler runs. 8116 ai_streaming_pre_code: null, 8117 // Accumulated code chunks. Always the full snapshot 8118 // of what the stream has emitted so far (chunks are 8119 // appended as they arrive). 8120 ai_streaming_buffer: '', 8121 // Local reactive tick for store.ai_streams snapshots. 8122 // Bumped by subscriber events; lets computeds that 8123 // read entry.code_buffer / entry.message_text 8124 // re-evaluate. Launcher status no longer depends on 8125 // this â that lives in store.codegen_request_state. 8126 ai_stream_event_tick: 0, 8127 ai_loading_pane_height: null, 8128 ai_loading_pane_height_release_timer: null, 8129 ai_streaming_child_min_heights: [], 8130 // Welcome composer drag-drop counter â increments on 8131 // dragenter, decrements on dragleave so the overlay 8132 // doesn't flicker on nested-element drag events. The 8133 // attachment LIST itself lives on the chat panel 8134 // (store.components.ai_mcp_panel.mcp_attachments) so 8135 // both composers stay in sync without a second prep 8136 // pipeline. 8137 welcome_drop_counter: 0, 8138 welcome_drop_active: false, 8139 // True between submitting from the blank welcome composer and 8140 // the page agent's first result landing. Swaps the welcome 8141 // composer for a "Generatingâ¦" pane. The codegen stream no 8142 // longer drives this flow (the page agent does), so this is a 8143 // local flag cleared when content arrives or on a safety timer. 8144 ai_welcome_generating: false, 8145 } 8146 }, 8147 props: { 8148 custom_code: String, 8149 //admin 8150 class_string: String, 8151 preview_element: { 8152 type: Boolean, 8153 default: false, 8154 }, 8155 is_first: { 8156 type: Boolean, 8157 default: false, 8158 }, 8159 component: { 8160 type: Object, 8161 default: null, 8162 }, 8163 first_section_top_spacer_px_override: { 8164 type: Number, 8165 default: null, 8166 }, 8167 last_update: Number, 8168 }, 8169 watch: { 8170 active_tab(val) { 8171 if (val !== 'preview') { 8172 this.close_text_edit_popover(); 8173 } 8174 if (val === 'edit' && this.cm_instance) { 8175 this.$nextTick(() => this.cm_instance.refresh()); 8176 } 8177 if (val === 'preview') { 8178 this.$nextTick(() => this.initialize_custom_code_interactions()); 8179 } 8180 this.publish_active_tab_to_store(); 8181 }, 8182 // First result from the page agent has landed â drop the welcome 8183 // "Generatingâ¦" pane so the new section renders. 8184 custom_code_value(val) { 8185 if (this.ai_welcome_generating && (val || '').trim()) { 8186 this.clear_welcome_generating_state(); 8187 } 8188 }, 8189 last_update() { 8190 this.close_text_edit_popover(); 8191 // Don't fight a LIVE stream â the streaming buffer is the 8192 // source of truth until the build settles. But only an actually 8193 // active stream (entry still streaming/queued) should block the 8194 // resync: a stale `ai_streaming` flag (entry terminal or gone, 8195 // e.g. an aborted/errored build) must NOT wedge the section on 8196 // old HTML, or the agent's authoritative __refresh re-pull can 8197 // never land. Otherwise sync the preview straight from the 8198 // component config â the live preview repaints off 8199 // custom_code_value even when the (hidden) editor isn't mounted. 8200 let entry = this.active_codegen_stream_entry; 8201 let stream_active = this.ai_streaming 8202 && entry 8203 && (entry.status === 'streaming' || entry.status === 'queued'); 8204 if (!stream_active) { 8205 if (this.ai_streaming) { 8206 // Stale flag â release the editor lock before syncing. 8207 this.force_clear_ai_loading_state(); 8208 } 8209 let config_latest = this.component.get('config_latest') || {}; 8210 let new_value = config_latest.custom_code || ''; 8211 if (this.cm_instance && new_value !== this.cm_instance.getValue()) { 8212 this.cm_instance.setValue(new_value); 8213 } 8214 this.custom_code_value = new_value; 8215 this.publish_current_code_to_store(); 8216 } 8217 this.$nextTick(() => this.initialize_custom_code_interactions()); 8218 }, 8219 // Re-measure the auto-grow welcome composer whenever the 8220 // prompt changes from outside the textarea â example pill 8221 // click, post-submit clear. Typing still routes through 8222 // @input for synchronous response. 8223 ai_prompt() { 8224 this.$nextTick(() => this.auto_resize_ai_prompt()); 8225 }, 8226 // Editor-lock cleanup. The launcher computed reads the 8227 // store entry directly (and the 1Hz heartbeat keeps it
8228 // fresh), so this watcher is no longer the launcher's 8229 // safety net â it just makes sure `ai_streaming` and the 8230 // editor's readOnly flag release when the entry goes 8231 // terminal, since the morph-preview / editor lock are 8232 // still driven from local flags rather than the store. 8233 ai_stream_event_tick() { 8234 let entry = this.active_codegen_stream_entry; 8235 if (!entry) { 8236 return; 8237 } 8238 if (entry.status !== 'done' && entry.status !== 'error' && entry.status !== 'aborted_local') { 8239 return; 8240 } 8241 // Editor-lock-only cleanup: this watcher does not 8242 // gate launcher visibility (the launcher binds to 8243 // store.codegen_request_state). It exists to release 8244 // ai_streaming / readOnly when the central store 8245 // says the request is over, in case the subscription 8246 // path missed an event. 8247 let needs_clear = this.ai_streaming 8248 || this.ai_streaming_status === 'streaming' 8249 || this.ai_streaming_status === 'queued'; 8250 if (!needs_clear) { 8251 return; 8252 } 8253 this.force_clear_ai_loading_state(); 8254 }, 8255 }, 8256 computed: { 8257 safe_custom_code_value() { 8258 return this.sanitize_custom_code_html(this.custom_code_value); 8259 }, 8260 safe_custom_code() { 8261 return this.sanitize_custom_code_html(this.custom_code || ''); 8262 }, 8263 // Source emptiness, not sanitized HTML â a
8263<script> load is 8264 // still "has code" even though preview strips it. 8265 custom_code_is_empty() { 8266 return !(this.custom_code_value || '').trim(); 8267 }, 8268 custom_code_preview_is_blank() { 8269 return !custom_code_html_has_visible_preview_content(this.safe_custom_code_value); 8270 }, 8271 // Unique external (non-Clubspot-CDN) image URLs referenced 8272 // by this section's persisted HTML â both <img src> and 8273 // inline `background-image: url(...)`. Drives the bulk 8274 // "save external images to my library" affordance. Recomputes 8275 // only when custom_code_value changes, so parsing cost is 8276 // bounded to actual edits. 8277 section_external_image_urls() { 8278 return this.collect_section_external_image_urls(this.custom_code_value); 8279 }, 8280 section_external_image_count() { 8281 return this.section_external_image_urls.length; 8282 }, 8283 ai_loading_pane_style() { 8284 if (!this.ai_loading_pane_height) { 8285 return null; 8286 } 8287 return { 8288 minHeight: this.ai_loading_pane_height + 'px', 8289 }; 8290 }, 8291 ai_loading_wrap_style() { 8292 if (!this.ai_loading_pane_height) { 8293 return null; 8294 } 8295 return { 8296 minHeight: this.ai_loading_pane_height + 'px', 8297 }; 8298 }, 8299 has_ai_thread_for_component() { 8300 if (typeof store === 'undefined') { 8301 return false; 8302 } 8303 let component_id = this.component && this.component.id; 8304 if (!component_id) { 8305 return false; 8306 } 8307 let config_latest = this.component && typeof this.component.get === 'function' 8308 ? (this.component.get('config_latest') || {}) 8309 : {}; 8310 if (config_latest.ai_thread_id) { 8311 return true; 8312 } 8313 let thread_map = store.webbuilder_code_component_has_ai_thread || {}; 8314 return !!thread_map[component_id]; 8315 }, 8316 ai_prompt_loading() { 8317 if (typeof store === 'undefined' || !store.components || !store.components.ai_mcp_panel) { 8318 return false; 8319 } 8320 return !!store.components.ai_mcp_panel.mcp_loading; 8321 }, 8322 ai_prompt_can_submit() { 8323 if (!(this.ai_prompt || '').trim().length) { 8324 return false; 8325 } 8326 return !this.ai_prompt_loading; 8327 }, 8328 // âââ Welcome composer image attachments ââââââââââââââââââ 8329 // 8330 // The welcome-state composer (rendered when a custom-code 8331 // section is empty and there's no prior AI thread) gets a 8332 // paperclip + drag-drop + thumbnail row mirroring the 8333 // chat panel. To avoid duplicating the canvas-resize / 8334 // base64 prep pipeline, we delegate ALL state to the chat 8335 // panel via the global ai_mcp_panel_attach_files helper. 8336 // This computed is just a reactive read of that shared 8337 // state so the welcome composer's thumbnails update in 8338 // lockstep with the chat panel's thumbnails. 8339 welcome_file_upload_enabled() { 8340 if (typeof store === 'undefined' || !store.ai_mcp_panel_component_instance) { 8341 return false; 8342 } 8343 return !!store.ai_mcp_panel_component_instance.mode_enable_file_upload; 8344 }, 8345 welcome_attachments() { 8346 if (typeof store === 'undefined' || !store.components || !store.components.ai_mcp_panel) { 8347 return []; 8348 } 8349 let panel = store.components.ai_mcp_panel; 8350 return Array.isArray(panel.mcp_attachments) ? panel.mcp_attachments : []; 8351 }, 8352 // âââ Single source of truth: store.codegen_request_state ââ 8353 // 8354 // The launcher's "Generatingâ¦" UI reads ONE reactive field: 8355 // store.codegen_request_state[component_id] 8356 // 8357 // That field is owned by store.ai_streams (the central
8358 // streams manager in ai-mcp-panel.html) and written ONLY 8359 // from there as the request transitions through: 8360 // 'starting' â 'queued' â 'streaming' â 'done' / 'error' / 'aborted' 8361 // 8362 // Because `store` is a Vue 3 `reactive()` proxy, both the 8363 // launcher (this file) and the chat panel bind to the same 8364 // key reactively. START and STOP are visible to both 8365 // surfaces from a single write â no subscriptions, no 8366 // ticks, no heartbeats, no watchdog needed. 8367 // 8368 // Local flags (`ai_streaming`, `ai_streaming_buffer`, 8369 // `ai_streaming_status`) still drive the EDITOR LOCK / 8370 // morph preview pane via the subscription path; those 8371 // concerns are deliberately decoupled from the launcher 8372 // so a missed editor-side event can't strand the spinner. 8373 codegen_request_status() { 8374 if (typeof store === 'undefined' || !store.codegen_request_state) { 8375 return null; 8376 } 8377 let component_id = this.component && this.component.id; 8378 if (!component_id) { 8379 return null; 8380 } 8381 return store.codegen_request_state[component_id] || null; 8382 }, 8383 active_codegen_stream_entry() { 8384 /* eslint-disable-next-line no-unused-expressions */ 8385 this.ai_stream_event_tick; 8386 if (typeof store === 'undefined' || !store.ai_streams || !store.ai_streams.get) { 8387 return null; 8388 } 8389 let component_id = this.component && this.component.id; 8390 if (!component_id) { 8391 return null; 8392 } 8393 return store.ai_streams.get('codegen:' + component_id); 8394 }, 8395 effective_ai_streaming_status() { 8396 let s = this.codegen_request_status; 8397 if (s) { 8398 return s; 8399 } 8400 return this.ai_streaming_status; 8401 }, 8402 // Launcher visibility â one rule, one field. 8403 ai_generation_active() { 8404 let s = this.codegen_request_status; 8405 return s === 'starting' || s === 'queued' || s === 'streaming'; 8406 }, 8407 ai_panel_is_open() { 8408 if (typeof store === 'undefined' || !store.components || !store.components.ai_mcp_panel) { 8409 return false; 8410 } 8411 return !!store.components.ai_mcp_panel.expanded; 8412 }, 8413 ai_panel_is_open_for_this_component() { 8414 if (!this.ai_panel_is_open || typeof store === 'undefined') { 8415 return false; 8416 } 8417 store.webbuilder_code_active_component_revision; 8418 let component_id = this.component && this.component.id; 8419 if (!component_id) { 8420 return false; 8421 } 8422 return store.webbuilder_code_active_component_id === component_id; 8423 }, 8424 // Idle Edit with AI lives on the hover toolbar. This in-section 8425 // pill is only the generating/queued indicator. 8426 ai_launcher_visible() { 8427 if (this.ai_panel_is_open) { 8428 return false; 8429 } 8430 return this.ai_generation_active; 8431 }, 8432 // Dots overlay â visible while we have a stream that 8433 // hasn't started emitting code yet. Reads the central 8434 // status for the bookended states; reads the entry's 8435 // code_buffer (still owned by the streams manager) to 8436 // distinguish "streaming but no chunks yet" from 8437 // "streaming with content". 8438 ai_streaming_pending() { 8439 let s = this.codegen_request_status; 8440 if (s === 'starting' || s === 'queued') { 8441 return true; 8442 } 8443 if (s !== 'streaming') { 8444 return false;
8445 } 8446 let entry = this.active_codegen_stream_entry; 8447 if (this.ai_streaming_buffer && this.ai_streaming_buffer.trim()) { 8448 return false; 8449 } 8450 return !!(entry && !(entry.code_buffer || '').trim()); 8451 }, 8452 }, 8453 mounted() { 8454 this.loading = false; 8455 // Listen for the section-actions overlay's "Save external 8456 // images" action. The overlay only has the component Parse 8457 // object, so it broadcasts the id and the matching live 8458 // instance runs the batch (which owns the persist + 8459 // re-render path). 8460 if (typeof store !== 'undefined' && store.eventBus) { 8461 this._save_external_images_handler = (component) => { 8462 let target_id = component && component.id ? component.id : component; 8463 if (this.component && this.component.id === target_id) { 8464 this.import_all_external_images_in_section(); 8465 } 8466 }; 8467 store.eventBus.$on('webbuilderSaveExternalImages', this._save_external_images_handler); 8468 this._webbuilder_refresh_replay_handler = () => { 8469 let component_id = this.component && this.component.id; 8470 if (!component_id || !store.ai_streams || typeof store.ai_streams.get !== 'function') { 8471 return; 8472 } 8473 let entry = store.ai_streams.get('codegen:' + component_id); 8474 if (!entry) { 8475 return; 8476 } 8477 this.subscribe_to_codegen_stream(); 8478 }; 8479 store.eventBus.$on('webbuilderPageRefreshReconciled', this._webbuilder_refresh_replay_handler); 8480 } 8481 if (this.preview_element) { 8482 let config_latest = (this.component && this.component.get('config_latest')) || {}; 8483 this.custom_code_value = config_latest.custom_code || ''; 8484 // Publish the live editor contents to the shared store 8485 // so the chat panel's per-message apply-state badge 8486 // can diff against it reactively without needing a 8487 // back-reference to this component. 8488 this.publish_current_code_to_store(); 8489 this.$nextTick(() => { 8490 this.cm_instance = CodeMirror(this.$refs.cm_editor, { 8491 value: this.custom_code_value, 8492 mode: 'htmlmixed', 8493 lineNumbers: true, 8494 autoCloseBrackets: true, 8495 autoCloseTags: true, 8496 matchBrackets: true, 8497 lineWrapping: true, 8498 tabSize: 2, 8499 indentWithTabs: false, 8500 extraKeys: { 8501 'Tab': function(cm) { 8502 cm.replaceSelection(' ', 'end'); 8503 } 8504 }, 8505 }); 8506 this.cm_instance.on('change', (editor) => { 8507 // Suppress save while we're driving the editor 8508 // from a streaming buffer â the stream's `done` 8509 // handler runs the canonical save once at the 8510 // end, and persisting partial frames would 8511 // spam the API. 8512 if (this._suppress_change_save) { 8513 // Still surface the live value to the 8514 // store so the apply-state badge stays 8515 // accurate during the typewriter â just 8516 // don't fire the debounced save. 8517 this.custom_code_value = editor.getValue(); 8518 this.publish_current_code_to_store(); 8519 return; 8520 } 8521 this.debounce(() => { 8522 let value = editor.getValue(); 8523 this.custom_code_value = value; 8524 this.publish_current_code_to_store(); 8525 save_changes_for_component(this.component, 'custom_code', value); 8526 this.mark_ai_code_revision_changed(); 8527 }); 8528 }); 8529 }); 8530 // Subscribe to this component's codegen stream key so 8531 // the editor + preview update live, independently of 8532 // whether the chat panel is open. Re-subscribing on 8533 // every mount also covers the page-reload recovery 8534 // case â if a passive entry exists for our key, the 8535 // replay event hands us the partial buffer. 8536 this.subscribe_to_codegen_stream(); 8537 } 8538 this.$nextTick(() => { 8539 this.initialize_custom_code_interactions(); 8540 this.auto_resize_ai_prompt(); 8541 this.setup_ai_builder_presence_observer(); 8542 this.publish_active_tab_to_store(); 8543 if (typeof store !== 'undefined' && 8544 store.webbuilder_code_active_component_id === (this.component && this.component.id)) { 8545 store.webbuilder_code_active_component = this; 8546 store.webbuilder_code_active_component_revision = Date.now(); 8547 this.subscribe_to_codegen_stream(); 8548 } 8549 }); 8550 }, 8551 beforeUnmount() { 8552 this.close_text_edit_popover(); 8553 this.cm_instance = null; 8554 if (typeof store !== 'undefined' && store.eventBus && this._save_external_images_handler) { 8555 if (typeof store.eventBus.$off === 'function') { 8556 store.eventBus.$off('webbuilderSaveExternalImages', this._save_external_images_handler); 8557 } else if (store.eventBus.listeners && Array.isArray(store.eventBus.listeners.webbuilderSaveExternalImages)) { 8558 store.eventBus.listeners.webbuilderSaveExternalImages = store.eventBus.listeners.webbuilderSaveExternalImages.filter(callback => { 8559 return callback !== this._save_external_images_handler; 8560 }); 8561 } 8562 this._save_external_images_handler = null; 8563 } 8564 if (typeof store !== 'undefined' && store.eventBus && this._webbuilder_refresh_replay_handler) { 8565 if (typeof store.eventBus.$off === 'function') { 8566 store.eventBus.$off('webbuilderPageRefreshReconciled', this._webbuilder_refresh_replay_handler); 8567 } else if (store.eventBus.listeners && Array.isArray(store.eventBus.listeners.webbuilderPageRefreshReconciled)) { 8568 store.eventBus.listeners.webbuilderPageRefreshReconciled = store.eventBus.listeners.webbuilderPageRefreshReconciled.filter(callback => { 8569 return callback !== this._webbuilder_refresh_replay_handler; 8570 }); 8571 } 8572 this._webbuilder_refresh_replay_handler = null; 8573 } 8574 // Unsubscribe â but do NOT cancel the underlying stream. 8575 // The entry stays alive in store.ai_streams so a remount 8576 // (e.g. component re-rendered after a save) can re-attach 8577 // and continue showing progress. 8578 if (typeof this._codegen_stream_unsubscribe === 'function') { 8579 try { this._codegen_stream_unsubscribe(); } catch (e) {} 8580 this._codegen_stream_unsubscribe = null; 8581 } 8582 if (this._codegen_stream_raf) { 8583 cancelAnimationFrame(this._codegen_stream_raf); 8584 this._codegen_stream_raf = null; 8585 } 8586 if (this._ai_streaming_starting_timer) { 8587 clearTimeout(this._ai_streaming_starting_timer); 8588 this._ai_streaming_starting_timer = null; 8589 } 8590 if (this._ai_welcome_generating_timer) { 8591 clearTimeout(this._ai_welcome_generating_timer); 8592 this._ai_welcome_generating_timer = null; 8593 } 8594 if (this.ai_loading_pane_height_release_timer) { 8595 clearTimeout(this.ai_loading_pane_height_release_timer); 8596 this.ai_loading_pane_height_release_timer = null; 8597 } 8598 if (this._ai_builder_presence_observer) { 8599 this._ai_builder_presence_observer.disconnect(); 8600 this._ai_builder_presence_observer = null; 8601 } 8602 if (this._ai_builder_presence_timeout) { 8603 clearTimeout(this._ai_builder_presence_timeout); 8604 this._ai_builder_presence_timeout = null; 8605 } 8606 // If this component was the active target for the AI 8607 // builder, clear the pointer so the panel doesn't try to 8608 // apply code into a Vue instance that's no longer mounted. 8609 if (typeof store !== 'undefined' && store.webbuilder_code_active_component === this) { 8610 store.webbuilder_code_active_component = null; 8611 store.webbuilder_code_active_component_id = null; 8612 }
8613 // Drop this section's published tab state so a stale 'edit' value 8614 // can't keep the launcher shown after the section is gone. 8615 if (typeof store !== 'undefined' 8616 && store.webbuilder_custom_code_tab_by_client_id 8617 && this.component 8618 && typeof this.component.get === 'function') { 8619 let cid = this.component.get('client_id'); 8620 if (cid) { 8621 delete store.webbuilder_custom_code_tab_by_client_id[cid]; 8622 } 8623 } 8624 }, 8625 methods: { 8626 page_has_multiple_custom_code_components() { 8627 if (typeof store === 'undefined' || !Array.isArray(store.page_components_latest)) { 8628 return false; 8629 } 8630 let custom_code_count = 0; 8631 for (let i = 0; i < store.page_components_latest.length; i++) { 8632 let page_component = store.page_components_latest[i]; 8633 if (page_component && page_component.get && page_component.get('type') === 'custom-code') { 8634 custom_code_count++; 8635 } 8636 if (custom_code_count > 1) { 8637 return true; 8638 } 8639 } 8640 return false; 8641 }, 8642 setup_ai_builder_presence_observer() { 8643 if (!this.preview_element) { 8644 return; 8645 } 8646 if (!this.page_has_multiple_custom_code_components()) { 8647 return; 8648 } 8649 if (typeof IntersectionObserver === 'undefined') { 8650 return; 8651 } 8652 if (!this.$el || this.$el.nodeType !== 1) { 8653 return; 8654 } 8655 this._ai_builder_presence_observer = new IntersectionObserver(entries => { 8656 entries.forEach(entry => { 8657 if (entry && entry.isIntersecting && entry.intersectionRatio >= 0.55) { 8658 this.activate_ai_builder_target_from_presence(); 8659 } 8660 }); 8661 }, { 8662 threshold: [0.55], 8663 }); 8664 this._ai_builder_presence_observer.observe(this.$el); 8665 }, 8666 activate_ai_builder_target_from_presence() { 8667 if (typeof store === 'undefined') { 8668 return; 8669 } 8670 if (!this.page_has_multiple_custom_code_components()) { 8671 return; 8672 } 8673 if (!store.ai_mcp_floating_panel_ui) { 8674 return; 8675 } 8676 let panel_state = store.components && store.components.ai_mcp_panel; 8677 if (!panel_state || !panel_state.expanded) { 8678 return; 8679 } 8680 if (panel_state.mcp_loading || (panel_state.mcp_prompt || '').trim()) { 8681 return; 8682 } 8683 let messages = Array.isArray(panel_state.mcp_messages) ? panel_state.mcp_messages : []; 8684 let last_message = messages.length ? messages[messages.length - 1] : null; 8685 if (last_message && last_message.streaming) { 8686 return; 8687 } 8688 let component_id = this.component && this.component.id ? this.component.id : null; 8689 if (!component_id || store.webbuilder_code_active_component_id === component_id) { 8690 return; 8691 } 8692 if (this._ai_builder_presence_timeout) { 8693 clearTimeout(this._ai_builder_presence_timeout); 8694 } 8695 this._ai_builder_presence_timeout = setTimeout(() => { 8696 this.activate_ai_builder_target(); 8697 this._ai_builder_presence_timeout = null; 8698 }, 120); 8699 }, 8700 activate_ai_builder_target(options) { 8701 if (typeof store === 'undefined') { 8702 return; 8703 } 8704 // Reclaim the per-component codegen mode in case the 8705 // page-level webbuilder-agent launcher switched it. 8706 store.ai_mcp_mode = 'webbuilder-code'; 8707 let opts = options || {}; 8708 let previous_component_id = store.webbuilder_code_active_component_id || null; 8709 store.webbuilder_code_active_component = this; 8710 store.webbuilder_code_active_component_id = this.component && this.component.id ? this.component.id : null; 8711 store.webbuilder_code_active_component_revision = Date.now(); 8712 this.subscribe_to_codegen_stream(); 8713 let panel = store.ai_mcp_panel_component_instance || null; 8714 if (panel && typeof panel.switch_webbuilder_component_thread === 'function') { 8715 panel.switch_webbuilder_component_thread( 8716 store.webbuilder_code_active_component_id, 8717 previous_component_id, 8718 opts 8719 ); 8720 } 8721 }, 8722 set_ai_prompt_example(example) { 8723 this.ai_prompt = example || ''; 8724 }, 8725 // âââ Welcome composer attachment handlers ââââââââââââââââ 8726 // 8727 // All paths funnel through the global helper 8728 // ai_mcp_panel_attach_files which writes into the shared 8729 // chat panel state (canvas-resize + base64 prep happens 8730 // there). The welcome composer is a UI-only mirror â no 8731 // local Blob storage, no local prep. 8732 open_welcome_file_picker() { 8733 if (!this.welcome_file_upload_enabled) { 8734 return; 8735 } 8736 if (this.ai_prompt_loading) { 8737 return; 8738 } 8739 let input = this.$refs && this.$refs.ai_prompt_file_input; 8740 if (!input) { 8741 return; 8742 } 8743 input.click(); 8744 }, 8745 handle_welcome_file_input_change(event) { 8746 let files = event && event.target && event.target.files 8747 ? Array.from(event.target.files) 8748 : []; 8749 if (event && event.target) { 8750 event.target.value = ''; 8751 } 8752 if (!files.length) { 8753 return; 8754 } 8755 if (typeof ai_mcp_panel_attach_files === 'function') { 8756 ai_mcp_panel_attach_files({ 8757 component: this, 8758 files: files, 8759 }); 8760 } 8761 }, 8762 handle_welcome_dragenter(event) { 8763 if (!this.welcome_file_upload_enabled) { 8764 return; 8765 } 8766 if (!this.welcome_event_has_image_files(event)) { 8767 return; 8768 } 8769 event.preventDefault(); 8770 this.welcome_drop_counter++; 8771 this.welcome_drop_active = this.welcome_drop_counter > 0; 8772 }, 8773 handle_welcome_dragover(event) { 8774 if (!this.welcome_file_upload_enabled) { 8775 return; 8776 } 8777 if (!this.welcome_event_has_image_files(event)) { 8778 return; 8779 } 8780 if (event.dataTransfer) { 8781 try { 8782 event.dataTransfer.dropEffect = 'copy'; 8783 } catch (drop_effect_err) {} 8784 } 8785 }, 8786 handle_welcome_dragleave() { 8787 if (!this.welcome_file_upload_enabled) { 8788 return; 8789 } 8790 if (this.welcome_drop_counter > 0) { 8791 this.welcome_drop_counter--; 8792 } 8793 if (this.welcome_drop_counter <= 0) { 8794 this.welcome_drop_counter = 0; 8795 this.welcome_drop_active = false;
8796 } 8797 }, 8798 handle_welcome_drop(event) { 8799 this.welcome_drop_counter = 0; 8800 this.welcome_drop_active = false; 8801 if (!this.welcome_file_upload_enabled) { 8802 return; 8803 } 8804 let files = event && event.dataTransfer && event.dataTransfer.files 8805 ? Array.from(event.dataTransfer.files) 8806 : []; 8807 if (!files.length) { 8808 return; 8809 } 8810 let images = files.filter(function (file) { 8811 return file && typeof file.type === 'string' && file.type.indexOf('image/') === 0; 8812 }); 8813 if (!images.length) { 8814 return; 8815 } 8816 if (typeof ai_mcp_panel_attach_files === 'function') { 8817 ai_mcp_panel_attach_files({ 8818 component: this, 8819 files: images, 8820 }); 8821 } 8822 }, 8823 handle_welcome_paste(event) { 8824 if (!this.welcome_file_upload_enabled) { 8825 return; 8826 } 8827 if (!event || !event.clipboardData || !event.clipboardData.items) { 8828 return; 8829 } 8830 let items = Array.from(event.clipboardData.items); 8831 let pasted = []; 8832 for (let i = 0; i < items.length; i++) { 8833 let item = items[i]; 8834 if (!item || item.kind !== 'file') { 8835 continue; 8836 } 8837 if (typeof item.type !== 'string' || item.type.indexOf('image/') !== 0) { 8838 continue; 8839 } 8840 let file = item.getAsFile && item.getAsFile(); 8841 if (file) { 8842 pasted.push(file); 8843 } 8844 } 8845 if (!pasted.length) { 8846 return; 8847 } 8848 event.preventDefault(); 8849 if (typeof ai_mcp_panel_attach_files === 'function') { 8850 ai_mcp_panel_attach_files({ 8851 component: this, 8852 files: pasted, 8853 }); 8854 } 8855 }, 8856 welcome_event_has_image_files(event) { 8857 if (!event || !event.dataTransfer) { 8858 return false; 8859 } 8860 let types = event.dataTransfer.types; 8861 if (!types) { 8862 return false; 8863 } 8864 for (let i = 0; i < types.length; i++) { 8865 if (types[i] === 'Files' || types[i] === 'application/x-moz-file') { 8866 return true; 8867 } 8868 } 8869 return false; 8870 }, 8871 welcome_remove_attachment(index) { 8872 if (typeof store === 'undefined' || !store.ai_mcp_panel_component_instance) { 8873 return; 8874 } 8875 let panel = store.ai_mcp_panel_component_instance; 8876 if (typeof panel.remove_attachment === 'function') { 8877 panel.remove_attachment(index); 8878 } 8879 }, 8880 // Auto-grow the welcome-page composer textarea. Starts at 8881 // a single line (height matches one line-height) and grows 8882 // up to the CSS max-height (~8 lines), after which the 8883 // textarea scrolls. We reset height to '0' before measuring 8884 // scrollHeight so the height can shrink as well as grow 8885 // (otherwise scrollHeight only ever increases). 8886 // 8887 // Called from @input (typing), the watcher on `ai_prompt` 8888 // (programmatic changes like example pill click / 8889 // post-submit clear), and once on mounted() to settle the 8890 // initial layout. 8891 auto_resize_ai_prompt() { 8892 let el = this.$refs && this.$refs.ai_prompt_textarea; 8893 if (!el) { 8894 return; 8895 } 8896 el.style.height = '0px'; 8897 let next = el.scrollHeight; 8898 if (!next) { 8899 next = 21; 8900 } 8901 el.style.height = next + 'px'; 8902 }, 8903 submit_ai_prompt() { 8904 if (!this.ai_prompt_can_submit) { 8905 return; 8906 } 8907 // The blank welcome composer now seeds the single page agent 8908 // (focused on this section) rather than the per-component 8909 // codegen chat. The page agent owns the conversation + send. 8910 if (typeof ai_mcp_panel_open_agent_with_reference !== 'function') { 8911 this.open_ai_builder(); 8912 return; 8913 } 8914 let opened = ai_mcp_panel_open_agent_with_reference({ 8915 client_id: this.component && this.component.get('client_id'), 8916 component_type: 'custom-code', 8917 seed_prompt: this.ai_prompt,
8918 auto_submit: true, 8919 }); 8920 if (opened) { 8921 this.ai_prompt = ''; 8922 this.start_welcome_generating_state(); 8923 } else { 8924 this.open_ai_builder(); 8925 } 8926 }, 8927 // Swap the welcome composer for the "Generatingâ¦" pane while the 8928 // page agent produces the first result. Cleared when content lands 8929 // (custom_code_value watcher) or by a safety timer. 8930 start_welcome_generating_state() { 8931 this.active_tab = 'preview'; 8932 this.close_text_edit_popover(); 8933 this.ai_welcome_generating = true; 8934 if (this._ai_welcome_generating_timer) { 8935 clearTimeout(this._ai_welcome_generating_timer); 8936 } 8937 this._ai_welcome_generating_timer = setTimeout(() => { 8938 this.clear_welcome_generating_state(); 8939 }, 90000); 8940 }, 8941 clear_welcome_generating_state() { 8942 this.ai_welcome_generating = false; 8943 if (this._ai_welcome_generating_timer) { 8944 clearTimeout(this._ai_welcome_generating_timer); 8945 this._ai_welcome_generating_timer = null; 8946 } 8947 }, 8948 // Writes 'starting' into the central store so the 8949 // launcher reflects intent instantly. store.ai_streams.start 8950 // overwrites this with 'queued' or 'streaming' once the 8951 // entry materializes. The 15s timer is the safety net for 8952 // the rare case where the panel never actually fires a 8953 // submit (e.g. the panel app didn't mount yet). 8954 start_ai_streaming_starting_state() { 8955 this.capture_ai_loading_pane_height(); 8956 this.blur_ai_prompt_before_loading(); 8957 let prior_scroll_y = window.scrollY; 8958 let component_id = this.component && this.component.id; 8959 if (typeof store !== 'undefined' && store.ai_streams && typeof store.ai_streams.mark_starting === 'function' && component_id) { 8960 store.ai_streams.mark_starting(component_id); 8961 } 8962 this.active_tab = 'preview'; 8963 this.close_text_edit_popover(); 8964 this.$nextTick(() => { 8965 if (Math.abs(window.scrollY - prior_scroll_y) > 1) { 8966 window.scrollTo(window.scrollX, prior_scroll_y); 8967 } 8968 }); 8969 if (this._ai_streaming_starting_timer) { 8970 clearTimeout(this._ai_streaming_starting_timer); 8971 } 8972 this._ai_streaming_starting_timer = setTimeout(() => { 8973 this.clear_ai_streaming_starting_state(); 8974 }, 15000); 8975 }, 8976 // Clears the 'starting' bridge from central state if (and 8977 // only if) it's still the current value â never overwrites 8978 // a real downstream status like 'streaming' / 'done'. 8979 clear_ai_streaming_starting_state() { 8980 if (typeof store !== 'undefined' && store.codegen_request_state) { 8981 let component_id = this.component && this.component.id; 8982 if (component_id && store.codegen_request_state[component_id] === 'starting') { 8983 delete store.codegen_request_state[component_id]; 8984 } 8985 } 8986 if (this._ai_streaming_starting_timer) { 8987 clearTimeout(this._ai_streaming_starting_timer); 8988 this._ai_streaming_starting_timer = null; 8989 } 8990 }, 8991 // Idempotent hard-reset of every flag that feeds the 8992 // launcher / loading-overlay computeds. Called from the 8993 // panel's done / fail / snap paths so the launcher always 8994 // converges to idle even if a stream subscriber missed 8995 // an event, threw mid-handler, or fired its `done` while 8996 // the component was unmounted. Safe to call multiple 8997 // times. Does NOT touch the editor contents â the panel's 8998 // apply_generated_code is responsible for the canonical 8999 // code save. 9000 force_clear_ai_loading_state() { 9001 this.clear_ai_streaming_starting_state(); 9002 this.ai_streaming = false;
9003 this.release_ai_loading_pane_height_after_render(); 9004 if (this.ai_streaming_status === 'streaming' || this.ai_streaming_status === 'queued' || this.ai_streaming_status === null) { 9005 this.ai_streaming_status = 'done'; 9006 } 9007 this.ai_streaming_buffer = ''; 9008 this._last_flushed_buffer_len = -1; 9009 this.ai_streaming_child_min_heights = []; 9010 if (this._codegen_stream_raf) { 9011 cancelAnimationFrame(this._codegen_stream_raf); 9012 this._codegen_stream_raf = null; 9013 } 9014 if (this.cm_instance && this.cm_instance.getOption && this.cm_instance.getOption('readOnly')) { 9015 this.cm_instance.setOption('readOnly', false); 9016 } 9017 this.ai_stream_event_tick = (this.ai_stream_event_tick + 1) | 0; 9018 }, 9019 capture_ai_loading_pane_height() { 9020 if (this.ai_loading_pane_height_release_timer) { 9021 clearTimeout(this.ai_loading_pane_height_release_timer); 9022 this.ai_loading_pane_height_release_timer = null; 9023 } 9024 // `$el` can be a Comment / Text node when the root 9025 // `<template v-if>` is currently false (Vue 3 treats 9026 // a falsy single-root v-if as a placeholder comment), 9027 // and it lacks querySelectorAll. Bail in that case 9028 // so the streaming subscriber doesn't throw on every 9029 // pane-height capture. 9030 let root = this.$el; 9031 if (!root || typeof root.querySelectorAll !== 'function') { 9032 return; 9033 } 9034 let panes = Array.prototype.slice.call(root.querySelectorAll('.custom-code-preview-pane')); 9035 let visible = panes.find((pane) => { 9036 if (!pane || pane.classList.contains('custom-code-loading-pane')) { 9037 return false; 9038 } 9039 let style = window.getComputedStyle(pane); 9040 return style.display !== 'none' && pane.getClientRects().length > 0; 9041 }); 9042 if (!visible) { 9043 let hidden_height = this.measure_hidden_custom_code_preview_height(); 9044 if (hidden_height > 0) { 9045 this.ai_loading_pane_height = Math.max(420, hidden_height); 9046 } 9047 return; 9048 } 9049 let rect_height = visible.getBoundingClientRect().height || 0; 9050 let scroll_height = visible.scrollHeight || 0; 9051 let height = Math.ceil(Math.max(rect_height, scroll_height)); 9052 if (height > 0) { 9053 this.ai_loading_pane_height = Math.max(420, height); 9054 } 9055 this.capture_streaming_child_min_heights_from_node(visible); 9056 }, 9057 measure_hidden_custom_code_preview_height() { 9058 let root = this.$el; 9059 if (!root || typeof root.getBoundingClientRect !== 'function') { 9060 return 0; 9061 } 9062 let html = this.safe_custom_code_value || ''; 9063 if (!html.trim()) { 9064 return 0; 9065 } 9066 let root_rect = root.getBoundingClientRect(); 9067 let width = Math.floor(root_rect.width || root.clientWidth || 0); 9068 if (width <= 0 && root.parentElement) { 9069 let parent_rect = root.parentElement.getBoundingClientRect(); 9070 width = Math.floor(parent_rect.width || root.parentElement.clientWidth || 0); 9071 } 9072 if (width <= 0) { 9073 return 0; 9074 } 9075 let probe = document.createElement('div'); 9076 probe.className = 'custom-code-pane custom-code-preview-pane'; 9077 probe.style.position = 'absolute'; 9078 probe.style.visibility = 'hidden'; 9079 probe.style.pointerEvents = 'none'; 9080 probe.style.left = '-10000px'; 9081 probe.style.top = '0'; 9082 probe.style.width = width + 'px'; 9083 probe.style.height = 'auto'; 9084 probe.style.overflow = 'visible'; 9085 probe.style.boxSizing = 'border-box'; 9086 probe.innerHTML = html; 9087 document.body.appendChild(probe); 9088 let rect_height = probe.getBoundingClientRect().height || 0; 9089 let scroll_height = probe.scrollHeight || 0;
9090 let height = Math.ceil(Math.max(rect_height, scroll_height)); 9091 this.capture_streaming_child_min_heights_from_node(probe); 9092 probe.remove(); 9093 return height; 9094 }, 9095 capture_streaming_child_min_heights_from_node(node) { 9096 if (!node || !node.childNodes) { 9097 this.ai_streaming_child_min_heights = []; 9098 return; 9099 } 9100 this.ai_streaming_child_min_heights = Array.prototype.slice.call(node.childNodes).map((child) => { 9101 if (!child || child.nodeType !== 1) { 9102 return 0; 9103 } 9104 let rect_height = child.getBoundingClientRect().height || 0; 9105 let scroll_height = child.scrollHeight || 0; 9106 return Math.ceil(Math.max(rect_height, scroll_height)); 9107 }); 9108 }, 9109 release_ai_loading_pane_height_after_render() { 9110 if (this.ai_loading_pane_height_release_timer) { 9111 clearTimeout(this.ai_loading_pane_height_release_timer); 9112 this.ai_loading_pane_height_release_timer = null; 9113 } 9114 if (!this.ai_loading_pane_height) { 9115 this.ai_streaming_child_min_heights = []; 9116 return; 9117 } 9118 this.ai_loading_pane_height_release_timer = setTimeout(() => { 9119 this.ai_loading_pane_height = null; 9120 this.ai_streaming_child_min_heights = []; 9121 this.ai_loading_pane_height_release_timer = null; 9122 }, 50); 9123 }, 9124 blur_ai_prompt_before_loading() { 9125 let prompt = this.$refs && this.$refs.ai_prompt_textarea; 9126 if (!prompt || document.activeElement !== prompt) { 9127 return; 9128 } 9129 prompt.blur(); 9130 }, 9131 // Launcher click â pin this component as the AI panel's 9132 // active target and expand the panel. The panel runs in 9133 // a separate Vue app (app_ai_mcp, mounted in 9134 // web-builder/primary.ejs) and watches 9135 // store.webbuilder_code_active_component_id; setting it 9136 // here triggers the panel to load this component's thread. 9137 open_ai_builder() { 9138 if (typeof store === 'undefined') { 9139 return; 9140 } 9141 // Open the single page agent focused on this section. (The old 9142 // per-component codegen activation is left dormant below for a 9143 // future live agent->codegen stream hookup.) 9144 if (typeof ai_mcp_panel_open_agent_with_reference === 'function' 9145 && this.component 9146 && typeof this.component.get === 'function') { 9147 ai_mcp_panel_open_agent_with_reference({ 9148 client_id: this.component.get('client_id'), 9149 component_type: 'custom-code', 9150 }); 9151 return; 9152 } 9153 let panel_state = store.components && store.components.ai_mcp_panel; 9154 if (panel_state) { 9155 panel_state.user_toggled = true; 9156 panel_state.expanded = true; 9157 if (panel_state.mcp_panel_width) { 9158 document.body.style.setProperty('--mcp-panel-width', panel_state.mcp_panel_width + 'px'); 9159 } 9160 document.body.classList.add('ai-mcp-expanded'); 9161 } 9162 this.activate_ai_builder_target({ force: true }); 9163 }, 9164 mark_ai_thread_for_component() { 9165 if (typeof store === 'undefined') { 9166 return; 9167 } 9168 let component_id = this.component && this.component.id; 9169 if (!component_id) { 9170 return; 9171 } 9172 if (!store.webbuilder_code_component_has_ai_thread) { 9173 store.webbuilder_code_component_has_ai_thread = {}; 9174 } 9175 store.webbuilder_code_component_has_ai_thread[component_id] = true; 9176 }, 9177 contains_ai_builder_target(target) { 9178 if (!target) { 9179 return false;
9180 } 9181 if (this.$el && this.$el.contains(target)) { 9182 return true; 9183 } 9184 if (this.$refs.cm_editor && this.$refs.cm_editor.contains(target)) { 9185 return true; 9186 } 9187 // The text-edit popover lives at document.body level 9188 // (position: fixed) so it isn't inside $el. Treat clicks 9189 // landing on it as in-component interactions so the AI 9190 // panel's outside-click handler doesn't auto-close 9191 // while the admin is typing in the popover. 9192 if (typeof target.closest === 'function' && target.closest('.cs-text-edit-popover')) { 9193 return true; 9194 } 9195 return false; 9196 }, 9197 get_current_code() { 9198 if (this.cm_instance) { 9199 return this.cm_instance.getValue(); 9200 } 9201 if (typeof this.custom_code_value === 'string') { 9202 return this.custom_code_value; 9203 } 9204 if (this.component) { 9205 let config_latest = this.component.get('config_latest') || {}; 9206 return config_latest.custom_code || ''; 9207 } 9208 return ''; 9209 }, 9210 mark_ai_code_revision_changed() { 9211 if (typeof store === 'undefined') { 9212 return; 9213 } 9214 if (store.webbuilder_code_active_component !== this) { 9215 return; 9216 } 9217 store.webbuilder_code_active_component_revision = Date.now(); 9218 }, 9219 custom_code_render_root() { 9220 let host = null; 9221 if (this.preview_element) { 9222 host = this.$refs.custom_code_preview || null; 9223 } else { 9224 host = this.$refs.custom_code_static || null; 9225 } 9226 if (!host) { 9227 return null; 9228 } 9229 let html = this.preview_element 9230 ? (this.safe_custom_code_value || '') 9231 : (this.safe_custom_code || ''); 9232 let root = this.ensure_isolated_custom_code_host(host, html); 9233 this.apply_first_section_top_spacer(root); 9234 return root; 9235 }, 9236 first_section_top_spacer_px(){ 9237 if( this.first_section_top_spacer_px_override !== null ){ 9238 return normalize_first_section_top_spacer_px(this.first_section_top_spacer_px_override); 9239 } 9240 if( !this.is_first || !this.component ){ 9241 return 0; 9242 } 9243 let config_latest = this.component.get('config_latest') || {}; 9244 let formatting = config_latest.formatting || {}; 9245 return normalize_first_section_top_spacer_px(formatting.first_section_top_spacer_px); 9246 }, 9247 apply_first_section_top_spacer(root){ 9248 if( typeof apply_first_section_top_spacer_to_root === 'function' ){ 9249 apply_first_section_top_spacer_to_root(root, this.first_section_top_spacer_px()); 9250 return; 9251 } 9252 if( !root ){ 9253 return; 9254 } 9255 }, 9256 // Mount sanitized HTML into an open shadow root so hosted theme 9257 // CSS cannot override section styles (preview â live). 9258 ensure_isolated_custom_code_host(host, html) { 9259 if (!host || typeof host.attachShadow !== 'function') { 9260 // Very old browsers: fall back to light-DOM innerHTML. 9261 host.innerHTML = html || ''; 9262 return host; 9263 } 9264 let shadow = host.shadowRoot; 9265 if (!shadow) { 9266 shadow = host.attachShadow({ mode: 'open' }); 9267 // Font Awesome lives on the light DOM page; clone its 9268 // stylesheet so icons inside custom-code still render. 9269 let fa_link = document.querySelector( 9270 'link[href*="font-awesome"], link[href*="fontawesome"], link[href*="FontAwesome"]' 9271 ); 9272 if (fa_link) { 9273 let cloned = fa_link.cloneNode(true); 9274 cloned.setAttribute('data-cs-fa-isolation', '1'); 9275 shadow.appendChild(cloned); 9276 } 9277 let root = document.createElement('div'); 9278 root.className = 'cs-cc-root'; 9279 shadow.appendChild(root); 9280 } 9281 // Only direct shadow children â never a <style> inside the 9282 // section HTML (querySelector would walk into .cs-cc-root). 9283 let isolation_style = null; 9284 let shadow_children = shadow.children; 9285 for (let i = 0; i < shadow_children.length; i++) { 9286 let node = shadow_children[i]; 9287 if (node.tagName === 'STYLE') { 9288 if (node.getAttribute('data-cs-isolation') === '1') { 9289 isolation_style = node; 9290 break; 9291 } 9292 } 9293 } 9294 if (!isolation_style) { 9295 isolation_style = document.createElement('style'); 9296 isolation_style.setAttribute('data-cs-isolation', '1'); 9297 shadow.insertBefore(isolation_style, shadow.firstChild); 9298 } 9299 isolation_style.textContent = CS_CUSTOM_CODE_ISOLATION_CSS; 9300 if (this.preview_element) { 9301 isolation_style.textContent += '\n' + CS_CUSTOM_CODE_PREVIEW_CHROME_CSS; 9302 } 9303 let content_root = shadow.querySelector('.cs-cc-root'); 9304 if (!content_root) { 9305 content_root = document.createElement('div'); 9306 content_root.className = 'cs-cc-root'; 9307 shadow.appendChild(content_root); 9308 } 9309 // Compare against the source we last wrote, not live 9310 // innerHTML. Decoration injects Change/Add chips into 9311 // the tree; a live compare would see that as a mismatch 9312 // and wipe the chips on the next render_root call 9313 // (text-edit decorate runs immediately after). 9314 let next_html = html || ''; 9315 if (content_root.__cs_source_html !== next_html) { 9316 content_root.innerHTML = next_html; 9317 content_root.__cs_source_html = next_html; 9318 } 9319 return content_root; 9320 }, 9321 sanitize_custom_code_html(html) { 9322 if (!html || typeof html !== 'string') { 9323 return ''; 9324 } 9325 let template = document.createElement('template'); 9326 template.innerHTML = html; 9327 template.content.querySelectorAll('script').forEach((node) => { 9328 node.remove(); 9329 }); 9330 // Drop external FA kits â they override Clubspot's Pro icons document-wide. 9331 template.content.querySelectorAll('link[rel="stylesheet"], link[rel="preload"]').forEach((node) => { 9332 let href = String(node.getAttribute('href') || '').toLowerCase(); 9333 if ( 9334 href.indexOf('font-awesome') >= 0 9335 || href.indexOf('fontawesome') >= 0 9336 || href.indexOf('kit.fontawesome') >= 0 9337 ) { 9338 node.remove(); 9339 } 9340 }); 9341 template.content.querySelectorAll('*').forEach((node) => { 9342 Array.prototype.slice.call(node.attributes || []).forEac
9342h((attr) => { 9343 let name = String(attr.name || '').toLowerCase(); 9344 let value = String(attr.value || '').trim().toLowerCase(); 9345 if (name.indexOf('on') === 0) { 9346 node.removeAttribute(attr.name); 9347 } 9348 if ((name === 'href' || name === 'src' || name === 'xlink:href') && value.indexOf('javascript:') === 0) { 9349 node.removeAttribute(attr.name); 9350 } 9351 }); 9352 }); 9353 return template.innerHTML; 9354 }, 9355 initialize_custom_code_interactions() { 9356 let root = this.custom_code_render_root(); 9357 if (!root) { 9358 return; 9359 } 9360 this.apply_first_section_top_spacer(root); 9361 // Show/hide a tab panel. The `hidden` attribute alone is 9362 // not reliable: panels usually carry an inline `display` 9363 // (e.g. `display:flex`), and an inline declaration beats 9364 // the UA `[hidden]{display:none}` rule, so inactive panels 9365 // would stay visible and every tab would render at once. 9366 // Drive `display` directly (remembering each panel's 9367 // original inline value) and keep `hidden` for semantics. 9368 let set_panel_active = (panel, active) => { 9369 if (panel.__csPanelDisplay === undefined) { 9370 panel.__csPanelDisplay = panel.style.display || ''; 9371 } 9372 panel.hidden = !active; 9373 panel.classList.toggle('is-active', active); 9374 panel.style.display = active ? panel.__csPanelDisplay : 'none'; 9375 }; 9376 let find_tab_panels = (group) => { 9377 let panels = Array.prototype.slice.call(group.querySelectorAll('[data-cs-panel]')); 9378 if (panels.length) { 9379 return panels; 9380 } 9381 let panel_scope = group.closest('section') || root; 9382 return Array.prototype.slice.call(panel_scope.querySelectorAll('[data-cs-panel]')); 9383 }; 9384 let sync_tabs = () => { 9385 root.querySelectorAll('[data-cs-tabs]').forEach((group) => { 9386 let tabs = Array.prototype.slice.call(group.querySelectorAll('[data-cs-tab]')); 9387 let panels = find_tab_panels(group); 9388 if (!tabs.length || !panels.length) { 9389 return; 9390 } 9391 let active_tab = tabs.find((tab) => tab.classList.contains('is-active')) || tabs[0]; 9392 let active_name = active_tab.getAttribute('data-cs-tab');
9393 tabs.forEach((tab) => { 9394 let active = tab === active_tab; 9395 tab.classList.toggle('is-active', active); 9396 tab.setAttribute('aria-selected', active ? 'true' : 'false'); 9397 }); 9398 panels.forEach((panel) => { 9399 set_panel_active(panel, panel.getAttribute('data-cs-panel') === active_name); 9400 }); 9401 }); 9402 }; 9403 // The switcher group for a `<select data-cs-select>`: its wrapping 9404 // [data-cs-tabs] if present, else its section, else root â the same 9405 // scope rule tabs use, so a select can drive the same panels. 9406 let find_switcher_group = (el) => el.closest('[data-cs-tabs]') || el.closest('section') || root; 9407 // `<select data-cs-select>` whose `<option value="X">` reveal the 9408 // matching `[data-cs-panel="X"]`. The native control renders/opens 9409 // with no JS; only the panel switch needs the runtime. Ideal for a 9410 // many-option filter (year/category) where tabs would be unwieldy. 9411 let sync_selects = () => { 9412 root.querySelectorAll('[data-cs-select]').forEach((select) => { 9413 let panels = find_tab_panels(find_switcher_group(select)); 9414 if (!panels.length) { 9415 return; 9416 } 9417 let active_name = (select.value !== null && select.value !== '') 9418 ? select.value 9419 : panels[0].getAttribute('data-cs-panel'); 9420 panels.forEach((panel) => { 9421 set_panel_active(panel, panel.getAttribute('data-cs-panel') === active_name); 9422 }); 9423 }); 9424 }; 9425 let sync_all = () => { 9426 sync_tabs(); 9427 sync_selects(); 9428 }; 9429 if (root.__csCustomCodeInteractionsInitialized) { 9430 sync_all(); 9431 if (this.preview_element) { 9432 this.decorate_asset_placeholders(); 9433 this.decorate_editable_text_nodes(); 9434 } 9435 return; 9436 } 9437 root.__csCustomCodeInteractionsInitialized = true; 9438 if (this.preview_element) { 9439 this.decorate_asset_placeholders(); 9440 this.decorate_editable_text_nodes(); 9441 } 9442 root.addEventListener('click', (event) => { 9443 let trigger = event.target && event.target.closest('[data-cs-toggle], [data-cs-tab]'); 9444 if (!trigger || !root.contains(trigger)) { 9445 return; 9446 } 9447 9448 let toggle_selector = trigger.getAttribute('data-cs-toggle'); 9449 if (toggle_selector) { 9450 event.preventDefault(); 9451 let target = root.querySelector(toggle_selector); 9452 if (target) { 9453 let class_name = trigger.getAttribute('data-cs-toggle-class') || 'is-active'; 9454 target.classList.toggle(class_name); 9455 let expanded = target.classList.contains(class_name); 9456 trigger.setAttribute('aria-expanded', expanded ? 'true' : 'false'); 9457 } 9458 } 9459 9460 let tab_name = trigger.getAttribute('data-cs-tab'); 9461 if (tab_name) { 9462 event.preventDefault(); 9463 let group = trigger.closest('[data-cs-tabs]') || root; 9464 group.querySelectorAll('[data-cs-tab]').forEach((tab) => { 9465 tab.classList.toggle('is-active', tab === trigger); 9466 tab.setAttribute('aria-selected', tab === trigger ? 'true' : 'false'); 9467 });
9468 find_tab_panels(group).forEach((panel) => { 9469 set_panel_active(panel, panel.getAttribute('data-cs-panel') === tab_name); 9470 }); 9471 } 9472 }); 9473 root.addEventListener('change', (event) => { 9474 let select = event.target && event.target.closest('[data-cs-select]'); 9475 if (!select || !root.contains(select)) { 9476 return; 9477 } 9478 let active_name = select.value; 9479 find_tab_panels(find_switcher_group(select)).forEach((panel) => { 9480 set_panel_active(panel, panel.getAttribute('data-cs-panel') === active_name); 9481 }); 9482 }); 9483 sync_all(); 9484 }, 9485 // âââ Asset placeholders âââââââââââââââââââââââââââââââââââ 9486 // 9487 // The AI route plants inert `<div data-cs-asset-placeholder>` 9488 // (or `<a>`) elements in the section HTML when it needs an 9489 // asset it couldn't verify. The live (public) site renders 9490 // those placeholders as styled fallback wrappers as-is â 9491 // they're authored to look intentional. In PREVIEW mode 9492 // (admin builder) we overlay a click-to-fill control on top 9493 // of each placeholder so the admin can choose a club photo, 9494 // upload a new one, or paste a URL. On selection we mutate 9495 // the persisted HTML so the next page render shows the 9496 // attached asset. 9497 // 9498 // Two element states: 9499 // - `data-cs-asset-placeholder="<id>"` unfilled slot; 9500 // gets the big full-bleed "Add image" overlay. 9501 // - `data-cs-asset-attached="<id>"` filled slot; 9502 // gets the small top-right "Change" chip. 9503 // 9504 // We keep the wrapper element across the fill transition â 9505 // the wrapper preserves the section's layout box (size, 9506 // border-radius, positioning) and we just swap its inner 9507 // content for an `<img>`/`<a>`/`background-image`. That way 9508 // the section never reflows when an admin fills a slot, and 9509 // styled fallback "frames" the resolved asset. 9510 // 9511 // Sequencing: decorate runs after every preview re-render 9512 // (initialize_custom_code_interactions). The overlay DOM 9513 // lives ONLY in the live preview tree â we never persist 9514 // the overlay markup back to `custom_code`. 9515 9516 decorate_asset_placeholders() { 9517 if (!this.preview_element) { 9518 return; 9519 } 9520 let root = this.custom_code_render_root(); 9521 if (!root) { 9522 return; 9523 } 9524 // Unfilled placeholders â big "Add image" overlay. 9525 let placeholders = root.querySelectorAll('[data-cs-asset-placeholder]'); 9526 placeholders.forEach((placeholder) => { 9527 if (placeholder.__cs_asset_decorated) { 9528 return; 9529 } 9530 placeholder.__cs_asset_decorated = true; 9531 let computed_position = window.getComputedStyle(placeholder).position; 9532 if (computed_position === 'static') { 9533 placeholder.style.position = 'relative'; 9534 } 9535 placeholder.classList.add('cs-asset-placeholder-host'); 9536 placeholder.style.cursor = 'pointer'; 9537 9538 let id = placeholder.getAttribute('data-cs-asset-placeholder') || ''; 9539 let target = (placeholder.getAttribute('data-cs-asset-target') || '').toLowerCase(); 9540 if (target === 'background-image') { 9541 placeholder.classList.add('cs-asset-background-placeholder-host'); 9542 } 9543 let label_attr = (placeholder.getAttribute('data-cs-asset-label') || '').trim(); 9544 let label = label_attr || 'asset'; 9545 9546 if (target === 'background-image') { 9547 let background_chip = document.createElement('button'); 9548 background_chip.type = 'button'; 9549 background_chip.className = 'cs-asset-background-placeholder-chip exclude-from-custom-button-styles'; 9550 background_chip.setAttribute('data-cs-asset-placeholder-id', id); 9551 background_chip.setAttribute('aria-label', 'Add ' + label); 9552 background_chip.setAttribute('title', 'Add ' + label); 9553 background_chip.innerHTML = '<i class="fa-regular fa-image"></i>'; 9554 background_chip.addEventListener('mousedown', (event) => { 9555 event.stopPropagation(); 9556 }); 9557 background_chip.addEventListener('click', (event) => { 9558 event.preventDefault(); 9559 event.stopPropagation(); 9560 this.open_placeholder_menu(id, { mode: 'placeholder' }); 9561 }); 9562 let background_chip_host = placeholder; 9563 if (placeholder.parentElement 9564 && placeholder.parentElement !== root 9565 && root.contains(placeholder.parentElement)) { 9566 background_chip_host = placeholder.parentElement; 9567 if (window.getComputedStyle(background_chip_host).position === 'static') { 9568 background_chip_host.style.position = 'relative'; 9569 } 9570 } 9571 background_chip_host.appendChild(background_chip); 9572 } else {
9573 let overlay = document.createElement('button'); 9574 overlay.type = 'button'; 9575 overlay.className = 'cs-asset-placeholder-overlay exclude-from-custom-button-styles'; 9576 overlay.setAttribute('data-cs-asset-placeholder-id', id); 9577 overlay.innerHTML = 9578 '<span class="cs-asset-placeholder-icon"><i class="fa-regular fa-image"></i></span>' + 9579 '<span class="cs-asset-placeholder-label">Add ' + this.escape_html_text(label) + '</span>'; 9580 overlay.addEventListener('mousedown', (event) => { 9581 event.stopPropagation(); 9582 }); 9583 overlay.addEventListener('click', (event) => { 9584 event.preventDefault(); 9585 event.stopPropagation(); 9586 this.open_placeholder_menu(id, { mode: 'placeholder' }); 9587 }); 9588 placeholder.appendChild(overlay); 9589 } 9590 9591 // Host-level click â make the entire placeholder area 9592 // clickable so admins can tap anywhere inside the 9593 // styled fallback wrapper to open the fill menu, not 9594 // just the centered overlay button. The overlay's own 9595 // listener stops propagation, so we only fire when 9596 // the click originated outside it (e.g. on padding, 9597 // background, or fallback child text/icons). 9598 placeholder.addEventListener('click', (event) => { 9599 if (event.target.closest('.cs-asset-placeholder-overlay')) { 9600 return; 9601 } 9602 if (event.target.closest('.cs-asset-background-placeholder-chip')) { 9603 return; 9604 } 9605 if (target === 'background-image' && event.target !== placeholder) { 9606 return; 9607 } 9608 event.preventDefault(); 9609 event.stopPropagation(); 9610 this.open_placeholder_menu(id, { mode: 'placeholder' }); 9611 }); 9612 }); 9613 9614 // Filled placeholders â small "Change" chip. 9615 let attached = root.querySelectorAll('[data-cs-asset-attached]'); 9616 attached.forEach((attached_el) => { 9617 if (attached_el.__cs_asset_attached_decorated) { 9618 return; 9619 } 9620 attached_el.__cs_asset_attached_decorated = true; 9621 let computed_position = window.getComputedStyle(attached_el).position; 9622 if (computed_position === 'static') { 9623 attached_el.style.position = 'relative'; 9624 } 9625 attached_el.classList.add('cs-asset-attached-host'); 9626 attached_el.style.cursor = 'pointer'; 9627 9628 let id = attached_el.getAttribute('data-cs-asset-attached') || ''; 9629 let change_button = document.createElement('button'); 9630 change_button.type = 'button'; 9631 change_button.className = 'cs-asset-change-button exclude-from-custom-button-styles'; 9632 change_button.setAttribute('data-cs-asset-attached-id', id); 9633 change_button.innerHTML = 9634 '<i class="fa-regular fa-image" style="margin-right:5px;"></i>' + 9635 '<span>Change</span>'; 9636 change_button.addEventListener('mousedown', (event) => { 9637 event.stopPropagation(); 9638 }); 9639 change_button.addEventListener('click', (event) => { 9640 event.preventDefault(); 9641 event.stopPropagation(); 9642 this.open_placeholder_menu(id, { mode: 'attached' }); 9643 }); 9644 attached_el.appendChild(change_button); 9645 9646 // Host-level click â clicking anywhere on the filled
9647 // image / link area opens the same change menu the 9648 // small "Change" hover chip uses. The chip's listener 9649 // stops propagation, so this only fires for clicks 9650 // outside it (e.g. directly on the image surface). 9651 attached_el.addEventListener('click', (event) => { 9652 if (event.target.closest('.cs-asset-change-button')) { 9653 return; 9654 } 9655 event.preventDefault(); 9656 event.stopPropagation(); 9657 this.open_placeholder_menu(id, { mode: 'attached' }); 9658 }); 9659 }); 9660 9661 // Bare `<img>` tags that weren't emitted as a 9662 // `data-cs-asset-*` slot â for example, every image the 9663 // model dropped in during a `capture_web_reference` build 9664 // (`<img src="https://oakmont.../">` directly). Without 9665 // this pass those imgs had no change/import affordance, 9666 // so the only way to swap one out was to hand-edit the 9667 // section HTML. 9668 // 9669 // Implementation: 9670 // - Wrap each bare img in a transient `<span 9671 // class="cs-bare-image-host">` host purely in the 9672 // LIVE preview DOM. The persisted custom_code never 9673 // sees this wrapper â it's recreated every time 9674 // `decorate_asset_placeholders` runs. 9675 // - Append a "Change" chip into the host. The chip 9676 // reuses `.cs-asset-change-button` so it matches 9677 // filled placeholder slots. 9678 // - On click, route to a bare-img menu that uses 9679 // `src`-string replacement against the persisted 9680 // html instead of the `data-cs-asset-attached` 9681 // marker lookup the placeholder pipeline depends 9682 // on. That lets us avoid touching the persisted 9683 // HTML until the admin actually picks a different 9684 // image â there's no surprise `unsaved_changes` 9685 // bump just from opening a reference-built section. 9686 let bare_imgs = Array.from(root.querySelectorAll('img')).filter((img) => { 9687 if (img.__cs_bare_image_decorated) { 9688 return false; 9689 } 9690 if (img.closest('[data-cs-asset-placeholder], [data-cs-asset-attached]')) { 9691 return false; 9692 } 9693 if (img.parentElement && img.parentElement.classList && img.parentElement.classList.contains('cs-bare-image-host')) { 9694 return false; 9695 } 9696 let src = img.getAttribute('src') || ''; 9697 return src.trim().length > 0; 9698 }); 9699 bare_imgs.forEach((img) => { 9700 img.__cs_bare_image_decorated = true; 9701 if (!img.parentNode) { 9702 return; 9703 } 9704 let host = document.createElement('span'); 9705 host.className = 'cs-bare-image-host cs-asset-attached-host'; 9706 host.setAttribute('aria-hidden', 'true'); 9707 // line-height: 0 kills the descender gap an inline 9708 // img would otherwise produce; max-width lets the 9709 // img stay responsive inside flex/grid layouts. 9710 host.style.cssText = 'position: relative; display: inline-block; line-height: 0; max-width: 100%;'; 9711 img.parentNode.insertBefore(host, img); 9712 host.appendChild(img); 9713 9714 let change_button = document.createElement('button'); 9715 change_button.type = 'button'; 9716 change_button.className = 'cs-asset-change-button cs-bare-image-change-button exclude-from-custom-button-styles'; 9717 change_button.innerHTML = 9718 '<i class="fa-regular fa-image" style="margin-right:5px;"></i>' + 9719 '<span>Change</span>'; 9720 change_button.addEventListener('mousedown', (event) => { 9721 event.stopPropagation(); 9722 }); 9723 change_button.addEventListener('click', (event) => { 9724 event.preventDefault(); 9725 event.stopPropagation(); 9726 this.open_bare_image_menu(img); 9727 }); 9728 host.appendChild(change_button); 9729 9730 // Host click â same menu, for clicks on the image 9731 // surface itself (chip stops propagation so this 9732 // only fires when the user clicks outside it). 9733 host.addEventListener('click', (event) => { 9734 if (event.target.closest('.cs-asset-change-button')) { 9735 return; 9736 } 9737 event.preventDefault(); 9738 event.stopPropagation(); 9739 this.open_bare_image_menu(img); 9740 }); 9741 }); 9742 }, 9743 escape_html_text(text) { 9744 if (typeof text !== 'string') { 9745 return ''; 9746 } 9747 let div = document.createElement('div'); 9748 div.appendChild(document.createTextNode(text)); 9749 return div.innerHTML; 9750 }, 9751 open_placeholder_menu(id, options_in) { 9752 let opts = options_in || {}; 9753 let mode = opts.mode || 'placeholder'; 9754 if (typeof handle_common !== 'function') { 9755 // Fallback: jump straight to the image picker. 9756 this.pick_existing_for_placeholder(id); 9757 return; 9758 } 9759 let target_el = this.find_asset_element_by_id(id); 9760 if (!target_el) { 9761 return; 9762 } 9763 let accepts = (target_el.getAttribute('data-cs-asset-accepts') || 'image').toLowerCase(); 9764 let label_attr = (target_el.getAttribute('data-cs-asset-label') || '').trim(); 9765 let label = label_attr || 'asset'; 9766 // Sniff the currently-attached URL so we can offer 9767 // "Copy image to club photos" only when it actually
9768 // points somewhere outside the Clubspot CDN. The same 9769 // helper handles <img src>, <a href>, and inline 9770 // background-image: url(...) wrappers. 9771 let attached_url = mode === 'attached' 9772 ? this.return_attached_url_for_wrapper(target_el) 9773 : null; 9774 let attached_is_external = accepts === 'image' && this.is_external_image_url(attached_url); 9775 let options = []; 9776 // Photos picker â same webbuilder asset picker, filtered to photos. 9777 if (accepts === 'image') { 9778 options.push({ 9779 text: mode === 'attached' ? 'Pick a different photo' : 'Upload or choose from club photos', 9780 subtext: 'Upload a new image or pick an existing one from this club', 9781 fn: () => this.pick_existing_for_placeholder(id), 9782 }); 9783 } 9784 // Documents/files picker â `webbuilder_asset_picker` 9785 // overlay, backed by `browse_webbuilder_assets`. Covers 9786 // PDFs and any public-access club document. Also offered 9787 // for image accepts because admins sometimes store hero 9788 // images in the documents folder. 9789 options.push({ 9790 text: mode === 'attached' 9791 ? 'Pick a different file from club documents' 9792 : (accepts === 'pdf' ? 'Choose from club documents' : 'Choose from club documents'), 9793 subtext: accepts === 'pdf' 9794 ? 'Public-access PDFs in the club documents folder' 9795 : 'Public-access files in the club documents folder', 9796 fn: () => this.pick_document_for_placeholder(id), 9797 }); 9798 options.push({ 9799 text: mode === 'attached' ? 'Paste a different URL' : 'Paste a URL', 9800 subtext: accepts === 'pdf' 9801 ? 'Paste a public PDF link (e.g. a public document on this club)' 9802 : 'Paste a public image URL (Clubspot CDN or external host)', 9803 fn: () => this.paste_url_for_placeholder(id), 9804 }); 9805 if (mode === 'attached' && attached_is_external) { 9806 // Only surface this when the slot is currently 9807 // pointing at a non-Clubspot host - importing a 9808 // CDN URL into itself is a no-op and the server 9809 // rejects it explicitly. Sits ABOVE "Remove" so 9810 // it lines up with the constructive actions 9811 // (pick, paste, copy) rather than the destructive 9812 // one. 9813 options.push({ 9814 text: 'Copy image to club photos', 9815 subtext: 'Save a local copy in your photo library and use that instead of the external URL.', 9816 fn: () => this.run_after_placeholder_menu_close(() => this.import_external_image_for_attached(id)), 9817 }); 9818 } 9819 if (mode === 'attached') { 9820 options.push({ 9821 text: 'Remove this image', 9822 subtext: 'Revert this slot back to its empty placeholder.', 9823 fn: () => this.run_after_placeholder_menu_close(() => this.revert_attached_to_placeholder(id)), 9824 }); 9825 } else { 9826 options.push({ 9827 text: 'Keep empty for now', 9828 subtext: 'Hide this prompt â the slot stays available with a "Change" button if you want to fill it later.', 9829 fn: () => this.run_after_placeholder_menu_close(() => this.remove_placeholder(id)), 9830 }); 9831 } 9832 let params = { 9833 title: mode === 'attached' ? 'Change "' + label + '"' : 'Fill "' + label + '"', 9834 subtitle: mode === 'attached' 9835 ? 'Pick a different photo, paste another URL, or remove the current asset.' 9836 : 'Pick how you want to attach this asset.', 9837 hide_on_selection: false, 9838 options: options, 9839 }; 9840 handle_common(params); 9841 }, 9842 run_after_placeholder_menu_close(fn) { 9843 store.overlays.handle_common = false;
9844 let run_next = () => { 9845 setTimeout(fn, 0); 9846 }; 9847 if (typeof this.$nextTick === 'function') { 9848 this.$nextTick(run_next); 9849 return; 9850 } 9851 run_next(); 9852 }, 9853 pick_existing_for_placeholder(id) { 9854 if (typeof webbuilder_asset_picker !== 'function') { 9855 if (typeof displayMessage === 'function') { 9856 displayMessage('Club photo picker is not loaded. Refresh the page and try again.', true, 5000); 9857 } 9858 return; 9859 } 9860 this.run_after_placeholder_menu_close(() => { 9861 let params = { 9862 title: 'Select club photo', 9863 accepts: 'image', 9864 source: 'photos', 9865 hide_on_selection: true, 9866 fn: (result) => { 9867 let url = result && result.url ? result.url : null; 9868 if (!url) { 9869 return; 9870 } 9871 let attach_params = { 9872 id: id, 9873 url: url, 9874 verified: true, 9875 source: 'club_photos', 9876 visibility: 'public_web', 9877 }; 9878 this.attach_url_to_placeholder(attach_params); 9879 }, 9880 }; 9881 webbuilder_asset_picker(params); 9882 }); 9883 }, 9884 // Opens the documents picker overlay. Same deferred-tick 9885 // dance as `pick_existing_for_placeholder` so the chain of 9886 // handle_common â documents picker doesn't race. 9887 pick_document_for_placeholder(id) { 9888 if (typeof webbuilder_asset_picker !== 'function') { 9889 if (typeof displayMessage === 'function') { 9890 displayMessage('Documents picker is not loaded. Refresh the page and try again.', true, 5000); 9891 } 9892 return; 9893 } 9894 let placeholder = this.find_asset_element_by_id(id); 9895 if (!placeholder) { 9896 return; 9897 } 9898 let accepts = (placeholder.getAttribute('data-cs-asset-accepts') || 'image').toLowerCase(); 9899 this.run_after_placeholder_menu_close(() => { 9900 let params = { 9901 title: accepts === 'pdf' ? 'Select a document' : 'Select a file', 9902 accepts: accepts, 9903 source: 'documents', 9904 hide_on_selection: true, 9905 fn: (result) => { 9906 let url = result && result.url ? result.url : null; 9907 if (!url) { 9908 return; 9909 } 9910 let attach_params = { 9911 id: id, 9912 url: url, 9913 verified: true, 9914 source: result.source || 'public_document', 9915 visibility: result.visibility || 'public_link', 9916 }; 9917 this.attach_url_to_placeholder(attach_params); 9918 }, 9919 }; 9920 webbuilder_asset_picker(params); 9921 }); 9922 }, 9923 paste_url_for_placeholder(id) { 9924 let placeholder = this.find_placeholder_element(id); 9925 if (!placeholder) { 9926 return; 9927 } 9928 let prompt_label = placeholder.getAttribute('data-cs-asset-label') || 'asset'; 9929 let do_attach = (attach) => { 9930 this.attach_url_to_placeholder({ 9931 id: id, 9932 url: attach.url, 9933 verified: attach.verified, 9934 source: attach.source, 9935 visibility: attach.visibility, 9936 }); 9937 }; 9938 // Defer one tick so handle_common can finish dismissing 9939 // before we open the URL-entry modal â otherwise the 9940 // menu overlay stays painted on top. 9941 this.run_after_placeholder_menu_close(() => { 9942 this.prompt_for_pasted_url({ 9943 slot_label: prompt_label, 9944 alt: prompt_label, 9945 do_attach: do_attach, 9946 }); 9947 }); 9948 }, 9949 // âââ Pasted-URL pipeline (shared) ââââââââââââââââââââââââââ 9950 // 9951 // Both the placeholder-marker flow and the bare-<img> flow 9952 // funnel pasted URLs through these three helpers: 9953 // 9954 // prompt_for_pasted_url 9955 // â opens a Clubspot universal_edit_overlay modal for 9956 // URL entry (replaces window.prompt, which blocks 9957 // the event loop, ignores app styling, and on iOS 9958 // leaves overlays painted on top of the OS dialog). 9959 // process_pasted_url 9960 // â verifies the URL server-side and dispatches to 9961 // do_attach() for trusted hosts, or to 9962 // confirm_external_url_action() for external ones. 9963 // confirm_external_url_action 9964 // â opens an are_you_sure modal that rolls what used 9965 // to be two separate browser confirms (the 9966 // "external URL â attach anyway?" confirm and the 9967 // follow-up "save to library?" prompt) into a single 9968 // decision: "Save to club photos" (primary) or 9969 // "Attach as external URL" (secondary). 9970 // 9971 // `do_attach(attach)` is the only caller-specific bit â 9972 // placeholder flow calls attach_url_to_placeholder, bare- 9973 // img flow calls swap_bare_image_src. Both receive the 9974 // same { url, verified, source, visibility } payload. 9975 prompt_for_pasted_url({ slot_label, current_url, alt, do_attach }) { 9976 let label = (typeof slot_label === 'string' && slot_label.trim()) ? slot_label.trim() : 'asset'; 9977 if (typeof universal_edit_overlay !== 'function') { 9978 // Fallback to the browser prompt if the overlay 9979 // isn't loaded for some reason â better than 9980 // silently dropping the action. 9981 let pasted = window.prompt('Paste a URL for "' + label + '":', current_url || ''); 9982 if (!pasted) { 9983 return; 9984 } 9985 this.process_pasted_url({ pasted: pasted, alt: alt, do_attach: do_attach }); 9986 return; 9987 } 9988 universal_edit_overlay({ 9989 title: 'Paste a URL', 9990 subtitle: { label: 'Paste a public URL for "' + label + '". Clubspot
9990CDN links attach directly; external URLs will prompt you to save a local copy.' }, 9991 button_label: 'Attach URL', 9992 hide_on_selection: true, 9993 fields: [{ 9994 key: 'url', 9995 label: 'URL', 9996 value: current_url || '', 9997 type: 'text', 9998 required: true, 9999 }], 10000 fn: (params) => { 10001 let field = (params && params.fields ? params.fields : []).find((f) => f.key === 'url'); 10002 let value = field && typeof field.value === 'string' ? field.value.trim() : ''; 10003 if (!value) { 10004 return; 10005 } 10006 this.process_pasted_url({ pasted: value, alt: alt, do_attach: do_attach }); 10007 }, 10008 }); 10009 }, 10010 process_pasted_url({ pasted, alt, do_attach }) { 10011 let trimmed = (typeof pasted === 'string') ? pasted.trim() : ''; 10012 if (!trimmed) { 10013 return; 10014 } 10015 let club_id = this.return_active_club_id(); 10016 if (!club_id || typeof Parse === 'undefined') {
10017 // Without a club_id we can't run the server-side 10018 // verifier â fall back to attaching the URL with a 10019 // warning so the admin isn't stuck. 10020 if (typeof displayMessage === 'function') { 10021 displayMessage('Could not verify the URL; attaching as-is. Review before publishing.', true, 5000); 10022 } 10023 do_attach({ url: trimmed, verified: false, source: 'pasted_url', visibility: 'unknown' }); 10024 return; 10025 } 10026 Parse.Cloud.run('verify_webbuilder_asset_url', { 10027 club_id: club_id, 10028 url: trimmed, 10029 }, this.parse_session_options()).then((verdict) => { 10030 if (verdict && verdict.allowed) { 10031 do_attach({ 10032 url: verdict.embed_url || trimmed, 10033 verified: true, 10034 source: verdict.source || 'verified_url', 10035 visibility: verdict.visibility || 'public_web', 10036 }); 10037 return; 10038 } 10039 let reason = verdict && verdict.reason ? verdict.reason : 'unverified'; 10040 // External URLs come back as `external_url_not_allowed` 10041 // â surface as a Clubspot modal so the admin can 10042 // choose between importing to the library or 10043 // attaching as-is. The custom-code sanitizer still 10044 // strips dangerous schemes at render time. 10045 if (reason === 'external_url_not_allowed') { 10046 this.confirm_external_url_action({ 10047 url: trimmed, 10048 alt: alt, 10049 on_import: () => { 10050 return this.import_external_image_url(trimmed, alt).then((cdn_url) => { 10051 do_attach({ 10052 url: cdn_url, 10053 verified: true, 10054 source: 'imported_external_url', 10055 visibility: 'public_web', 10056 }); 10057 }); 10058 }, 10059 on_attach_external: () => { 10060 do_attach({ url: trimmed, verified: false, source: 'external', visibility: 'unknown' }); 10061 }, 10062 }); 10063 return; 10064 } 10065 if (typeof displayMessage === 'function') { 10066 displayMessage('URL rejected: ' + reason.replace(/_/g, ' '), true, 5000); 10067 } 10068 }).catch((error) => { 10069 // The verifier cloud function ships in the same 10070 // deploy as this UI, but local devs sometimes don't 10071 // restart their primary server between code pulls. 10072 // Treat "Invalid function" as a soft fallback: attach 10073 // the URL with a warning so the admin isn't stuck, 10074 // and surface the underlying error in the console. 10075 let msg = error && error.message ? error.message : ''; 10076 if (msg.indexOf('Invalid function') !== -1) { 10077 console.warn('[custom-code] verify_webbuilder_asset_url not deployed â attaching URL without verification.'); 10078 if (typeof displayMessage === 'function') { 10079 displayMessage('Attached without verification (server hasn\'t reloaded the new function yet).', true, 5000); 10080 } 10081 do_attach({ url: trimmed, verified: false, source: 'pasted_url', visibility: 'unknown' }); 10082 return; 10083 } 10084 if (typeof displayMessage === 'function') { 10085 displayMessage(msg || 'Could not verify URL.', true, 5000); 10086 } 10087 }); 10088 }, 10089 confirm_external_url_action({ url, alt, on_import, on_attach_external }) { 10090 if (typeof are_you_sure !== 'function') { 10091 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)) { 10092 Promise.resolve().then(() => on_import()).catch(() => {}); 10093 } else { 10094 on_attach_external(); 10095 } 10096 return; 10097 } 10098 let host = ''; 10099 try { 10100 host = new URL(url).hostname || ''; 10101 } catch (err) { 10102 host = ''; 10103 } 10104 let truncated = url.length > 90 ? (url.slice(0, 87) + '...') : url; 10105 let escaped = (typeof url === 'string') ? url.replace(/"/g, '"') : ''; 10106 are_you_sure({ 10107 title: 'External image URL', 10108 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.', 10109 callout: { html: '<a href="' + escaped + '" target="_blank" rel="noopener" style="word-break:break-all;">' + truncated + '</a>' }, 10110 button_label: 'Save to club photos', 10111 warning_button: false, 10112 no_thanks_label: 'Attach as external URL', 10113 fn: () => { 10114 store.components.are_you_sure.loading = true; 10115 Promise.resolve().then(() => on_import()).then(() => { 10116 store.components.are_you_sure.loading = false;
10117 store.overlays.are_you_sure = false; 10118 if (typeof displayMessage === 'function') { 10119 displayMessage('Image saved to your photo library.', false, 4000); 10120 } 10121 }).catch((error) => { 10122 store.components.are_you_sure.loading = false; 10123 if (typeof checkParseError === 'function') { 10124 checkParseError(error); 10125 } 10126 let msg = error && error.message ? error.message : 'Image import failed.'; 10127 if (typeof displayMessage === 'function') { 10128 displayMessage(msg, true, 6000); 10129 } 10130 }); 10131 }, 10132 secondary_fn: () => { 10133 store.overlays.are_you_sure = false; 10134 on_attach_external(); 10135 }, 10136 }); 10137 }, 10138 // Run the `import_external_image_to_club_photos` Cloud 10139 // function and resolve with the canonical CDN URL. 10140 // Centralizes the embed_url fallback + "no URL returned" 10141 // diagnostics that used to be duplicated across both 10142 // `import_external_image_for_attached` and 10143 // `import_external_image_for_bare`. 10144 import_external_image_url(url, alt) { 10145 let club_id = this.return_active_club_id(); 10146 if (!club_id || typeof Parse === 'undefined') { 10147 return Promise.reject(new Error('Could not determine the active club. Refresh and try again.')); 10148 } 10149 return Parse.Cloud.run('import_external_image_to_club_photos', { 10150 club_id: club_id, 10151 url: url, 10152 alt: alt || '', 10153 }, this.parse_session_options()).then((result) => { 10154 let returned_cdn = result && result.cdn_url ? result.cdn_url : null; 10155 let embed_fallback = result && result.result && result.result.embed_url ? result.result.embed_url : null; 10156 let cdn_url = returned_cdn || embed_fallback; 10157 if (!cdn_url) { 10158 let detail = result ? (' (server returned: ' + JSON.stringify(result).slice(0, 160) + ')') : ''; 10159 throw new Error('Image import finished but no URL was returned.' + detail); 10160 } 10161 return cdn_url; 10162 }); 10163 }, 10164 // âââ Section-level "save external images" batch âââââââââââ 10165 // 10166 // Pure CLIENT operation, mirroring upload_webbuilder_asset_photo: 10167 // discover every external image URL in the section, loop the 10168 // existing import_external_image_to_club_photos cloud function 10169 // per URL (with the admin's real session token / permissions), 10170 // then rewrite the persisted HTML to point at the new CDN 10171 // URLs. No AI service involvement. 10172 // 10173 // Parses the persisted source (not the live preview DOM) so 10174 // it sees the canonical URLs and isn't confused by transient 10175 // decoration wrappers. Returns a deduped array of external 10176 // http(s) image URLs found in <img src> and inline 10177 // `background-image: url(...)`. 10178 collect_section_external_image_urls(source_html) { 10179 return webbuilder_collect_external_image_urls(source_html); 10180 }, 10181 import_all_external_images_in_section() { 10182 if (this.importing_external_images) { 10183 return; 10184 } 10185 let urls = this.section_external_image_urls; 10186 if (!urls.length) { 10187 if (typeof displayMessage === 'function') { 10188 displayMessage('No external images to save in this section.', false, 4000); 10189 } 10190 return; 10191 } 10192 let club_id = this.return_active_club_id(); 10193 if (!club_id || typeof Parse === 'undefined') { 10194 if (typeof displayMessage === 'function') { 10195 displayMessage('Could not determine the active club. Refresh and try again.', true, 5000); 10196 } 10197 return; 10198 } 10199 let count = urls.length; 10200 let run_batch = () => { 10201 this.importing_external_images = true; 10202 if (typeof displayMessage === 'function') { 10203 displayMessage('Saving ' + count + ' image' + (count === 1 ? '' : 's') + ' to your photo library...', false, 4000); 10204 } 10205 this.run_external_image_batch(urls).then((summary) => { 10206 this.importing_external_images = false;
10207 this.report_external_image_batch_result(summary); 10208 }).catch((error) => { 10209 this.importing_external_images = false; 10210 if (typeof checkParseError === 'function') { 10211 checkParseError(error); 10212 } 10213 let msg = error && error.message ? error.message : 'Image import failed.'; 10214 if (typeof displayMessage === 'function') { 10215 displayMessage(msg, true, 6000); 10216 } 10217 }); 10218 }; 10219 if (typeof are_you_sure === 'function') { 10220 are_you_sure({ 10221 title: 'Save external images', 10222 subtext: 'This section uses ' + count + ' image' + (count === 1 ? '' : 's') + ' hosted on other sites. Save ' 10223 + (count === 1 ? 'a copy' : 'copies') + ' to your club photo library and swap the section to use ' 10224 + (count === 1 ? 'it' : 'them') + '? This keeps the images available even if the source site changes.', 10225 button_label: 'Save to club photos', 10226 warning_button: false, 10227 fn: () => { 10228 store.overlays.are_you_sure = false; 10229 run_batch(); 10230 }, 10231 }); 10232 return; 10233 } 10234 run_batch(); 10235 }, 10236 // Import each external URL sequentially (bounded to one 10237 // in-flight request â these are byte-level server fetches, 10238 // and a club reference page rarely has more than a handful 10239 // of images). Builds a url->cdn_url map, then rewrites the 10240 // persisted HTML once at the end so the editor only takes a 10241 // single save + re-render. 10242 run_external_image_batch(urls) { 10243 let url_map = {}; 10244 let errors = []; 10245 let chain = Promise.resolve(); 10246 urls.forEach((url) => { 10247 chain = chain.then(() => { 10248 return this.import_external_image_url(url, '').then((cdn_url) => { 10249 url_map[url] = cdn_url; 10250 }).catch((error) => { 10251 errors.push({ url: url, message: error && error.message ? error.message : 'import failed' }); 10252 }); 10253 }); 10254 }); 10255 return chain.then(() => { 10256 let swapped = this.rewrite_section_external_image_urls(url_map); 10257 return { 10258 requested: urls.length, 10259 imported: Object.keys(url_map).length, 10260 errors: errors, 10261 swapped: swapped, 10262 }; 10263 }); 10264 }, 10265 // Replace every occurrence of each imported external URL 10266 // with its new CDN URL across the persisted HTML â both 10267 // <img src> and inline background-image declarations â and 10268 // persist once. Returns the number of URLs actually swapped. 10269 rewrite_section_external_image_urls(url_map) { 10270 let keys = Object.keys(url_map || {}); 10271 if (!keys.length) { 10272 return 0; 10273 } 10274 let swapped = 0; 10275 let mutated = this.mutate_persisted_html((doc) => { 10276 let changed = false; 10277 let imgs = Array.from(doc.querySelectorAll('img[src]')); 10278 for (let img of imgs) { 10279 let src = img.getAttribute('src'); 10280 if (src && url_map[src]) { 10281 img.setAttribute('src', url_map[src]); 10282 changed = true; 10283 } 10284 } 10285 let styled = Array.from(doc.querySelectorAll('[style*="background-image"]')); 10286 for (let el of styled) { 10287 let style = el.getAttribute('style') || ''; 10288 let next_style = style; 10289 keys.forEach((old_url) => { 10290 if (next_style.indexOf(old_url) !== -1) { 10291 next_style = next_style.split(old_url).join(url_map[old_url]); 10292 } 10293 }); 10294 if (next_style !== style) { 10295 el.setAttribute('style', next_style); 10296 changed = true; 10297 } 10298 } 10299 return changed; 10300 }); 10301 if (mutated) { 10302 this.persist_mutated_code(mutated); 10303 swapped = keys.length; 10304 } 10305 return swapped; 10306 }, 10307 report_external_image_batch_result(summary) { 10308 if (typeof displayMessage !== 'function') { 10309 return; 10310 } 10311 let safe = summary || {}; 10312 let imported = safe.imported || 0; 10313 let errors = Array.isArray(safe.errors) ? safe.errors : []; 10314 if (imported > 0 && errors.length === 0) { 10315 displayMessage('Saved ' + imported + ' image' + (imported === 1 ? '' : 's') + ' to your photo library.', false, 4000); 10316 return; 10317 } 10318 if (imported > 0 && errors.length > 0) { 10319 displayMessage('Saved ' + imported + ', but ' + errors.length + ' failed: ' + (errors[0].message || 'unknown error') + '.', true, 6000); 10320 return; 10321 } 10322 displayMessage('Could not save the external images: ' + (errors.length ? (errors[0].message || 'unknown error') : 'nothing imported') + '.', true, 6000); 10323 }, 10324 return_active_club_id() { 10325 if (typeof store !== 'undefined' && store.clubObject && store.clubObject.id) { 10326 return store.clubObject.id; 10327 } 10328 return null; 10329 }, 10330 parse_session_options() { 10331 if (typeof Parse === 'undefined' || !Parse.User || !Parse.User.current()) { 10332 return {}; 10333 } 10334 let token = Parse.User.current().getSessionToken ? Parse.User.current().getSessionToken() : null; 10335 if (!token) { 10336 return {}; 10337 } 10338 return { sessionToken: token }; 10339 },
10340 find_placeholder_element(id) { 10341 return this.find_asset_element_by_id(id); 10342 }, 10343 // Match either `data-cs-asset-placeholder="<id>"` (unfilled) 10344 // or `data-cs-asset-attached="<id>"` (already filled) so the 10345 // action menu can re-open against the same slot regardless 10346 // of state. 10347 find_asset_element_by_id(id) { 10348 let root = this.custom_code_render_root(); 10349 if (!root || !id) { 10350 return null; 10351 } 10352 let escaped = (typeof CSS !== 'undefined' && CSS.escape) ? CSS.escape(id) : id.replace(/"/g, '\\"'); 10353 let selector = 10354 '[data-cs-asset-placeholder="' + escaped + '"], ' + 10355 '[data-cs-asset-attached="' + escaped + '"]'; 10356 return root.querySelector(selector); 10357 }, 10358 // Hostnames that mean "the file is already in the Clubspot 10359 // library." Mirrors the server-side PUBLIC_ASSET_HOSTS set 10360 // in primary/cloud/webbuilder-assets.js. Drift in this list 10361 // is non-fatal: at worst we offer the user "Copy to club 10362 // photos" on a URL that turns out to be local, and the 10363 // server rejects the import with a clear error. The set 10364 // here matters only for the UX surface (showing/hiding the 10365 // menu option). 10366 return_clubspot_asset_hosts() { 10367 return new Set(WEBBUILDER_CLUBSPOT_ASSET_HOSTS); 10368 }, 10369 is_external_image_url(url) { 10370 return webbuilder_is_external_image_url(url); 10371 }, 10372 // Pull the currently-attached URL out of an asset wrapper. 10373 // Handles the three shapes attach_url_to_placeholder writes: 10374 // - <a data-cs-asset-attached href="..."> for documents. 10375 // - <img data-cs-asset-attached src="..."> (rare; the 10376 // wrapper itself is the <img>). 10377 // - <div data-cs-asset-attached style="background-image: 10378 // url(...);"> for the background-image target. 10379 // - A wrapper <div data-cs-asset-attached> whose first 10380 // <img> child carries the real src. 10381 // 10382 // Returns the raw URL string or null when nothing readable 10383 // is attached. 10384 return_attached_url_for_wrapper(wrapper) { 10385 if (!wrapper || wrapper.nodeType !== 1) { 10386 return null; 10387 } 10388 let tag_name = (wrapper.tagName || '').toLowerCase(); 10389 let target = (wrapper.getAttribute('data-cs-asset-target') || '').toLowerCase(); 10390 if (target === 'background-image') { 10391 let style = wrapper.getAttribute('style') || ''; 10392 let match = style.match(/background-image\s*:\s*url\(\s*(['"]?)([^'"\)]+)\1\s*\)/i); 10393 return match ? match[2] : null; 10394 } 10395 if (tag_name === 'a' || target === 'href') { 10396 return wrapper.getAttribute('href') || null; 10397 } 10398 if (tag_name === 'img') { 10399 return wrapper.getAttribute('src') || null; 10400 } 10401 let child_img = wrapper.querySelector('img[src]'); 10402 return child_img ? child_img.getAttribute('src') : null; 10403 }, 10404 // Single-image "Copy image to club photos" flow for an 10405 // already-attached external image. Reads the wrapper's 10406 // current URL, runs the import via the shared 10407 // `import_external_image_url` helper, and swaps the 10408 // returned CDN URL into the same wrapper. The wrapper 10409 // stays put across the swap, so layout doesn't reflow. 10410 import_external_image_for_attached(id) { 10411 let wrapper = this.find_asset_element_by_id(id); 10412 if (!wrapper) { 10413 return; 10414 } 10415 let original_url = this.return_attached_url_for_wrapper(wrapper); 10416 if (!this.is_external_image_url(original_url)) { 10417 if (typeof displayMessage === 'function') { 10418 displayMessage('This image is already in your library.', false, 4000); 10419 } 10420 return; 10421 } 10422 let alt = wrapper.getAttribute('data-cs-asset-label') 10423 || wrapper.getAttribute('alt') 10424 || ''; 10425 if (typeof displayMessage === 'function') { 10426 displayMessage('Importing image to your photo library...', false, 4000); 10427 } 10428 this.import_external_image_url(original_url, alt).then((cdn_url) => { 10429 this.attach_url_to_placeholder({ 10430 id: id, 10431 url: cdn_url, 10432 verified: true, 10433 source: 'imported_external_url', 10434 visibility: 'public_web', 10435 }); 10436 if (typeof displayMessage === 'function') { 10437 displayMessage('Image saved to your photo library.', false, 4000); 10438 } 10439 }).catch((error) => { 10440 if (typeof checkParseError === 'function') { 10441 checkParseError(error); 10442 } 10443 let msg = error && error.message ? error.message : 'Image import failed.'; 10444 if (typeof displayMessage === 'function') { 10445 displayMessage(msg, true, 6000); 10446 } 10447 }); 10448 }, 10449 10450 // âââ Bare <img> change flow âââââââââââââââââââââââââââââââ 10451 // 10452 // Parallel to the placeholder-marker flow above, but keyed 10453 // on the image's current `src` instead of a synthetic id. 10454 // Used by the third pass of `decorate_asset_placeholders` 10455 // that catches bare `<img>` tags from URL-reference builds. 10456 // 10457 // The same handle_common menu shape powers both â the 10458 // bare-img menu just routes its callbacks through src-based 10459 // mutation so the persisted HTML stays bare (no surprise 10460 // wrappers, no surprise unsaved_changes) until the admin 10461 // actually swaps the image. 10462 10463 open_bare_image_menu(img_el) { 10464 if (typeof handle_common !== 'function') { 10465 return; 10466 } 10467 if (!img_el || typeof img_el.getAttribute !== 'function') { 10468 return; 10469 } 10470 let current_src = img_el.getAttribute('src') || ''; 10471 let current_alt = img_el.getAttribute('alt') || ''; 10472 if (!current_src.trim()) { 10473 return; 10474 } 10475 let is_external = this.is_external_image_url(current_src); 10476 let options = []; 10477 options.push({ 10478 text: 'Pick a different photo', 10479 subtext: 'Upload a new image or pick an existing one from this club', 10480 fn: () => this.run_after_placeholder_menu_close(() => this.pick_existing_for_bare_image(img_el)), 10481 }); 10482 options.push({ 10483 text: 'Choose from club documents', 10484 subtext: 'Public-access files in the club documents folder', 10485 fn: () => this.run_after_placeholder_menu_close(() => this.pick_document_for_bare_image(img_el)), 10486 }); 10487 options.push({ 10488 text: 'Paste a different URL', 10489 subtext: 'Paste a public image URL (Clubspot
10489CDN or external host)', 10490 fn: () => this.run_after_placeholder_menu_close(() => this.paste_url_for_bare_image(img_el)), 10491 }); 10492 if (is_external) { 10493 options.push({ 10494 text: 'Copy image to club photos', 10495 subtext: 'Save a local copy in your photo library and use that instead of the external URL.', 10496 fn: () => this.run_after_placeholder_menu_close(() => this.import_external_image_for_bare(img_el)), 10497 }); 10498 } 10499 let label = (current_alt || '').trim() || 'image'; 10500 handle_common({ 10501 title: 'Change "' + label + '"', 10502 subtitle: is_external 10503 ? 'Pick a different photo, paste another URL, or save this external image into your library.' 10504 : 'Pick a different photo or paste another URL.', 10505 hide_on_selection: false, 10506 options: options, 10507 }); 10508 }, 10509 // Persist a swap from one img src to another. Matches by 10510 // exact `src` attribute value, which covers the model's 10511 // output and any direct admin edits (the codegen pipeline 10512 // doesn't normalize URL strings between turns). Also lifts 10513 // a missing `alt` from the new label so SEO/accessibility 10514 // copy survives the swap. 10515 swap_bare_image_src(old_src, new_url, new_alt) { 10516 if (typeof old_src !== 'string' || !old_src.trim()) { 10517 return false; 10518 } 10519 if (typeof new_url !== 'string' || !new_url.trim()) { 10520 return false; 10521 } 10522 let mutated = this.mutate_persisted_html((doc) => { 10523 let imgs = Array.from(doc.querySelectorAll('img[src]')); 10524 let changed = false; 10525 for (let img of imgs) { 10526 if (img.getAttribute('src') !== old_src) { 10527 continue; 10528 } 10529 img.setAttribute('src', new_url); 10530 if (new_alt && !(img.getAttribute('alt') || '').trim()) { 10531 img.setAttribute('alt', new_alt); 10532 } 10533 changed = true; 10534 } 10535 return changed; 10536 }); 10537 if (mutated) { 10538 this.persist_mutated_code(mutated); 10539 return true; 10540 } 10541 return false; 10542 }, 10543 pick_existing_for_bare_image(img_el) { 10544 if (typeof webbuilder_asset_picker !== 'function') { 10545 if (typeof displayMessage === 'function') { 10546 displayMessage('Club photo picker is not loaded. Refresh the page and try again.', true, 5000); 10547 } 10548 return; 10549 } 10550 let old_src = img_el.getAttribute('src') || ''; 10551 if (!old_src) { 10552 return; 10553 } 10554 webbuilder_asset_picker({ 10555 title: 'Select club photo', 10556 accepts: 'image', 10557 source: 'photos', 10558 hide_on_selection: true, 10559 fn: (result) => { 10560 let url = result && result.url ? result.url : null; 10561 if (!url) { 10562 return; 10563 } 10564 this.swap_bare_image_src(old_src, url, result.label || ''); 10565 }, 10566 }); 10567 }, 10568 pick_document_for_bare_image(img_el) { 10569 if (typeof webbuilder_asset_picker !== 'function') { 10570 if (typeof displayMessage === 'function') { 10571 displayMessage('Documents picker is not loaded. Refresh the page and try again.', true, 5000); 10572 } 10573 return; 10574 } 10575 let old_src = img_el.getAttribute('src') || ''; 10576 if (!old_src) { 10577 return; 10578 } 10579 webbuilder_asset_picker({ 10580 title: 'Select a file', 10581 accepts: 'image', 10582 source: 'documents', 10583 hide_on_selection: true, 10584 fn: (result) => { 10585 let url = result && result.url ? result.url : null; 10586 if (!url) { 10587 return; 10588 } 10589 this.swap_bare_image_src(old_src, url, result.label || ''); 10590 }, 10591 }); 10592 }, 10593 paste_url_for_bare_image(img_el) { 10594 let old_src = img_el.getAttribute('src') || ''; 10595 let label = img_el.getAttribute('alt') || 'image'; 10596 if (!old_src) { 10597 return; 10598 } 10599 let do_attach = (attach) => { 10600 this.swap_bare_image_src(old_src, attach.url, label); 10601 }; 10602 this.prompt_for_pasted_url({ 10603 slot_label: label, 10604 alt: label, 10605 do_attach: do_attach, 10606 }); 10607 }, 10608 // Single-image import path for bare imgs. Reads the img's 10609 // current src, runs the same Cloud function the attached- 10610 // wrapper menu uses, and swaps the src for the CDN URL on 10611 // success. The img stays exactly where it is â no wrapper 10612 // upgrade, no layout change. 10613 import_external_image_for_bare(img_el) { 10614 let original_url = img_el.getAttribute('src') || ''; 10615 let alt = img_el.getAttribute('alt') || ''; 10616 if (!this.is_external_image_url(original_url)) { 10617 if (typeof displayMessage === 'function') { 10618 displayMessage('This image is already in your library.', false, 4000); 10619 } 10620 return; 10621 } 10622 if (typeof displayMessage === 'function') { 10623 displayMessage('Importing image to your photo library...', false, 4000); 10624 } 10625 this.import_external_image_url(original_url, alt).then((cdn_url) => { 10626 this.swap_bare_image_src(original_url, cdn_url, alt); 10627 if (typeof displayMessage === 'function') { 10628 displayMessage('Image saved to your photo library.', false, 4000); 10629 } 10630 }).catch((error) => { 10631 if (typeof checkParseError === 'function') { 10632 checkParseError(error); 10633 } 10634 let msg = error && error.message ? error.message : 'Image import failed.'; 10635 if (typeof displayMessage === 'function') { 10636 displayMessage(msg, true, 6000); 10637 } 10638 }); 10639 }, 10640 10641 // Rewrite the persisted custom-code string so the matching 10642 // placeholder element gains a real `src`/`href`/`background- 10643 // image`. We keep the wrapper element across the transition 10644 // â it carries the section's layout box (dimensions, border- 10645 // radius, positioning, etc) â and just rotate its children 10646 // for `target="src"`. The wrapper's `data-cs-asset- 10647 // placeholder` attribute is renamed to `data-cs-asset- 10648 // attached` so: 10649 // 10650 // 1. Subsequent decorate passes show the small "Change" 10651 // overlay instead of the big "Add" overlay. 10652 // 2. The AI model can tell on follow-up turns that the 10653 // slot is filled (and must not be re-placeholderized). 10654 // 10655 // We also accept being called against an already-attached 10656 // element â that's the "Change" flow swapping one URL for 10657 // another. 10658 attach_url_to_placeholder(params) { 10659 let { id, url } = params; 10660 if (!id || typeof url !== 'string' || !url.trim()) { 10661 return; 10662 } 10663 let mutated = this.mutate_persisted_html((doc) => { 10664 let escaped = (typeof CSS !== 'undefined' && CSS.escape) ? CSS.escape(id) : id.replace(/"/g, '\\"'); 10665 let wrapper = doc.querySelector( 10666 '[data-cs-asset-placeholder="' + escaped + '"], ' + 10667 '[data-cs-asset-attached="' + escaped + '"]' 10668 ); 10669 if (!wrapper) { 10670 return false;
10671 } 10672 let tag_name = (wrapper.tagName || '').toLowerCase(); 10673 let target = (wrapper.getAttribute('data-cs-asset-target') || '').toLowerCase(); 10674 if (!target) { 10675 if (tag_name === 'a') { 10676 target = 'href'; 10677 } else if (tag_name === 'img') { 10678 target = 'src'; 10679 } else { 10680 target = 'src'; 10681 } 10682 } 10683 let alt = wrapper.getAttribute('data-cs-asset-label') || wrapper.getAttribute('alt') || ''; 10684 10685 if (target === 'background-image') { 10686 let existing_style = wrapper.getAttribute('style') || ''; 10687 // Strip any existing background-image declaration 10688 // so the new url() wins without doubling up. Also 10689 // wipe any inner content the placeholder used as 10690 // a fallback label â once we've got a real image, 10691 // the gradient/icon "Add a photo" copy would just 10692 // clutter the live site. 10693 let stripped = existing_style.replace(/background-image\s*:[^;]*;?/gi, '').trim(); 10694 let suffix = stripped && !/;\s*$/.test(stripped) ? '; ' : (stripped ? ' ' : ''); 10695 let bg_value = 'background-image: url("' + this.escape_css_url_segment(url) + '"); background-size: cover; background-position: center;'; 10696 wrapper.setAttribute('style', (stripped + suffix + bg_value).trim()); 10697 } else if (target === 'href' || tag_name === 'a') { 10698 wrapper.setAttribute('href', url); 10699 wrapper.setAttribute('target', '_blank'); 10700 wrapper.setAttribute('rel', 'noopener noreferrer'); 10701 } else if (tag_name === 'img') { 10702 wrapper.setAttribute('src', url); 10703 if (alt && !wrapper.getAttribute('alt')) { 10704 wrapper.setAttribute('alt', alt); 10705 } 10706 } else { 10707 // `target=src` on a wrapper element: drop any 10708 // fallback children (icon, text label) and insert 10709 // an <img> sized to fill the wrapper. The wrapper 10710 // continues to carry the layout box, so the live 10711 // site never reflows between empty/filled states. 10712 this.empty_placeholder_inner(wrapper); 10713 let owner = wrapper.ownerDocument || document; 10714 let img = owner.createElement('img'); 10715 img.setAttribute('src', url); 10716 if (alt) { 10717 img.setAttribute('alt', alt); 10718 } 10719 img.setAttribute('style', 'width:100%;height:100%;object-fit:cover;display:block;border-radius:inherit;'); 10720 wrapper.appendChild(img); 10721 } 10722 // Rename the placeholder marker â attached marker 10723 // (same id). Preserve everything else so the model 10724 // still has the role/label/target context next turn. 10725 let placeholder_id = wrapper.getAttribute('data-cs-asset-placeholder'); 10726 if (placeholder_id !== null) { 10727 wrapper.removeAttribute('data-cs-asset-placeholder'); 10728 wrapper.setAttribute('data-cs-asset-attached', placeholder_id); 10729 } 10730 return true; 10731 }); 10732 if (mutated) { 10733 this.persist_mutated_code(mutated); 10734 } 10735 }, 10736 // Drop any inert fallback content the placeholder shipped 10737 // with (e.g. "Add a hero photo" label, decorative icon spans). 10738 // Leaves the wrapper element itself untouched. 10739 empty_placeholder_inner(wrapper) { 10740 if (!wrapper) { 10741 return; 10742 } 10743 while (wrapper.firstChild) { 10744 wrapper.removeChild(wrapper.firstChild); 10745 } 10746 }, 10747 // "Keep empty for now" â dismiss the prominent "Add image" 10748 // overlay but keep the wrapper marked so the admin can 10749 // still attach an asset later via the small Change button. 10750 // We rename `data-cs-asset-placeholder` â `data-cs-asset- 10751 // attached` (same id, no URL set) which: 10752 // - swaps the decorator from big overlay â Change chip, 10753 // - tells the AI to preserve the slot as-is on follow-up 10754 // rewrites (don't re-placeholderize).
10755 // Permanent deletion is a code-editor operation. 10756 remove_placeholder(id) { 10757 if (!id) { 10758 return; 10759 } 10760 let mutated = this.mutate_persisted_html((doc) => { 10761 let escaped = (typeof CSS !== 'undefined' && CSS.escape) ? CSS.escape(id) : id.replace(/"/g, '\\"'); 10762 let placeholder = doc.querySelector('[data-cs-asset-placeholder="' + escaped + '"]'); 10763 if (!placeholder) { 10764 return false; 10765 } 10766 placeholder.removeAttribute('data-cs-asset-placeholder'); 10767 placeholder.setAttribute('data-cs-asset-attached', id); 10768 return true; 10769 }); 10770 if (mutated) { 10771 this.persist_mutated_code(mutated); 10772 } 10773 }, 10774 // "Remove this image" on an attached slot â wipe the 10775 // filled URL/contents and put the slot back into placeholder 10776 // state so the admin can re-fill from scratch. For `src` 10777 // wrappers we also need to drop the inner <img>. 10778 revert_attached_to_placeholder(id) { 10779 if (!id) { 10780 return; 10781 } 10782 let mutated = this.mutate_persisted_html((doc) => { 10783 let escaped = (typeof CSS !== 'undefined' && CSS.escape) ? CSS.escape(id) : id.replace(/"/g, '\\"'); 10784 let wrapper = doc.querySelector('[data-cs-asset-attached="' + escaped + '"]'); 10785 if (!wrapper) { 10786 return false; 10787 } 10788 let tag_name = (wrapper.tagName || '').toLowerCase(); 10789 let target = (wrapper.getAttribute('data-cs-asset-target') || '').toLowerCase(); 10790 if (!target) { 10791 if (tag_name === 'a') { 10792 target = 'href'; 10793 } else if (tag_name === 'img') { 10794 target = 'src'; 10795 } else { 10796 target = 'src'; 10797 } 10798 } 10799 if (target === 'background-image') { 10800 let style = wrapper.getAttribute('style') || ''; 10801 let stripped = style.replace(/background-image\s*:[^;]*;?/gi, '').trim(); 10802 if (stripped) { 10803 wrapper.setAttribute('style', stripped); 10804 } else { 10805 wrapper.removeAttribute('style'); 10806 } 10807 } else if (target === 'href' || tag_name === 'a') { 10808 wrapper.removeAttribute('href'); 10809 wrapper.removeAttribute('target'); 10810 wrapper.removeAttribute('rel'); 10811 } else { 10812 this.empty_placeholder_inner(wrapper); 10813 } 10814 let attached_id = wrapper.getAttribute('data-cs-asset-attached'); 10815 if (attached_id !== null) { 10816 wrapper.removeAttribute('data-cs-asset-attached'); 10817 wrapper.setAttribute('data-cs-asset-placeholder', attached_id); 10818 } 10819 return true; 10820 }); 10821 if (mutated) { 10822 this.persist_mutated_code(mutated); 10823 } 10824 }, 10825 strip_all_asset_attributes(element) { 10826 if (!element) { 10827 return; 10828 } 10829 let attrs_to_remove = []; 10830 Array.prototype.slice.call(element.attributes || []).forEach((attr) => { 10831 let name = String(attr.name || '').toLowerCase(); 10832 if (name.indexOf('data-cs-asset-') === 0) { 10833 attrs_to_remove.push(attr.name); 10834 } 10835 }); 10836 attrs_to_remove.forEach((name) => element.removeAttribute(name)); 10837 }, 10838 escape_css_url_segment(url) { 10839 if (typeof url !== 'string') { 10840 return ''; 10841 } 10842 return url.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); 10843 }, 10844 // Parse the persisted custom-code string into a scratch 10845 // <template>, hand it to `mutator` for in-place changes, 10846 // and serialize back. Returns the new HTML string or null 10847 // if the mutator reported no change. 10848 mutate_persisted_html(mutator) { 10849 let source = typeof this.custom_code_value === 'string' ? this.custom_code_value : ''; 10850 let template = document.createElement('template'); 10851 template.innerHTML = source; 10852 let changed = mutator(template.content); 10853 if (!changed) { 10854 return null; 10855 } 10856 return template.innerHTML; 10857 }, 10858 persist_mutated_code(new_html) { 10859 if (typeof new_html !== 'string') { 10860 return; 10861 } 10862 this.custom_code_value = new_html; 10863 if (this.cm_instance) { 10864 this.cm_instance.setValue(new_html); 10865 } 10866 if (this.component && typeof save_changes_for_component === 'function') { 10867 save_changes_for_component(this.component, 'custom_code', new_html); 10868 } 10869 this.mark_ai_code_revision_changed(); 10870 this.$nextTick(() => this.initialize_custom_code_interactions()); 10871 }, 10872 10873 // âââ Editable text leaves âââââââââââââââââââââââââââââââââ 10874 // 10875 // Walk the rendered preview, find every "text leaf" element 10876 // (any element whose own children are inline-only, plus 10877 // direct text), and attach a click handler that opens an 10878 // inline edit popover. Skip: 10879 // - asset placeholder/attached wrappers (their inner text 10880 // is fallback content the placeholder fill flow wipes), 10881 // - data-cs-toggle / data-cs-tab interactive triggers 10882 // (admin uses those to test interactions in preview), 10883 // - the AI launcher button. 10884 // 10885 // Each decorated element gets a `__cs_text_decorated` flag 10886 // so we don't double-bind across re-decorate passes within 10887 // one render, and a `cs-text-editable-host` class so CSS can 10888 // give it a hover outline + cursor:pointer affordance. 10889 decorate_editable_text_nodes() { 10890 if (!this.preview_element) { 10891 return; 10892 } 10893 let root = this.custom_code_render_root(); 10894 if (!root) { 10895 return; 10896 } 10897 let candidates = this.collect_editable_text_leaves(root);
10898 candidates.forEach((el) => { 10899 if (el.__cs_text_decorated) { 10900 return; 10901 } 10902 el.__cs_text_decorated = true; 10903 el.classList.add('cs-text-editable-host'); 10904 let handler = (event) => { 10905 if (this.should_allow_text_click_through(event)) { 10906 return; 10907 } 10908 event.preventDefault(); 10909 event.stopPropagation(); 10910 this.open_text_edit_popover(el); 10911 }; 10912 el.addEventListener('click', handler); 10913 }); 10914 }, 10915 10916 // DFS-collect text leaves. When an element qualifies, we do 10917 // NOT recurse into it â we want the OUTERMOST text leaf so 10918 // `<h1>Hello <em>world</em></h1>` is edited as one string, 10919 // not two. We also stop recursing into media/script/svg 10920 // wrappers and into asset placeholder/attached wrappers 10921 // (those manage their own UI), since nothing inside should 10922 // be a text leaf anyway. 10923 collect_editable_text_leaves(root) { 10924 let results = []; 10925 let visit = (el) => { 10926 if (!el || el.nodeType !== 1) { 10927 return; 10928 } 10929 let tag = (el.tagName || '').toLowerCase(); 10930 if (TEXT_SKIP_TAGS.has(tag)) { 10931 return; 10932 } 10933 if (this.is_preview_injected_node(el)) { 10934 return; 10935 } 10936 if (el.hasAttribute && ( 10937 el.hasAttribute('data-cs-asset-placeholder') || 10938 el.hasAttribute('data-cs-asset-attached') 10939 )) { 10940 return; 10941 } 10942 if (this.is_editable_text_leaf(el, root)) { 10943 results.push(el); 10944 return; 10945 } 10946 let child_elements = el.children || []; 10947 for (let child of child_elements) { 10948 visit(child); 10949 } 10950 }; 10951 let top = root.children || []; 10952 for (let child of top) { 10953 visit(child); 10954 } 10955 return results; 10956 }, 10957 10958 is_editable_text_leaf(el, root) { 10959 if (!el || el.nodeType !== 1) { 10960 return false; 10961 } 10962 let tag = (el.tagName || '').toLowerCase(); 10963 if (TEXT_SKIP_TAGS.has(tag)) { 10964 return false; 10965 } 10966 if (el.classList && ( 10967 el.classList.contains('cs-asset-placeholder-overlay') || 10968 el.classList.contains('cs-asset-change-button') || 10969 el.classList.contains('cs-asset-background-placeholder-chip') || 10970 el.classList.contains('cs-bare-image-preview-host') || 10971 el.classList.contains('custom-code-ai-launcher') || 10972 el.classList.contains('cs-text-edit-popover') 10973 )) { 10974 return false; 10975 } 10976 // Skip declarative-interaction triggers â admins want to 10977 // click those to test the toggle/tab behavior in preview. 10978 if (el.hasAttribute && ( 10979 el.hasAttribute('data-cs-toggle') || 10980 el.hasAttribute('data-cs-tab') 10981 )) { 10982 return false; 10983 } 10984 // Skip if self or ancestor is an asset placeholder/attached 10985 // wrapper â those wrappers own their fallback content. 10986 let ancestor = el; 10987 while (ancestor && ancestor !== root) { 10988 if (ancestor.nodeType === 1 && ancestor.hasAttribute && ( 10989 ancestor.hasAttribute('data-cs-asset-placeholder') || 10990 ancestor.hasAttribute('data-cs-asset-attached') 10991 )) { 10992 return false;
10993 } 10994 if (this.is_preview_injected_node(ancestor)) { 10995 return false; 10996 } 10997 ancestor = ancestor.parentNode; 10998 } 10999 let has_text = false; 11000 for (let child of el.childNodes) { 11001 if (child.nodeType === 3 && child.nodeValue && child.nodeValue.trim()) { 11002 has_text = true; 11003 break; 11004 } 11005 } 11006 if (!has_text) { 11007 return false; 11008 } 11009 let element_children = el.children || []; 11010 for (let child of element_children) { 11011 let child_tag = (child.tagName || '').toLowerCase(); 11012 if (!TEXT_INLINE_TAGS.has(child_tag)) { 11013 return false; 11014 } 11015 } 11016 return true; 11017 }, 11018 11019 // Walk up from `element` to `root`, recording the index of 11020 // each step among its parent's child NODES (text + element). 11021 // Skip preview-only injected nodes (asset overlays) since 11022 // they don't exist in the persisted source. The same path 11023 // can then be replayed on the parsed source (skipping 11024 //
11024<script>, which sanitize_custom_code_html removes from 11025 // the rendered preview). 11026 compute_text_element_path(element, root) { 11027 if (!element || !root || element === root) { 11028 return null; 11029 } 11030 let path = []; 11031 let current = element; 11032 while (current && current !== root) { 11033 let parent = current.parentNode; 11034 if (!parent) { 11035 return null; 11036 } 11037 let visible_index = -1; 11038 let count = 0; 11039 let found = false; 11040 for (let child of parent.childNodes) { 11041 if (this.is_preview_injected_node(child)) { 11042 continue; 11043 } 11044 if (child === current) { 11045 visible_index = count; 11046 found = true; 11047 break; 11048 } 11049 count++; 11050 } 11051 if (!found || visible_index === -1) { 11052 return null; 11053 } 11054 path.unshift(visible_index); 11055 current = parent; 11056 } 11057 if (current !== root) { 11058 return null; 11059 } 11060 return path; 11061 }, 11062 11063 is_preview_injected_node(node) { 11064 if (!node || node.nodeType !== 1) { 11065 return false; 11066 } 11067 if (!node.classList) { 11068 return false; 11069 } 11070 return ( 11071 node.classList.contains('cs-asset-placeholder-overlay') || 11072 node.classList.contains('cs-asset-change-button') || 11073 node.classList.contains('cs-asset-background-placeholder-chip') || 11074 node.classList.contains('cs-bare-image-host') || 11075 node.classList.contains('cs-bare-image-preview-host') 11076 ); 11077 }, 11078 should_allow_text_click_through(event) { 11079 if (!this._text_click_through_until || Date.now() > this._text_click_through_until) { 11080 return false; 11081 } 11082 let target = event && event.target ? event.target : null; 11083 if (!target || typeof target.closest !== 'function') { 11084 return false; 11085 } 11086 return !!target.closest('a, button, [role="button"], [data-cs-toggle], [data-cs-tab]'); 11087 }, 11088 11089 // Walk a parsed-source tree using a path captured from the 11090 // preview. Skip
11090<script> nodes on the source side because 11091 // sanitize_custom_code_html strips them at render time â 11092 // without skipping, source/preview indices diverge any 11093 // time a section embeds a script tag. 11094 find_text_element_by_path(root, path) { 11095 if (!root || !path) { 11096 return null; 11097 } 11098 let current = root; 11099 for (let target_index of path) { 11100 if (!current || !current.childNodes) { 11101 return null; 11102 } 11103 let visible_index = 0; 11104 let next = null; 11105 for (let child of current.childNodes) { 11106 if ( 11107 child.nodeType === 1 && 11108 (child.tagName || '').toLowerCase() === 'script' 11109 ) { 11110 continue; 11111 } 11112 if (visible_index === target_index) { 11113 next = child; 11114 break; 11115 } 11116 visible_index++; 11117 } 11118 if (!next) { 11119 return null; 11120 } 11121 current = next; 11122 } 11123 return current; 11124 }, 11125 11126 // Inline popover: textarea + Save/Cancel, positioned near 11127 // the clicked element via fixed coords. We close on outside 11128 // click, ESC, or scroll of the preview pane (re-positioning 11129 // on scroll added complexity for little gain â admins will 11130 // re-click after scrolling). 11131 open_text_edit_popover(target_el) { 11132 this.close_text_edit_popover(); 11133 if (!target_el) { 11134 return; 11135 } 11136 let root = this.custom_code_render_root(); 11137 if (!root) { 11138 return; 11139 } 11140 let path = this.compute_text_element_path(target_el, root); 11141 if (!path) { 11142 return; 11143 } 11144 let initial_value = this.read_element_text(target_el); 11145 let link_target = this.find_text_edit_link_target(target_el, root); 11146 let link_path = link_target ? this.compute_text_element_path(link_target, root) : null; 11147 let initial_url = link_target ? this.read_text_edit_url(link_target) : ''; 11148 11149 let popover = document.createElement('div'); 11150 popover.className = 'cs-text-edit-popover'; 11151 popover.setAttribute('data-cs-text-popover', '1'); 11152 11153 let header = document.createElement('div'); 11154 header.className = 'cs-text-edit-popover-header'; 11155 header.textContent = 'Edit text'; 11156 popover.appendChild(header); 11157 11158 let textarea = document.createElement('textarea'); 11159 textarea.className = 'cs-text-edit-textarea'; 11160 textarea.value = initial_value; 11161 textarea.rows = Math.min(8, Math.max(2, initial_value.split('\n').length + 1)); 11162 popover.appendChild(textarea); 11163 11164 let url_input = null; 11165 if (link_target && link_path) { 11166 let url_wrap = document.createElement('label'); 11167 url_wrap.className = 'cs-text-edit-url-field'; 11168 11169 let url_label = document.createElement('span'); 11170 url_label.className = 'cs-text-edit-url-label'; 11171 url_label.textContent = 'URL or path'; 11172 url_wrap.appendChild(url_label); 11173 11174 url_input = document.createElement('input'); 11175 url_input.type = 'text'; 11176 url_input.className = 'cs-text-edit-url-input'; 11177 url_input.value = initial_url; 11178 url_input.placeholder = ''; 11179 url_wrap.appendChild(url_input); 11180 11181 popover.appendChild(url_wrap); 11182 } 11183 11184 let footer = document.createElement('div'); 11185 footer.className = 'cs-text-edit-popover-footer'; 11186 let ask_btn = document.createElement('button'); 11187 ask_btn.type = 'button'; 11188 ask_btn.className = 'cs-text-edit-ask'; 11189 ask_btn.title = 'Ask AI about this text â adds it as a reference in the chat'; 11190 ask_btn.innerHTML = 11191 '<i class="fa-regular fa-sparkles"></i>' + 11192 '<span>Ask</span>'; 11193 let footer_spacer = document.createElement('div'); 11194 footer_spacer.className = 'cs-text-edit-popover-footer-spacer';
11195 let cancel_btn = document.createElement('button'); 11196 cancel_btn.type = 'button'; 11197 cancel_btn.className = 'cs-text-edit-cancel'; 11198 cancel_btn.textContent = 'Cancel'; 11199 let save_btn = document.createElement('button'); 11200 save_btn.type = 'button'; 11201 save_btn.className = 'cs-text-edit-save'; 11202 save_btn.textContent = 'Save'; 11203 footer.appendChild(ask_btn); 11204 footer.appendChild(footer_spacer); 11205 footer.appendChild(cancel_btn); 11206 footer.appendChild(save_btn); 11207 popover.appendChild(footer); 11208 11209 document.body.appendChild(popover); 11210 this.position_text_edit_popover(popover, target_el); 11211 11212 let do_save = () => { 11213 let new_value = textarea.value; 11214 let new_url = url_input ? url_input.value : null; 11215 this.commit_text_edit(path, new_value, link_path, new_url); 11216 this.close_text_edit_popover(); 11217 }; 11218 let do_cancel = () => { 11219 this.close_text_edit_popover(); 11220 }; 11221 let do_ask = () => { 11222 // Snapshot the textarea value (admin may have 11223 // tweaked the text before clicking Ask) and add 11224 // it as a chat reference. We don't persist the 11225 // edit â the chip carries the current draft so 11226 // the AI sees what the admin is looking at. 11227 let snippet = textarea.value; 11228 this.send_text_edit_to_ai_chat(target_el, snippet); 11229 this.close_text_edit_popover(); 11230 }; 11231 save_btn.addEventListener('click', do_save); 11232 cancel_btn.addEventListener('click', do_cancel); 11233 ask_btn.addEventListener('click', do_ask); 11234 textarea.addEventListener('keydown', (event) => { 11235 if (event.key === 'Escape') { 11236 event.preventDefault(); 11237 do_cancel(); 11238 return; 11239 } 11240 if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { 11241 event.preventDefault(); 11242 do_save(); 11243 } 11244 }); 11245 if (url_input) { 11246 url_input.addEventListener('keydown', (event) => { 11247 if (event.key === 'Escape') { 11248 event.preventDefault(); 11249 do_cancel(); 11250 return; 11251 } 11252 if (event.key === 'Enter') { 11253 event.preventDefault(); 11254 do_save(); 11255 } 11256 }); 11257 } 11258 // Stop propagation so our outside-click handler doesn't 11259 // see clicks landing inside the popover and dismiss it. 11260 popover.addEventListener('mousedown', (event) => { 11261 event.stopPropagation(); 11262 }); 11263 popover.addEventListener('click', (event) => { 11264 event.stopPropagation(); 11265 }); 11266 11267 let outside_handler = (event) => { 11268 if (popover.contains(event.target)) { 11269 return; 11270 } 11271 let target = event && event.target ? event.target : null; 11272 if (target && root.contains(target)) { 11273 this._text_click_through_until = Date.now() + 250; 11274 } 11275 do_cancel(); 11276 }; 11277 // Pin the popover to the target element on scroll + 11278 // resize. Capture-phase scroll listener on window 11279 // catches scroll events from any nested scroll 11280 // container (scroll doesn't bubble, but capture fires 11281 // for every ancestor). If the target gets detached 11282 // (e.g. AI re-renders the section, admin switches 11283 // tabs) we close instead of leaving an orphan popover 11284 // pointing at a stale rect. 11285 let reposition = () => { 11286 if (!this._text_popover || this._text_popover.el !== popover) { 11287 return; 11288 } 11289 if (!target_el || !target_el.isConnected) { 11290 do_cancel(); 11291 return; 11292 } 11293 this.position_text_edit_popover(popover, target_el); 11294 }; 11295 // Defer attach so the click that opened the popover 11296 // doesn't immediately close it. Also listen for 11297 // touchstart so taps on a touch device dismiss the 11298 // popover the same way mouse clicks do. 11299 setTimeout(() => { 11300 document.addEventListener('mousedown', outside_handler, true); 11301 document.addEventListener('touchstart', outside_handler, true); 11302 window.addEventListener('scroll', reposition, true); 11303 window.addEventListener('resize', reposition); 11304 }, 0); 11305 11306 this._text_popover = { 11307 el: popover, 11308 target_el: target_el, 11309 outside_handler: outside_handler, 11310 reposition: reposition, 11311 }; 11312 11313 // Highlight the element being edited so it's obvious 11314 // which text the popover targets. 11315 target_el.classList.add('cs-text-editable-active'); 11316 11317 this.$nextTick(() => { 11318 textarea.focus(); 11319 textarea.select(); 11320 }); 11321 }, 11322 11323 close_text_edit_popover() { 11324 if (!this._text_popover) { 11325 return; 11326 } 11327 let popover_state = this._text_popover; 11328 this._text_popover = null; 11329 if (popover_state.outside_handler) { 11330 document.removeEventListener('mousedown', popover_state.outside_handler, true); 11331 document.removeEventListener('touchstart', popover_state.outside_handler, true); 11332 } 11333 if (popover_state.reposition) { 11334 window.removeEventListener('scroll', popover_state.reposition, true); 11335 window.removeEventListener('resize', popover_state.reposition); 11336 } 11337 if (popover_state.target_el && popover_state.target_el.classList) { 11338 popover_state.target_el.classList.remove('cs-text-editable-active'); 11339 } 11340 if (popover_state.el && popover_state.el.parentNode) { 11341 popover_state.el.parentNode.removeChild(popover_state.el); 11342 } 11343 }, 11344 11345 // Position the popover below the element when there's room, 11346 // otherwise above. Use viewport (fixed) coordinates so the 11347 // popover doesn't get clipped by overflow:hidden ancestors 11348 // (the preview pane itself scrolls). 11349 position_text_edit_popover(popover, target_el) { 11350 if (!popover || !target_el) { 11351 return; 11352 } 11353 let rect = target_el.getBoundingClientRect();
11354 // Width: clamp between 240 and 420 px, max 92vw. 11355 let max_width = Math.min(window.innerWidth - 24, 420); 11356 let width = Math.max(240, Math.min(max_width, Math.round(rect.width))); 11357 popover.style.width = width + 'px'; 11358 11359 // Render once so we can measure height for vertical fit. 11360 popover.style.left = '0px'; 11361 popover.style.top = '0px'; 11362 popover.style.visibility = 'hidden'; 11363 popover.style.display = 'block'; 11364 11365 let popover_rect = popover.getBoundingClientRect(); 11366 let popover_height = popover_rect.height; 11367 let gap = 8; 11368 let viewport_h = window.innerHeight; 11369 let viewport_w = window.innerWidth; 11370 11371 let left = Math.round(rect.left); 11372 if (left + width + 8 > viewport_w) { 11373 left = Math.max(8, viewport_w - width - 8); 11374 } 11375 if (left < 8) { 11376 left = 8; 11377 } 11378 11379 let top; 11380 let space_below = viewport_h - rect.bottom; 11381 let space_above = rect.top; 11382 if (space_below >= popover_height + gap || space_below >= space_above) { 11383 top = Math.round(rect.bottom + gap); 11384 if (top + popover_height + 8 > viewport_h) { 11385 top = Math.max(8, viewport_h - popover_height - 8); 11386 } 11387 } else { 11388 top = Math.round(rect.top - popover_height - gap); 11389 if (top < 8) { 11390 top = 8; 11391 } 11392 } 11393 11394 popover.style.left = left + 'px'; 11395 popover.style.top = top + 'px'; 11396 popover.style.visibility = ''; 11397 }, 11398 11399 // Concatenate all text nodes inside the element, preserving 11400 // line breaks for `<br>` so multi-line copy round-trips 11401 // sensibly. Inline emphasis tags collapse to plain text on 11402 // save by design â admins use the Edit tab for fancy 11403 // inline markup. 11404 read_element_text(el) { 11405 if (!el) { 11406 return ''; 11407 } 11408 let parts = []; 11409 let walk = (node) => { 11410 if (!node) { 11411 return; 11412 } 11413 if (node.nodeType === 3) { 11414 parts.push(node.nodeValue || ''); 11415 return; 11416 } 11417 if (node.nodeType !== 1) { 11418 return; 11419 } 11420 let tag = (node.tagName || '').toLowerCase(); 11421 if (tag === 'br') { 11422 parts.push('\n'); 11423 return; 11424 } 11425 if (TEXT_SKIP_TAGS.has(tag)) { 11426 return; 11427 } 11428 for (let child of node.childNodes) { 11429 walk(child); 11430 } 11431 }; 11432 for (let child of el.childNodes) { 11433 walk(child); 11434 } 11435 return parts.join('').replace(/[ \t]+/g, ' ').replace(/\s+\n/g, '\n').replace(/\n\s+/g, '\n').trim(); 11436 }, 11437 11438 // "Ask" button on the text-edit popover â push a chat 11439 // composer chip referencing this element and pin the panel 11440 // open. The shared AI panel handles attachment + 11441 // formatting; we just describe the element. 11442 send_text_edit_to_ai_chat(target_el, snippet) { 11443 if (typeof ai_mcp_panel_attach_context !== 'function') { 11444 if (typeof displayMessage === 'function') { 11445 displayMessage('AI panel is not available right now.', true, 4000); 11446 } 11447 return; 11448 } 11449 if (!target_el) { 11450 return; 11451 } 11452 let tag = (target_el.tagName || 'element').toLowerCase(); 11453 let raw_text = typeof snippet === 'string' && snippet.length 11454 ? snippet 11455 : this.read_element_text(target_el); 11456 let display = (raw_text || '').replace(/\s+/g, ' ').trim(); 11457 let label = display.length > 60 ? display.slice(0, 60).trim() + 'â¦' : display; 11458 if (!label) { 11459 label = '(empty)'; 11460 } 11461 let id = 'ctx_' + Math.random().toString(36).slice(2, 10) + '_' + Date.now().toString(36); 11462 ai_mcp_panel_attach_context({ 11463 component: this, 11464 context: { 11465 id: id, 11466 kind: 'preview-text', 11467 tag: tag, 11468 text: raw_text || '', 11469 label: label, 11470 preview: display, 11471 }, 11472 }); 11473 }, 11474 11475 find_text_edit_link_target(target_el, root) { 11476 if (!target_el || !root || typeof target_el.closest !== 'function') { 11477 return null; 11478 } 11479 let link_target = target_el.closest('a, button'); 11480 if (!link_target || !root.contains(link_target)) { 11481 return null; 11482 } 11483 if (link_target.hasAttribute && ( 11484 link_target.hasAttribute('data-cs-toggle') || 11485 link_target.hasAttribute('data-cs-tab') 11486 )) { 11487 return null; 11488 } 11489 return link_target; 11490 }, 11491 read_text_edit_url(link_target) { 11492 if (!link_target || !link_target.getAttribute) { 11493 return ''; 11494 } 11495 let tag = (link_target.tagName || '').toLowerCase(); 11496 if (tag === 'a') { 11497 return link_target.getAttribute('href') || ''; 11498 } 11499 return link_target.getAttribute('data-cs-href') || ''; 11500 }, 11501 normalize_text_edit_url(value) { 11502 let url = typeof value === 'string' ? value.trim() : ''; 11503 if (!url) { 11504 return ''; 11505 } 11506 let lowered = url.toLowerCase(); 11507 if (lowered.indexOf('javascript:') === 0) { 11508 return ''; 11509 } 11510 return url; 11511 }, 11512 apply_text_edit_url(doc, link_path, url_value) { 11513 if (!doc || !link_path) { 11514 return false;
11515 } 11516 let target = this.find_text_element_by_path(doc, link_path); 11517 if (!target || target.nodeType !== 1) { 11518 return false; 11519 } 11520 let url = this.normalize_text_edit_url(url_value); 11521 let tag = (target.tagName || '').toLowerCase(); 11522 if (tag === 'a') { 11523 if (url) { 11524 target.setAttribute('href', url); 11525 } else { 11526 target.removeAttribute('href'); 11527 } 11528 return true; 11529 } 11530 if (tag !== 'button') { 11531 return false; 11532 } 11533 if (!url) { 11534 target.removeAttribute('data-cs-href'); 11535 return true; 11536 } 11537 // A plain <button> cannot navigate without JavaScript, 11538 // and inline JS is stripped by sanitize_custom_code_html. 11539 // For CTA-like buttons, convert to an anchor while 11540 // preserving classes, children, and safe generic attrs. 11541 let owner = doc.ownerDocument || document; 11542 let anchor = owner.createElement('a'); 11543 Array.prototype.slice.call(target.attributes || []).forEach((attr) => { 11544 let name = String(attr.name || '').toLowerCase(); 11545 if (name === 'type' || name === 'disabled' || name.indexOf('form') === 0) { 11546 return; 11547 } 11548 if (name.indexOf('on') === 0 || name === 'data-cs-href') { 11549 return; 11550 } 11551 anchor.setAttribute(attr.name, attr.value); 11552 }); 11553 anchor.setAttribute('href', url); 11554 while (target.firstChild) { 11555 anchor.appendChild(target.firstChild); 11556 } 11557 if (target.parentNode) { 11558 target.parentNode.replaceChild(anchor, target); 11559 } 11560 return true; 11561 }, 11562 11563 // Apply the edit against the parsed source and persist. 11564 // Multi-line input becomes textNode + <br> + textNode + ... 11565 // so it round-trips to the same lines on next read. 11566 commit_text_edit(path, new_value, link_path, url_value) { 11567 if (!path) { 11568 return; 11569 } 11570 let safe_value = typeof new_value === 'string' ? new_value : ''; 11571 let mutated = this.mutate_persisted_html((doc) => { 11572 let target = this.find_text_element_by_path(doc, path); 11573 if (!target || target.nodeType !== 1) { 11574 return false; 11575 } 11576 while (target.firstChild) { 11577 target.removeChild(target.firstChild); 11578 } 11579 let lines = safe_value.split('\n'); 11580 let owner = doc.ownerDocument || document; 11581 lines.forEach((line, index) => { 11582 if (index > 0) { 11583 target.appendChild(owner.createElement('br')); 11584 } 11585 if (line.length) { 11586 target.appendChild(owner.createTextNode(line)); 11587 } 11588 }); 11589 if (link_path) { 11590 this.apply_text_edit_url(doc, link_path, url_value); 11591 } 11592 return true; 11593 }); 11594 if (mutated !== null) { 11595 this.persist_mutated_code(mutated); 11596 } 11597 }, 11598 11599 // Called from ai-mcp-panel.html. Pushes AI-generated HTML 11600 // into CodeMirror, persists via save_changes_for_component, 11601 // and returns the prior editor value so the panel can offer 11602 // Undo for auto-applied responses. 11603 // 11604 // Streaming-aware: when we've been driving the editor from 11605 // a live stream, the canonical "previous_code" the panel 11606 // wants is the snapshot we took at begin_streaming_code, 11607 // NOT the streamed-in code currently in the buffer. 11608 apply_generated_code(code, options) { 11609 if (typeof code !== 'string') { 11610 return null; 11611 } 11612 this.close_text_edit_popover(); 11613 let opts = options || {}; 11614 if (opts.manual_apply || opts.auto) { 11615 this.clear_ai_streaming_starting_state(); 11616 this.ai_streaming = false;
11617 this.release_ai_loading_pane_height_after_render(); 11618 if (opts.manual_apply || this.ai_streaming_status === 'streaming' || this.ai_streaming_status === 'queued') { 11619 this.ai_streaming_status = 'done'; 11620 } 11621 this.ai_streaming_buffer = ''; 11622 this._last_flushed_buffer_len = -1; 11623 this.ai_streaming_child_min_heights = []; 11624 if (this._codegen_stream_raf) { 11625 cancelAnimationFrame(this._codegen_stream_raf); 11626 this._codegen_stream_raf = null; 11627 } 11628 this.ai_stream_event_tick = (this.ai_stream_event_tick + 1) | 0; 11629 } 11630 let previous_code; 11631 if (this.ai_streaming_pre_code !== null && this.ai_streaming_pre_code !== undefined) { 11632 previous_code = this.ai_streaming_pre_code; 11633 this.ai_streaming_pre_code = null; 11634 } else { 11635 previous_code = this.get_current_code(); 11636 } 11637 let safe_code = this.sanitize_custom_code_html(code); 11638 if (this.cm_instance) { 11639 this._suppress_change_save = true; 11640 this.cm_instance.setValue(safe_code); 11641 this._suppress_change_save = false; 11642 } 11643 this.custom_code_value = safe_code; 11644 this.publish_current_code_to_store(); 11645 this.save_ai_applied_custom_code(safe_code); 11646 this.mark_ai_code_revision_changed(); 11647 if (opts.switch_to_preview !== false) { 11648 this.active_tab = 'preview'; 11649 } 11650 this.$nextTick(() => this.initialize_custom_code_interactions()); 11651 return previous_code; 11652 }, 11653 save_ai_applied_custom_code(code) { 11654 if (!this.component) { 11655 return; 11656 } 11657 let config_latest = this.component.get('config_latest') || this.component.get('config') || {}; 11658 let next_config_latest = Object.assign({}, config_latest, { custom_code: code }); 11659 let ai_description = this.extract_ai_description_from_code(code); 11660 if (ai_description) { 11661 next_config_latest.ai_description = ai_description; 11662 } 11663 this.component.set('config_latest', next_config_latest); 11664 if (typeof save_changes_for_component === 'function') { 11665 save_changes_for_component(this.component, 'custom_code', code); 11666 return; 11667 } 11668 this.component.set('unsaved_changes', true); 11669 this.component.save().catch((error) => { 11670 console.warn('[custom-code] AI custom code save failed:', error && error.message); 11671 }); 11672 }, 11673 11674 // âââ Codegen stream subscription ââââââââââââââââââââââ 11675 // 11676 // Subscribes to store.ai_streams for this component's key. 11677 // The component owns the editor + preview, so it's the 11678 // right place to handle code-side events directly â the 11679 // panel only cares about message text + tool calls. 11680 subscribe_to_codegen_stream() { 11681 if (typeof store === 'undefined' || !store.ai_streams) { 11682 return; 11683 } 11684 let component_id = this.component && this.component.id; 11685 if (!component_id) { 11686 return; 11687 } 11688 if (typeof this._codegen_stream_unsubscribe === 'function') { 11689 try { 11690 this._codegen_stream_unsubscribe(); 11691 } catch (e) {} 11692 this._codegen_stream_unsubscribe = null; 11693 } 11694 let key = 'codegen:' + component_id; 11695 let me = this; 11696 this._codegen_stream_unsubscribe = store.ai_streams.subscribe(key, function (event_type, payload) { 11697 me.handle_codegen_stream_event(event_type, payload); 11698 }); 11699 // If we're (re-)subscribing into an already-terminal 11700 // entry, the synchronous `replay` event above won't 11701 // unwind any stuck loading flags on its own (replay 11702 // for status='done' is intentionally lightweight to 11703 // avoid re-touching the editor). Hard-converge here 11704 // so a stale "Generatingâ¦" label clears on the next 11705 // mount / panel-open. 11706 let entry = (store.ai_streams.get && store.ai_streams.get(key)) || null; 11707 if (entry && (entry.status === 'done' || entry.status === 'error' || entry.status === 'aborted_local')) { 11708 let needs_clear = this.ai_streaming 11709 || this.ai_streaming_status === 'streaming' 11710 || this.ai_streaming_status === 'queued'; 11711 if (needs_clear) { 11712 this.force_clear_ai_loading_state(); 11713 } 11714 } 11715 }, 11716 handle_codegen_stream_event(event_type, payload) { 11717 let safe = payload || {}; 11718 this.ai_stream_event_tick = (this.ai_stream_event_tick + 1) | 0; 11719 switch (event_type) { 11720 case 'replay': 11721 // Late subscriber catch-up. If a stream is 11722 // already in flight we lock the editor + flush 11723 // the accumulated buffer in one shot so the 11724 // preview catches up before the next delta. 11725 if (safe.status === 'streaming' || safe.status === 'queued') { 11726 this.capture_ai_loading_pane_height(); 11727 this.clear_ai_streaming_starting_state(); 11728 if (safe.response_json_buffer) { 11729 this._response_json_buffer = ''; 11730 this._response_json_html = ''; 11731 this._append_split_ready = false;
11732 this._append_head = ''; 11733 this._append_tail = ''; 11734 this.handle_codegen_response_json_delta({ text: safe.response_json_buffer }); 11735 } else if (!this.ai_streaming && safe.code_buffer) { 11736 this.begin_streaming_code(safe.code_buffer || ''); 11737 } else if (safe.code_buffer) { 11738 this.ai_streaming_buffer = safe.code_buffer; 11739 this.schedule_streaming_flush(); 11740 } 11741 this.ai_streaming_status = safe.status; 11742 } else if (safe.status === 'done' && safe.code_buffer) { 11743 // Parent-agent loopback streams are passive: a new 11744 // section may mount only after the child stream has 11745 // already settled. Replay the final buffered HTML so 11746 // the preview catches up even before the page refresh 11747 // reconciles the persisted component. 11748 this.clear_ai_streaming_starting_state(); 11749 this.force_clear_ai_loading_state(); 11750 let replay_code = safe.code_buffer || ''; 11751 if (this.cm_instance && replay_code !== this.cm_instance.getValue()) { 11752 this._suppress_change_save = true; 11753 this.cm_instance.setValue(replay_code); 11754 this._suppress_change_save = false; 11755 } 11756 this.custom_code_value = replay_code; 11757 this.publish_current_code_to_store(); 11758 this.$nextTick(() => this.initialize_custom_code_interactions()); 11759 } else if (safe.final && safe.status === 'done') { 11760 // We mounted right after the standalone stream 11761 // finished â make sure chrome is clear without 11762 // touching the canonical editor value. 11763 this.clear_ai_streaming_starting_state(); 11764 } 11765 break; 11766 case 'status': 11767 this.clear_ai_streaming_starting_state(); 11768 if (safe.status === 'streaming' || safe.status === 'queued') { 11769 this.capture_ai_loading_pane_height(); 11770 } 11771 if (safe.status === 'queued') { 11772 // Show queued chrome without engaging the 11773 // editor lock â nothing is actually being 11774 // generated yet. 11775 this.ai_streaming_status = 'queued'; 11776 } 11777 if (safe.status === 'aborted_local' && this.ai_streaming) { 11778 this.cancel_streaming({ initiated_remotely: true }); 11779 } 11780 break; 11781 case 'code_delta': 11782 if (safe.component_id && this.component && safe.component_id !== this.component.id) { 11783 break; 11784 } 11785 this.clear_ai_streaming_starting_state(); 11786 if (typeof safe.text !== 'string' || !safe.text.length) { 11787 break; 11788 } 11789 if (safe.atomic) { 11790 if (!this.ai_streaming) { 11791 this.begin_streaming_code(safe.text); 11792 } else { 11793 this.ai_streaming_buffer = safe.text; 11794 this.schedule_streaming_flush(); 11795 } 11796 break; 11797 } 11798 if (!this.ai_streaming) { 11799 this.begin_streaming_code(''); 11800 } 11801 this.append_streaming_code(safe.text); 11802 break; 11803 case 'response_json_delta': 11804 this.handle_codegen_response_json_delta(safe); 11805 break; 11806 case 'new_section_created': 11807 if (safe.new_component && this.component && safe.new_component.id !== this.component.id) { 11808 this.force_clear_ai_loading_state(); 11809 } 11810 break; 11811 case 'agent_settle': 11812 // Parent webbuilder-agent build finished/failed. The 11813 // live preview already morphed in the full document via 11814 // the forwarded deltas, so just clear the loading 11815 // overlay WITHOUT a client save â the agent's 11816 // authoritative refresh re-pull reconciles the canonical 11817 // HTML (incl. post-build image-import URL swaps). 11818 this.clear_ai_streaming_starting_state(); 11819 this.force_clear_ai_loading_state(); 11820 if (safe.error) { 11821 this.ai_streaming_status = 'error'; 11822 } 11823 break; 11824 case 'stream_reset': 11825 this.clear_ai_streaming_starting_state(); 11826 this.ai_streaming_buffer = ''; 11827 this._response_json_buffer = ''; 11828 this._response_json_html = ''; 11829 this._append_split_ready = false;
11830 this._append_head = ''; 11831 this._append_tail = ''; 11832 if (this.ai_streaming) { 11833 let restore_code = (this.ai_streaming_pre_code !== null && this.ai_streaming_pre_code !== undefined) 11834 ? this.ai_streaming_pre_code 11835 : this.get_current_code(); 11836 if (this.cm_instance) { 11837 if (this.cm_instance.getValue() !== restore_code) { 11838 this._suppress_change_save = true; 11839 this.cm_instance.setValue(restore_code); 11840 this._suppress_change_save = false; 11841 } 11842 this.cm_instance.setOption('readOnly', false); 11843 } 11844 this.custom_code_value = restore_code; 11845 this.publish_current_code_to_store(); 11846 this.ai_streaming = false; 11847 this.ai_streaming_status = 'streaming'; 11848 }
11849 // Wipe the morph preview too â next deltas 11850 // re-build it from a clean slate. 11851 let stream_root = this.$refs.custom_code_stream_preview; 11852 if (stream_root) { 11853 while (stream_root.firstChild) { 11854 stream_root.removeChild(stream_root.firstChild); 11855 } 11856 } 11857 break; 11858 case 'done': 11859 this.clear_ai_streaming_starting_state(); 11860 if (safe.edit_mode === 'new_section' || safe.edit_mode === 'new_sections') { 11861 this.finish_streaming_code(null); 11862 break; 11863 } 11864 let final_code = null; 11865 if (typeof safe.code === 'string') { 11866 final_code = safe.code; 11867 } else if (safe.result && typeof safe.result.code === 'string') { 11868 final_code = safe.result.code; 11869 } 11870 this.finish_streaming_code(final_code); 11871 break; 11872 case 'error': 11873 this.clear_ai_streaming_starting_state(); 11874 // Leave the partial buffer in place but flip 11875 // the overlay so the admin can dismiss. 11876 this.ai_streaming_status = 'error'; 11877 if (this.ai_streaming) { 11878 this.snap_streaming_done({ error: true }); 11879 } 11880 break; 11881 default: 11882 break; 11883 } 11884 }, 11885 handle_codegen_response_json_delta(payload) { 11886 if (!payload || typeof payload.text !== 'string') { 11887 return; 11888 } 11889 this._response_json_buffer = (this._response_json_buffer || '') + payload.text; 11890 let tool_name = this.detect_streamed_response_action_name(this._response_json_buffer); 11891 if (tool_name === 'set_html') { 11892 this.stream_set_html_from_buffer(); 11893 return; 11894 } 11895 if (tool_name === 'append_html') { 11896 this.stream_append_html_from_buffer(); 11897 return; 11898 } 11899 }, 11900 // Live set_html: the streamed `html` field IS the whole section, so 11901 // forward each new tail slice as a code delta. 11902 stream_set_html_from_buffer() { 11903 let extracted = this.extract_streamed_json_string_field(this._response_json_buffer, 'html'); 11904 if (!extracted || !extracted.value) { 11905 return; 11906 } 11907 let previous = this._response_json_html || ''; 11908 if (extracted.value.length <= previous.length) { 11909 return; 11910 } 11911 let delta = extracted.value.slice(previous.length); 11912 this._response_json_html = extracted.value; 11913 this.clear_ai_streaming_starting_state(); 11914 if (!this.ai_streaming) { 11915 this.begin_streaming_code(''); 11916 } 11917 this.append_streaming_code(delta); 11918 }, 11919 // Live append_html: the streamed `html` field is only the NEW items 11920 // (a fragment). Reconstruct the whole document each delta as 11921 // head + fragment + tail, split at the <!--cs-append-anchor--> so 11922 // the new items render in place while existing items stay stable. 11923 stream_append_html_from_buffer() { 11924 let extracted = this.extract_streamed_json_string_field(this._response_json_buffer, 'html'); 11925 if (!extracted) { 11926 return; 11927 } 11928 let fragment = extracted.value || ''; 11929 if (!this._append_split_ready) { 11930 let code = this.get_current_code() || ''; 11931 let anchor_index = code.indexOf('<!--cs-append-anchor-->'); 11932 if (anchor_index >= 0) { 11933 this._append_head = code.slice(0, anchor_index); 11934 this._append_tail = code.slice(anchor_index); 11935 } else { 11936 this._append_head = code; 11937 this._append_tail = ''; 11938 } 11939 this._append_split_ready = true; 11940 } 11941 this.clear_ai_streaming_starting_state(); 11942 if (!this.ai_streaming) { 11943 this.begin_streaming_code(''); 11944 } 11945 // Fragment grows in the MIDDLE, so set the whole buffer (not an 11946 // append) and let the preview morph reconcile against it. 11947 this.ai_streaming_buffer = this._append_head + fragment + this._append_tail; 11948 this.schedule_streaming_flush(); 11949 }, 11950 detect_streamed_response_action_name(buffer) { 11951 let match = /"name"\s*:\s*"(set_html|patch_html|append_html|create_section|propose_sections)"/.exec(buffer || ''); 11952 if (!match) { 11953 return null; 11954 } 11955 return match[1]; 11956 }, 11957 extract_streamed_json_string_field(buffer, field_name) { 11958 if (!buffer || !field_name) { 11959 return null; 11960 } 11961 let target = '"' + field_name + '"'; 11962 let key_index = buffer.indexOf(target); 11963 if (key_index === -1) { 11964 return null; 11965 } 11966 let colon_index = buffer.indexOf(':', key_index + target.length); 11967 if (colon_index === -1) { 11968 return null; 11969 } 11970 let quote_index = buffer.indexOf('"', colon_index + 1); 11971 if (quote_index === -1) { 11972 return null; 11973 } 11974 let value = ''; 11975 let escaping = false;
11976 let unicode_remaining = 0; 11977 let unicode_buffer = ''; 11978 for (let i = quote_index + 1; i < buffer.length; i++) { 11979 let character = buffer.charAt(i); 11980 if (unicode_remaining > 0) { 11981 unicode_buffer += character; 11982 unicode_remaining--; 11983 if (unicode_remaining === 0) { 11984 let code = parseInt(unicode_buffer, 16); 11985 if (Number.isFinite(code)) { 11986 value += String.fromCharCode(code); 11987 } 11988 unicode_buffer = ''; 11989 } 11990 continue; 11991 } 11992 if (escaping) { 11993 escaping = false; 11994 if (character === 'u') { 11995 unicode_remaining = 4; 11996 unicode_buffer = ''; 11997 } else { 11998 value += this.decode_streamed_json_escape(character); 11999 } 12000 continue; 12001 } 12002 if (character === '\\') { 12003 escaping = true; 12004 continue; 12005 } 12006 if (character === '"') { 12007 return { 12008 value: value, 12009 closed: true, 12010 }; 12011 } 12012 value += character; 12013 } 12014 return { 12015 value: value, 12016 closed: false, 12017 }; 12018 }, 12019 decode_streamed_json_escape(character) { 12020 if (character === 'n') { 12021 return '\n'; 12022 } 12023 if (character === 'r') { 12024 return '\r'; 12025 } 12026 if (character === 't') { 12027 return '\t'; 12028 } 12029 if (character === 'b') { 12030 return '\b'; 12031 } 12032 if (character === 'f') { 12033 return '\f'; 12034 } 12035 return character; 12036 }, 12037 // Lock the editor, snapshot prior code for Undo, switch to 12038 // preview tab. `initial_buffer` is the accumulated code we 12039 // missed if subscribing mid-stream â it seeds the buffer 12040 // and triggers an immediate flush. 12041 begin_streaming_code(initial_buffer) { 12042 if (this.ai_streaming) { 12043 return; 12044 } 12045 this.capture_ai_loading_pane_height(); 12046 this.clear_ai_streaming_starting_state(); 12047 this.ai_streaming = true; 12048 this.ai_streaming_status = 'streaming'; 12049 this.ai_streaming_pre_code = this.get_current_code(); 12050 this.ai_streaming_buffer = (typeof initial_buffer === 'string') ? initial_buffer : ''; 12051 if (this.cm_instance) { 12052 this.cm_instance.setOption('readOnly', 'nocursor'); 12053 } 12054 // Switch to preview so the admin sees the morph render 12055 // by default â most builds are done from preview anyway. 12056 this.active_tab = 'preview'; 12057 this.close_text_edit_popover(); 12058 if (this.ai_streaming_buffer) { 12059 this.schedule_streaming_flush(); 12060 } 12061 }, 12062 append_streaming_code(chunk) { 12063 this.ai_streaming_buffer += chunk; 12064 this.schedule_streaming_flush(); 12065 }, 12066 // Coalesce many code_delta events into one DOM update per 12067 // animation frame. cheap and keeps streaming responsive 12068 // even on chatty 100-chunk-per-second responses. 12069 schedule_streaming_flush() { 12070 if (this._codegen_stream_raf) { 12071 return; 12072 } 12073 let me = this; 12074 this._codegen_stream_raf = requestAnimationFrame(function () { 12075 me._codegen_stream_raf = null; 12076 me.flush_streaming_panes(); 12077 }); 12078 }, 12079 flush_streaming_panes() { 12080 if (!this.ai_streaming) { 12081 return; 12082 } 12083 let buffer = this.ai_streaming_buffer || ''; 12084 // Cheap stable check â avoids reflushing when the buffer length hasn't changed. 12085 if (this._last_flushed_buffer_len === buffer.length) { 12086 return; 12087 } 12088 this._last_flushed_buffer_len = buffer.length; 12089 this.flush_streaming_edit_pane(buffer); 12090 this.flush_streaming_preview_pane(buffer); 12091 }, 12092 flush_streaming_edit_pane(buffer) { 12093 if (!this.cm_instance) { 12094 return; 12095 } 12096 // setValue replaces the doc atomically. That's fine 12097 // for streaming because the doc is not user-editable 12098 // (readOnly: 'nocursor') and the buffer is always 12099 // strictly-grown â selection / scroll resets are 12100 // unavoidable but visually acceptable. 12101 let current = this.cm_instance.getValue(); 12102 if (current === buffer) { 12103 return; 12104 } 12105 this._suppress_change_save = true; 12106 this.cm_instance.setValue(buffer); 12107 this._suppress_change_save = false;
12108 this.custom_code_value = buffer; 12109 this.publish_current_code_to_store(); 12110 }, 12111 // Sequential morph: parse only the stable-prefix (chars up 12112 // through the last '>') of the buffer into a detached 12113 // tree, then walk top-level children in parallel against 12114 // the live preview root. Append-or-replace by index so 12115 // already-rendered sections stay stable while new ones 12116 // stream in below them. 12117 flush_streaming_preview_pane(buffer) { 12118 let root = this.$refs.custom_code_stream_preview; 12119 if (!root) { 12120 return; 12121 } 12122 let last_gt = buffer.lastIndexOf('>'); 12123 if (last_gt < 0) { 12124 // Nothing parseable yet (we haven't even seen one 12125 // closing bracket) â defer the flush. 12126 return; 12127 } 12128 let stable_prefix = buffer.slice(0, last_gt + 1); 12129 // Sanitize first so the morph never plants scripts / 12130 // inline handlers into the live DOM. Same function 12131 // safe_custom_code_value uses for the static pane. 12132 let safe_html = this.sanitize_custom_code_html(stable_prefix); 12133 let template_el = document.createElement('template'); 12134 template_el.innerHTML = safe_html; 12135 let new_nodes = template_el.content.childNodes; 12136 let live_nodes = root.childNodes; 12137 let i = 0; 12138 let max = new_nodes.length; 12139 // Track whether this flush appended brand-new top-level 12140 // nodes. If so the section is growing (new HTML in a 12141 // new_section / full build) and the page should follow 12142 // the bottom. If only existing nodes were morphed in 12143 // place (edit / edits flows replacing content) the 12144 // scroll position should stay put. 12145 let appended_new_node = false; 12146 let last_appended_node = null; 12147 while (i < max) { 12148 let new_node = new_nodes[i]; 12149 let live_node = live_nodes[i]; 12150 let next_node = new_node.cloneNode(true); 12151 this.apply_streaming_child_min_height(next_node, i); 12152 if (!live_node) { 12153 // Append-only path â most common during forward 12154 // streaming. next_node is cloned because new_node lives 12155 // in a <template> document fragment we don't 12156 // want to consume. 12157 root.appendChild(next_node); 12158 appended_new_node = true; 12159 last_appended_node = next_node; 12160 } else { 12161 let new_html = (next_node.outerHTML !== undefined) 12162 ? next_node.outerHTML 12163 : (next_node.textContent || ''); 12164 let live_html = (live_node.outerHTML !== undefined) 12165 ? live_node.outerHTML 12166 : (live_node.textContent || ''); 12167 if (new_html !== live_html) { 12168 root.replaceChild(next_node, live_node); 12169 } 12170 } 12171 i++; 12172 } 12173 // Trim any leftover live nodes â happens on stream_reset 12174 // (we wipe in the handler so this is rare) and on the 12175 // edge case where the model deletes trailing content. 12176 while (live_nodes.length > max) { 12177 root.removeChild(live_nodes[live_nodes.length - 1]); 12178 } 12179 this.$nextTick(() => this.initialize_custom_code_interactions()); 12180 // Forward-streaming auto-follow: new top-level nodes 12181 // were appended this frame, so the user is watching new 12182 // content land. Ask the panel to smooth-scroll the 12183 // window so the bottom of the newly-appended chunk
12184 // stays in view. Edits/replacements skip this branch 12185 // because their scroll position should be preserved. 12186 if (appended_new_node && last_appended_node) { 12187 let panel = (typeof store !== 'undefined') ? store.ai_mcp_panel_component_instance : null; 12188 if (panel && typeof panel.schedule_smooth_scroll_to_streaming_bottom === 'function') { 12189 let scroll_target = (last_appended_node.nodeType === 1) 12190 ? last_appended_node 12191 : root; 12192 panel.schedule_smooth_scroll_to_streaming_bottom(scroll_target); 12193 } 12194 } 12195 }, 12196 apply_streaming_child_min_height(node, index) { 12197 if (!node || node.nodeType !== 1) { 12198 return; 12199 } 12200 let min_height = this.ai_streaming_child_min_heights[index] || 0; 12201 if (min_height <= 0) { 12202 return; 12203 } 12204 node.style.minHeight = min_height + 'px'; 12205 }, 12206 // Snap streaming closed and run the canonical save. Called 12207 // from the SSE `done` event handler. Idempotent â a 12208 // duplicate `done` (e.g. from a cancel race) is safe. 12209 finish_streaming_code(final_code) { 12210 if (typeof final_code !== 'string' && !(this.ai_streaming_buffer || '').trim()) { 12211 this.clear_ai_streaming_starting_state(); 12212 this.ai_streaming = false; 12213 this.release_ai_loading_pane_height_after_render(); 12214 this.ai_streaming_status = 'done'; 12215 this.ai_streaming_buffer = ''; 12216 this._last_flushed_buffer_len = -1; 12217 this.ai_streaming_child_min_heights = []; 12218 if (this._codegen_stream_raf) { 12219 cancelAnimationFrame(this._codegen_stream_raf); 12220 this._codegen_stream_raf = null; 12221 } 12222 if (this.cm_instance) { 12223 this.cm_instance.setOption('readOnly', false); 12224 } 12225 this.ai_stream_event_tick = (this.ai_stream_event_tick + 1) | 0; 12226 return; 12227 } 12228 if (!this.ai_streaming && (this.ai_streaming_status !== 'done')) { 12229 // Late `done` for a stream we never engaged with â 12230 // usually because the panel auto-applied via the 12231 // separate apply_generated_code path. Still settle 12232 // local loading UI so the launcher / pending overlay 12233 // don't get stranded in "Generatingâ¦". 12234 this.clear_ai_streaming_starting_state(); 12235 this.ai_streaming = false; 12236 this.release_ai_loading_pane_height_after_render(); 12237 this.ai_streaming_status = 'done'; 12238 this.ai_streaming_buffer = ''; 12239 this._last_flushed_buffer_len = -1; 12240 this.ai_streaming_child_min_heights = []; 12241 if (this._codegen_stream_raf) { 12242 cancelAnimationFrame(this._codegen_stream_raf); 12243 this._codegen_stream_raf = null; 12244 } 12245 return; 12246 } 12247 this.snap_streaming_done({ final_code: final_code }); 12248 }, 12249 snap_streaming_done(options) { 12250 let opts = options || {}; 12251 let canonical = (typeof opts.final_code === 'string') 12252 ? this.sanitize_custom_code_html(opts.final_code) 12253 : (this.ai_streaming_buffer || ''); 12254 // Editor + persistence writes can throw (CodeMirror 12255 // teardown race, save failures, etc). Wrap so the 12256 // loading-state convergence in `finally` still runs 12257 // and the launcher never gets stranded in 12258 // "Generatingâ¦". 12259 try { 12260 if (this.cm_instance) { 12261 if (this.cm_instance.getValue() !== canonical) { 12262 this._suppress_change_save = true; 12263 this.cm_instance.setValue(canonical); 12264 this._suppress_change_save = false;
12265 } 12266 this.cm_instance.setOption('readOnly', false); 12267 } 12268 this.custom_code_value = canonical; 12269 this.publish_current_code_to_store(); 12270 this.save_ai_applied_custom_code(canonical); 12271 this.mark_ai_code_revision_changed(); 12272 } finally { 12273 this.ai_streaming = false; 12274 this.release_ai_loading_pane_height_after_render(); 12275 this.ai_streaming_status = opts.canceled ? 'aborted_local' : (opts.error ? 'error' : 'done'); 12276 this.ai_streaming_buffer = ''; 12277 this._last_flushed_buffer_len = -1; 12278 this.ai_streaming_child_min_heights = []; 12279 if (this._codegen_stream_raf) { 12280 cancelAnimationFrame(this._codegen_stream_raf); 12281 this._codegen_stream_raf = null; 12282 } 12283 this.ai_stream_event_tick = (this.ai_stream_event_tick + 1) | 0; 12284 } 12285 // Defer the snapshot teardown â the chat panel's `done`
12286 // subscriber will consume it via apply_generated_code in 12287 // the same JS task. Clearing after 5s guards against 12288 // a stale-message Apply click later misreading it as 12289 // the live editor's previous_code. 12290 if (this._clear_pre_code_timer) { 12291 clearTimeout(this._clear_pre_code_timer); 12292 } 12293 this._clear_pre_code_timer = setTimeout(() => { 12294 this.ai_streaming_pre_code = null; 12295 this._clear_pre_code_timer = null; 12296 }, 5000); 12297 this.$nextTick(() => this.initialize_custom_code_interactions()); 12298 }, 12299 // Local soft-cancel. Tells the streams manager to stop 12300 // emitting deltas to subscribers (the upstream LLM keeps 12301 // running for billing + persistence), then snaps our 12302 // local view of the editor to whatever we have so far. 12303 cancel_streaming(options) { 12304 let opts = options || {}; 12305 if (!opts.initiated_remotely && typeof store !== 'undefined' && store.ai_streams) { 12306 let component_id = this.component && this.component.id; 12307 if (component_id) { 12308 store.ai_streams.cancel_local('codegen:' + component_id); 12309 } 12310 } 12311 this.snap_streaming_done({ canceled: true }); 12312 }, 12313 // Echo the current editor code into the shared store so 12314 // the chat panel's apply-state badge can diff against it 12315 // reactively. Setting on `store` directly is safe â the 12316 // panel uses a manual tick for reactivity anyway, and the 12317 // value is also surfaced via a computed in the panel. 12318 publish_current_code_to_store() { 12319 if (typeof store === 'undefined') { 12320 return; 12321 } 12322 if (this.component) { 12323 let config_latest = this.component.get('config_latest') || {}; 12324 let next_config_latest = Object.assign({}, config_latest, { 12325 custom_code: this.custom_code_value || '', 12326 }); 12327 let ai_description = this.extract_ai_description_from_code(this.custom_code_value || ''); 12328 if (ai_description) { 12329 next_config_latest.ai_description = ai_description; 12330 } 12331 this.component.set('config_latest', next_config_latest); 12332 // Do NOT bump store.component_updates here. That field 12333 // drives the `last_update` prop, whose watcher calls 12334 // back into this method â bumping it would re-trigger 12335 // the watcher and spin a reactive feedback loop on 12336 // every edit (runaway re-render â tab hang). The 12337 // canonical save path (save_changes_for_component) 12338 // owns the re-render signal; the live preview here 12339 // re-renders off custom_code_value directly. 12340 if (this.component.id === store.webbuilder_code_active_component_id) { 12341 store.webbuilder_code_active_component_revision = Date.now(); 12342 } 12343 } 12344 if (typeof store.webbuilder_code_active_component_id !== 'undefined' 12345 && this.component 12346 && this.component.id === store.webbuilder_code_active_component_id) { 12347 store.webbuilder_code_current_code = this.custom_code_value || ''; 12348 } 12349 }, 12350 // Mirror this section's active tab (edit/preview) into the shared 12351 // store so the per-section AI launcher (toolbar-option-component) 12352 // can show "Edit with AI" only while the code editor is open. 12353 publish_active_tab_to_store() { 12354 if (typeof store === 'undefined' || !this.preview_element) { 12355 return; 12356 } 12357 let cid = (this.component && typeof this.component.get === 'function') 12358 ? this.component.get('client_id') 12359 : null; 12360 if (!cid) { 12361 return; 12362 } 12363 if (!store.webbuilder_custom_code_tab_by_client_id) { 12364 store.webbuilder_custom_code_tab_by_client_id = {}; 12365 } 12366 store.webbuilder_custom_code_tab_by_client_id[cid] = this.active_tab; 12367 }, 12368 extract_ai_description_from_code(code) { 12369 if (typeof code !== 'string') { 12370 return ''; 12371 } 12372 let description_regex = new RegExp("^\\s*<!--\\s*cs-ai-description:\\s*([\\s\\S]*?)-->", "i"); 12373 let description_match = code.match(description_regex); 12374 if (!description_match || !description_match[1]) { 12375 return ''; 12376 } 12377 return description_match[1].replace(/\s+/g, ' ').trim().slice(0, 160); 12378 }, 12379 }, 12380 }); 12381</script>
12381 12382 12383<style> 12384 .cs-custom-code-wrap{ 12385 position: relative !important; 12386 isolation: isolate; 12387 z-index: 1; 12388 overflow: visible; 12389 } 12390 /* Overlap masks clip short/non-visual sections (script pixels, etc.). */ 12391 .componentPreview > .cs-custom-code-wrap.custom-code-preview-blank{ 12392 -webkit-mask-image: none !important; 12393 mask-image: none !important; 12394 } 12395 .cs-custom-code-live-host{ 12396 display: block; 12397 width: 100%; 12398 } 12399 .cs-custom-code-wrap.first-page-section{ 12400 isolation: auto; 12401 z-index: auto; 12402 } 12403 .cs-custom-code-wrap.active-ai-thread-target{ 12404 z-index: 3; 12405 } 12406 .cs-custom-code-wrap.active-ai-thread-target::after{ 12407 content: ""; 12408 position: absolute; 12409 inset: 0; 12410 z-index: 120; 12411 pointer-events: none; 12412 border: 3px solid rgba(74, 86, 248, 0.62); 12413 box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.75), 0 0 0 4px rgba(74, 86, 248, 0.12); 12414 } 12415 12416 .custom-code-ai-launcher{ 12417 position: absolute !important; 12418 left: auto !important; 12419 right: 125px !important; 12420 bottom: calc(20px + var(--cs-overlap-chrome-lift, 0px)) !important; 12421 z-index: 99 !important; 12422 display: inline-flex !important; 12423 align-items: center !important; 12424 justify-content: center !important; 12425 gap: 8px !important; 12426 pointer-events: auto !important; 12427 touch-action: manipulation !important; 12428 width: auto !important; 12429 min-width: 0 !important; 12430 height: auto !important; 12431 min-height: 45px !important; 12432 max-height: none !important; 12433 padding: 0 15px !important; 12434 border-radius: 6px !important; 12435 background: linear-gradient(135deg, #4a56f8 0%, #7b58ff 100%) !important; 12436 color: #fff !important; 12437 border: none !important; 12438 font-family: Roboto, Arial, Helvetica, sans-serif !important; 12439 font-size: 14px !important; 12440 font-weight: 600 !important; 12441 font-style: normal !important; 12442 line-height: 45px !important; 12443 letter-spacing: 0 !important; 12444 text-transform: none !important; 12445 white-space: nowrap !important; 12446 box-shadow: 0 12px 40px rgba(40, 40, 41, 0.05), 0 0 0 1px rgba(0, 0, 0, 0.01) !important; 12447 transition: transform 0.12s ease, box-shadow 0.12s ease !important; 12448 } 12449 .custom-code-ai-launcher.has-active-stream{ 12450 box-shadow: 0 12px 40px rgba(74, 86, 248, 0.35), 0 0 0 1px rgba(74, 86, 248, 0.25) !important; 12451 } 12452 .custom-code-ai-launcher-spinner{ 12453 display: inline-block; 12454 width: 12px; 12455 height: 12px; 12456 border-radius: 999px; 12457 border: 2px solid rgba(255,255,255,0.4); 12458 border-top-color: #fff; 12459 animation: csCustomCodeStreamSpin 0.9s linear infinite; 12460 } 12461 .custom-code-welcome-generating-spinner{ 12462 display: inline-block; 12463 width: 30px; 12464 height: 30px; 12465 border-radius: 999px; 12466 border: 3px solid rgba(74, 86, 248, 0.2); 12467 border-top-color: #4a56f8; 12468 animation: csCustomCodeStreamSpin 0.9s linear infinite; 12469 } 12470 @keyframes csCustomCodeStreamSpin{ 12471 from { transform: rotate(0deg); } 12472 to { transform: rotate(360deg); } 12473 } 12474 12475 .custom-code-tab-bar{ 12476 position: relative; 12477 z-index: 101; 12478 } 12479 .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){ 12480 padding: 0; 12481 } 12482 12483 /* Welcome composer (Build a custom section) is builder-chrome, 12484 not user-rendered hosted-site content. Hosted sites can apply 12485 a custom theme font that would otherwise cascade in here and 12486 make the heading / textarea look unbranded. Pin everything 12487 inside this pane to the Clubspot dashboard font so the 12488 builder UI is always consistent across sites. !important 12489 because some host themes set font-family with high 12490 specificity on `body *` selectors. */ 12491 .custom-code-welcome-pane, 12492 .custom-code-welcome-pane *:not(i):not([class*="fa-"]), 12493 .custom-code-welcome-pane input, 12494 .custom-code-welcome-pane textarea,
12495 .custom-code-welcome-pane button:not([class*="fa-"]){ 12496 font-family: Roboto, Arial, Helvetica, sans-serif !important; 12497 } 12498 /* Re-assert the Font Awesome stack on the icon elements inside 12499 the welcome pane. The wildcard rule above used `!important` 12500 to beat hosted-site themes that set `font-family` with high 12501 specificity on `body *`, which had the side-effect of also 12502 beating Font Awesome's own selectors (.fa-regular, .fa-solid, 12503 etc.) on the icon `<i>` tags â the icon glyphs are unicode 12504 private-use codepoints, so without the FA font-family they 12505 render as empty tofu boxes. Excluding `i` / `[class*="fa-"]` 12506 from the wildcard above lets FA's own font-family win again, 12507 and this explicit fallback covers any host theme that ALSO 12508 pinned font-family on `i` or `.fa-*` with high specificity. */ 12509 .custom-code-welcome-pane i[class*="fa-"]{ 12510 font-family: "Font Awesome 6 Pro", "Font Awesome 6 Free", "Font Awesome 5 Pro", "Font Awesome 5 Free", "FontAwesome" !important; 12511 } 12512 12513 /* Example-prompt suggestions on the welcome composer. The 12514 ai-mcp-panel.html sheet defines a heavier .ai-composer-pill 12515 look intended for the dashboard chat composer, but that 12516 file doesn't load on hosted-sites pages. Style here is 12517 intentionally low-key â dotted border + normal-weight 12518 muted text â so the suggestions read as inline hints 12519 rather than competing call-to-action buttons. Hover 12520 firms up the border + tints the surface to confirm 12521 they're clickable. */ 12522 .cs-custom-code-wrap .ai-composer-pill{ 12523 display: inline-flex; 12524 align-items: center; 12525 max-width: 100%; 12526 padding: 5px 11px; 12527 border: 1px dotted rgba(8, 19, 51, 0.18); 12528 border-radius: 8px; 12529 background: transparent; 12530 color: #7280a0; 12531 font-size: 12.5px; 12532 line-height: 18px; 12533 font-weight: 400; 12534 letter-spacing: 0; 12535 text-align: left; 12536 white-space: normal; 12537 transition: border-color 0.12s ease, background 0.12s ease, color 0.12s ease; 12538 } 12539 .cs-custom-code-wrap .ai-composer-pill:hover{ 12540 border-color: rgba(74, 86, 248, 0.45); 12541 background: rgba(74, 86, 248, 0.04); 12542 color: #4a56f8; 12543 } 12544 .cs-custom-code-wrap .ai-composer-pill:focus-visible{ 12545 outline: 2px solid rgba(74, 86, 248, 0.35); 12546 outline-offset: 1px; 12547 } 12548 12549 /* Send button on the welcome composer. The ai-mcp-panel.html 12550 sheet defines an .ai-submit-button rule too, but that file 12551 isn't loaded on hosted-sites pages â so we redeclare it 12552 locally to guarantee the blue pill renders here. 12553 - flex centering keeps the paper-plane glyph on the 12554 button's optical center 12555 - paper-plane has visual weight on the right tip, so a 12556 1px left nudge looks balanced */ 12557 .cs-custom-code-wrap .ai-submit-button{ 12558 flex: 0 0 auto; 12559 width: 36px; 12560 min-width: 36px; 12561 height: 36px; 12562 padding: 0; 12563 margin: 0; 12564 border: 0; 12565 border-radius: 999px; 12566 background: #4a56f8; 12567 color: #fff; 12568 display: inline-flex; 12569 align-items: center; 12570 justify-content: center; 12571 line-height: 1; 12572 box-shadow: 0 2px 6px rgba(74, 86, 248, 0.30); 12573 cursor: pointer; 12574 transition: background 0.12s ease, box-shadow 0.12s ease, transform 0.12s ease; 12575 } 12576 .cs-custom-code-wrap .ai-submit-button:hover{ 12577 background: #3a45e0; 12578 box-shadow: 0 4px 12px rgba(74, 86, 248, 0.40); 12579 transform: translateY(-1px); 12580 } 12581 .cs-custom-code-wrap .ai-submit-button .fa-paper-plane{ 12582 font-size: 14px; 12583 line-height: 1; 12584 transform: translateX(-1px); 12585 } 12586 .cs-custom-code-wrap .ai-submit-button.inactive, 12587 .cs-custom-code-wrap .ai-submit-button[disabled]{ 12588 background: #c4cbe2; 12589 color: #fff; 12590 box-shadow: none; 12591 cursor: not-allowed; 12592 transform: none; 12593 } 12594 .cs-custom-code-wrap .ai-submit-button.loading{ 12595 background: #4a56f8; 12596 color: #fff; 12597 cursor: progress; 12598 } 12599 12600 /* Pre-first-delta loading pane. This is intentionally a real 12601 preview pane, not an overlay: while waiting for the first code 12602 chunk, the preview window shows only the dots. The chat panel is 12603 already open for context/progress, so avoid duplicating labels or 12604 exposing the stale welcome/current preview underneath. */ 12605 .custom-code-loading-pane{ 12606 display: flex; 12607 align-items: center; 12608 justify-content: center; 12609 min-height: 420px; 12610 padding: 48px 20px; 12611 background: #fbfcff; 12612 } 12613 .custom-code-loading-pane.is-overlay{ 12614 position: absolute !important; 12615 inset: 0 !important; 12616 z-index: 80 !important; 12617 min-height: 0 !important; 12618 padding: 0 !important; 12619 background: rgba(251, 252, 255, 0.62) !important; 12620 backdrop-filter: blur(1px); 12621 pointer-events: none; 12622 } 12623 .custom-code-empty-state-pane{ 12624 display: flex; 12625 align-items: center; 12626 justify-content: center; 12627 min-height: 420px; 12628 padding: 48px 20px; 12629 background: #fbfcff; 12630 } 12631 .custom-code-nonvisual-pane{ 12632 min-height: 88px; 12633 padding: 20px 16px; 12634 } 12635 .custom-code-empty-state-copy{ 12636 display: inline-flex; 12637 align-items: center; 12638 justify-content: center; 12639 flex-wrap: wrap; 12640 gap: 8px; 12641 max-width: 100%; 12642 text-align: center; 12643 color: #9aa5ba; 12644 font-family: Roboto, Arial, Helvetica, sans-serif !important; 12645 font-size: 14px; 12646 font-style: normal; 12647 line-height: 20px; 12648 font-weight: 500; 12649 } 12650 .custom-code-empty-state-copy i{ 12651 font-size: 14px; 12652 color: #b0bad0; 12653 } 12654 .custom-code-pending-dots{ 12655 display: inline-flex; 12656 align-items: center; 12657 gap: 8px; 12658 } 12659 .custom-code-pending-dots span{ 12660 display: inline-block; 12661 width: 10px; 12662 height: 10px; 12663 border-radius: 50%; 12664 background: #4a56f8; 12665 opacity: 0.35; 12666 animation: csCustomCodePendingPulse 1.2s ease-in-out infinite both; 12667 } 12668 .custom-code-pending-dots span:nth-child(2){ animation-delay: 0.15s; } 12669 .custom-code-pending-dots span:nth-child(3){ animation-delay: 0.30s; }
12670 @keyframes csCustomCodePendingPulse{ 12671 0%, 80%, 100% { transform: scale(0.6); opacity: 0.35; } 12672 40% { transform: scale(1.0); opacity: 1.0; } 12673 } 12674 12675 .custom-code-ai-launcher:hover{ 12676 transform: translateY(-1px) !important; 12677 box-shadow: 0 3px 8px rgba(74, 86, 248, 0.34) !important; 12678 } 12679 12680 .custom-code-ai-launcher .fa-sparkles{ 12681 display: inline-block !important; 12682 flex: 0 0 auto !important; 12683 font-family: "Font Awesome 6 Pro", "Font Awesome 6 Free", "Font Awesome 5 Pro", "Font Awesome 5 Free", "FontAwesome" !important; 12684 font-size: 14px !important; 12685 line-height: 1 !important; 12686 margin: 0 7px 0 0 !important; 12687 } 12688 12689 .custom-code-ai-launcher-label{ 12690 display: inline-block !important; 12691 flex: 0 0 auto !important; 12692 font-family: Roboto, Arial, Helvetica, sans-serif !important; 12693 font-style: normal !important; 12694 letter-spacing: 0 !important; 12695 text-transform: none !important; 12696 } 12697 12698 /* 12699 Placeholder pick/upload/paste overlay. Only rendered in PREVIEW 12700 mode by `decorate_asset_placeholders` â the live (public) site 12701 never sees this class because the overlay element is injected 12702 into the rendered preview DOM and is never persisted back to 12703 the `custom_code` source. 12704 */ 12705 .cs-asset-placeholder-host{ 12706 position: relative; 12707 } 12708 .custom-code-preview-pane .cs-asset-background-placeholder-host:hover{ 12709 outline: 2px dashed rgba(74, 86, 248, 0.55) !important; 12710 outline-offset: -4px !important; 12711 } 12712 .custom-code-preview-pane .cs-asset-background-placeholder-chip{ 12713 position: absolute !important; 12714 top: 10px !important; 12715 right: 10px !important; 12716 z-index: 10000 !important; 12717 display: inline-flex !important; 12718 align-items: center !important; 12719 justify-content: center !important; 12720 width: 34px !important; 12721 height: 34px !important; 12722 padding: 0 !important; 12723 margin: 0 !important; 12724 border: 1px solid rgba(74, 86, 248, 0.45) !important; 12725 border-radius: 999px !important; 12726 background: rgba(255, 255, 255, 0.88) !important; 12727 color: #4a56f8 !important; 12728 box-shadow: 0 6px 18px rgba(8, 19, 51, 0.14) !important; 12729 cursor: pointer !important; 12730 } 12731 .custom-code-preview-pane .cs-asset-background-placeholder-chip:hover{ 12732 background: #ffffff !important; 12733 border-color: rgba(74, 86, 248, 0.85) !important; 12734 } 12735 .custom-code-preview-pane .cs-asset-placeholder-overlay{ 12736 position: absolute !important; 12737 inset: 0 !important; 12738 z-index: 125 !important; 12739 display: flex !important; 12740 flex-direction: column !important; 12741 align-items: center !important; 12742 justify-content: center !important; 12743 gap: 4px !important; 12744 margin: 0 !important; 12745 padding: 12px !important; 12746 border: 2px dashed rgba(74, 86, 248, 0.55) !important; 12747 border-radius: 8px !important; 12748 background: rgba(74, 86, 248, 0.08) !important; 12749 color: #2a2f5a !important; 12750 font-family: Roboto, Arial, Helvetica, sans-serif !important; 12751 font-size: 13px !important; 12752 font-style: normal !important; 12753 font-weight: 500 !important; 12754 line-height: 1.25 !important; 12755 letter-spacing: 0 !important; 12756 text-align: center !important; 12757 white-space: normal !important; 12758 cursor: pointer !important; 12759 backdrop-filter: blur(2px) !important; 12760 transition: background 0.12s ease, border-color 0.12s ease !important; 12761 } 12762 .custom-code-preview-pane .cs-asset-placeholder-overlay:hover{ 12763 background: rgba(74, 86, 248, 0.16) !important; 12764 border-color: rgba(74, 86, 248, 0.85) !important; 12765 } 12766 .custom-code-preview-pane .cs-asset-placeholder-icon{ 12767 display: inline-flex !important; 12768 align-items: center !important; 12769 justify-content: center !important; 12770 width: 28px !important; 12771 height: 28px !important; 12772 flex: 0 0 28px !important; 12773 margin-bottom: 4px !important; 12774 border-radius: 50% !important; 12775 background: rgba(255, 255, 255, 0.65) !important; 12776 color: #4a56f8 !important; 12777 font-size: 14px !important; 12778 font-style: normal !important; 12779 line-height: 1 !important; 12780 letter-spacing: 0 !important; 12781 } 12782 .custom-code-preview-pane .cs-asset-placeholder-label{ 12783 display: inline-block !important; 12784 padding: 4px 10px !important; 12785 border-radius: 999px !important; 12786 background: rgba(255, 255, 255, 0.92) !important; 12787 color: #2a2f5a !important; 12788 font-family: Roboto, Arial, Helvetica, sans-serif !important; 12789 font-size: 13px !important; 12790 font-style: normal !important; 12791 font-weight: 600 !important; 12792 line-height: 1.2 !important; 12793 letter-spacing: 0 !important; 12794 min-width: 118px !important; 12795 max-width: 180px !important; 12796 white-space: normal !important; 12797 } 12798 12799 /* 12800 "Change" overlay for filled placeholder slots 12801 (`[data-cs-asset-attached]`). Light-DOM fallback only â preview 12802 chrome for the isolated shadow tree is in 12803 CS_CUSTOM_CODE_PREVIEW_CHROME_CSS. Always visible, matching 12804 image-with-text / image-grid slot chips. 12805 */ 12806 .cs-asset-attached-host{ 12807 position: relative; 12808 } 12809 .custom-code-preview-pane .cs-asset-change-button{ 12810 position: absolute !important; 12811 top: 8px !important; 12812 right: 8px !important; 12813 z-index: 10000 !important; 12814 display: inline-flex !important; 12815 align-items: center !important; 12816 justify-content: center !important; 12817 padding: 6px 10px !important; 12818 margin: 0 !important; 12819 border: none !important; 12820 border-radius: 6px !important; 12821 background: rgba(20, 24, 45, 0.82) !important; 12822 color: #fff !important; 12823 font-family: Roboto, Arial, Helvetica, sans-serif !important; 12824 font-size: 12px !important; 12825 font-weight: 600 !important; 12826 line-height: 1 !important; 12827 cursor: pointer !important; 12828 opacity: 0.92 !important; 12829 pointer-events: auto !important; 12830 box-shadow: 0 4px 12px rgba(8, 19, 51, 0.22) !important; 12831 transition: opacity 0.12s ease, background 0.12s ease !important; 12832 } 12833 .custom-code-preview-pane .cs-asset-change-button:hover, 12834 .custom-code-preview-pane .cs-asset-change-button:focus{ 12835 opacity: 1 !important; 12836 background: rgba(20, 24, 45, 0.95) !important; 12837 } 12838 .custom-code-preview-pane .cs-ai-asset-hover-highlight{ 12839 outline: 2px solid rgba(74, 86, 248, 0.32) !important; 12840 outline-offset: 4px !important; 12841 box-shadow: 0 0 0 6px rgba(74, 86, 248, 0.08) !important; 12842 transition: outline-color 0.12s ease, box-shadow 0.12s ease !important; 12843 } 12844 12845 /* 12846 Click-to-edit text leaves. Only active in PREVIEW mode â the 12847 host class is added by `decorate_editable_text_nodes` after 12848 every preview re-render and never persisted to source. The 12849 hover affordance is a soft dashed outline with cursor:pointer 12850 so admins can tell the text is editable without the section 12851 feeling like a form. 12852 */ 12853 .custom-code-preview-pane .cs-text-editable-host{ 12854 cursor: text !important; 12855 position: relative !important; 12856 transition: outline-color 0.12s ease, background-color 0.12s ease !important; 12857 outline: 1px dashed transparent !important; 12858 outline-offset: 2px !important; 12859 border-radius: 3px !important; 12860 } 12861 .custom-code-preview-pane .cs-text-editable-host:hover{ 12862 outline-color: rgba(74, 86, 248, 0.55) !important; 12863 background-color: rgba(74, 86, 248, 0.06) !important; 12864 } 12865 .custom-code-preview-pane .cs-text-editable-host.cs-text-editable-active{ 12866 outline: 1px solid rgba(74, 86, 248, 0.85) !important; 12867 background-color: rgba(74, 86, 248, 0.12) !important; 12868 } 12869 12870 /* 12871 Inline text-edit popover. Lives at document.body level (fixed 12872 positioning) so it isn't clipped by overflow:hidden ancestors 12873 in the preview pane. Closed on outside click, ESC, scroll, or 12874 a Vue re-render of the preview. 12875 */ 12876 .cs-text-edit-popover{ 12877 position: fixed !important; 12878 z-index: 10000 !important; 12879 box-sizing: border-box !important; 12880 max-width: 92vw !important; 12881 padding: 10px !important; 12882 background: #fff !important; 12883 border: 1px solid rgba(20, 24, 45, 0.12) !important; 12884 border-radius: 8px !important; 12885 box-shadow: 0 18px 48px rgba(20, 24, 45, 0.18), 0 2px 6px rgba(20, 24, 45, 0.08) !important; 12886 font-family: inherit !important; 12887 font-size: 13px !important; 12888 color: #2a2f5a !important; 12889 } 12890 .cs-text-edit-popover-header{ 12891 display: block !important; 12892 margin-bottom: 6px !important; 12893 font-size: 11px !important; 12894 font-weight: 600 !important; 12895 text-transform: uppercase !important; 12896 letter-spacing: 0.04em !important; 12897 color: #4a4f6a !important; 12898 } 12899 .cs-text-edit-popover .cs-text-edit-textarea{ 12900 display: block !important; 12901 box-sizing: border-box !important; 12902 width: 100% !important; 12903 min-height: 60px !important; 12904 max-height: 240px !important; 12905 padding: 8px 10px !important; 12906 margin: 0 !important; 12907 border: 1px solid rgba(20, 24, 45, 0.18) !important; 12908 border-radius: 6px !important; 12909 background: #fff !important; 12910 color: #14182d !important; 12911 font-family: inherit !important; 12912 font-size: 13px !important; 12913 line-height: 1.4 !important; 12914 resize: vertical !important; 12915 outline: none !important; 12916 box-shadow: none !important; 12917 } 12918 .cs-text-edit-popover .cs-text-edit-textarea:focus{ 12919 border-color: rgba(74, 86, 248, 0.7) !important; 12920 box-shadow: 0 0 0 3px rgba(74, 86, 248, 0.15) !important; 12921 } 12922 .cs-text-edit-popover .cs-text-edit-url-field{ 12923 display: block !important; 12924 margin: 8px 0 0 0 !important; 12925 } 12926 .cs-text-edit-popover .cs-text-edit-url-label{ 12927 display: block !important; 12928 margin: 0 0 4px 0 !important; 12929 color: #4a4f6a !important; 12930 font-size: 11px !important; 12931 font-weight: 600 !important; 12932 line-height: 14px !important; 12933 } 12934 .cs-text-edit-popover .cs-text-edit-url-input{ 12935 display: block !important; 12936 box-sizing: border-box !important; 12937 width: 100% !important; 12938 height: 34px !important; 12939 padding: 7px 10px !important; 12940 margin: 0 !important; 12941 border: 1px solid rgba(20, 24, 45, 0.18) !important; 12942 border-radius: 6px !important; 12943 background: #fff !important; 12944 color: #14182d !important; 12945 font-family: inherit !important; 12946 font-size: 13px !important; 12947 line-height: 18px !important; 12948 outline: none !important; 12949 box-shadow: none !important; 12950 } 12951 .cs-text-edit-popover .cs-text-edit-url-input:focus{ 12952 border-color: rgba(74, 86, 248, 0.7) !important; 12953 box-shadow: 0 0 0 3px rgba(74, 86, 248, 0.15) !important; 12954 } 12955 .cs-text-edit-popover-footer{ 12956 display: flex !important; 12957 align-items: center !important; 12958 gap: 6px !important; 12959 margin-top: 8px !important; 12960 } 12961 .cs-text-edit-popover-footer-spacer{ 12962 flex: 1 1 auto !important; 12963 } 12964 .cs-text-edit-popover-footer button{ 12965 appearance: none !important; 12966 -webkit-appearance: none !important; 12967 display: inline-flex !important; 12968 align-items: center !important; 12969 justify-content: center !important; 12970 padding: 6px 12px !important; 12971 margin: 0 !important; 12972 border-radius: 6px !important; 12973 border: 1px solid transparent !important; 12974 font-family: inherit !important; 12975 font-size: 12px !important; 12976 font-weight: 600 !important; 12977 line-height: 1 !important; 12978 cursor: pointer !important; 12979 transition: background 0.12s ease, transform 0.12s ease, border-color 0.12s ease, color 0.12s ease !important; 12980 } 12981 .cs-text-edit-popover .cs-text-edit-ask{ 12982 padding: 6px 8px !important; 12983 background: transparent !important; 12984 border-color: transparent !important; 12985 color: #4a56f8 !important; 12986 gap: 6px !important; 12987 } 12988 .cs-text-edit-popover .cs-text-edit-ask:hover{ 12989 background: rgba(74, 86, 248, 0.08) !important; 12990 color: #3b46d6 !important; 12991 } 12992 .cs-text-edit-popover .cs-text-edit-ask .fa-sparkles{ 12993 font-size: 12px !important; 12994 } 12995 .cs-text-edit-popover .cs-text-edit-cancel{ 12996 background: #fff !important; 12997 border-radius: 999px !important; 12998 border-color: rgba(20, 24, 45, 0.18) !important; 12999 color: #4a4f6a !important; 13000 } 13001 .cs-text-edit-popover .cs-text-edit-cancel:hover{ 13002 background: rgba(20, 24, 45, 0.04) !important; 13003 } 13004 .cs-text-edit-popover .cs-text-edit-save{ 13005 background: linear-gradient(135deg, #4a56f8 0%, #7b58ff 100%) !important; 13006 border-radius: 999px !important; 13007 color: #fff !important; 13008 border-color: transparent !important; 13009 } 13010 .cs-text-edit-popover .cs-text-edit-save:hover{ 13011 transform: translateY(-1px) !important; 13012 } 13013</style> 13014
13014<script type="text/html" id="template-text"> 13015 <div 13016 v-memo="[last_update, last_element_update]" 13017 :class="[{'hidden': (column_index + 1) > number_of_columns }, 'text-column_' + column_index]" 13018 class="cs-text_text-wrap-column cs-element-stack" 13019 :data-column="column_index" 13020 > 13021 <template 13022 v-for="(element, element_index) in nice_elements" 13023 :key="element.client_id || element_index" 13024 > 13025 <template v-if="preview_element"> 13026 <preview-element-component 13027 :element="element" 13028 :element_type="element.type" 13029 :element_content="element.content" 13030 :element_column_count="element.column_count" 13031 :element_column_gap="element.column_gap" 13032 :column_index="column_index" 13033 :element_index="element_index" 13034 :componentObject="component" 13035 ></preview-element-component> 13036 </template> 13037 <template v-if="!preview_element"> 13038 <element-component 13039 :element_type="element.type" 13040 :element_content="element.content" 13041 :element_column_count="element.column_count" 13042 :element_column_gap="element.column_gap" 13043 :diff_changed="element._wb_diff_changed === true" 13044 :element_index="element_index" 13045 :section_element_animation="section_element_animation" 13046 ></element-component> 13047 </template> 13048 </template> 13049 <!-- handle case where there are no elements (admin only) --> 13050 <template v-if="preview_element && elements && elements.length == 0"> 13051 <div class="no-elements-wrap"> 13052 <div 13053 class="tapped-insert-new-element-relative" 13054 @click="tapped_add_element( $event )" 13055 >+</div> 13056 </div> 13057 </template> 13058 </div> 13059</script>
13059 13060
13061<script> 13062 app_content.component('text-component', { 13063 template: '#template-text', 13064 data() { 13065 return {} 13066 }, 13067 props: { 13068 number_of_columns: [String, Number], 13069 preview_element: Boolean, 13070 component: Object, 13071 last_update: Number, //last component update 13072 last_element_update: Number, //last element update 13073 elements: Array, 13074 column_prop: String, 13075 column_index: Number, 13076 section_element_animation: { 13077 type: String, 13078 default: '', 13079 }, 13080 }, 13081 methods:{ 13082 tapped_add_element(){ 13083 var params = {}; 13084 params["component"] = this.component; 13085 params["insert_after"] = 0; 13086 params["column_index"] = this.column_index; 13087 handle_add_element( params ); 13088 } 13089 }, 13090 computed: { 13091 nice_elements(){ 13092 var nice_elements = []; 13093 13094 if (this.elements){ 13095 nice_elements = this.elements; 13096 } else if (this.column_prop && typeof this.column_prop === 'string'){ 13097 nice_elements = JSON.parse(unescape(decodeURIComponent(this.column_prop))); 13098 } 13099 return nice_elements; 13100 } 13101 }, 13102 }); 13103</script>
13103
13103<script type="text/html" id="template-image-with-text"> 13104 <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"> 13105 <template v-if="!loading && left_right_orientation === 'right'"> 13106 <!-- text first (image on the right) --> 13107 <div class="cs-image-with-text_text-wrap flexGrowOne"> 13108 <div :style="element_style_string"> 13109 <div v-memo="[last_update, last_element_update]" class="element-wrapper cs-element-stack"> 13110 <template 13111 v-for="(element, element_index) in elements_obj" 13112 :key="element.client_id || element_index" 13113 > 13114 <template v-if="preview_element"> 13115 <preview-element-component 13116 :element="element" 13117 :element_type="element.type" 13118 :element_content="element.content" 13119 :element_column_count="element.column_count" 13120 :element_column_gap="element.column_gap" 13121 :element_index="element_index" 13122 :componentObject="component" 13123 ></preview-element-component> 13124 </template> 13125 <template v-if="!preview_element"> 13126 <element-component 13127 :element_type="element.type" 13128 :element_content="element.content" 13129 :element_column_count="element.column_count" 13130 :element_column_gap="element.column_gap" 13131 :diff_changed="element._wb_diff_changed === true" 13132 :element_index="element_index" 13133 :section_element_animation="section_element_animation" 13134 ></element-component> 13135 </template> 13136 </template> 13137 <!-- handle case where there are no elements (admin only) --> 13138 <template v-if="preview_element && elements_obj && elements_obj.length == 0"> 13139 <div class="no-elements-wrap"> 13140 <div 13141 class="tapped-insert-new-element-relative" 13142 @click="tapped_add_element( $event )" 13143 >+</div> 13144 </div> 13145 </template> 13146 </div> 13147 </div> 13148 </div> 13149 <div class="cs-image-with-text_image-wrap flexGrowOne"> 13150 <div class="cs-image-with-text_image-wrap-inner"> 13151 <div :style="check_full_background()" class="cs-image-with-text_image-slider-outer"> 13152 <div 13153 v-if="formatting_obj.background_size !== 'full'" 13154 :class="['cs-image-with-text-image-mat', { background: image_mat === 'show' }]" 13155 ></div> 13156 <image-slider-component 13157 :client_id="client_id" 13158 :formatting="formatting" 13159 :images_string="images_string" 13160 :preview_element="preview_element" 13161 :component="component" 13162 :last_update="last_update" 13163 ></image-slider-component> 13164 </div> 13165 </div> 13166 </div> 13167 </template> 13168 <template v-if="!loading && left_right_orientation === 'left'"> 13169 <!-- image first (text on the right) --> 13170 <div class="cs-image-with-text_image-wrap flexGrowOne"> 13171 <div class="cs-image-with-text_image-wrap-inner"> 13172 <div :style="check_full_background()" class="cs-image-with-text_image-slider-outer"> 13173 <div 13174 v-if="formatting_obj.background_size !== 'full'" 13175 :class="['cs-image-with-text-image-mat', { background: image_mat === 'show' }]" 13176 ></div> 13177 <image-slider-component 13178 :client_id="client_id" 13179 :formatting="formatting" 13180 :images_string="images_string" 13181 :preview_element="preview_element" 13182 :component="component" 13183 :last_update="last_update" 13184 ></image-slider-component> 13185 </div> 13186 </div> 13187 </div> 13188 <div class="cs-image-with-text_text-wrap flexGrowOne"> 13189 <div :style="element_style_string"> 13190 <div 13191 v-memo="[last_update, last_element_update]" 13192 class="element-wrapper cs-element-stack" 13193 > 13194 <template 13195 v-for="(element, element_index) in elements_obj" 13196 :key="element.client_id || element_index" 13197 > 13198 <template v-if="preview_element"> 13199 <preview-element-component 13200 :element="element" 13201 :element_type="element.type"
13202 :element_content="element.content" 13203 :element_column_count="element.column_count" 13204 :element_column_gap="element.column_gap" 13205 :element_index="element_index" 13206 :componentObject="component" 13207 ></preview-element-component> 13208 </template> 13209 <template v-if="!preview_element"> 13210 <element-component 13211 :element_type="element.type" 13212 :element_content="element.content" 13213 :element_column_count="element.column_count" 13214 :element_column_gap="element.column_gap" 13215 :diff_changed="element._wb_diff_changed === true" 13216 :element_index="element_index" 13217 :section_element_animation="section_element_animation" 13218 ></element-component> 13219 </template> 13220 </template> 13221 <!-- handle case where there are no elements (admin only) --> 13222 <template v-if="preview_element && elements_obj && elements_obj.length == 0"> 13223 <div class="no-elements-wrap"> 13224 <div 13225 class="tapped-insert-new-element-relative" 13226 @click="tapped_add_element( $event )" 13227 >+</div> 13228 </div> 13229 </template> 13230 </div> 13231 </div> 13232 </div> 13233 </template> 13234 </div> 13235</script>
13235 13236 13237<style> 13238 .delay5s{ 13239 animation-delay: 0.5s; 13240 } 13241 13242 .view_hidden{ 13243 opacity: 0; 13244 } 13245 13246 @keyframes fade-left-right { 13247 0% { 13248 opacity: 0; 13249 transform: translateX(-100%) scaleX(0.5); 13250 } 13251 100% { 13252 opacity: 1; 13253 transform: translateX(0) scaleX(1); 13254 } 13255 } 13256 13257 @keyframes fade-right-left { 13258 0% { 13259 opacity: 0; 13260 transform: translateX(100%) scaleX(0.5); 13261 } 13262 100% { 13263 opacity: 1; 13264 transform: translateX(0) scaleX(1); 13265 } 13266 } 13267 13268 .fade-left-right { 13269 animation: fade-left-right 1s ease-in-out forwards; 13270 } 13271 13272 .fade-right-left { 13273 animation: fade-right-left 1s ease-in-out backwards; 13274 } 13275</style> 13276
13277<script> 13278 app_content.component('image-with-text-component', { 13279 template: '#template-image-with-text', 13280 data() { 13281 return { 13282 is_admin: false, 13283 loading: true, 13284 } 13285 }, 13286 props: { 13287 class_string: String, 13288 style_string: String, 13289 element_style_string: String, 13290 elements_string: [String, Object], 13291 left_right_orientation: String, 13292 image_mat: String, 13293 //image slider 13294 client_id: String, 13295 formatting: String, 13296 images_string: String, 13297 //admin 13298 preview_element: Boolean, 13299 component: Object, 13300 last_update: Number, //last component update 13301 last_element_update: Number, //last element update 13302 tab_element: Boolean, 13303 }, 13304 mounted() { 13305 this.loading = false; 13306 this.is_admin = window.location.href.indexOf('website-builder') !== -1; 13307 }, 13308 updated(){ 13309 this.createIntersectionObserver(); 13310 }, 13311 methods: { 13312 check_full_background(){ 13313 if ( this.formatting_obj && this.formatting_obj.background_size === 'full' ){ 13314 return { 13315 top: 0, 13316 bottom: 0, 13317 left: 0, 13318 right: 0, 13319 } 13320 } 13321 }, 13322 tapped_add_element(){ 13323 var params = {}; 13324 params["component"] = this.component; 13325 params["insert_after"] = 0; 13326 params["column_index"] = 0; 13327 handle_add_element( params ); 13328 }, 13329 createIntersectionObserver(){ 13330 const options = { 13331 root: null, 13332 rootMargin: '0px', 13333 threshold: 0.5 //trigger animation when at least 50% of the target is visible 13334 }; 13335 const observer = new IntersectionObserver(this.handleIntersection, options); 13336 //observe the target element 13337 observer.observe(this.$refs.animationTarget); 13338 }, 13339 handleIntersection(entries, observer) { 13340 entries.forEach(entry => { 13341 //check if target is intersecting 13342 if (entry.isIntersecting) { 13343 //apply animation class 13344 entry.target.classList.add(this.formatting_obj.animation); 13345 entry.target.classList.remove('view_hidden'); 13346 //stop observing to prevent re-triggering 13347 observer.unobserve(entry.target); 13348 } 13349 }); 13350 }, 13351 }, 13352 computed: { 13353 elements_obj(){ 13354 if (this.elements_string && typeof this.elements_string === 'string'){ 13355 return JSON.parse(unescape(decodeURIComponent(this.elements_string))); 13356 } else if (this.elements_string){ 13357 return this.elements_string; 13358 } else { 13359 return {}; 13360 } 13361 }, 13362 formatting_obj(){ 13363 if (this.formatting){ 13364 return JSON.parse(this.formatting); 13365 } else { 13366 return {}; 13367 } 13368 }, 13369 section_element_animation(){ 13370 let component_animation = resolve_web_builder_component_animation(this.formatting_obj); 13371 return section_element_animation_token(component_animation); 13372 }, 13373 }, 13374 }); 13375</script>
13375
13375<script type="text/html" id="template-image-slider"> 13376 <div class="cs-image-slider-wrap background"> 13377 <div 13378 class="cs-image-slider" 13379 :class="[ 'cs-image-slider-' + filtered_images_length, 'cs-image-slider_' + client_id ]" 13380 :data-client-id="client_id" 13381 data-interval="8000" 13382 data-slider-index="0" 13383 > 13384 <!-- now add the images --> 13385 <template v-if="!loading && filtered_images_length > 0" v-for="(image, index) in filtered_images"> 13386 <template v-if="image && typeof image !== 'object'"> 13387 <template v-if="image && image.indexOf('video') >= 1"> 13388 <div 13389 class="cs-image-slider-card" 13390 :class="[ 13391 'cs-image-slider-card-' + index, 13392 { 'active': index === display_index }, 13393 { 'cs-image-slot-attached-host': preview_element && !is_hero_banner } 13394 ]" 13395 style="display:flex;align-items:center;justify-content:center;" 13396 @click.stop="preview_element && !is_hero_banner && change_image_slot(index)" 13397 > 13398 <video class="cs-background-video" autoplay playsinline loop muted :src="image" style="display:initial;"></video> 13399 <button 13400 v-if="preview_element && !is_hero_banner" 13401 type="button" 13402 class="cs-image-slot-change-chip exclude-from-custom-button-styles" 13403 @click.stop="change_image_slot(index)" 13404 ><i class="fa-regular fa-image"></i><span>Change</span></button> 13405 </div> 13406 </template> 13407 <template v-if="image && typeof image === 'string'"> 13408 <div 13409 class="cs-image-slider-card" 13410 :class="[ 13411 'cs-image-slider-card-' + index, 13412 { 'active': index === display_index }, 13413 { 'cs-image-slot-attached-host': preview_element && !is_hero_banner } 13414 ]" 13415 @click.stop="preview_element && !is_hero_banner && change_image_slot(index)" 13416 > 13417 <div class="cs-background-image-photo" :style="`background-image: url(${image})`"></div> 13418 <button 13419 v-if="preview_element && !is_hero_banner" 13420 type="button" 13421 class="cs-image-slot-change-chip exclude-from-custom-button-styles" 13422 @click.stop="change_image_slot(index)" 13423 ><i class="fa-regular fa-image"></i><span>Change</span></button> 13424 </div> 13425 </template> 13426 </template> 13427 <template v-if="is_image_with_text && !image"> 13428 <div 13429 class="cs-image-slider-card" 13430 :class="[ 13431 'cs-image-slider-card-' + index, 13432 { 'active': index === display_index }, 13433 { 'cs-image-slot-placeholder-host': preview_element } 13434 ]" 13435 @click.stop="fill_image_slot(index)" 13436 > 13437 <div v-if="preview_element" class="cs-image-slot-placeholder"> 13438 <span class="cs-image-slot-placeholder-icon"><i class="fa-regular fa-image"></i></span> 13439 <span class="cs-image-slot-placeholder-label">Add image</span> 13440 </div> 13441 <div v-else class="cs-background-image-photo default-image-icon" style="background-size:30%!important;"></div> 13442 </div> 13443 </template> 13444 </template> 13445 <template v-for="(layer, layer_index) in overlay_layers" :key="'overlay-layer-' + layer_index + '-' + (layer.class_string || layer.kind || '')"> 13446 <div 13447 class="cs-image-overlay" 13448 :class="(layer.class_string || '').trim().split(/\s+/).filter(
13448Boolean)" 13449 :style="layer.style_obj" 13450 ></div> 13451 </template> 13452 </div> 13453 </div> 13454</script>
13454 13455 13456<style> 13457 /* Preview-only "Change" chip for hero-banner / image-with-text slots. */ 13458 .cs-image-slot-attached-host { 13459 cursor: pointer; 13460 } 13461 .cs-image-slot-change-chip { 13462 position: absolute; 13463 top: 12px; 13464 right: 12px; 13465 z-index: 2000; 13466 display: inline-flex; 13467 align-items: center; 13468 gap: 5px; 13469 padding: 6px 10px; 13470 border: none; 13471 border-radius: 6px; 13472 background: rgba(20, 24, 45, 0.82); 13473 color: #fff; 13474 font-family: 'Roboto', Arial, Helvetica, sans-serif; 13475 font-size: 12px; 13476 font-weight: 600; 13477 font-style: normal; 13478 line-height: 1; 13479 letter-spacing: 0; 13480 text-transform: none; 13481 cursor: pointer; 13482 opacity: 0.92; 13483 pointer-events: auto; 13484 box-shadow: 0 4px 12px rgba(8, 19, 51, 0.22); 13485 transition: opacity 0.12s ease, background 0.12s ease; 13486 } 13487 .cs-image-slot-attached-host:hover .cs-image-slot-change-chip, 13488 .cs-image-slot-change-chip:hover, 13489 .cs-image-slot-change-chip:focus { 13490 opacity: 1; 13491 background: rgba(20, 24, 45, 0.95); 13492 } 13493</style> 13494
13495<script> 13496 /* ----- COMPONENT ----- */ 13497 app_content.component('image-slider-component', { 13498 template: '#template-image-slider', 13499 data() { 13500 return { 13501 loading: true, 13502 display_index: 0, 13503 } 13504 }, 13505 props: { 13506 client_id: String, 13507 formatting: String, 13508 images_string: String, 13509 //for preview element 13510 preview_element: { 13511 type: Boolean, 13512 default: false, 13513 }, 13514 component: { 13515 type: Object, 13516 default: null, 13517 }, 13518 last_update: { 13519 type: Number, 13520 default: null, 13521 }, 13522 }, 13523 mounted() { 13524 this.loading = false; 13525 this.sync_display_index_with_config_and_images(); 13526 }, 13527 methods: { 13528 parsed_images(){ 13529 if( !this.images_string ){ 13530 return []; 13531 } 13532 try { 13533 let parsed = JSON.parse(this.images_string); 13534 return Array.isArray(parsed) ? parsed : []; 13535 } catch (error) { 13536 console.error('Error parsing image slider images: ', error); 13537 return []; 13538 } 13539 }, 13540 // image-with-text keeps an empty slot in preview for the Add-image card. 13541 // Hero has no image-layer fill â empty state is the section color_scheme. 13542 preview_images_with_slots(){ 13543 let parsed = this.parsed_images(); 13544 if( !this.preview_element || (!this.is_hero_banner && !this.is_image_with_text) ){ 13545 return parsed; 13546 } 13547 if( this.is_hero_banner ){ 13548 return parsed.filter((image) => { 13549 if( image ){ 13550 return true; 13551 } 13552 return false; 13553 }); 13554 } 13555 if( !parsed.length ){ 13556 return [null]; 13557 } 13558 return parsed; 13559 }, 13560 return_first_filled_index( images ){ 13561 if( !Array.isArray(images) || images.length === 0 ){ 13562 return 0; 13563 } 13564 for( let i = 0; i < images.length; i++ ){ 13565 if( images[i] ){ 13566 return i; 13567 } 13568 } 13569 return 0; 13570 }, 13571 sync_display_index_with_config_and_images(){ 13572 let config_latest = this.component ? (this.component.get('config_latest') || {}) : {}; 13573 let saved_display_index = Number(config_latest.display_index); 13574 let has_saved_display_index = config_latest.display_index === 0 || !isNaN(saved_display_index); 13575 let fallback_index = this.display_index; 13576 if( this.preview_element && this.is_image_with_text ){ 13577 fallback_index = this.return_first_filled_index(this.parsed_images()); 13578 } 13579 let target_index = has_saved_display_index ? saved_display_index : fallback_index; 13580 let max_index = this.filtered_images_length > 0 ? this.filtered_images_length - 1 : 0; 13581 this.display_index = Math.min(Math.max(target_index, 0), max_index); 13582 }, 13583 // Write `value` (a URL string or null) into slot `index`, preserving 13584 // the fixed 5-slot string|null shape the settings panel expects. 13585 write_image_slot( index, value ){ 13586 let component = this.component; 13587 if( !component ){ 13588 return; 13589 } 13590 let current = (component.get('config_latest') && component.get('config_latest').images) || []; 13591 let next = []; 13592 for( let i = 0; i <= 4; i++ ){ 13593 if( i === index ){ 13594 next.push( value ); 13595 } else if( current[i] ){ 13596 next.push( current[i] ); 13597 } else { 13598 next.push( null ); 13599 } 13600 } 13601 this.display_index = index; 13602 save_changes_for_component( component, 'images', next ); 13603 save_changes_for_component( component, 'display_index', index ); 13604 }, 13605 // Empty-slot picker for image-with-text / hero-banner. 13606 fill_image_slot( index ){ 13607 if( !this.preview_element ){ 13608 return; 13609 } 13610 if( typeof webbuilder_open_image_slot_picker !== 'function' ){ 13611 if( typeof displayMessage === 'function' ){ 13612 displayMessage('Image picker is not loaded. Refresh the page and try again.', true, 5000); 13613 } 13614 return; 13615 } 13616 if( !this.component ){ 13617 return; 13618 } 13619 webbuilder_open_image_slot_picker({ 13620 label: 'image', 13621 mode: 'placeholder', 13622 on_pick: ( url ) => { 13623 if( !url ){ 13624 return; 13625 } 13626 this.write_image_slot( index, url ); 13627 }, 13628 }); 13629 }, 13630 // Filled-slot "Change" action â swap or remove the image in slot 13631 // `index` via the shared picker (pick/paste/copy-external/remove), 13632 // matching custom-code's attached-asset menu. 13633 change_image_slot( index ){ 13634 if( !this.preview_element ){ 13635 return; 13636 } 13637 if( typeof webbuilder_open_image_slot_picker !== 'function' ){ 13638 if( typeof displayMessage === 'function' ){ 13639 displayMessage('Image picker is not loaded. Refresh the page and try again.', true, 5000); 13640 } 13641 return; 13642 } 13643 let component = this.component; 13644 if( !component ){ 13645 return; 13646 } 13647 let current = (component.get('config_latest') && component.get('config_latest').images) || []; 13648 let current_url = typeof current[index] === 'string' ? current[index] : ''; 13649 webbuilder_open_image_slot_picker({ 13650 label: 'image', 13651 mode: 'attached', 13652 current_url: current_url, 13653 on_pick: ( url ) => { 13654 if( !url ){ 13655 return; 13656 } 13657 this.write_image_slot( index, url ); 13658 }, 13659 on_remove: () => { 13660 this.write_image_slot( index, null ); 13661 }, 13662 }); 13663 }, 13664 }, 13665 created(){ 13666 setInterval(() => { 13667 if (!this.preview_element){ 13668 if (this.display_index < this.filtered_images_length - 1){ 13669 this.display_index++; 13670 } else { 13671 this.display_index = 0; 13672 } 13673 } 13674 }, 8000); 13675 }, 13676 computed: { 13677 component_type(){ 13678 if( this.component && typeof this.component.get === 'function' ){ 13679 return this.component.get('type'); 13680 } 13681 return null; 13682 }, 13683 is_hero_banner(){ 13684 return this.component_type === 'hero-banner'; 13685 }, 13686 is_image_with_text(){ 13687 return this.component_type === 'image-with-text'; 13688 }, 13689 formatting_obj(){ 13690 if (this.formatting){ 13691 return JSON.parse(this.formatting); 13692 } 13693 }, 13694 overlay_layers(){ 13695 let formatting = this.formatting_obj || {}; 13696 if( typeof build_web_builder_overlay_layers === 'function' ){ 13697 return build_web_builder_overlay_layers(formatting).layers || []; 13698 } 13699 if( typeof build_web_builder_overlay_attrs === 'function' ){ 13700 let attrs = build_web_builder_overlay_attrs(formatting); 13701 return attrs.has_overlay ? [{ class_string: attrs.class_string, style_obj: attrs.style_obj }] : []; 13702 } 13703 let opacity = Number(formatting.overlay_opacity) || 0; 13704 if( opacity > 0 ){ 13705 return [{ class_string: '', style_obj: { 'opacity': opacity / 100 } }]; 13706 } 13707 return []; 13708 }, 13709 filtered_images(){ 13710 if (!this.preview_element){ 13711 return this.parsed_images().filter(image => { 13712 if ( image !== null ){ 13713 return image; 13714 } 13715 }); 13716 } else { 13717 return this.preview_images_with_slots(); 13718 } 13719 return []; 13720 }, 13721 filtered_images_length(){ 13722 if (!this.preview_element){ 13723 return this.parsed_images().filter(image => { 13724 if ( image !== null ){ 13725 return image; 13726 } 13727 }).length; 13728 } else { 13729 return this.preview_images_with_slots().length; 13730 } 13731 return 0; 13732 }, 13733 }, 13734 watch: { 13735 last_update(newVal, oldVal){ 13736 if (newVal !== oldVal){ 13737 this.sync_display_index_with_config_and_images(); 13738 } 13739 }, 13740 } 13741 }); 13742</script>
13742
13742<script type="text/html" id="template-hero-banner"> 13743 <div class="cs-background-image background relative" :class="[class_string, { 'scroll-fade': !is_admin && !tab_element }]" :style="style_string"> 13744 <image-slider-component 13745 :client_id="client_id" 13746 :formatting="formatting" 13747 :images_string="images_string" 13748 :preview_element="preview_element" 13749 :component="component" 13750 :last_update="last_update" 13751 ></image-slider-component> 13752 <div 13753 class="element-wrapper" 13754 :class="'element-wrapper_' + component_type" 13755 :style="element_style_string" 13756 > 13757 <div v-memo="[last_update, last_element_update]" class="cs-element-stack"> 13758 <!-- loop through elements --> 13759 <template 13760 v-for="(element, element_index) in elements_obj" 13761 :key="element.client_id || element_index" 13762 > 13763 <template v-if="preview_element"> 13764 <preview-element-component 13765 :element="element" 13766 :element_type="element.type" 13767 :element_content="element.content" 13768 :element_column_count="element.column_count" 13769 :element_column_gap="element.column_gap" 13770 :element_index="element_index" 13771 :componentObject="component" 13772 ></preview-element-component> 13773 </template> 13774 <template v-if="!preview_element"> 13775 <element-component 13776 :element_type="element.type" 13777 :element_content="element.content" 13778 :element_column_count="element.column_count" 13779 :element_column_gap="element.column_gap" 13780 :diff_changed="element._wb_diff_changed === true" 13781 :element_index="element_index" 13782 :section_element_animation="section_element_animation" 13783 ></element-component> 13784 </template> 13785 </template> 13786 <!-- handle case where there are no elements (admin only) --> 13787 <template v-if="preview_element && elements_obj.length == 0"> 13788 <div class="no-elements-wrap"> 13789 <div 13790 class="tapped-insert-new-element-relative" 13791 @click="tapped_add_element( $event )" 13792 >+</div> 13793 </div> 13794 </template> 13795 </div> 13796 </div> 13797 </div> 13798</script>
13798 13799
13800<script> 13801 app_content.component('hero-banner-component', { 13802 template: '#template-hero-banner', 13803 data() { 13804 return { 13805 is_admin: false, 13806 loading: true, 13807 } 13808 }, 13809 props: { 13810 class_string: String, 13811 style_string: String, 13812 element_style_string: String, 13813 component_type: String, 13814 elements_string: [String, Object], 13815 //image slider 13816 client_id: String, 13817 formatting: String, 13818 images_string: String, 13819 //admin 13820 preview_element: Boolean, 13821 component: Object, 13822 last_update: Number, //last component update 13823 last_element_update: Number, //last element update 13824 tab_element: Boolean, 13825 }, 13826 mounted() { 13827 this.loading = false; 13828 this.is_admin = window.location.href.indexOf('website-builder') !== -1; 13829 }, 13830 methods:{ 13831 tapped_add_element(){ 13832 var params = {}; 13833 params["component"] = this.component; 13834 params["insert_after"] = 0; 13835 params["column_index"] = 0; 13836 handle_add_element( params ); 13837 }, 13838 parsed_hero_images(){ 13839 if( !this.images_string ){ 13840 return []; 13841 } 13842 try { 13843 let parsed = JSON.parse(this.images_string); 13844 return Array.isArray(parsed) ? parsed : []; 13845 } catch (error) { 13846 console.error('Error parsing hero banner images: ', error); 13847 return []; 13848 } 13849 }, 13850 // Prefer the first filled slot so multi-image heroes edit the visible one. 13851 hero_image_slot_index(){ 13852 let images = this.parsed_hero_images(); 13853 for( let i = 0; i < images.length; i++ ){ 13854 if( images[i] ){ 13855 return i; 13856 } 13857 } 13858 return 0; 13859 }, 13860 write_hero_image_slot( index, value ){ 13861 let component = this.component; 13862 if( !component ){ 13863 return; 13864 } 13865 let current = (component.get('config_latest') && component.get('config_latest').images) || []; 13866 let next = []; 13867 for( let i = 0; i <= 4; i++ ){ 13868 if( i === index ){ 13869 next.push( value ); 13870 } else if( current[i] ){ 13871 next.push( current[i] ); 13872 } else { 13873 next.push( null ); 13874 } 13875 } 13876 save_changes_for_component( component, 'images', next ); 13877 }, 13878 tapped_change_hero_image(){ 13879 if( !this.preview_element ){ 13880 return; 13881 } 13882 if( typeof webbuilder_open_image_slot_picker !== 'function' ){ 13883 if( typeof displayMessage === 'function' ){ 13884 displayMessage('Image picker is not loaded. Refresh the page and try again.', true, 5000); 13885 } 13886 return; 13887 } 13888 if( !this.component ){ 13889 return; 13890 } 13891 let index = this.hero_image_slot_index(); 13892 let current = (this.component.get('config_latest') && this.component.get('config_latest').images) || []; 13893 let current_url = typeof current[index] === 'string' ? current[index] : ''; 13894 if( current_url ){ 13895 webbuilder_open_image_slot_picker({ 13896 label: 'image', 13897 mode: 'attached', 13898 current_url: current_url, 13899 on_pick: ( url ) => { 13900 if( !url ){ 13901 return; 13902 } 13903 this.write_hero_image_slot( index, url ); 13904 }, 13905 on_remove: () => { 13906 this.write_hero_image_slot( index, null ); 13907 }, 13908 }); 13909 return; 13910 } 13911 webbuilder_open_image_slot_picker({ 13912 label: 'image', 13913 mode: 'placeholder', 13914 on_pick: ( url ) => { 13915 if( !url ){ 13916 return; 13917 } 13918 this.write_hero_image_slot( index, url ); 13919 }, 13920 }); 13921 }, 13922 }, 13923 computed: { 13924 elements_obj(){ 13925 if (this.elements_string && typeof this.elements_string === 'string'){ 13926 return JSON.parse(unescape(decodeURIComponent(this.elements_string))) 13927 } else { 13928 return this.elements_string 13929 } 13930 return []; 13931 }, 13932 formatting_obj(){ 13933 if( this.formatting && typeof this.formatting === 'string' ){ 13934 try { 13935 return JSON.parse(this.formatting); 13936 } catch (error) { 13937 return {}; 13938 } 13939 } 13940 if( this.formatting && typeof this.formatting === 'object' ){ 13941 return this.formatting; 13942 } 13943 return {}; 13944 }, 13945 section_element_animation(){ 13946 let component_animation = resolve_web_builder_component_animation(this.formatting_obj); 13947 return section_element_animation_token(component_animation); 13948 }, 13949 }, 13950 }); 13951</script>
13951
13951<script type="text/html" id="template-element"> 13952 <template v-if="element_type === 'accordion_header'"> 13953 <div 13954 class="cs-accordion-header" 13955 :class="[element_anim_class, { 'wb-diff-element-changed': diff_changed }]" 13956 :style="element_anim_inline_style" 13957 > 13958 <div class="cs-accordion-header-toggle-wrap flexWrap"> 13959 <div class="cs-accordion-header-toggle">+</div> 13960 </div> 13961 <span v-html="element_content"></span> 13962 </div> 13963 </template> 13964 <template v-if="element_type === 'accordion_body'"> 13965 <div 13966 class="cs-accordion-body" 13967 :class="[element_anim_class, { 'wb-diff-element-changed': diff_changed }]" 13968 v-html="element_content" 13969 :style="element_body_inline_style" 13970 ></div> 13971 </template> 13972 <template v-else-if="element_type !== 'accordion_header' && element_type !== 'accordion_body'"> 13973 <div 13974 :class="[element_anim_class, { 'wb-diff-element-changed': diff_changed }]" 13975 :style="element_body_inline_style" 13976 > 13977 <span v-html="element_content"></span> 13978 </div> 13979 </template> 13980</script>
13980 13981
13982<script> 13983 app_content.component('element-component', { 13984 template: '#template-element', 13985 data() { 13986 return { 13987 loading: true, 13988 } 13989 }, 13990 props: { 13991 element_type: String, 13992 element_content: String, 13993 element_column_count: Number, 13994 element_column_gap: Number, 13995 diff_changed: Boolean, 13996 element_index: { 13997 type: [Number, String], 13998 default: 0, 13999 }, 14000 section_element_animation: { 14001 type: String, 14002 default: '', 14003 }, 14004 }, 14005 mounted() { 14006 this.loading = false; 14007 }, 14008 computed: { 14009 uses_section_element_animation(){ 14010 return this.section_element_animation === 'up' 14011 || this.section_element_animation === 'fade_up' 14012 || this.section_element_animation === 'fade_in'; 14013 }, 14014 element_anim_class(){ 14015 if( this.uses_section_element_animation ){ 14016 return 'cs-el-anim-target'; 14017 } 14018 return ''; 14019 }, 14020 el_index_style_fragment(){ 14021 if( this.uses_section_element_animation ){ 14022 return '--el-index:' + ( Number(this.element_index) || 0 ) + ';'; 14023 } 14024 return ''; 14025 }, 14026 element_anim_inline_style(){ 14027 return this.el_index_style_fragment; 14028 }, 14029 element_body_inline_style(){ 14030 return 'column-count: ' + this.element_column_count + ' !important; column-gap: ' + this.element_column_gap + 'em;' + this.el_index_style_fragment; 14031 }, 14032 }, 14033 }); 14034</script>
14034 14035
14035<script type="text/html" id="template-unlayer-component"> 14036 <template v-if="preview_element"> 14037 <div class="cs-unlayer-component-wrap unlayer-content" :class="class_string"> 14038 <!-- hover button to open unlayer builder --> 14039 <div class="darken-overlay easeFast"> 14040 <div class="hover-toolbar"> 14041 <div class="hover-button flexNoWrap centeredBlock pointer" @click="openUnlayerBuilder()"> 14042 <div class="hover-button-text"> 14043 Update <i class="fa-regular fa-pen-to-square" style="padding-left:3px;"></i> 14044 </div> 14045 </div> 14046 </div> 14047 </div> 14048 <!-- if there is content --> 14049 <div v-if="!loading && unlayerHtml" class="relative" > 14050 <div 14051 :id="'freeform_id_' + component.id" 14052 v-html="return_html_with_css()" 14053 ></div> 14054 </div> 14055 <!-- if there is no content --> 14056 <div v-else-if="!loading"> 14057 <div class="addFreeformContentZone" @click="openUnlayerBuilder()"> 14058 <div 14059 class="quick-action-wrap noMaxWidth centeredBlock" 14060 > 14061 <div class="quick-action-icon-wrap"> 14062 <i class="fa-light fa-scribble"></i> 14063 </div> 14064 <p class="quick-action-title" style="font-family:roboto;">Freeform section</p> 14065 </div> 14066 </div> 14067 </div> 14068 </div> 14069 </template> 14070 <template v-if="!preview_element"> 14071 <div class="cs-unlayer-component-wrap"> 14072 <div class="relative"></div> 14073 <div v-if="!loading" v-html="return_html_with_css()"></div> 14074 </div> 14075 </template> 14076</script>
14076 14077
14078<script> 14079 14080 function createDebounce() { 14081 let timeout = null; 14082 return function (fnc, delayMs) { 14083 clearTimeout(timeout); 14084 timeout = setTimeout(() => { 14085 fnc(); 14086 }, delayMs || 800); 14087 }; 14088 } 14089 app_content.component('unlayer-component', { 14090 template: '#template-unlayer-component', 14091 data() { 14092 return { 14093 unlayerHtml: null, 14094 loading: true, 14095 debounce: createDebounce(), 14096 template: null, 14097 unlayer: '', 14098 } 14099 }, 14100 props: { 14101 unlayer_chunks: Object, 14102 class_string: String, 14103 preview_element: { 14104 type: Boolean, 14105 default: false, 14106 }, 14107 component: { 14108 type: Object, 14109 default: null, 14110 }, 14111 }, 14112 mounted() { 14113 if(!this.unlayer_chunks){ 14114 // find template for component id 14115 this.return_unlayer_data() 14116 } 14117 this.loading = false; 14118 }, 14119 methods: { 14120 return_unlayer_data(){ 14121 let config_latest = this.component.get('config_latest') || {}; 14122 if(!config_latest.unlayer_component || !config_latest.unlayer_component.chunks){ 14123 return; 14124 } 14125 this.unlayerHtml = config_latest.unlayer_component.chunks.body || ''; 14126 this.unlayerHtml = this.unlayerHtml.replace('style="min-height: 100vh;', 'style="min-height: auto;'); 14127 }, 14128 openUnlayerBuilder(){ 14129 let jsonContent; 14130 let config_latest = this.component.get('config_latest') || {}; 14131 if(config_latest.unlayer_component){ 14132 jsonContent = config_latest.unlayer_component.design; 14133 } 14134 let params = { 14135 jsonContent: jsonContent, 14136 fn: this.saveUnlayerContent 14137 } 14138 launch_unlayer_builder(params) 14139 }, 14140 saveUnlayerContent(content){ 14141 let promise = Promise.resolve().then(() => { 14142 let htmlContent = content && content.chunks ? (content.chunks.body || '') : ''; 14143 htmlContent = htmlContent.replace('style="min-height: 100vh;', 'style="min-height: auto;'); 14144 this.unlayerHtml = htmlContent; 14145 save_changes_for_component( this.component, 'unlayer_component', content ); 14146 }) 14147 return promise; 14148 }, 14149 return_html_with_css(){ 14150 let data; 14151 if(this.preview_element){ 14152 let config_latest = this.component.get('config_latest') || {}; 14153 if(!config_latest.unlayer_component || !config_latest.unlayer_component.chunks){ 14154 return ''; 14155 } 14156 data = config_latest.unlayer_component.chunks; 14157 }else{ 14158 data = this.unlayer_chunks; 14159 } 14160 if(!data){ 14161 return ''; 14162 } 14163 let css = data.css || ''; 14164 let body = data.body || ''; 14165 let html_with_css = juice.inlineContent(body, css); 14166 html_with_css = html_with_css.replace('100vh', 'auto') 14167 return html_with_css 14168 } 14169 }, 14170 computed: { 14171 14172 } 14173 }); 14174</script>
14174 14175 14176<style scoped> 14177 14178 @media screen and (max-width: 768px) { 14179 .u-row:not(.no-stack) .u-col:not(:only-child) { 14180 flex: 0 0 50% !important; 14181 max-width: 50% !important; 14182 } 14183 } 14184 @media screen and (max-width: 480px) { 14185 .u-col { 14186 flex: 0 0 100% !important; /* One column per row */ 14187 max-width: 100% !important; 14188 } 14189 } 14190 14191</style>
14191<script type="text/html" id="template-form"> 14192 <div class="form-wrapper centeredBlock centeredText"> 14193 <template v-if="!loading && is_admin"> 14194 <div 14195 v-if="!form_id" 14196 @click="link_form()" 14197 class="noObjectsMSG centeredBlock centeredText" 14198 style="padding: 5em;" 14199 > 14200 <p style="cursor: pointer;"><i class="fa-light fa-list-dropdown smallMarginRight"></i>Click to link a form</p> 14201 </div> 14202 </template> 14203 <template v-if="!loading"> 14204 <div 14205 v-if="form_id && json_form" 14206 id="form-template" 14207 class="cs-min-section-width" 14208 :style="{ 'max-width': formatting_obj.section_width + '%' }" 14209 > 14210 <div 14211 class="form-color-theme" 14212 :style="{ 'background-color': color_theme }"> 14213 </div> 14214 <div class="form-header-wrapper"> 14215 <!-- name --> 14216 <div v-if="json_form && json_form.name" class="form-name flex-row-start"> 14217 <h3 class="default-font-css" style="font-size: 32px; padding: 5px; text-align: left;">{{ json_form.name }}</h3> 14218 </div> 14219 <!-- description --> 14220 <div v-if="json_form && json_form.description" class="form-description flex-row-start tinyMarginTop"> 14221 <p class="default-font-css" style="padding: 5px; text-align: left;">{{ json_form.description }}</p> 14222 </div> 14223 <!-- disabled notice --> 14224 <div v-if="form_disabled" class="form-description flex-row-start tinyMarginTop"> 14225 <p class="extra-copy-v2" style="padding: 5px; text-align: left;">This form is currently disabled and not accepting responses.</p> 14226 </div> 14227 <template v-if="!valid_membership"> 14228 <div 14229 @click="tapped_log_in()" 14230 class="noObjectsMSG leftText" 14231 style="padding: 5px; min-width: fit-content;" 14232 > 14233 <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> 14234 </div> 14235 </template> 14236 </div> 14237 14238 <template v-if="valid_membership && !pending_redirect_url"> 14239 14240 <!-- default fields --> 14241 <template v-if="collect_default_fields && selected_fields.length > 0"> 14242 <default-field-component-wb 14243 :form="json_form" 14244 :is_admin="is_admin" 14245 :default_fields="selected_fields" 14246 :rerender_key="rerender_key" 14247 ></default-field-component-wb> 14248 </template> 14249 <!-- custom fields --> 14250 <template v-if="json_form.custom_fields" v-for="(custom_field, index) in custom_fields"> 14251 <custom-field-component-wb 14252 :id="custom_field.id" 14253 :form="json_form" 14254 :is_admin="is_admin" 14255 :custom_field="custom_field" 14256 :last_update="last_update" 14257 ref="custom_field_components" 14258 @field-updated="handle_field_update" 14259 ></custom-field-component-wb> 14260 </template> 14261 <!-- submit --> 14262 <div style="width: 100%; display: flex; justify-content: space-between; align-items: center;" class="tinyMarginTop"> 14263 <button 14264 v-if="!loading_submission" 14265 @click="tapped_submit_form()" 14266 :style="{ 'background-color': button_disabled ? 'var(--custom-field-grey-2)' : color_theme }" 14267 :class="[ 'default-font-css', 'submit-button', { 'inactive': button_disabled } ]" 14268 :disabled="is_admin" 14269 >Submit</button> 14270 <i 14271 v-if="loading_submission" 14272 class="fa-sharp-duotone fa-solid fa-spinner-third fa-spin smallMarginLeft" 14273 :style="{ 'color': color_theme, 'font-size': '1.2em', 'animation-duration': '1.2s' }" 14274 ></i> 14275 <p 14276 @click="!loading_submission && tapped_clear_form()" 14277 :style="{ 'color': loading_submission ? 'var(--custom-field-grey-2)': color_theme }" 14278 style="font-size: 14px; font-weight: 600; cursor: pointer;" 14279 >Clear form</p> 14280 </div> 14281 </template> 14282 <template v-if="valid_membership && pending_redirect_url"> 14283 <div class="centeredText" style="padding: 2em 5px;"> 14284 <i 14285 class="fa-solid fa-circle-check solid-circle-check" 14286 style="font-size: 48px;" 14287 ></i> 14288 <h3 class="default-font-css smallMarginTop">Response submitted</h3> 14289 <p class="extra-copy-v2 tinyMarginTop">Redirecting...</p> 14290 </div> 14291 </template> 14292 </div> 14293 <div 14294 v-if="form_id && !json_form" 14295 class="noObjectsMSG centeredBlock centeredText" 14296 style="padding: 5em;" 14297 > 14298 <p style="cursor: pointer;"><i class="fa-light fa-list-dropdown smallMarginRight"></i>No form found.</p> 14299 </div> 14300 </template> 14301 </div> 14302</script>
14302 14303 14304<style> 14305 14306 #form-template{ 14307 display: flex; 14308 flex-direction: column; 14309 align-items: center; 14310 justify-content: flex-start; 14311 margin: 0.5em 0.5em 1em 0.5em; 14312 padding: 0 0 1.5em 0; 14313 width: 100%; 14314 max-width: 700px; 14315 height: 100%; 14316 } 14317 #form-template .form-color-theme{ 14318 color: #fff; 14319 height: 10px; 14320 border-top-left-radius: 8px; 14321 border-top-right-radius: 8px; 14322 width: 100%; 14323 padding: 0 1.5em 0 1.5em; 14324 box-sizing: border-box; 14325 } 14326 #form-template .default-font-css{ 14327 font-family: Roboto, Arial, Helvetica, sans-serif; 14328 font-size: 16px; 14329 font-weight: 400; 14330 } 14331 #form-template .flex-row-start{ 14332 display: flex; 14333 flex-direction: row; 14334 align-items: center; 14335 justify-content: flex-start; 14336 } 14337 #form-template .submit-button{ 14338 color: #fff; 14339 border-radius: 6px; 14340 font-size: 14px; 14341 padding: 8px 24px; 14342 cursor: pointer; 14343 border: none; 14344 } 14345 #form-template .submit-button.inactive{ 14346 color: #fff; 14347 border-radius: 6px; 14348 font-size: 14px; 14349 padding: 8px 24px; 14350 cursor: auto; 14351 border: none; 14352 } 14353 .form-wrapper{ 14354 display: flex; 14355 align-items: center; 14356 justify-content: center; 14357 height: fit-content; 14358 width: 100%; 14359 } 14360 .form-header-wrapper{ 14361 border-width: 0px 1px 1px 1px; 14362 border-color: rgba(195, 195, 195, 0.7); 14363 border-style: solid; 14364 width: 100%; 14365 border-bottom-left-radius: 8px; 14366 border-bottom-right-radius: 8px; 14367 padding: 1.45em; 14368 box-sizing: border-box; 14369 } 14370 .form-name{ 14371 color: rgb(32, 33, 36); 14372 box-sizing: border-box; 14373 font-weight: 400; 14374 font-size: 32px; 14375 width: 100%; 14376 min-height: 21px; 14377 border-bottom: 1.5px solid transparent; 14378 transition: border-bottom 0.1s; 14379 } 14380 .form-description{ 14381 color: rgb(32, 33, 36); 14382 box-sizing: border-box; 14383 font-weight: 400; 14384 font-size: 14px; 14385 width: 100%; 14386 line-height: 1.5; 14387 letter-spacing: 0; 14388 min-height: 21px; 14389 border-bottom: 1.5px solid transparent; 14390 transition: border-bottom 0.1s; 14391 } 14392 .form-name.input-is-focused, .form-description.input-is-focused{ 14393 border-bottom: 1.5px solid rgba(195, 195, 195, 0.7); 14394 } 14395</style> 14396
14397<script> 14398 app_content.component("form-component", { 14399 template: "#template-form", 14400 data() { 14401 return { 14402 global: store, 14403 loading: false, 14404 loading_submission: false, 14405 json_form: null, 14406 invalid_membership: false, 14407 selected_fields: [ 14408 { 14409 label: 'First name', 14410 value: 'firstName', 14411 selected: false, 14412 input_value: null, 14413 }, 14414 { 14415 label: 'Last name', 14416 value: 'lastName', 14417 selected: false, 14418 input_value: null, 14419 }, 14420 { 14421 label: 'Email', 14422 value: 'email', 14423 selected: false, 14424 input_value: null, 14425 }, 14426 { 14427 label: 'Mobile', 14428 value: 'mobile', 14429 selected: false, 14430 input_value: null, 14431 }, 14432 ], 14433 login_url: null, 14434 pending_redirect_url: null, 14435 redirect_timeout: null, 14436 }; 14437 14438 }, 14439 props: { 14440 client_id: String, 14441 is_admin: Boolean, 14442 component: Object, 14443 form: Object, 14444 formatting: String, 14445 last_update: String, 14446 }, 14447 mounted() { 14448 this.load_forms( this.client_id ); 14449 }, 14450 beforeUnmount() { 14451 if (this.redirect_timeout) { 14452 clearTimeout(this.redirect_timeout); 14453 } 14454 }, 14455 methods: { 14456 load_forms( client_id, overrides = {} ){ 14457 this.loading = true; 14458 var promise = Parse.Promise.as(); 14459 var thisPromise = promise.then(() => { 14460 var params = {}; 14461 params["client_id"] = client_id; 14462 params["form_id"] = this.form_id; 14463 params["overrides"] = overrides; 14464 params["request_url"] = window.location.href; 14465 return Parse.Cloud.run('retrieve_data_for_component', params); 14466 }).then(retrieved_form => { 14467 this.json_form = retrieved_form; 14468 if(this.json_form.members_only){ 14469 //check if theres a logged in member and redirect them if needed 14470 return this.check_valid_membership().then(is_member => { 14471 if(is_member){ 14472 this.invalid_membership = false; 14473 this.set_default_fields(); 14474 if ( this.is_admin ){ 14475 return Parse.Promise.as(); 14476 } 14477 return this.prefill_member_fields(); 14478 } 14479 }).catch(error => { 14480 if(error === "invalid_membership"){ 14481 this.invalid_membership = true; 14482 this.loading = false; 14483 this.set_login_url(); 14484 // return the error 14485 return Parse.Promise.error("invalid_membership"); 14486 } 14487 }); 14488 }else{ 14489 this.set_default_fields(); 14490 return Parse.Promise.as(); 14491 } 14492 14493 }).then(() => { 14494 this.loading = false; 14495 }, error => { 14496 checkParseError(error); 14497 this.loading = false; 14498 }); 14499 return thisPromise; 14500 }, 14501 prefill_member_fields(){ 14502 let query = new Parse.Query(Parse.User); 14503 query.include('memberObject'); 14504 return query.get(Parse.User.current().id).then(fullUser => { 14505 let memberObject = fullUser.get('memberObject'); 14506 if ( !memberObject ){ 14507 return Promise.resolve(); 14508 } 14509 let member_data = { 14510 firstName: memberObject.get('firstName') || null, 14511 lastName: memberObject.get('lastName') || null, 14512 email: memberObject.get('email') || null, 14513 mobile: memberObject.get('mobile') || null, 14514 };
14515 this.selected_fields.flat().forEach(field => { 14516 if ( member_data[field.value] ){ 14517 field.input_value = member_data[field.value]; 14518 } 14519 }); 14520 return Promise.resolve(); 14521 }); 14522 }, 14523 check_valid_membership(){ 14524 var club_id = $('body').data('clubId'); 14525 var user = Parse.User.current(); 14526 14527 var memberObject; 14528 var promise = Parse.Promise.as(); 14529 var thisPromise = promise.then(() => { 14530 if( user && user.get('memberObject') ){ 14531 //ok, get the member and membership 14532 var query = new Parse.Query("members"); 14533 query.select('membershipObject.status'); 14534 query.select('membershipObject.memberNumber'); 14535 query.select('membershipObject.tierObject'); 14536 query.select('clubObject.web_settings.custom_domain'); 14537 query.include('membershipObject'); 14538 query.include('clubObject.web_settings'); 14539 return query.get( user.get('memberObject').id ); 14540 }else{ 14541 //redirect to login 14542 return Promise.reject("invalid_membership"); 14543 } 14544 }).then(( retrievedMember )=>{ 14545 if( retrievedMember && retrievedMember.get('clubObject')?.id == club_id ){ 14546 //ok, they're legit 14547 memberObject = retrievedMember; 14548 } 14549 14550 if( ! memberObject ){ 14551 //redirect to login 14552 return Promise.reject("invalid_membership"); 14553 } 14554 //check membership 14555 let membershipObject = memberObject.get('membershipObject'); 14556 if( ! membershipObject ){ 14557 return Promise.reject("invalid_membership"); 14558 } 14559 if( membershipObject.get('status') == "canceled" ){ 14560 return Promise.reject("invalid_membership"); 14561 } 14562 var valid_membership = true; 14563 //ok, they're a legit member 14564 return Parse.Promise.as(valid_membership); 14565 }); 14566 return thisPromise; 14567 }, 14568 set_login_url(){ 14569 const clubId = $('body').data('clubId'); // or wherever you're storing it 14570 const query = new Parse.Query("clubs"); 14571 query.include("web_settings"); 14572 query.get(clubId).then(club => { 14573 const webSettings = club.get('web_settings'); 14574 const customDomain = webSettings?.get('custom_domain'); 14575 const currentUrl = window.location.href; 14576 14577 let redirectBase = customDomain 14578 ? `https://${customDomain}` 14579 : "https://theclubspot.com"; 14580 14581 const loginUrl = `${redirectBase}/login?next=${encodeURIComponent(currentUrl)}&msg=${encodeURIComponent("Please log in to view and complete this form.")}`; 14582 this.login_url = loginUrl; 14583 }); 14584 14585 14586 }, 14587 tapped_clear_form(){ 14588 if ( this.collect_default_fields && this.selected_fields.length > 0 ){ 14589 this.selected_fields.flat().forEach(default_field => { 14590 default_field.input_value = null; 14591 }) 14592 } 14593 if ( this.custom_fields && this.$refs.custom_field_components ){ 14594 this.$refs.custom_field_components.forEach(ref => { 14595 ref.clear_field(); 14596 }); 14597 } 14598 }, 14599 maybe_redirect_after_submit(redirect_url){ 14600 if (!redirect_url) { 14601 return false;
14602 } 14603 this.pending_redirect_url = redirect_url; 14604 this.redirect_timeout = setTimeout(() => { 14605 window.location.href = redirect_url; 14606 }, 2000); 14607 return true; 14608 }, 14609 tapped_submit_form(){ 14610 if ( this.form_disabled ){ 14611 return displayMessage("This form is no longer accepting submissions.", true, 4000); 14612 } 14613 if ( this.button_disabled ){ 14614 return displayMessage("Required fields are missing.", true, 4000); 14615 } 14616 let club_id = $('body').data('clubId'); 14617 if ( !club_id || !this.form_id ){ 14618 return displayMessage("There was an issue submitting your form. Please try again, or contact support if the problem persists.", true, 4000); 14619 } 14620 this.loading_submission = true; 14621 if ( this.custom_fields && this.custom_fields.length > 0 ){ 14622 for ( const custom_field of this.custom_fields ){ 14623 if ( custom_field.type === 'number' ){ 14624 if ( custom_field.input_value !== null && custom_field.input_value !== undefined && custom_field.input_value !== '' ){ 14625 if ( isNaN(Number(custom_field.input_value)) ){ 14626 this.loading_submission = false; 14627 displayMessage("Please enter a valid number.", true, 4000); 14628 return; 14629 } 14630 } 14631 } 14632 } 14633 } 14634 let club_timezone = store.clubObject && store.clubObject.get ? (store.clubObject.get('timezone') || 'UTC') : 'UTC'; 14635 return this.prepare_custom_fields_for_submit(this.custom_fields || []).then(preparedCustomFields => { 14636 let params = { 14637 club_id: club_id, 14638 form_id: this.form_id, 14639 selected_fields: this.selected_fields, 14640 collect_default_fields: this.collect_default_fields, 14641 custom_fields: preparedCustomFields, 14642 club_timezone: club_timezone 14643 }; 14644 return Parse.Cloud.run('submit_form_response', params); 14645 }).then((result) => { 14646 this.loading_submission = false; 14647 if (this.maybe_redirect_after_submit(result && result.redirect_url)) { 14648 return; 14649 } 14650 displayMessage('Your response has been successfully submitted', false, 6000); 14651 this.tapped_clear_form(); 14652 }).catch(error => { 14653 this.loading_submission = false; 14654 checkParseError(error); 14655 displayMessage(error.message || error, true, 4000); 14656 }); 14657 }, 14658 prepare_custom_fields_for_submit(custom_fields){ 14659 let promise = Promise.resolve(); 14660 let thisPromise = promise.then(() => { 14661 if( !custom_fields || custom_fields.length === 0 ){ 14662 return Promise.resolve([]); 14663 } 14664 let preparedCustomFields = []; 14665 let promises = []; 14666 for (let index = 0; index < custom_fields.length; index++) { 14667 let methodToRun = (customField, index) => { 14668 let innerPromise = promise.then(() => { 14669 let preparedCustomField = { 14670 id: customField.id, 14671 name: customField.name, 14672 type: customField.type, 14673 required: customField.required, 14674 input_value: customField.input_value, 14675 custom_field_options: customField.custom_field_options || [], 14676 order: index, 14677 }; 14678 if( customField.type !== 'file_upload' ){ 14679 preparedCustomFields.push(preparedCustomField); 14680 return Promise.resolve(); 14681 } 14682 preparedCustomField.input_value = ''; 14683 if( customField.document_id ){ 14684 preparedCustomField.document_id = customField.document_id; 14685 preparedCustomFields.push(preparedCustomField); 14686 return Promise.resolve(); 14687 } 14688 let uploadFile = this.return_upload_file_from_custom_field(customField); 14689 if( !uploadFile || !uploadFile.name ){ 14690 preparedCustomFields.push(preparedCustomField); 14691 return Promise.resolve(); 14692 } 14693 let uploadParams = { 14694 file: uploadFile, 14695 fileName: uploadFile.name 14696 }; 14697 return upload_private_file_v2(uploadParams).then(uploadResult => { 14698 preparedCustomField.uploaded_file_url = uploadResult.url; 14699 preparedCustomField.uploaded_file_key = uploadResult.file_key; 14700 preparedCustomField.uploaded_file_name = uploadFile.name; 14701 preparedCustomFields.push(preparedCustomField); 14702 return Promise.resolve(); 14703 }); 14704 }); 14705 promises.push(innerPromise); 14706 }; 14707 methodToRun(custom_fields[index], index); 14708 } 14709 return Promise.all(promises).then(() => { 14710 return Promise.resolve(preparedCustomFields); 14711 }); 14712 }); 14713 return thisPromise; 14714 }, 14715 return_upload_file_from_custom_field(customField){ 14716 if( !customField ){ 14717 return null; 14718 } 14719 if( customField.input_value && customField.input_value.name ){ 14720 return customField.input_value; 14721 } 14722 if( Array.isArray(customField.input_value) && customField.input_value.length > 0 ){ 14723 let firstFile = customField.input_value[0]; 14724 if( firstFile && firstFile.name ){ 14725 return firstFile; 14726 } 14727 } 14728 if( customField.input_value && customField.input_value.file && customField.input_value.file.name ){ 14729 return customField.input_value.file; 14730 } 14731 if( customField.input_value && customField.input_value.value && customField.input_value.value.name ){ 14732 return customField.input_value.value; 14733 } 14734 return null; 14735 }, 14736 returnIsVideo(filename) { 14737 var ext = this.getExtension(filename); 14738 switch (ext.toLowerCase()) { 14739 case 'm4v': 14740 case 'avi': 14741 case 'mpg': 14742 case 'mp4': 14743 case 'mp3': 14744 case 'mov': 14745 // etc 14746 return true; 14747 } 14748 return false;
14749 }, 14750 getExtension(filename) { 14751 var parts = filename.split('.'); 14752 return parts[parts.length - 1]; 14753 }, 14754 load_clean_image( file, type, newFileName ){ 14755 return new Promise((resolve, reject) => { 14756 setTimeout(() => { 14757 loadImage(file, canvas => { 14758 if (canvas) { 14759 this.clean_image_callback(canvas, type, newFileName, file) 14760 .then(url => resolve(url)) 14761 .catch(err => reject(err)); 14762 } else { 14763 reject(new Error('Failed to load image')); 14764 } 14765 }, 14766 { orientation: 1, canvas: true } 14767 ); 14768 }, 500); 14769 }); 14770 }, 14771 clean_image_callback( canvas, type, newFileName, file ){ 14772 return new Promise((resolve, reject) => { 14773 if (canvas) { 14774 var url = canvas.toDataURL(type); 14775 canvas.toBlob(blob => { 14776 uploadFileToAWSV2( blob, newFileName ) 14777 .then(uploadUrl => resolve(uploadUrl)) 14778 .catch(err => reject(err)); 14779 }, type); 14780 } else { 14781 reject(new Error('canvas is null')); 14782 } 14783 }); 14784 }, 14785 link_form(){ 14786 store.components.link_form.component = this.component; 14787 store.overlays.link_form = true; 14788 }, 14789 set_default_fields(){ 14790 if ( this.json_form && this.default_fields.length > 0 ){ 14791 this.selected_fields = this.selected_fields.map(default_field => { 14792 if ( this.default_fields.indexOf(default_field.value ) !== -1 ){ 14793 return { 14794 ...default_field, 14795 selected: true, 14796 } 14797 } else { 14798 return { 14799 ...default_field, 14800 selected: false, 14801 } 14802 } 14803 }); 14804 } 14805 if ( this.default_configuration === 'list' ){ 14806 //list 14807 this.selected_fields = this.selected_fields.filter(field => field.selected); 14808 } else if ( this.default_configuration === 'grid' ){ 14809 //grid 14810 this.selected_fields = this.selected_fields.filter(field => field.selected).reduce((acc, field) => { 14811 if ( field.value === 'firstName' || field.value === 'lastName' ){ 14812 acc[0].push(field); 14813 } else { 14814 acc[1].push(field); 14815 } 14816 return acc; 14817 }, [[], []]) 14818 } 14819 }, 14820 handle_field_update({ id, value }){ 14821 const field = this.custom_fields.find(f => f.id === id); 14822 if (field) { 14823 field.input_value = value; 14824 } 14825 }, 14826 }, 14827 computed: { 14828 form_id(){ 14829 if ( typeof( this.form ) === 'string' ){ 14830 return JSON.parse(this.form).id; 14831 } 14832 return this.form.id || null; 14833 }, 14834 color_theme(){ 14835 if ( this.json_form && this.json_form.color_theme ){ 14836 return this.json_form.color_theme; 14837 } 14838 return '#5280ff'; 14839 }, 14840 custom_fields(){ 14841 if ( this.json_form && this.json_form.custom_fields ){ 14842 return this.json_form.custom_fields; 14843 } 14844 return []; 14845 }, 14846 default_fields(){ 14847 if ( this.json_form && this.json_form.default_fields ){ 14848 return this.json_form.default_fields; 14849 } 14850 return []; 14851 }, 14852 collect_default_fields(){ 14853 if ( this.json_form && this.json_form.collect_default_fields ){ 14854 return this.json_form.collect_default_fields; 14855 } 14856 return false;
14857 }, 14858 default_configuration(){ 14859 if ( this.json_form && this.json_form.default_configuration ){ 14860 return this.json_form.default_configuration; 14861 } 14862 return 'list'; 14863 }, 14864 missing_default_field(){ 14865 if ( this.json_form && this.collect_default_fields && this.selected_fields.length > 0 ){ 14866 let default_values = this.default_configuration === 'list' 14867 ? this.selected_fields.map(field => field.input_value) 14868 : this.selected_fields.flatMap(arr => arr.map(field => field.input_value)); 14869 return default_values.includes(null) || default_values.includes(''); 14870 } 14871 return false; 14872 }, 14873 missing_custom_field(){ 14874 if ( this.json_form && this.custom_fields ){ 14875 for ( let i = 0; i < this.custom_fields.length; i++ ){ 14876 let custom_field = this.custom_fields[i]; 14877 let option_values = custom_field.custom_field_options && custom_field.custom_field_options.length > 0 14878 ? custom_field.custom_field_options.map(option => option.selected) 14879 : []; 14880 if ( custom_field.required && ( !custom_field.input_value && option_values.indexOf(true) === -1 ) ){ 14881 return true; 14882 } 14883 } 14884 return false; 14885 } 14886 }, 14887 form_disabled(){ 14888 if ( this.json_form && this.json_form.disabled ){ 14889 return true; 14890 } 14891 return false; 14892 }, 14893 button_disabled(){ 14894 if ( this.is_admin ){ 14895 return true; 14896 }else{ 14897 return this.form_disabled || this.missing_custom_field || this.missing_default_field; 14898 } 14899 }, 14900 formatting_obj(){ 14901 if ( this.formatting ){ 14902 return JSON.parse(this.formatting); 14903 } 14904 return; 14905 }, 14906 valid_membership(){ 14907 if(this.json_form && this.json_form.members_only){ 14908 return !this.invalid_membership; 14909 } 14910 //not members only, doesn't matter 14911 return true; 14912 }, 14913 }, 14914 watch: { 14915 form_id(newVal, oldVal){ 14916 if (newVal !== oldVal){ 14917 this.load_forms( this.client_id ); 14918 } 14919 } 14920 } 14921 }) 14922</script>
14922
14922<script type="text/html" id="template-tab-bar"> 14923 <template v-if="is_web_builder()"> 14924 <template v-if="config_latest()['tabs']?.length > 0"> 14925 <div 14926 class="tab-bar-wrapper" 14927 :style="{ 14928 gap: (config_latest().formatting.section_width || 50) + 'px', 14929 height: (config_latest().formatting.section_height || 50) + 'px', 14930 }" 14931 > 14932 <div 14933 v-for="tab in config_latest()['tabs']" 14934 :key="tab.id" 14935 style="width: 200px; display: flex; align-items: center; justify-content: center;" 14936 > 14937 <div 14938 @click="selected_tab_id = tab.id" 14939 style="width: 100%; text-align: center;" 14940 class="tab-bar-label" 14941 :class="{ 'active': selected_tab_id === tab.id }" 14942 > 14943 <span> 14944 <preview-element-component 14945 v-if="tab.elements" 14946 :element="tab.elements" 14947 :element_type="tab.elements.type" 14948 :element_content="tab.elements.content" 14949 :componentObject="component" 14950 :disable_remove="true" 14951 :disable_column="true" 14952 :disable_new_element="true" 14953 ></preview-element-component> 14954 </span> 14955 </div> 14956 </div> 14957 </div> 14958 <web-builder-components 14959 v-if="selected_tab_id" 14960 :component="return_page_component(selected_tab_id)" 14961 :component_index="0" 14962 :preview_element="true" 14963 ></web-builder-components> 14964 </template> 14965 <template v-else> 14966 <div 14967 class="noObjectsMSG centeredBlock centeredText" 14968 style="padding: 5em;" 14969 > 14970 <p style="cursor: pointer;"><i class="fa-light fa-table-columns smallMarginRight"></i>Use the left side menu to start adding tabs</p> 14971 </div> 14972 </template> 14973 </template> 14974 <template v-if="!is_web_builder() && !loading"> 14975 <template v-if="hosted_config()['tabs']?.length > 0"> 14976 <div 14977 class="tab-bar-wrapper" 14978 :style="{ 14979 gap: (hosted_config().formatting.section_width || 50) + 'px', 14980 height: (hosted_config().formatting.section_height || 50) + 'px', 14981 }" 14982 > 14983 <div 14984 v-for="tab in hosted_config()['tabs']" 14985 :key="tab.id" 14986 style="width: 200px; display: flex; align-items: center; justify-content: center;" 14987 > 14988 <div 14989 @click="selected_tab_id = tab.id" 14990 style="width: 100%; text-align: center;" 14991 class="tab-bar-label" 14992 :class="{ 'active': selected_tab_id === tab.id }" 14993 > 14994 <element-component 14995 v-if="tab.elements" 14996 :element="tab.elements" 14997 :element_type="tab.elements.type" 14998 :element_content="tab.elements.content" 14999 :componentObject="component" 15000 :disable_remove="true" 15001 :disable_column="true" 15002 :disable_new_element="true" 15003 ></element-component> 15004 </div> 15005 </div> 15006 </div> 15007 <web-builder-components 15008 v-if="selected_tab_id" 15009 :component="return_hosted_page_component(selected_tab_id)" 15010 :component_index="0" 15011 :tab_element="true" 15012 :preview_element="false" 15013 >
15013</web-builder-components> 15014 </template> 15015 </template> 15016</script>
15016 15017 15018<style> 15019 .tab-bar-wrapper{ 15020 display: flex; 15021 flex-direction: row; 15022 align-items: center; 15023 justify-content: center; 15024 border-top: 1.5px solid rgb(204, 203, 203); 15025 border-bottom: 1.5px solid rgb(204, 203, 203); 15026 box-sizing: border-box; 15027 } 15028 .tab-bar-label span { 15029 position: relative; 15030 display: inline-block; 15031 padding-bottom: 4px; 15032 border-bottom: none !important; 15033 text-decoration: none !important; 15034 } 15035 .tab-bar-label span::after { 15036 content: ""; 15037 position: absolute; 15038 bottom: 0; 15039 left: 50%; 15040 width: 0; 15041 height: 1px; 15042 background-color: #000; 15043 transform: translateX(-50%); 15044 transition: width 0.3s ease; 15045 } 15046 .tab-bar-label.active span::after { 15047 width: 100%; 15048 } 15049</style> 15050
15051<script> 15052 app_content.component('tab-bar-component', { 15053 template: '#template-tab-bar', 15054 data() { 15055 return { 15056 loading: true, 15057 selected_tab_id: null, 15058 hosted_tab_component: null, 15059 hosted_page_components: [], 15060 } 15061 }, 15062 props: { 15063 client_id: String, 15064 component: { 15065 type: Object, 15066 default: null, 15067 }, 15068 last_update: Number, 15069 }, 15070 mounted() { 15071 if(this.is_web_builder()){ 15072 this.selected_tab_id = this.config_latest()?.tabs?.[0]?.id || null; 15073 }else{ 15074 this.load_tab_bar( this.client_id ); 15075 } 15076 }, 15077 methods: { 15078 load_tab_bar( client_id, overrides = {} ){ 15079 this.loading = true; 15080 var promise = Parse.Promise.as(); 15081 var thisPromise = promise.then(() => { 15082 var params = {}; 15083 params["client_id"] = client_id; 15084 params["overrides"] = overrides; 15085 params["request_url"] = window.location.href; 15086 return Parse.Cloud.run('retrieve_data_for_component', params); 15087 }).then(response => { 15088 this.hosted_tab_component = response.tab_component || null; 15089 this.hosted_page_components = response.page_components || []; 15090 this.selected_tab_id = this.hosted_config()?.tabs?.[0]?.id || null; 15091 }).catch(error => { 15092 checkParseError(error); 15093 }).finally(() => { 15094 this.loading = false; 15095 }); 15096 return thisPromise; 15097 }, 15098 return_page_component(id){ 15099 return store.page_components_latest.find(c => c.id === id) 15100 }, 15101 return_hosted_page_component(id){ 15102 return this.hosted_page_components.find(c => c.id === id) 15103 }, 15104 config_latest(){ 15105 if(this.component && this.component.get('config_latest')){ 15106 return this.component.get('config_latest'); 15107 } 15108 }, 15109 hosted_config(){ 15110 if(this.hosted_tab_component && this.hosted_tab_component.get('config')){ 15111 return this.hosted_tab_component.get('config'); 15112 } 15113 }, 15114 is_web_builder(){ 15115 return window.location.href.includes('website-builder'); 15116 }, 15117 }, 15118 watch: { 15119 last_update(newVal) { 15120 if (this.is_web_builder()) { 15121 this.selected_tab_id = this.config_latest()?.tabs?.[0]?.id || null; 15122 } 15123 } 15124 } 15125 }); 15126</script>
15126
15126<script type="text/html" id="template-web-builder-components"> 15127 <div 15128 :id="'componentPreview_' + component.get('client_id')" 15129 class="componentPreview" 15130 :class="{'hidePreview': tab_element}" 15131 :data-client-id="component.get('client_id')" 15132 :style="return_preview_style(component)" 15133 > 15134 <div 15135 v-if="component.get('visibility') === 'members_only'" 15136 class="event-page-callout" 15137 :class="{ 'no-hover': !can_open_visibility_settings }" 15138 style="width:fit-content;font-size:12px;position: absolute;z-index:100;display:flex;align-items:center;justify-content:center;" 15139 :title="can_open_visibility_settings ? 'Edit visibility' : null" 15140 @click.stop="open_members_only_visibility_settings" 15141 > 15142 <i class="fa-solid fa-user-shield"></i> 15143 <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> 15144 </div> 15145 <template v-if="component.get('type') === 'iframe'"> 15146 <iframe-component 15147 :class_string="return_attributes(component, 'classString')" 15148 :style_string="return_attributes(component, 'style_string')" 15149 :iframe_src="return_attributes(component, 'config_latest').iframe_src" 15150 :component="component" 15151 :formatting_string="return_attributes(component, 'formatting')" 15152 :last_update="return_last_update(component)" 15153 ></iframe-component> 15154 <toolbar-option-component 15155 @click.stop 15156 :component="component" 15157 :is_first="component_index === 0" 15158 :disable_options="disable_options(component)" 15159 ></toolbar-option-component> 15160 </template> 15161 <template v-if="component.get('type') === 'camera-feed'"> 15162 <camera-feed-component 15163 :class_string="return_attributes(component, 'classString')" 15164 :style_string="return_attributes(component, 'style_string')" 15165 :client_id="return_attributes(component, 'client_id')" 15166 :camera_feed_id="return_attributes(component, 'config_latest').camera_feed_id" 15167 :refresh_interval_ms="return_attributes(component, 'config_latest').refresh_interval_ms" 15168 :component="component" 15169 :formatting_string="return_attributes(component, 'formatting')" 15170 :last_update="return_last_update(component)" 15171 ></camera-feed-component> 15172 <toolbar-option-component 15173 @click.stop 15174 :component="component" 15175 :is_first="component_index === 0" 15176 :disable_options="disable_options(component)" 15177 ></toolbar-option-component> 15178 </template> 15179 <template v-if="component.get('type') === 'blog-list'"> 15180 <blog-list-component 15181 :client_id="return_attributes(component, 'client_id')" 15182 :class_string="return_attributes(component, 'classString')" 15183 :style_string="return_attributes(component, 'style_string')" 15184 :element_style_string="return_attributes(component, 'element_style')" 15185 :formatting="return_attributes(component, 'formatting')" 15186 :component_type="return_attributes(component, 'component_type')" 15187 :is_larchmont="global.is_larchmont" 15188 :component="component" 15189 :last_update="return_last_update(component)" 15190 ></blog-list-component> 15191 <toolbar-option-component 15192 @click.stop 15193 :component="component" 15194 :is_first="component_index === 0" 15195 :disable_options="disable_options(component)" 15196 ></toolbar-option-component> 15197 </template> 15198 <template v-if="component.get('type') === 'calendar'"> 15199 <membership-calendar-component 15200 :class_string="return_attributes(component, 'classString')" 15201 :client_id="return_attributes(component, 'client_id')" 15202 :style_string="return_attributes(component, 'style_string')" 15203 :component="component" 15204 :last_update="return_last_update(component)" 15205 ></membership-calendar-component> 15206 <toolbar-option-component 15207 @click.stop 15208 :component="component" 15209 :is_first="component_index === 0" 15210 :disable_options="disable_options(component)" 15211 ></toolbar-option-component> 15212 </template> 15213 <template v-if="component.get('type') === 'membership-directory'"> 15214 <membership-directory-component 15215 :formatting="JSON.parse(return_attributes(component, 'formatting'))" 15216 :class_string="return_attributes(component, 'classString')" 15217 :client_id="return_attributes(component, 'client_id')" 15218 :style_string="return_attributes(component, 'style_string')" 15219 :element_style_string="return_attributes(component, 'element_style')" 15220 :component_type="return_attributes(component, 'component_type')" 15221 :last_update="return_last_update(component)" 15222 ></membership-directory-component> 15223 <toolbar-option-component 15224 @click.stop 15225 :component="component" 15226 :is_first="component_index === 0" 15227 :disable_options="disable_options(component)" 15228 ></toolbar-option-component> 15229 </template> 15230 <template v-if="component.get('type') === 'event-list'"> 15231 <event-list-component 15232 :class_string="return_attributes(component, 'classString')" 15233 :style_string="return_attributes(component, 'style_string')" 15234 :element_style_string="return_attributes(component, 'element_style')" 15235 :formatting="return_attributes(component, 'formatting')" 15236 :filter_types="return_attributes(component, 'filter_types')" 15237 :filter_tag_ids="return_attributes(component, 'filter_tag_ids')" 15238 :event_dates="return_attributes(component, 'event_dates')" 15239 :component_type="return_attributes(component, 'component_type')" 15240 :client_id="return_attributes(component, 'client_id')" 15241 :component="component" 15242 :last_update="return_last_update(component)" 15243 ></event-list-component> 15244 <toolbar-option-component 15245 @click.stop 15246 :component="component" 15247 :is_first="component_index === 0" 15248 :disable_options="disable_options(component)" 15249 ></toolbar-option-component> 15250 </template> 15251 <template v-if="component.get('type') === 'image-grid'"> 15252 <image-grid-component 15253 :client_id="return_attributes(component, 'client_id')" 15254 :class_string="return_attributes(component, 'classString')" 15255 :image_grid_layout="return_attributes(component, 'image_grid_layout')" 15256 :images_per_row="return_attributes(component, 'images_per_row')" 15257 :caption_size="return_attributes(component, 'caption_size')" 15258 :subtext_size="return_attributes(component, 'subtext_size')" 15259 :link_label_size="return_attributes(component, 'link_label_size')" 15260 :text_position="return_attributes(component, 'text_position')" 15261 :caption_font_weight="return_attributes(component, 'caption_font_weight')" 15262 :image_grid_opacity="return_attributes(component, 'image_grid_overlay_opacity')" 15263 :image_grid_border_radius="return_attributes(component, 'image_grid_border_radius')" 15264 :formatting="return_attributes(component, 'formatting')" 15265 :is_admin="preview_element" 15266 :data_source_type="return_attributes(component, 'data_source')['type']" 15267 :images_arr="return_attributes(component, 'images')" 15268 :images_string="JSON.stringify(return_attributes(component, 'images'))" 15269 :component="component" 15270 :last_update="return_last_update(component)" 15271 ></image-grid-component> 15272 <toolbar-option-component 15273 @click.stop 15274 :component="component" 15275 :is_first="component_index === 0" 15276 :disable_options="disable_options(component)" 15277 ></toolbar-option-component> 15278 </template> 15279 <template v-if="component.get('type') === 'photo-gallery'"> 15280 <photo-gallery-component 15281 :client_id="return_attributes(component, 'client_id')" 15282 :component="component" 15283 :photo_album="return_attributes(component, 'photo_album')"
15284 :show_caption="return_attributes(component, 'show_caption')" 15285 :caption_size="return_attributes(component, 'caption_size')" 15286 :caption_font_weight="return_attributes(component, 'caption_font_weight')" 15287 :photos_per_row="return_attributes(component, 'photos_per_row')" 15288 :is_admin="preview_element" 15289 :last_update="return_last_update(component)" 15290 ></photo-gallery-component> 15291 <toolbar-option-component 15292 @click.stop 15293 :component="component" 15294 :is_first="component_index === 0" 15295 :disable_options="disable_options(component)" 15296 ></toolbar-option-component> 15297 </template> 15298 <template v-if="component.get('type') === 'form'"> 15299 <form-component 15300 :client_id="return_attributes(component, 'client_id')" 15301 :component="component" 15302 :form="return_attributes(component, 'form')" 15303 :formatting="return_attributes(component, 'formatting')" 15304 :is_admin="preview_element" 15305 :last_update="return_last_update(component)" 15306 ></form-component> 15307 <toolbar-option-component 15308 @click.stop 15309 :component="component" 15310 :is_first="component_index === 0" 15311 :disable_options="disable_options(component)" 15312 ></toolbar-option-component> 15313 </template> 15314 <template v-if="component.get('type') === 'image-with-text'"> 15315 <image-with-text-component 15316 :client_id="return_attributes(component, 'client_id')" 15317 :formatting="return_attributes(component, 'formatting')" 15318 :images_string="JSON.stringify(return_attributes(component, 'images'))" 15319 :class_string="return_attributes(component, 'classString')" 15320 :style_string="return_attributes(component, 'style_string')" 15321 :element_style_string="return_attributes(component, 'element_style')" 15322 :image_mat="return_attributes(component, 'image_mat')" 15323 :elements_string="return_attributes(component, 'elements')" 15324 :left_right_orientation="return_attributes(component, 'left_right_orientation')" 15325 :preview_element="preview_element" 15326 :component="component" 15327 :last_update="return_last_update(component)" 15328 :last_element_update="return_last_element_update( component )" 15329 :tab_element="tab_element" 15330 ></image-with-text-component> 15331 <toolbar-option-component 15332 @click.stop 15333 :component="component" 15334 :is_first="component_index === 0" 15335 :disable_options="disable_options(component)" 15336 ></toolbar-option-component> 15337 </template> 15338 <template v-if="component.get('type') === 'text' || component.get('type') === 'table'"> 15339 <div 15340 class="cs-text background flexWrap relative" 15341 :class="return_attributes(component, 'classString')" 15342 :style="return_attributes(component, 'style_string')" 15343 > 15344 <div 15345 v-if="return_attributes(component, 'columns').length > 0" 15346 class="element-wrapper flexNoWrap flexWrapOnMobile" 15347 :class="'element-wrapper_' + return_attributes(component, 'component_type')" 15348 :style="return_attributes(component, 'element_style')" 15349 > 15350 <template 15351 v-for="(elements, column_index) in return_attributes(component, 'columns')" 15352 :key="column_index" 15353 > 15354 <text-component 15355 :number_of_columns="return_attributes(component, 'number_of_columns')" 15356 :preview_element="preview_element" 15357 :component="component" 15358 :last_update="return_last_update(component)" 15359 :last_element_update="return_last_element_update( component )" 15360 :elements="elements" 15361 :column_index="column_index" 15362 :section_element_animation="return_attributes(component, 'section_element_animation')" 15363 ></text-component> 15364 </template> 15365 </div> 15366 </div> 15367 <toolbar-option-component 15368 @click.stop 15369 :component="component" 15370 :is_first="component_index === 0" 15371 :disable_options="disable_options(component)" 15372 ></toolbar-option-component> 15373 </template> 15374 <template v-if="component.get('type') === 'hero-banner'"> 15375 <hero-banner-component 15376 ref="heroBanner" 15377 :client_id="return_attributes(component, 'client_id')" 15378 :formatting="return_attributes(component, 'formatting')" 15379 :class_string="return_attributes(component, 'classString')" 15380 :style_string="return_attributes(component, 'style_string')" 15381 :element_style_string="return_attributes(component, 'element_style')" 15382 :component_type="return_attributes(component, 'component_type')" 15383 :elements_string="return_attributes(component, 'elements')" 15384 :images_string="JSON.stringify(return_attributes(component, 'images'))" 15385 :preview_element="preview_element" 15386 :component="component" 15387 :last_update="return_last_update(component)" 15388 :last_element_update="return_last_element_update( component )" 15389 :tab_element="tab_element" 15390 ></hero-banner-component> 15391 <button 15392 v-if="preview_element" 15393 type="button" 15394 class="cs-image-slot-change-chip cs-hero-image-change-chip exclude-from-custom-button-styles" 15395 @click.stop="tapped_change_hero_image()" 15396 ><i class="fa-regular fa-image"></i><span>Change</span></button> 15397 <toolbar-option-component 15398 @click.stop 15399 :component="component" 15400 :is_first="component_index === 0" 15401 :disable_options="disable_options(component)" 15402 ></toolbar-option-component> 15403 </template> 15404 <template v-if="component.get('type') === 'custom-code'"> 15405 <custom-code-component 15406 :class_string="return_attributes(component, 'classString')" 15407 :preview_element="preview_element" 15408 :is_first="is_first_visible_component(component)" 15409 :component="component" 15410 :last_update="return_last_update(component)" 15411 ></custom-code-component> 15412 <toolbar-option-component 15413 @click.stop 15414 :component="component" 15415 :is_first="component_index === 0" 15416 :disable_options="disable_options(component)" 15417 ></toolbar-option-component> 15418 </template> 15419 <template v-if="component.get('type') === 'unlayer-component'"> 15420 <unlayer-component 15421 :class_string="return_attributes(component, 'classString')" 15422 :preview_element="preview_element" 15423 :component="component" 15424 :last_update="return_last_update(component)" 15425 ></unlayer-component> 15426 <toolbar-option-component 15427 @click.stop 15428 :component="component" 15429 :is_first="component_index === 0" 15430 :disable_options="disable_options(component)" 15431 ></toolbar-option-component> 15432 </template> 15433 <!-- <template v-if="component.get('type') === 'tab-bar'"> 15434 <tab-bar-component 15435 :client_id="return_attributes(component, 'client_id')" 15436 :preview_element="preview_element" 15437 :component="component" 15438 :last_update="return_last_update(component)" 15439 ></tab-bar-component> 15440 <toolbar-option-component 15441 @click.stop 15442 :component="component" 15443 :is_first="component_index === 0" 15444 :disable_options="disable_options(component)" 15445 ></toolbar-option-component> 15446 </template> --> 15447 </div> 15448</script>
15448 15449 15450<style> 15451 /* Hero Change chip: sibling of the section under .componentPreview so 15452 overlap masks / fades can't cover it. */ 15453 .componentPreview > .cs-hero-image-change-chip { 15454 position: absolute; 15455 top: auto; 15456 right: auto; 15457 bottom: calc(12px + var(--cs-overlap-chrome-lift, 0px)); 15458 left: 50%; 15459 transform: translateX(-50%); 15460 z-index: 3000; 15461 display: inline-flex; 15462 align-items: center; 15463 gap: 5px; 15464 padding: 6px 10px; 15465 border: none; 15466 border-radius: 6px; 15467 background: rgba(20, 24, 45, 0.82); 15468 color: #fff; 15469 font-family: 'Roboto', Arial, Helvetica, sans-serif; 15470 font-size: 12px; 15471 font-weight: 600; 15472 font-style: normal; 15473 line-height: 1; 15474 letter-spacing: 0; 15475 text-transform: none; 15476 cursor: pointer; 15477 opacity: 0; 15478 pointer-events: none; 15479 box-shadow: 0 4px 12px rgba(8, 19, 51, 0.22); 15480 transition: opacity 0.12s ease, background 0.12s ease; 15481 } 15482 .componentPreview:hover > .cs-hero-image-change-chip, 15483 .componentPreview > .cs-hero-image-change-chip:focus { 15484 opacity: 0.92; 15485 pointer-events: auto; 15486 } 15487 .componentPreview:hover > .cs-hero-image-change-chip:hover, 15488 .componentPreview > .cs-hero-image-change-chip:focus { 15489 opacity: 1; 15490 background: rgba(20, 24, 45, 0.95); 15491 } 15492 .componentPreview.settings-active > .cs-hero-image-change-chip { 15493 opacity: 0 !important; 15494 visibility: hidden !important; 15495 pointer-events: none !important; 15496 } 15497</style> 15498
15499<script> 15500 app_content.component('web-builder-components', { 15501 template: '#template-web-builder-components', 15502 data() { 15503 return { 15504 global: store, 15505 is_admin: false, 15506 } 15507 }, 15508 props: { 15509 component: Object, 15510 component_index: Number, 15511 tab_element: Boolean, 15512 preview_element: Boolean, 15513 last_update: Number, 15514 }, 15515 methods: { 15516 component_is_linked_to_tab_bar( componentObject ){ 15517 if (!componentObject || !Array.isArray(store.page_components_latest)) { 15518 return false; 15519 } 15520 return store.page_components_latest.some(c => { 15521 if (!c || !c.get || c.get('type') !== 'tab-bar') { 15522 return false; 15523 } 15524 let tabs = (c.get('config_latest') && c.get('config_latest').tabs) || []; 15525 return tabs.some(tab => { 15526 return tab && tab.id === componentObject.id; 15527 }); 15528 }); 15529 }, 15530 is_first_visible_component( componentObject ){ 15531 if (!componentObject || !Array.isArray(store.page_components_latest)) { 15532 return this.component_index === 0; 15533 } 15534 let first_visible = store.page_components_latest.find(c => { 15535 return c && !this.component_is_linked_to_tab_bar(c); 15536 }); 15537 return !!(first_visible && first_visible.id === componentObject.id); 15538 }, 15539 disable_options( componentObject ){ 15540 return this.component_is_linked_to_tab_bar(componentObject); 15541 }, 15542 open_members_only_visibility_settings(){ 15543 if( typeof window.wb_open_component_visibility_settings !== 'function' ){ 15544 return; 15545 } 15546 window.wb_open_component_visibility_settings(this.component); 15547 }, 15548 tapped_change_hero_image(){ 15549 let hero = this.$refs.heroBanner; 15550 if( !hero || typeof hero.tapped_change_hero_image !== 'function' ){ 15551 return; 15552 } 15553 hero.tapped_change_hero_image(); 15554 }, 15555 return_last_element_update( componentObject ){ 15556 return store.element_updates ? store.element_updates[ componentObject.id ] : 0; 15557 }, 15558 return_last_update(componentObject){ 15559 return store.component_updates ? store.component_updates[ componentObject.id ] : 0; 15560 }, 15561 return_preview_style(componentObject){ 15562 let type = componentObject.get('type'); 15563 if ( type === 'hero-banner' || type === 'image-with-text' ){ 15564 return this.return_attributes(componentObject, 'background_image_style'); 15565 } 15566 }, 15567 return_attributes(componentObject, attribute){ 15568 let attributes = {}; 15569 if(componentObject){ 15570 var type = componentObject.get('type'); 15571 var config_latest = componentObject.get('config_latest') || returnDefaultConfigForComponent( type ); 15572 var images = config_latest.images; 15573 if(!images){ 15574 // images = []; 15575 images = [null, null, null, null, null] 15576 } 15577 var formatting = config_latest.formatting; 15578 if( ! formatting ){ 15579 formatting = returnDefaultFormattingForComponent( type ); 15580 } 15581 var color_scheme = config_latest.color_scheme; 15582 if( !color_scheme ){ 15583 let default_config = returnDefaultConfigForComponent( type ); 15584 color_scheme = (default_config && default_config.color_scheme) ? default_config.color_scheme : 'white'; 15585 } 15586 if (store.is_larchmont && store.pageObject.id === 'QAcpSXHP5G'){ 15587 color_scheme = 'larchmont_secondary'; 15588 } 15589 //start the class string up here 15590 var classString = ''; 15591 classString += ' color_scheme_' + color_scheme; 15592 //now generate markup 15593 var background_image_style = ""; 15594 var style_string = ""; 15595 if( formatting.section_height ){ 15596 // Browser vh is the outer window; the canvas is #previewZoneHeight. 15597 // Use cqh (preview container) so heights match iframe/live viewport, 15598 // including absolute/sticky header overlay. Keep vh as fallback. 15599 let section_vh = ( formatting.section_height ).toString(); 15600 style_string += "min-height:" + section_vh + "vh;"; 15601 style_string += "min-height:calc(var(--wb-preview-vh, 1vh) * " + section_vh + ");"; 15602 style_string += "min-height:" + section_vh + "cqh;"; 15603 } 15604 if( formatting.align_items 15605 && ( typeof is_web_builder_element_stack_component !== 'function' 15606 || is_web_builder_element_stack_component( type ) ) ){ 15607 style_string += "align-items:" + formatting.align_items + ";" ; 15608 } 15609 if ( formatting.background_size === 'inset' ){ 15610 background_image_style += "padding: 30px;" ; 15611 } else if ( formatting.background_size === 'cover' ){ 15612 background_image_style += "padding: 0px;" ; 15613 } 15614 //element wrapper 15615 var element_style = ""; 15616 //extra_columns 15617 var extra_columns = config_latest.extra_columns; 15618 if( extra_columns ){ 15619 for (var c = extra_columns.length - 1; c >= 0; c--) { 15620 classString += ' show_' + extra_columns[c]; 15621 } 15622 } 15623 //images per row (image grid) 15624 var images_per_row = config_latest.images_per_row; 15625 if( ! images_per_row ){ 15626 images_per_row = 3; 15627 } 15628 //photos per row (photo gallery) 15629 var photos_per_row = config_latest.photos_per_row; 15630 if( ! photos_per_row ){ 15631 photos_per_row = 4; 15632 } 15633 //left/right orientation (image with text) 15634 var left_right_orientation = config_latest.left_r
15634ight_orientation; 15635 if( ! left_right_orientation ){ 15636 left_right_orientation = "left"; 15637 } 15638 classString += " orientation-" + left_right_orientation; 15639 //number of columns (text) 15640 var number_of_columns = config_latest.number_of_columns; 15641 if( ! number_of_columns ){ 15642 number_of_columns = 3; 15643 } 15644 classString += " number_of_columns-" + number_of_columns; 15645 15646 // Opt-in refinement tokens (padding, fade edges, scroll/element 15647 // anim, max-width cap). Absent keys leave classString/styles unchanged. 15648 let refinement_result = apply_web_builder_refinement_formatting({ 15649 formatting: formatting, 15650 class_string: classString, 15651 element_style: element_style, 15652 activate_immediately: ( typeof window !== 'undefined' && window.location.href.indexOf('website-builder') !== -1 ), 15653 is_first_section: this.is_first_visible_component(componentObject), 15654 }); 15655 classString = refinement_result.class_string; 15656 element_style = refinement_result.element_style; 15657 15658 //overlay opacity 15659 var opacity = 0; 15660 if( formatting.overlay_opacity ){ 15661 opacity = formatting.overlay_opacity; 15662 } 15663 //photo album id 15664 var photo_album = config_latest.photo_album; 15665 if( ! photo_album ){ 15666 photo_album = { 15667 id: null, 15668 name: null, 15669 }; 15670 } 15671 //form id 15672 var form = config_latest.form; 15673 if( ! form ){ 15674 form = { 15675 id: null, 15676 name: null, 15677 }; 15678 } 15679 //image with text 15680 var image_mat = config_latest.image_mat ? config_latest.image_mat : 'show'; 15681 //filter types (event list) 15682 var filter_types = config_latest.filter_types ? JSON.stringify(config_latest.filter_types) : null; 15683 //filter tag IDs (event list) 15684 var filter_tag_ids = config_latest.filter_tag_ids ? JSON.stringify(config_latest.filter_tag_ids) : null; 15685 //event_dates (event list) 15686 var event_dates = config_latest.event_dates ? config_latest.event_dates : 'upcoming'; 15687 //caption style 15688 if( formatting.caption_style ){ 15689 classString += " caption_style-" + formatting.caption_style; 15690 } 15691 //caption alignment 15692 if( formatting.caption_align && ( formatting.caption_align == "top" || formatting.caption_align == "bottom" || formatting.caption_align == "under" ) ){ 15693 classString += ' ' + formatting.caption_align + '-align-image-caption'; 15694 } 15695 var show_caption = formatting.show_caption; 15696 if( ! show_caption ){ 15697 show_caption = false; 15698 } 15699 var caption_size = formatting.caption_size; 15700 if( ! caption_size ){ 15701 caption_size = "24"; 15702 } 15703 var subtext_size = formatting.subtext_size; 15704 if( ! subtext_size ){ 15705 subtext_size = "24"; 15706 } 15707 var link_label_size = formatting.link_label_size; 15708 if( ! link_label_size ){ 15709 link_label_size = "24"; 15710 } 15711 var text_position = formatting.text_position; 15712 if( ! text_position ){ 15713 text_position = "center"; 15714 } 15715 var caption_font_weight = formatting.caption_font_weight; 15716 if( ! caption_font_weight ){ 15717 caption_font_weight = "400"; 15718 } 15719 var image_grid_overlay_opacity = formatting.image_grid_overlay_opacity; 15720 if( ! image_grid_overlay_opacity ){ 15721 image_grid_overlay_opacity = "40"; 15722 } 15723 var image_grid_border_radius = formatting.image_grid_border_radius; 15724 if ( ! image_grid_border_radius ){ 15725 image_grid_border_radius = "0" 15726 } 15727 var image_grid_layout = formatting.layout; 15728 if ( ! image_grid_layout ){ 15729 image_grid_layout = "grid" 15730 } 15731 //add all variables to attribute object 15732 attributes['client_id'] = componentObject.get('client_id'); 15733 attributes['component_type'] = componentObject.get('type'); 15734 if( componentObject.get('config') && componentObject.get('config').event_types ){ 15735 attributes['event_types'] = componentObject.get('config').event_types; 15736 } 15737 // attributes['images'] = componentObject.get('images'); 15738 attributes['type'] = type; 15739 attributes['config_latest'] = config_latest; 15740 attributes['data_source'] = config_latest['data_source']; 15741 attributes['images'] = images; 15742 attributes['formatting'] = JSON.stringify(formatting); 15743 attributes['color_scheme'] = color_scheme; 15744 attributes['classString'] = classString; 15745 attributes['style_string'] = style_string; 15746 attributes['elements'] = config_latest['elements']; 15747 attributes['element_style'] = element_style; 15748 attributes['opacity'] = opacity;
15749 attributes['extra_columns'] = extra_columns; 15750 attributes['images_per_row'] = images_per_row; 15751 attributes['photos_per_row'] = photos_per_row; 15752 attributes['left_right_orientation'] = left_right_orientation; 15753 attributes['columns'] = config_latest.columns; 15754 attributes['number_of_columns'] = number_of_columns; 15755 attributes['show_caption'] = show_caption; 15756 attributes['caption_size'] = caption_size; 15757 attributes['subtext_size'] = subtext_size; 15758 attributes['link_label_size'] = link_label_size; 15759 attributes['text_position'] = text_position; 15760 attributes['caption_font_weight'] = caption_font_weight; 15761 attributes['image_grid_overlay_opacity'] = image_grid_overlay_opacity; 15762 attributes['image_grid_border_radius'] = image_grid_border_radius; 15763 attributes['image_grid_layout'] = image_grid_layout; 15764 attributes['background_image_style'] = background_image_style; 15765 attributes['photo_album'] = photo_album; 15766 attributes['form'] = form; 15767 attributes['filter_types'] = filter_types; 15768 attributes['filter_tag_ids'] = filter_tag_ids; 15769 attributes['event_dates'] = event_dates; 15770 attributes['image_mat'] = image_mat; 15771 let section_element_animation = refinement_result.section_element_animation || ''; 15772 attributes['section_element_animation'] = section_element_animation; 15773 //return requested attribute 15774 return attributes[attribute]; 15775 } 15776 }, 15777 }, 15778 computed: { 15779 can_open_visibility_settings(){ 15780 return typeof window.wb_open_component_visibility_settings === 'function'; 15781 }, 15782 }, 15783 }); 15784</script>
15784 15785 <!-- overlays --> 15786
15786<script type="text/html" id="template-view-all-events"> 15787 <bottom-sheet-overlay 15788 id="overlay_view_all_events" 15789 @dismiss_overlay="dismiss_overlay()" 15790 > 15791 <template #header> 15792 <div class="sticky-wrap overflowHidden"> 15793 <div class="posScrollbar table-tab-wrap noPaddingBottom"> 15794 <div class="flexNoWrap table-tab-inner"> 15795 <div class="table-tab-option" :class="{'active': date_filter === 'all'}" @click="handleDatesFilter('all')" style="margin-left:22px;">All</div> 15796 <div class="table-tab-option noMarginLeft" :class="{'active': date_filter === 'upcoming'}" @click="handleDatesFilter('upcoming')">Upcoming</div> 15797 <div class="table-tab-option noMarginLeft" :class="{'active': date_filter === 'past'}" @click="handleDatesFilter('past')">Past</div> 15798 </div> 15799 </div> 15800 </div> 15801 <div class="searchBarWrap inline-search relative noMargin overflowHidden"> 15802 <div class="inputWrap searchBar searchBar_event-list flexGrowOne boxShadowBottom maxFourHundo"> 15803 <input 15804 class="easeFast" 15805 placeholder="Search events..." 15806 v-model="search" 15807 > 15808 </div> 15809 </div> 15810 </template> 15811 15812 <!-- body content --> 15813 <template #body> 15814 <div 15815 class="event-list-outer-contain lightBorderTop scrollable" 15816 :class="class_string" 15817 :style="style_string" 15818 > 15819 <div 15820 class="event-list-wrap centeredBlock centeredText event-list-inner-contain" 15821 > 15822 <div class="fff noPadding overflowHidden"> 15823 <div v-if="!loading && sorted_event_data.length >= 1" class="tableInsert hundop flexWrapEvent"> 15824 <template 15825 v-for="(event_data, index) in sorted_event_data" 15826 :key="event_key(event_data)" 15827 > 15828 <p 15829 v-if="is_first_visible_event_of_month(event_data)" 15830 class="cs-section-header" 15831 >{{ month_string( event_data ) }}</p> 15832 <event-list-event-component 15833 :event_data="event_data" 15834 :event_props="return_event_props( event_data )" 15835 @tapped-event-list-event="tapped_event_list_event" 15836 ></event-list-event-component> 15837 </template> 15838 </div> 15839 <no-objects-found 15840 v-if="!loading && sorted_event_data.length == 0" 15841 subtext="No events found" 15842 icon_class="fa-light fa-calendar-days" 15843 ></no-objects-found> 15844 </div> 15845 <div v-if="loading" class="cardBodySpinnerWrap active relative"> 15846 <div class="cardBodySpinner centeredBlock"></div> 15847 </div> 15848 </div> 15849 </div> 15850 </template> 15851 15852 </bottom-sheet-overlay> 15853</script>
vendor: 1 bytes, line 15853
15853
15854<script> 15855 15856 function view_all_events( params ){ 15857 store.components.view_all_events.event_data = params.event_data; 15858 store.components.view_all_events.class_string = params.class_string; 15859 store.components.view_all_events.style_string = params.style_string; 15860 store.components.view_all_events.element_style_string = params.element_style_string; 15861 store.components.view_all_events.props_generator = params.props_generator; 15862 store.overlays.view_all_events = true; 15863 } 15864 //global store 15865 store.components["view_all_events"] = { 15866 event_data: [], 15867 date_filter: 'upcoming', 15868 search: '', 15869 loading: false, 15870 sortObject: { 15871 order: 'ascending', 15872 key: 'startDate' 15873 }, 15874 //passed props 15875 class_string: '', 15876 style_string: '', 15877 element_style_string: '', 15878 props_generator: null, //function to generate props for the individual events 15879 }; 15880 15881 //define the component 15882 app_overlays.component("view-all-events-overlay", { 15883 template: "#template-view-all-events", 15884 data() { 15885 return store.components.view_all_events 15886 }, 15887 mounted(){ 15888 15889 }, 15890 methods: { 15891 month_string( event_data ){ 15892 let start_date = new Date(event_data.ts); 15893 return format_utc_date_v2({ 15894 "utcDate": start_date, 15895 "timezone": store.timezone, 15896 "formatString": "MMMM YYYY" 15897 }); 15898 }, 15899 event_key( event_data ){ 15900 return event_data.event.id + '_' + event_data.ts; 15901 }, 15902 is_first_visible_event_of_month( event_data ){ 15903 return this.first_visible_events.includes( this.event_key(event_data) ); 15904 }, 15905 return_event_props( event_data ){ 15906 return this.props_generator( event_data ); 15907 }, 15908 sortEventList( event_data, sortObject ){ 15909 event_data = [... event_data]; 15910 if( ! sortObject ){ 15911 sortObject = {} 15912 } 15913 if( ! sortObject.key ){ 15914 sortObject.key = "startDate"; 15915 } 15916 if( ! sortObject.order ){ 15917 sortObject.order = "ascending" 15918 } 15919 if( sortObject.key == "name" ){ 15920 if( sortObject.order == "ascending" ){ 15921 return event_data.sort( this.sortByEventName_ascending ); 15922 }else{ 15923 return event_data.sort( this.sortByEventName_descending ); 15924 } 15925 }else if( sortObject.key == "startDate" ){ 15926 if( sortObject.order == "ascending" ){ 15927 return event_data.sort( this.sortByStartDate_event_ascending ); 15928 }else{ 15929 return event_data.sort( this.sortByStartDate_event_descending ); 15930 } 15931 }else if( sortObject.key == "endDate" ){ 15932 if( sortObject.order == "ascending" ){ 15933 return event_data.sort( this.sortByEndDate_event_ascending ); 15934 }else{ 15935 return event_data.sort( this.sortByEndDate_event_descending ); 15936 } 15937 } 15938 return event_data; 15939 }, 15940 sortByEventName_ascending( a, b ){ 15941 let event_a = a.event; 15942 let event_b = b.event; 15943 var aName = ''; 15944 if( event_a.get('name') ){ 15945 aName += event_a.get('name').trim().toLowerCase(); 15946 } 15947 var bName = ''; 15948 if( event_b.get('name') ){ 15949 bName += event_b.get('name').trim().toLowerCase(); 15950 } 15951 if(aName < bName) { return -1; } 15952 if(aName > bName) { return 1; } 15953 //they had the same name 15954 return 0; 15955 }, 15956 sortByEventName_descending( a, b ){ 15957 let event_a = a.event; 15958 let event_b = b.event; 15959 var aName = ''; 15960 if( event_a.get('name') ){ 15961 aName += event_a.get('name').trim().toLowerCase(); 15962 } 15963 var bName = ''; 15964 if( event_b.get('name') ){ 15965 bName += event_b.get('name').trim().toLowerCase(); 15966 } 15967 if(aName > bName) { return -1; } 15968 if(aName < bName) { return 1; } 15969 //they had the same name 15970 return 0; 15971 }, 15972 sortByStartDate_event_ascending( a, b ){ 15973 let a_ts = a.ts; 15974 let b_ts = b.ts; 15975 if(a_ts < b_ts) { return -1; } 15976 if(a_ts > b_ts) { return 1; } 15977 return 0; 15978 }, 15979 sortByStartDate_event_descending( a, b ){ 15980 let a_ts = a.ts; 15981 let b_ts = b.ts; 15982 if(a_ts > b_ts) { return -1; } 15983 if(a_ts < b_ts) { return 1; } 15984 return 0; 15985 }, 15986 sortByEndDate_event_ascending( a, b ){ 15987 let a_end_ts = a.end_ts; 15988 let b_end_ts = b.end_ts; 15989 if(a_end_ts < b_end_ts) { return -1; } 15990 if(a_end_ts > b_end_ts) { return 1; } 15991 return 0; 15992 }, 15993 sortByEndDate_event_descending( a, b ){ 15994 let a_end_ts = a.end_ts; 15995 let b_end_ts = b.end_ts; 15996 if(a_end_ts < b_end_ts) { return 1; } 15997 if(a_end_ts > b_end_ts) { return -1; } 15998 return 0; 15999 }, 16000 tapped_event_list_event( event_data ){ 16001 let event = event_data.event; 16002 if(event && event.className == 'events') { 16003 $('#overlay_event-description-popup h3').text(event.get('name')); 16004 let start_date = this.start_date( event_data ); 16005 let nice_date = format_utc_date_v2({ 16006 "utcDate": start_date, 16007 "timezone": store.timezone, 16008 "formatString": "MMM D, YYYY" 16009 }); 16010 $('#overlay_event-description-popup .metric_start_date').text( nice_date ); 16011 if( this.show_time( event ) ){ 16012 let event_time = format_utc_date_v2({ 16013 "utcDate": start_date, 16014 "timezone": store.timezone, 16015 "formatString": "h:mm A" 16016 }); 16017 $('#overlay_event-description-popup .metric_start_date_time').text( event_time ).removeClass('incomplete'); 16018 }else{ 16019 $('#overlay_event-description-popup .metric_start_date_time').text('Not provided').addClass('incomplete'); 16020 } 16021 if(event.get('description')) { 16022 $('#overlay_event-description-popup .metric_description').html(event.get('description')).removeClass('incomplete'); 16023 } else { 16024 $('#overlay_event-description-popup .metric_description').text('Not provided').addClass('incomplete'); 16025 } 16026 fadeIn('#overlay_event-description-popup'); 16027 } 16028 }, 16029 start_date( event_data ){ 16030 if( event_data.ts ){ 16031 return new Date(event_data.ts); 16032 } 16033 return null; 16034 }, 16035 show_time( event ){ 16036 if( 16037 event.get('calendarOnly') && event.get('all_day') 16038 ){ 16039 return false;
16040 }else if( 16041 event.className == "camps" || 16042 event.className == "regattas" || 16043 event.className == "subevents" 16044 ){ 16045 return false; 16046 } 16047 return true; 16048 }, 16049 handleDatesFilter( option ){ 16050 this.date_filter = option; 16051 // handle sortObject["order"] here instead of in loadEventList post-retrieval for better UX 16052 if ( option === 'upcoming' ){ 16053 this.sortObject["order"] = "ascending"; 16054 } else { 16055 this.sortObject["order"] = "descending"; 16056 } 16057 }, 16058 dismiss_overlay(){ 16059 store.overlays.view_all_events = false; 16060 } 16061 }, 16062 computed: { 16063 first_visible_events(){ 16064 let first_visible_events = []; 16065 let handled_keys = []; 16066 //loop through the event_data 16067 this.sorted_event_data.forEach(event_data => { 16068 let start_date = new Date(event_data.ts); 16069 let start_components = return_date_components_v2({ 16070 "date": start_date, 16071 "timezone": store.timezone 16072 }); 16073 let key = start_components.year + '_' + start_components.month; 16074 if( ! handled_keys.includes(key) ){ 16075 first_visible_events.push( this.event_key(event_data) ); 16076 handled_keys.push(key); 16077 } 16078 }); 16079 return first_visible_events; 16080 }, 16081 pre_filtered_event_data(){ 16082 //filter by date first 16083 let pre_filtered_event_data = [...this.event_data]; 16084 var timestamp = new Date().getTime(); 16085 if ( this.date_filter !== 'all' ){ 16086 pre_filtered_event_data = pre_filtered_event_data.filter(event_data => { 16087 let start = event_data.ts; 16088 let end = event_data.end_ts; 16089 if (this.date_filter === 'past'){ 16090 if( start < timestamp && ( end < timestamp || ! end || end == "undefined" ) ){ 16091 return true; 16092 } 16093 } else if (this.date_filter === 'upcoming'){ 16094 if ( start > timestamp || end > timestamp ){ 16095 return true; 16096 } 16097 // Single-day events: ts and end_ts are both midnight, so they get filtered out 16098 // when the event is today. Include if the event's date is today or later. 16099 if ( start === end || !end ){ 16100 let startOfEventDay = new Date(start); 16101 startOfEventDay.setUTCHours(0, 0, 0, 0); 16102 let startOfToday = new Date(); 16103 startOfToday.setUTCHours(0, 0, 0, 0); 16104 if ( startOfEventDay.getTime() >= startOfToday.getTime() ){ 16105 return true; 16106 } 16107 } 16108 } 16109 return false; 16110 }) 16111 } 16112 return pre_filtered_event_data; 16113 }, 16114 filtered_event_data(){ 16115 let filtered_event_data = this.pre_filtered_event_data; 16116 //filter by search 16117 if (this.search.length > 0){ 16118 filtered_event_data = filtered_event_data.filter(event_data => { 16119 let name = event_data.event.get('name').toLowerCase(); 16120 if ( name.includes(this.search.toLowerCase()) ){ 16121 return event_data; 16122 } 16123 }) 16124 } 16125 // handle tab selection using filteredEvents 16126 var yesterday = new Date(); 16127 yesterday.setDate( yesterday.getDate() - 1 ); 16128 var timestamp = yesterday.getTime(); 16129 16130 //filter by tag 16131 if( this.selected_event_tag_id ){ 16132 filtered_event_data = filtered_event_data.filter(event_data => { 16133 let event_tag_ids = event_data.event_tag_ids; 16134 return event_tag_ids && event_tag_ids.includes(this.selected_event_tag_id); 16135 }) 16136 } 16137 //return them 16138 return filtered_event_data; 16139 }, 16140 sorted_event_data(){ 16141 return this.sortEventList( this.filtered_event_data, this.sortObject ); 16142 }, 16143 } 16144 }); 16145 16146</script>
16146
16146<script type="text/html" id="template-view-photo-carousel"> 16147 <div 16148 id="overlay_view-photo-carousel" 16149 class="carousel-v-overlay" 16150 :class="{ 'carousel-v-overlay-blur': should_blur_background }" 16151 @touchstart="touched_overlay()" 16152 @mousedown="touched_overlay()" 16153 @click.self="clicked_overlay()" 16154 > 16155 <div 16156 class="carousel-container" 16157 @click.self="clicked_overlay()" 16158 > 16159 <div class="actions-container" @click.stop> 16160 <div 16161 class="event-page-callout close-action-container" 16162 :class="{ 'carousel-action-icon-only': use_icon_only_actions }" 16163 style="color: #fff;" 16164 @click.stop="dismiss_overlay()" 16165 > 16166 <i class="fa-regular fa-xmark" style="color: #fff;"></i> 16167 <template v-if="!use_icon_only_actions">Close</template> 16168 </div> 16169 <div 16170 v-if="can_show_download" 16171 class="event-page-callout" 16172 :class="{ 'carousel-action-icon-only': use_icon_only_actions }" 16173 style="color: #fff;" 16174 @click.stop="tapped_download()" 16175 > 16176 <template v-if="!downloading"> 16177 <i class="fa-regular fa-arrow-down-to-line" style="color: #fff;"></i> 16178 <template v-if="!use_icon_only_actions">Download</template> 16179 </template> 16180 <template v-else> 16181 <i class="fa-regular fa-check" style="color: #fff;"></i> 16182 <template v-if="!use_icon_only_actions">Downloaded</template> 16183 </template> 16184 </div> 16185 <div 16186 v-if="can_show_share" 16187 class="event-page-callout" 16188 :class="{ 'carousel-action-icon-only': use_icon_only_actions }" 16189 style="color: #fff;" 16190 @click.stop="tapped_share()" 16191 > 16192 <template v-if="!sharing"> 16193 <i class="fa-regular fa-arrow-up-from-bracket" style="color: #fff;"></i> 16194 <template v-if="!use_icon_only_actions">Share</template> 16195 </template> 16196 <template v-else> 16197 <i class="fa-regular fa-check" style="color: #fff;"></i> 16198 <template v-if="!use_icon_only_actions">Link copied</template> 16199 </template> 16200 </div> 16201 </div> 16202 <i v-if="!hide_nav_arrows" class="fa-light fa-chevron-left nav-arrow" @click.stop="prev()"></i> 16203 <div v-if="current_photo" class="carousel-photo-container" @click="clicked_overlay()"> 16204 <div class="carousel-photo-frame" @click.stop> 16205 <video 16206 v-if="returnIsVideo(current_photo.file_name) && current_photo.url" 16207 class="carousel-photo" 16208 :class="{ 'is-loaded': detail_photo_loaded }" 16209 autoplay muted loop playsinline 16210 :key="'video-' + current_index" 16211 @loadeddata="detail_photo_loaded = true" 16212 > 16213 <source :src="current_photo.url" type="video/mp4"> 16214 </video> 16215 <img 16216 v-else-if="current_photo.url" 16217 class="carousel-photo" 16218 :class="{ 'is-loaded': detail_photo_loaded }" 16219 :src="current_photo.url" 16220 :key="'photo-' + current_index" 16221 alt="" 16222 ref="detail_photo" 16223 @load="detail_photo_loaded = true" 16224 > 16225 <template v-if="show_caption"> 16226 <p class="carousel-caption" v-if="current_photo.caption" :style="{ 'font-size': caption_size + 'px', 'font-weight': caption_font_weight }">{{ current_photo.caption }}</p> 16227 </template> 16228 </div> 16229 </div> 16230 <div v-else class="carousel-photo-container" @click="clicked_overlay()"> 16231 <p class="carousel-caption">No media found</p> 16232 </div> 16233 <i v-if="!hide_nav_arrows" class="fa-light fa-chevron-right nav-arrow" @click.stop="next()"></i> 16234 </div> 16235 </div> 16236</script>
16236 16237 16238<style> 16239 .carousel-v-overlay{ 16240 display: flex; 16241 align-items: center; 16242 justify-content: center; 16243 cursor: auto; 16244 background-color: transparent; 16245 position: fixed; 16246 z-index: 1000 !important; 16247 left: 0px; 16248 right: 0px; 16249 top: 0px; 16250 bottom: 0px; 16251 width: 100vw; 16252 height: 100vh; 16253 } 16254 .carousel-v-overlay::before{ 16255 content: ''; 16256 position: absolute; 16257 inset: 0; 16258 z-index: 0; 16259 pointer-events: none; 16260 background-color: rgba(8,8,19,0.64); 16261 opacity: 0; 16262 animation: carousel-overlay-fade 0.3s ease forwards; 16263 } 16264 .carousel-v-overlay-blur::before{ 16265 backdrop-filter: blur(6px); 16266 } 16267 @keyframes carousel-overlay-fade { 16268 0% { 16269 opacity: 0; 16270 } 16271 100% { 16272 opacity: 1; 16273 } 16274 } 16275 @keyframes carousel-chrome-fade { 16276 0% { 16277 opacity: 0; 16278 } 16279 100% { 16280 opacity: 1; 16281 } 16282 } 16283 @keyframes carousel-photo-scale { 16284 0% { 16285 transform: scale(0.8); 16286 } 16287 100% { 16288 transform: scale(1); 16289 } 16290 } 16291 .carousel-container{ 16292 display: flex; 16293 justify-content: space-between; 16294 align-items: center; 16295 height: 100vh; 16296 width: 98vw; 16297 position: relative; 16298 z-index: 1; 16299 overflow: hidden; 16300 cursor: auto; 16301 background-color: rgba(0, 0, 0, 0); 16302 } 16303 .carousel-container > .nav-arrow { 16304 font-size: 28px; 16305 padding: 1em; 16306 color: #fff; 16307 z-index: 1; 16308 opacity: 0; 16309 animation: carousel-nav-fade 0.3s ease forwards; 16310 transition: opacity 0.3s ease; 16311 cursor: pointer; 16312 } 16313 @keyframes carousel-nav-fade { 16314 0% { 16315 opacity: 0; 16316 } 16317 100% { 16318 opacity: 0.6; 16319 } 16320 } 16321 .carousel-container > .nav-arrow:hover { 16322 opacity: 1; 16323 } 16324 .carousel-photo-container { 16325 display: flex; 16326 flex-direction: column; 16327 justify-content: center; 16328 align-items: center; 16329 flex: 1 1 auto; 16330 width: auto; 16331 height: 70%; 16332 min-width: 0; 16333 min-height: 200px; 16334 cursor: pointer; 16335 } 16336 .carousel-photo-frame { 16337 display: flex; 16338 flex-direction: column; 16339 justify-content: center; 16340 align-items: center; 16341 max-width: 100%; 16342 max-height: 100%; 16343 cursor: auto; 16344 animation: carousel-photo-scale 0.3s ease forwards; 16345 } 16346 .carousel-photo { 16347 width: auto; 16348 height: auto; 16349 max-width: 100%; 16350 max-height: 70vh; 16351 object-fit: contain; 16352 background-size: contain; 16353 background-repeat: no-repeat; 16354 background-position: center; 16355 opacity: 0; 16356 transition: opacity 0.35s ease; 16357 } 16358 .carousel-photo.is-loaded { 16359 opacity: 1; 16360 } 16361 .carousel-caption{ 16362 margin: 10px 0 0 0; 16363 text-transform: uppercase; 16364 letter-spacing: 2px; 16365 color: #fff; 16366 padding: 5px; 16367 border-radius: 2px; 16368 background-color: rgba(0, 0 ,0, 0.1); 16369 } 16370 .actions-container{ 16371 position: fixed; 16372 top: 9px; 16373 right: 9px; 16374 display: flex; 16375 align-items: center; 16376 justify-content: center; 16377 opacity: 0; 16378 animation: carousel-chrome-fade 0.3s ease forwards; 16379 } 16380 .actions-container > .event-page-callout.close-action-container{ 16381 position: fixed; 16382 top: 9px; 16383 left: 9px; 16384 } 16385 .actions-container > .event-page-callout{ 16386 padding-left: 10px; 16387 padding-right: 10px; 16388 border-radius: 5px; 16389 border: 1px solid #e4ebf1; 16390 font-size: 13px; 16391 margin: 6px; 16392 color: #6a7482; 16393 padding-bottom: 8px; 16394 padding-top: 8px; 16395 cursor: pointer; 16396 } 16397 .actions-container > .event-page-callout i{ 16398 padding-right:3px; 16399 } 16400 .actions-container > .event-page-callout:hover{ 16401 color: #081333; 16402 } 16403 .actions-container > .event-page-callout.carousel-action-icon-only{ 16404 border: none; 16405 background: transparent; 16406 padding: 6px; 16407 margin: 4px; 16408 min-width: auto; 16409 } 16410 .actions-container > .event-page-callout.carousel-action-icon-only i{ 16411 padding-right: 0; 16412 font-size: 28px; 16413 } 16414</style> 16415
16416<script> 16417 16418 function downloadImage( url, filename ) { 16419 return fetch(url, { mode: 'cors' }) 16420 .then(response => { 16421 if (!response.ok) { 16422 throw new Error(`Network response was not ok: ${response.statusText}`); 16423 } 16424 return response.blob(); 16425 }) 16426 .then(blob => { 16427 const url = window.URL.createObjectURL(blob); 16428 const a = document.createElement('a'); 16429 a.style.display = 'none'; 16430 a.href = url; 16431 a.download = filename; 16432 // Append the anchor to the body 16433 document.body.appendChild(a); 16434 a.click(); 16435 // Remove the anchor from the body 16436 document.body.removeChild(a); 16437 window.URL.revokeObjectURL(url); 16438 }) 16439 .catch(err => console.error('Error downloading the image:', err)); 16440 } 16441 16442 store.components.view_photo_carousel = { 16443 json_photos: [], 16444 current_index: 0, 16445 downloading: false, 16446 sharing: false, 16447 detail_photo_loaded: false, 16448 json_config: { 16449 allow_share: false, 16450 allow_download: false, 16451 context: '', 16452 }, 16453 } 16454 16455 app_overlays.component("view-photo-carousel", { 16456 template: "#template-view-photo-carousel", 16457 data() { 16458 return store.components.view_photo_carousel; 16459 }, 16460 mounted() { 16461 this.bound_keydown = (event) => { 16462 this.handle_detail_keydown(event); 16463 }; 16464 window.addEventListener('keydown', this.bound_keydown); 16465 this.$nextTick(() => { 16466 this.check_detail_photo_cached(); 16467 }); 16468 }, 16469 unmounted() { 16470 if( this.bound_keydown ){ 16471 window.removeEventListener('keydown', this.bound_keydown); 16472 } 16473 }, 16474 watch: { 16475 current_index(){ 16476 this.detail_photo_loaded = false; 16477 this.$nextTick(() => { 16478 this.check_detail_photo_cached(); 16479 }); 16480 }, 16481 }, 16482 methods: { 16483 return_config_value( key, default_value ){ 16484 if ( this.json_config && this.json_config[key] !== undefined && this.json_config[key] !== null ){ 16485 return this.json_config[key]; 16486 } 16487 return default_value; 16488 }, 16489 handle_detail_keydown( event ){ 16490 if( !event ){ 16491 return; 16492 } 16493 let tag = event.target && event.target.tagName; 16494 if( tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' ){ 16495 return; 16496 } 16497 if( event.key === 'ArrowLeft' ){ 16498 event.preventDefault(); 16499 this.prev(); 16500 return; 16501 } 16502 if( event.key === 'ArrowRight' ){ 16503 event.preventDefault(); 16504 this.next(); 16505 } 16506 }, 16507 check_detail_photo_cached(){ 16508 if( this.current_photo && this.returnIsVideo(this.current_photo.file_name) ){ 16509 this.detail_photo_loaded = true; 16510 return; 16511 } 16512 let el = this.$refs.detail_photo; 16513 if( el && el.complete && el.naturalWidth ){ 16514 this.detail_photo_loaded = true; 16515 } 16516 }, 16517 async tapped_download(){ 16518 if ( !this.current_photo || !this.current_photo.url ){ 16519 return; 16520 } 16521 let url = this.current_photo.url; 16522 let file_name = this.current_photo.file_name || ('photo-' + (this.current_index + 1)); 16523 this.downloading = true; 16524 try { 16525 downloadImage( url, file_name ).then(() => { 16526 displayMessage("File downloaded.", false, 6000); 16527 this.downloading = false;
16528 }).catch(() => { 16529 displayMessage("Sorry, something went wrong with your download. Please try again, or contact support if the problem persists.", true, 8000); 16530 this.downloading = false; 16531 }) 16532 } catch (error) { 16533 displayMessage("Sorry, something went wrong with your download. Please try again, or contact support if the problem persists.", true, 8000); 16534 } 16535 }, 16536 returnIsVideo(filename) { 16537 var ext = this.getExtension(filename); 16538 switch (ext.toLowerCase()) { 16539 case 'm4v': 16540 case 'avi': 16541 case 'mpg': 16542 case 'mp4': 16543 case 'mp3': 16544 case 'mov': 16545 // etc 16546 return true; 16547 } 16548 return false; 16549 }, 16550 getExtension(filename) { 16551 var parts = filename.split('.'); 16552 return parts[parts.length - 1]; 16553 }, 16554 tapped_share(){ 16555 copyToClipboard( window.location.href ); 16556 displayMessage("Link copied.", false, 6000); 16557 this.sharing = true; 16558 setTimeout(() => { 16559 this.sharing = false; 16560 }, 3000); 16561 }, 16562 prev(){ 16563 if ( this.current_index === 0 ){ 16564 this.current_index = this.json_photos.length - 1; 16565 } else { 16566 this.current_index -= 1; 16567 } 16568 }, 16569 next(){ 16570 if ( this.current_index === this.json_photos.length - 1 ){ 16571 this.current_index = 0; 16572 } else { 16573 this.current_index += 1; 16574 } 16575 }, 16576 dismiss_overlay(){ 16577 store.overlays.view_photo_carousel = false; 16578 }, 16579 clicked_overlay(){ 16580 if( ! should_close_overlay( this.last_touch ) ){ 16581 return 16582 } 16583 this.dismiss_overlay(); 16584 }, 16585 touched_overlay(){ 16586 this.last_touch = new Date().getTime(); 16587 }, 16588 }, 16589 computed: { 16590 current_photo(){ 16591 if ( this.json_photos && this.json_photos.length > 0 && this.json_photos[this.current_index] ){ 16592 return this.json_photos[this.current_index]; 16593 } 16594 return null; 16595 }, 16596 can_show_download(){ 16597 var default_value = false; 16598 return this.return_config_value('allow_download', default_value) === true; 16599 }, 16600 can_show_share(){ 16601 var default_value = true; 16602 return this.return_config_value('allow_share', default_value) === true; 16603 }, 16604 show_caption(){ 16605 var default_value = false; 16606 return this.return_config_value('show_caption', default_value) === true; 16607 }, 16608 caption_size(){ 16609 var default_value = 16; 16610 return this.return_config_value('caption_size', default_value); 16611 }, 16612 caption_font_weight(){ 16613 var default_value = 400; 16614 return this.return_config_value('caption_font_weight', default_value); 16615 }, 16616 should_blur_background(){ 16617 var default_value = false; 16618 return this.return_config_value('blur_background', default_value) === true; 16619 }, 16620 use_icon_only_actions(){ 16621 var default_value = false; 16622 return this.return_config_value('icon_only_actions', default_value) === true; 16623 }, 16624 hide_nav_arrows(){ 16625 let default_value = false; 16626 return this.return_config_value('hide_nav_arrows', default_value) === true; 16627 }, 16628 } 16629 }); 16630 16631</script>
16631
16631<script type="text/html" id="template-bottom-sheet-overlay"> 16632 16633 <div 16634 class="v-overlay scrollable hugePaddingBottom active" 16635 @touchstart.self="touched_overlay()" 16636 @mousedown.self="touched_overlay()" 16637 @click.self="clicked_overlay()" 16638 :class="[{'noPointer': prevent_close}, {'bottom-sheet-blur': blur_background}]" 16639 > 16640 <div 16641 class="standardOverlayCard centeredBlock bottom-sheet" 16642 :class="[ 16643 {'short': height == 'short' || is_short}, 16644 {'narrow': narrow}, 16645 {'noMaxWidth': full_width} 16646 ]" 16647 > 16648 16649 <!-- generic header content --> 16650 <div class="bottom-sheet-overlay-header"> 16651 16652 <div 16653 v-if="! prevent_close" 16654 class="relative" 16655 > 16656 <div 16657 class="overlay-header-handle-wrap centeredBlock" 16658 @click.stop="toggle_height()" 16659 > 16660 <div class="overlay-header-handle"></div> 16661 </div> 16662 <i 16663 class="overlay-x-button fa-regular fa-xmark pointer" 16664 @click.stop="dismiss_overlay()" 16665 ></i> 16666 </div> 16667 16668 <!-- header content injected here --> 16669 <slot name="header"></slot> 16670 16671 </div> 16672 16673 <!-- body content injected here --> 16674 <slot name="body"></slot> 16675 16676 <!-- footer content injected here --> 16677 <slot name="footer"></slot> 16678 16679 </div> 16680 </div> 16681 16682</script>
16682 16683 16684<style> 16685 16686 .standardOverlayCard.bottom-sheet.narrow{ 16687 max-width: 600px; 16688 } 16689 16690 .standardOverlayCard.bottom-sheet.medium{ 16691 max-width: 800px; 16692 } 16693 16694 .standardOverlayCard.bottom-sheet{ 16695 position: fixed; 16696 bottom: 0px; 16697 top: 50px; 16698 left: 0px; 16699 right: 0px; 16700 z-index: 1001; 16701 background-color: #fff; 16702 box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); 16703 transition: bottom 0.3s cubic-bezier(0.25, 0.1, 0.25, 1); 16704 border-top-left-radius: 10px; 16705 border-top-right-radius: 10px; 16706 padding-bottom: 10px; 16707 padding-top: 10px; 16708 max-width: 1100px; 16709 margin-top: 0px; 16710 border-bottom-left-radius: 0px; 16711 border-bottom-right-radius: 0px; 16712 } 16713 16714 .standardOverlayCard.bottom-sheet.short{ 16715 top: 50%; 16716 } 16717 16718 @media (max-height: 600px) { 16719 16720 .standardOverlayCard.bottom-sheet.short{ 16721 top: 150px; 16722 } 16723 16724 } 16725 16726 .standardOverlayCard.bottom-sheet h3{ 16727 padding-top:0px; 16728 } 16729 16730 .standardOverlayCard.bottom-sheet{ 16731 display: flex; 16732 flex-direction: column; 16733 padding-bottom:0px; 16734 padding-top: 0px; 16735 } 16736 16737 .standardOverlayCard.bottom-sheet .bottom-sheet-overlay-card-header 16738 { 16739 flex: 0 0 auto; /* Fixed height, doesn't grow or shrink */ 16740 } 16741 16742 .standardOverlayCard.bottom-sheet .standardCardFooter{ 16743 position: absolute; 16744 bottom: 0px; 16745 padding-bottom: var(--cs-safe-area-bottom, 10px); 16746 left: 0px; 16747 right: 0px; 16748 background-color: #fff; 16749 border-top: 1px solid #e4ebf1; 16750 z-index: 10; 16751 } 16752 16753 .standardOverlayCard.bottom-sheet .standardCardBody{ 16754 flex: 1 1 auto; /* Flexible height, can grow and shrink */ 16755 padding-bottom: 90px !important; 16756 } 16757 16758 .overlay-header-handle-wrap{ 16759 padding: 10px; 16760 cursor: pointer; 16761 max-width: 120px; 16762 } 16763 .overlay-header-handle{ 16764 width: 70px; 16765 height: 3px; 16766 border-radius: 10px; 16767 background-color: #acb3ba; 16768 margin-left: auto; 16769 margin-right: auto; 16770 margin-top: 8px; 16771 margin-bottom: 8px; 16772 } 16773 .overlay-x-button{ 16774 position: absolute; 16775 right: 14px; 16776 color: #535e6c; 16777 font-size: 20px; 16778 top: 12px; 16779 } 16780</style> 16781
16782<script> 16783 16784 //define the component 16785 app_overlays.component("bottom-sheet-overlay", { 16786 template: "#template-bottom-sheet-overlay", 16787 data() { 16788 return { 16789 height: "tall" 16790 } 16791 }, 16792 props: { 16793 blur_background: { 16794 type: Boolean, 16795 default: false 16796 }, 16797 narrow: { 16798 type: Boolean, 16799 default: false 16800 }, 16801 prevent_close: { 16802 type: Boolean, 16803 default: false 16804 }, 16805 full_width: { 16806 type: Boolean, 16807 default: false 16808 }, 16809 is_short: { 16810 type: Boolean, 16811 default: false 16812 } 16813 }, 16814 mounted(){ 16815 const universalEditOverlay = document.getElementById('overlay_universal-edit'); 16816 if (universalEditOverlay) { 16817 universalEditOverlay.style.zIndex = "1000000"; 16818 } 16819 const membershipValidationOverlay = document.getElementById('overlay_verifyMembership'); 16820 if (membershipValidationOverlay) { 16821 membershipValidationOverlay.style.zIndex = "1000000"; 16822 } 16823 }, 16824 beforeUnmount() { 16825 //reset the z-index 16826 const universalEditOverlay = document.getElementById('overlay_universal-edit'); 16827 if (universalEditOverlay) { 16828 universalEditOverlay.style.zIndex = ""; // Revert to default 16829 } 16830 const membershipValidationOverlay = document.getElementById('overlay_verifyMembership'); 16831 if (membershipValidationOverlay) { 16832 membershipValidationOverlay.style.zIndex = ""; // Revert to default 16833 } 16834 }, 16835 methods: { 16836 dismiss_overlay(){ 16837 this.$emit('dismiss_overlay'); 16838 }, 16839 toggle_height(){ 16840 if( this.height === "tall" ){ 16841 this.height = "short"; 16842 }else{ 16843 this.height = "tall"; 16844 } 16845 }, 16846 clicked_overlay(){ 16847 if( this.prevent_close ){ 16848 return; 16849 } 16850 if (should_close_overlay(this.last_touch)) { 16851 this.dismiss_overlay(); 16852 } 16853 }, 16854 touched_overlay(){ 16855 this.last_touch = new Date().getTime(); 16856 }, 16857 }, 16858 }); 16859 16860 16861</script>
16861 16862 16863<style> 16864 .bottom-sheet-blur{ 16865 backdrop-filter: blur(6px); 16866 } 16867 16868 @keyframes bottom-sheet-fade-in { 16869 from { opacity: 0; } 16870 to { opacity: 1; } 16871 } 16872</style> <!-- common-web-builder.js does not include membership custom field grouping --> 16873
16873<script src="/public/assets/js/catch-all/common/custom-fields.js"></script>
16873 16874
16874<script type="text/html" id="template-inline-pair"> 16875 <div class="flexNoWrap tinyPaddingTop inline-pair align-items"> 16876 <p class="inline-label"> 16877 {{title}} 16878 <span v-if="callout" class="header-callout">{{callout}}</span> 16879 </p> 16880 <!-- Text input when editable and data_type is 'text' --> 16881 <div 16882 v-if="editable && (data_type === 'text' || data_type === 'number') " 16883 class="inline-edit-container relative data-type-text" 16884 > 16885 <input 16886 v-model="localAnswer" 16887 class="flexGrowOne inline-value" 16888 type="text" 16889 @input="update_answer" 16890 /> 16891 </div> 16892 <!-- Text input when editable and data_type is 'text' --> 16893 <div 16894 v-if="editable && data_type === 'textarea'" 16895 class="inline-edit-container relative data-type-text" 16896 > 16897 <!-- Textarea version --> 16898 <textarea 16899 style="width: 300px;" 16900 v-model="localAnswer" 16901 class="flexGrowOne inline-value " 16902 @input="update_answer" 16903 rows="4" 16904 ></textarea> 16905 </div> 16906 <!-- Select input when editable and data_type is 'select' --> 16907 <div 16908 v-if="editable && data_type === 'select' && !optionObjects" 16909 class="selectWrap inline-select-wrap relative inlineBlock easeFast fff data-type-select" 16910 > 16911 <select 16912 v-model="localAnswer" 16913 class="capitalize noBoxShadow" 16914 @change="update_answer" 16915 > 16916 <option v-for="option in options" :key="option" :value="option" class="capitalize">{{ option }}</option> 16917 </select> 16918 </div> 16919 <!-- Select input when editable for custom field data and data_type is 'select' --> 16920 <div 16921 v-if="editable && data_type === 'select' && optionObjects" 16922 class="selectWrap inline-select-wrap relative inlineBlock easeFast fff data-type-select" 16923 > 16924 <select 16925 v-model="localAnswer" 16926 class="capitalize noBoxShadow" 16927 @change="update_answer" 16928 > 16929 <option v-for="option in options" :key="option" :value="option.id" class="capitalize">{{ option.name }}</option> 16930 </select> 16931 </div> 16932 <!-- Searchable select (custom fields) --> 16933 <div 16934 v-if="editable && data_type === 'searchable_select' && optionObjects" 16935 class="inline-select-wrap relative easeFast fff data-type-select" 16936 style="flex: 1; min-width: 0; max-width: 100%;" 16937 > 16938 <rich-select-dropdown 16939 :value="localAnswer" 16940 :options="options || []" 16941 :placeholder="title || 'Select...'" 16942 @input="set_rich_local_answer($event)" 16943 :showFieldLabelWhenSelected="true" 16944 :searchable="true" 16945 :searchSubtext="false" 16946 ></rich-select-dropdown> 16947 </div> 16948 <!-- multi-select input when editable and data_type is 'radio' --> 16949 <div 16950 v-if="editable && data_type === 'radio'" 16951 class="data-type-radio" 16952 > 16953 <template v-for="option in options" :key="option"> 16954 <p 16955 @click="update_selection( option )" 16956 class="event-page-callout" 16957 style="font-size: 14px; padding: 6px 8px; margin: 0 6px 0 0;" 16958 > 16959 <i v-if="!isOptionSelected(option)" class="fa-regular fa-square" style="color: rgb(228, 235, 241);"></i> 16960 <i v-if="isOptionSelected(option)" class="fa-solid fa-square-check" style="color: rgb(46, 89, 214);"></i> 16961 {{ option.name }} 16962 </p> 16963 </template> 16964 </div> 16965 <!-- file upload input when editable and data_type is 'file_upload' --> 16966 <div 16967 v-if="editable && data_type === 'file_upload'" 16968 class="data-type-file_upload" 16969 > 16970 <a 16971 @click="triggerFileInput" 16972 > 16973 <i class="fa-regular fa-upload"></i>
16974 <span v-if="fileUploaded"> {{this.localAnswer.name}}</span> 16975 <span v-else-if="has_answer"> replace</span> 16976 <span v-else> upload</span> 16977 </a> 16978 <!-- Hidden file input --> 16979 <input type="file" ref="fileInput" @change="upload_file" style="display: none;" /> 16980 </div> 16981 <!-- Date input when editable and data_type is 'date' --> 16982 <div 16983 v-if="editable && data_type === 'date'" 16984 class="flexGrowOne inline-value data-type-date" 16985 > 16986 <div class="selectWrap inline-select-wrap relative inlineBlock easeFast fff"> 16987 <select 16988 v-model="selectedDay" 16989 class="capitalize noBoxShadow" 16990 @change="update_date" 16991 > 16992 <option data-non-option="true"></option> 16993 <option v-for="day in days" :key="year" :value="day">{{ day }}</option> 16994 </select> 16995 </div> 16996 <div class="selectWrap inline-select-wrap relative inlineBlock easeFast fff"> 16997 <select 16998 v-model="selectedMonth" 16999 class="capitalize noBoxShadow" 17000 @change="update_date" 17001 > 17002 <option data-non-option="true"></option> 17003 <option value=0>Jan</option> 17004 <option value=1>Feb</option> 17005 <option value=2>Mar</option> 17006 <option value=3>Apr</option> 17007 <option value=4>May</option> 17008 <option value=5>Jun</option> 17009 <option value=6>Jul</option> 17010 <option value=7>Aug</option> 17011 <option value=8>Sep</option> 17012 <option value=9>Oct</option> 17013 <option value=10>Nov</option> 17014 <option value=11>Dec</option> 17015 </select> 17016 </div> 17017 <div class="selectWrap inline-select-wrap relative inlineBlock easeFast fff"> 17018 <select 17019 v-model="selectedYear" 17020 class="capitalize noBoxShadow" 17021 @change="update_date" 17022 > 17023 <option data-non-option="true"></option> 17024 <option v-for="year in last100Years" :key="year" :value="year">{{ year }}</option> 17025 </select> 17026 </div> 17027 17028 </div> 17029 <!-- loading response --> 17030 <p v-if="loading" class="coming-soon">xxxxxxxxxxx</p> 17031 <!-- link --> 17032 <p 17033 v-else-if="has_answer && data_type == 'link'" 17034 class="inline-value" 17035 :class="{'flexGrowOne': !removeable, 'minOneHundo': removeable}" 17036 > 17037 <a @click="tapped_link">{{answer}}</a> 17038 </p> 17039 <!-- select --> 17040 <p v-else-if="!editable && has_answer && (data_type == 'select' || data_type == 'searchable_select') && optionObjects && Array.isArray(answer) && return_selected_options(answer)" class="flexGrowOne inline-value"> {{return_selected_options(answer)}} </p> 17041 <p v-else-if="!editable && has_answer && (data_type == 'select' || data_type == 'searchable_select') && optionObjects && return_selected_options(answer)" class="flexGrowOne inline-value"> {{return_selected_options(answer)}} </p> 17042 <!-- multi select --> 17043 <p v-else-if="!editable && has_answer && data_type == 'radio' && optionObjects && Array.isArray(answer) && return_multi_selected_options(answer)" class="flexGrowOne inline-value"> {{return_multi_selected_options(answer)}}</p> 17044 <p v-else-if="!editable && has_answer && data_type == 'radio' && optionObjects && return_multi_selected_options(answer)" class="flexGrowOne inline-value"> {{return_multi_selected_options(answer)}}</p> 17045 <!-- file upload --> 17046 <a v-else-if="!editable && has_answer && data_type == 'file_upload'" target="blank" :href="sanitize_string(answer)">View file</a> 17047 <!-- date --> 17048 <p v-else-if="!editable && has_answer && data_type == 'date'" class="flexGrowOne inline-value"> {{ answer }}</p> 17049 <!-- phone number --> 17050 <p v-else-if="!editable && has_answer && display_type == 'phone_number'" class="flexGrowOne inline-value"> 17051 <a 17052 :href="'tel:' + sanitize_string(answer)" 17053 > 17054 {{ format_mobile_number(answer) }} 17055 </a> 17056 </p> 17057 <!-- email --> 17058 <p v-else-if="!editable && has_answer && display_type == 'email'" class="flexGrowOne inline-value"> 17059 <a 17060 :href="'mailto:' + sanitize_string(answer)" 17061 > 17062 {{ answer }} 17063 </a> 17064 </p> 17065 <!-- text --> 17066 <p 17067 v-else-if="!editable && has_answer && (typeof answer === 'string' || typeof answer === 'number' )" 17068 class="flexGrowOne inline-value" 17069 > 17070 {{ answer }}
17071 <span v-if="response_callout" class="header-callout">{{response_callout}}</span> 17072 </p> 17073 <!-- When no answer is provided --> 17074 <p v-else-if="!editable" class="flexGrowOne inline-value incomplete">Not provided</p> 17075 17076 <i v-if="removeable" class="fa-light fa-xmark pointer" @click="tapped_remove_item"></i> 17077 </div> 17078</script>
17078 17079
17080<script> 17081 var config = { 17082 template: "#template-inline-pair", 17083 data() { 17084 return { 17085 localAnswer: this.answer, // Store the answer in a local variable for editing 17086 selectedDay: null, 17087 selectedMonth: null, 17088 selectedYear: null, 17089 days: [], 17090 fileUploaded: false 17091 }; 17092 }, 17093 props: { 17094 title: String, 17095 has_answer: Boolean, 17096 answer: null, 17097 editable: Boolean, 17098 data_type: String, // Prop to define the input type (e.g., 'text', 'select', 'date') 17099 options: Array, // Only for select dropdowns 17100 date: Date, 17101 optionObjects: Boolean, 17102 callout: String, 17103 response_callout: String, //only implemented for text responses for now 17104 display_type: String, // for email and phone number display 17105 refresh_key: null, 17106 loading: Boolean, 17107 removeable: { 17108 type: Boolean, 17109 default: false 17110 } 17111 }, 17112 mounted() { 17113 if(this.date){ 17114 this.selectedDay = this.date.getUTCDate(); 17115 this.selectedMonth = this.date.getMonth(); 17116 this.selectedYear = this.date.getFullYear(); 17117 this.loadDateOptions() 17118 } 17119 }, 17120 watch: { 17121 answer(newAnswer) { 17122 this.localAnswer = newAnswer; // Update local value when answer prop changes 17123 }, 17124 }, 17125 methods: { 17126 update_answer() { 17127 // Emit the updated value to the parent component 17128 this.$emit('update-answer', this.localAnswer); 17129 }, 17130 set_rich_local_answer(val) { 17131 if (val != null && typeof val === 'object' && (val.id !== undefined || (val.get && val.get('id') != null))) { 17132 this.localAnswer = val.id != null ? val.id : val.get('id'); 17133 } else { 17134 this.localAnswer = val; 17135 } 17136 this.update_answer(); 17137 }, 17138 tapped_link(){ 17139 // Emit the updated value to the parent component 17140 this.$emit('tapped-link'); 17141 }, 17142 tapped_remove_item(){ 17143 // Emit the remove event to the parent component 17144 this.$emit('tapped-remove-item'); 17145 }, 17146 update_date() { 17147 // 1. update day options 17148 this.loadDateOptions() 17149 // 2. check if date is valid 17150 if(this.selectedDay && this.selectedMonth && this.selectedYear){ 17151 var selectedDate = new Date(this.selectedYear, this.selectedMonth, this.selectedDay) 17152 // 3. emit date and handle any validation on parent 17153 this.localAnswer = selectedDate; 17154 this.update_answer(); 17155 } 17156 }, 17157 update_selection(option){ 17158 // Check if the option is already in the selectedOptions array 17159 if(this.localAnswer && this.localAnswer.length > 0) { 17160 const optionIndex = this.localAnswer.findIndex(selectedOption => selectedOption === option.id); 17161 if (optionIndex !== -1) { 17162 // Option is already selected, remove it 17163 this.localAnswer.splice(optionIndex, 1); 17164 } else { 17165 // Option is not selected, add it 17166 this.localAnswer.push(option.id); 17167 } 17168 17169 } else { 17170 // Option is not selected, add it 17171 this.localAnswer = [] 17172 this.localAnswer.push(option.id); 17173 } 17174 this.update_answer(); 17175 }, 17176 loadDateOptions(){ 17177 this.days = new Date(this.selectedYear, parseInt(this.selectedMonth,10) + 1, 0).getDate() || 31 17178 // figure out if we need to unselect date 17179 if( this.selectedDay > this.days){ 17180 this.selectedDay = null 17181 } 17182 }, 17183 isOptionSelected(option) { 17184 if(this.localAnswer && this.localAnswer.length > 0 ){ 17185 // Check if the option exists in the selectedOptions array 17186 return this.localAnswer.find(selectedOption => selectedOption === option.id); 17187 } 17188 return false 17189 }, 17190 return_selected_options(optionId){ 17191 // Use find to locate the object 17192 const optionObject = this.options.find(optionObject => optionObject.id == optionId); 17193 // Check if optionObject exists before accessing its properties 17194 if (optionObject) { 17195 return optionObject.name; 17196 } else { 17197 return false; // Return false if no match is found 17198 } 17199 }, 17200 return_multi_selected_options(optionIds){ 17201 var string = '' 17202 if( ! optionIds ){ 17203 optionIds = []; 17204 } 17205 optionIds.forEach(optionId => { 17206 // look up option name 17207 var option = this.options.find(optionObject => optionObject.id == optionId); 17208 if( option ){ 17209 let optionName = option.name; 17210 if(string != ''){
17211 string +=', ' 17212 } 17213 string += optionName 17214 } 17215 }); 17216 return string 17217 }, 17218 triggerFileInput() { 17219 this.$refs.fileInput.click(); 17220 }, 17221 upload_file(event) { 17222 const file = event.target.files[0]; 17223 if (file) { 17224 this.fileUploaded = true 17225 this.localAnswer = file; 17226 this.update_answer(); 17227 } 17228 }, 17229 format_mobile_number(mobile){ 17230 return formatMobileNumber( mobile ); 17231 }, 17232 sanitize_string(answer){ 17233 return sanitize_string(answer) 17234 } 17235 }, 17236 computed: { 17237 last100Years() { 17238 const currentYear = new Date().getFullYear(); 17239 return Array.from({ length: 100 }, (_, i) => currentYear - i); 17240 }, 17241 }, 17242 }; 17243 app_content.component("inline-pair-component", config) 17244 app_overlays.component("inline-pair-component", config) 17245</script>
17245 17246 17247<style> 17248 .inline-pair > .inline-label { 17249 min-width: 180px; 17250 max-width: 180px; 17251 margin-right: 5px; 17252 padding-right: 10px; 17253 flex-shrink: 0; 17254 text-align: left; 17255 white-space: pre-wrap; 17256 } 17257 @media all and (max-width: 500px) { 17258 .inline-pair > .inline-label { 17259 min-width: 150px; 17260 max-width: 150px; 17261 } 17262 } 17263</style> 17264
17264<script type="text/html" id="template-member-directory-profile"> 17265 17266 <bottom-sheet-overlay 17267 @dismiss_overlay="dismiss_overlay()" 17268 :narrow="true" 17269 > 17270 <template #header></template> 17271 17272 <template #body> 17273 <div class="standardCard noBoxShadow scrollable noPaddingTop noPaddingLeft noPaddingRight" style="padding-bottom: 80px !important;"> 17274 <div class="mdp-wrap"> 17275 <template v-if="member"> 17276 <!-- blurred background banner --> 17277 <div class="mdp-background overflowHidden"> 17278 <div 17279 class="mdp-background-inner hundop" 17280 style="height: 100%;" 17281 :style="{ backgroundImage: prof_pic_url && field_visible('photo') ? 'url(' + prof_pic_url + ')' : '' }" 17282 ></div> 17283 </div> 17284 <!-- profile picture / initials --> 17285 <div 17286 v-if="prof_pic_url && field_visible('photo')" 17287 class="mdp-avatar" 17288 style="cursor: pointer;" 17289 :style="{ backgroundImage: 'url(' + prof_pic_url + ')' }" 17290 @click="open_photo(prof_pic_url)" 17291 ></div> 17292 <div v-else class="mdp-avatar uppercase">{{ initials }}</div> 17293 <!-- name --> 17294 <div class="flexWrapLeftAlign align-items-center smallerMarginTop" style="padding-left: 20px;"> 17295 <p class="noPadding mdp-name">{{ display_name }}</p> 17296 </div> 17297 <!-- details --> 17298 <div class="flexWrapLeftAlign tinyPaddingTop smallPaddingBottom roboto"> 17299 <!-- left column --> 17300 <div class="minTwoHundo textLeft" style="padding-left: 20px; padding-right: 20px;"> 17301 <!-- member number --> 17302 <div v-if="member_number && field_visible('member_number')" class="mdp-section tinyMarginTop tinyMarginBottom"> 17303 <h4 class="smallBlockLetters leftText smallMarginTop">Member number</h4> 17304 <p class="flexGrowOne inline-value tinyPaddingTop">{{ member_number }}</p> 17305 </div> 17306 <!-- category / tier --> 17307 <div v-if="category && field_visible('category')" class="mdp-section tinyMarginTop tinyMarginBottom"> 17308 <h4 class="smallBlockLetters leftText smallMarginTop">Category</h4> 17309 <p class="flexGrowOne inline-value tinyPaddingTop">{{ category }}</p> 17310 </div> 17311 <!-- billing plan --> 17312 <div v-if="plan && field_visible('plan')" class="mdp-section tinyMarginTop tinyMarginBottom"> 17313 <h4 class="smallBlockLetters leftText smallMarginTop">Billing plan</h4> 17314 <p class="flexGrowOne inline-value tinyPaddingTop">{{ plan }}</p> 17315 </div> 17316 <!-- family members --> 17317 <div v-if="family_members.length > 0" class="mdp-section tinyMarginTop tinyMarginBottom"> 17318 <h4 class="smallBlockLetters leftText smallMarginTop">Family</h4> 17319 <template v-for="family_member in family_members" :key="family_member.id"> 17320 <p class="flexGrowOne inline-value tinyPaddingTop"> 17321 <a href="javascript:void(0);" @click="open_member(family_member)">{{ return_name(family_member) }}</a> 17322 </p> 17323 </template> 17324 </div> 17325 <!-- boats --> 17326 <div v-if="boats_enabled && formatted_boats.length > 0 && field_visible('boats')" class="mdp-section tinyMarginTop tinyMarginBottom"> 17327 <h4 class="smallBlockLetters leftText smallMarginTop">Boats</h4> 17328 <p 17329 v-for="formatted_boat in formatted_boats" 17330 :key="formatted_boat.id" 17331 class="flexGrowOne inline-value tinyPaddingTop" 17332 > 17333 <a href="javascript:void(0);" @click="open_boat(formatted_boat.boat)">
17333{{ formatted_boat.name }}</a> 17334 </p> 17335 </div> 17336 <!-- GHIN number --> 17337 <div v-if="ghin_number && field_visible('ghin_number')" class="mdp-section tinyMarginTop tinyMarginBottom"> 17338 <h4 class="smallBlockLetters leftText smallMarginTop">GHIN Number</h4> 17339 <p class="flexGrowOne inline-value tinyPaddingTop">{{ ghin_number }}</p> 17340 </div> 17341 <!-- start date --> 17342 <div v-if="start_date && field_visible('start_date')" class="mdp-section tinyMarginTop tinyMarginBottom"> 17343 <h4 class="smallBlockLetters leftText smallMarginTop">Start date</h4> 17344 <p class="flexGrowOne inline-value tinyPaddingTop">{{ start_date }}</p> 17345 </div> 17346 <!-- end date --> 17347 <div v-if="end_date && field_visible('end_date')" class="mdp-section tinyMarginTop tinyMarginBottom"> 17348 <h4 class="smallBlockLetters leftText smallMarginTop">End date</h4> 17349 <p class="flexGrowOne inline-value tinyPaddingTop">{{ end_date }}</p> 17350 </div> 17351 <!-- renewal date --> 17352 <div v-if="renewed && field_visible('renewed')" class="mdp-section tinyMarginTop tinyMarginBottom"> 17353 <h4 class="smallBlockLetters leftText smallMarginTop">Renewal date</h4> 17354 <p class="flexGrowOne inline-value tinyPaddingTop">{{ renewed }}</p> 17355 </div> 17356 <!-- member groups --> 17357 <div v-if="member_tag_list.length > 0 && field_visible('member_tags')" class="mdp-section tinyMarginTop tinyMarginBottom"> 17358 <h4 class="smallBlockLetters leftText smallMarginTop">Member groups</h4> 17359 <div class="flexWrapLeftAlign tinyPaddingTop maxTwoHundo"> 17360 <template v-for="tag in member_tag_list" :key="tag.id"> 17361 <p 17362 class="table-row-tag" 17363 :class="{ 'grey': !tag.hex }" 17364 :style="tag.hex ? { backgroundColor: '#' + tag.hex } : {}" 17365 >{{ tag.name }}</p> 17366 </template> 17367 </div> 17368 </div> 17369 </div> 17370 <!-- right column --> 17371 <div v-if="show_right_column" class="minTwoHundo textLeft" style="padding-left: 20px; padding-right: 20px;"> 17372 <!-- contact --> 17373 <div v-if="has_contact_info" class="mdp-section tinyMarginTop tinyMarginBottom"> 17374 <h4 class="smallBlockLetters leftText smallMarginTop">Contact</h4> 17375 <p v-if="email && field_visible('email')" class="flexGrowOne inline-value tinyPaddingTop"> 17376 <a :href="'mailto:' + email">{{ email }}</a> 17377 </p> 17378 <p v-if="mobile && field_visible('mobile')" class="flexGrowOne inline-value tinyPaddingTop"> 17379 <a :href="'tel:' + mobile">{{ formatted_mobile }}</a> 17380 </p> 17381 <p v-if="phone_home && field_visible('phone_home')" class="flexGrowOne inline-value tinyPaddingTop"> 17382 <a :href="'tel:' + phone_home">{{ formatted_phone_home }}</a> 17383 </p> 17384 <p v-if="has_contact_info" class="flexGrowOne inline-value smallPaddingTop"> 17385 <a href="javascript:void(0);" @click="download_vcf()"> 17386 <i class="fa-regular fa-address-card"></i> 17387 Download contact 17388 </a> 17389 </p> 17390 </div> 17391 <!-- address primary --> 17392 <div v-if="formatted_address && field_visible('address')" class="mdp-section tinyMarginTop tinyMarginBottom"> 17393 <h4 class="smallBlockLetters leftText smallMarginTop">Address</h4> 17394 <p class="flexGrowOne inline-value tinyPaddingTop" style="white-space: pre-line;">{{ formatted_address }}</p> 17395 </div> 17396 <!-- address secondary --> 17397 <div v-if="formatted_address_secondary && field_visible('address_secondary')" class="mdp-section tinyMarginTop tinyMarginBottom"> 17398 <h4 class="smallBlockLetters leftText smallMarginTop">Address (Secondary)</h4> 17399 <p class="flexGrowOne inline-value tinyPaddingTop" style="white-space: pre-line;">{{ formatted_address_secondary }}</p> 17400 </div> 17401 </div> 17402 <!-- custom fields span both columns --> 17403 <div 17404 v-if="custom_field_sections.length > 0" 17405 class="hundop textLeft lightBorderTop" 17406 style="flex: 0 0 100%; width: 100%; box-sizing: border-box; padding: 20px;" 17407 > 17408 <template 17409 v-for="(section, index) in custom_field_sections" 17410 :key="section.key" 17411 > 17412 <h4 17413 class="smallBlockLetters leftText smallMarginBottom" 17414 :class="{ marginTop: index > 0 }" 17415 >{{ section.name }}</h4> 17416 <div 17417 v-for="field in section.custom_fields" 17418 :key="field.id" 17419 class="mdp-section tinyMarginTop tinyMarginBottom" 17420 > 17421 <h4 class="smallBlockLetters leftText smallMarginTop" style="white-space: normal;
17421 overflow-wrap: anywhere;">{{ field.label }}</h4> 17422 <p v-if="!field.file_url" class="flexGrowOne inline-value tinyPaddingTop" style="white-space: pre-line;">{{ field.value }}</p> 17423 <p v-else class="flexGrowOne inline-value tinyPaddingTop"> 17424 <a :href="field.file_url" target="_blank">View uploaded file</a> 17425 </p> 17426 </div> 17427 </template> 17428 </div> 17429 </div> 17430 </template> 17431 </div> 17432 </div> 17433 </template> 17434 17435 </bottom-sheet-overlay> 17436 17437 <div 17438 v-if="photo_lightbox_url" 17439 @click="close_photo()" 17440 style="display: flex; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.85); z-index: 99999; cursor: pointer; align-items: center; justify-content: center;" 17441 > 17442 <img 17443 :src="photo_lightbox_url" 17444 alt="Profile photo" 17445 style="max-width: 90vw; max-height: 90vh; border-radius: 8px; object-fit: contain;" 17446 > 17447 </div> 17448 17449</script>
17449 17450
17451<script> 17452 17453 function generate_member_vcf(member) { 17454 if (!member) return ''; 17455 let first = member.get('firstName') || ''; 17456 let last = member.get('lastName') || ''; 17457 let nickname = member.get('nickname') || ''; 17458 let email = member.get('email') || ''; 17459 let mobile = member.get('mobile') ? returnCleanMobileNumber(member.get('mobile')) : ''; 17460 let phone_home = member.get('phone_home') || ''; 17461 let display_name = [first, last].filter(Boolean).join(' '); 17462 if (nickname) display_name = first + ' "' + nickname + '" ' + last; 17463 let lines = ['BEGIN:VCARD', 'VERSION:3.0']; 17464 lines.push('FN:' + display_name.trim()); 17465 lines.push('N:' + last + ';' + first + ';;;'); 17466 if (nickname) lines.push('NICKNAME:' + nickname); 17467 if (email) lines.push('EMAIL:' + email); 17468 if (mobile) lines.push('TEL;TYPE=CELL:' + mobile); 17469 if (phone_home) lines.push('TEL;TYPE=HOME:' + phone_home); 17470 17471 // vCard 3.0 ADR is semicolon-delimited: 17472 // ADR;TYPE=...:po-box;extended;street;city;region;postal;country 17473 // Embedded ; , : \ characters must be escaped per RFC 2426. 17474 let escape_adr = (v) => (v || '').toString().replace(/([\\;,:])/g, '\\$1'); 17475 let push_adr = (type, street, city, region, postal, country) => { 17476 if (!street && !city && !region && !postal && !country) return; 17477 lines.push( 17478 'ADR;TYPE=' + type + ':' + 17479 ';;' + escape_adr(street) + 17480 ';' + escape_adr(city) + 17481 ';' + escape_adr(region) + 17482 ';' + escape_adr(postal) + 17483 ';' + escape_adr(country) 17484 ); 17485 }; 17486 push_adr('HOME', 17487 member.get('street'), member.get('city'), member.get('state'), 17488 member.get('zip'), member.get('country')); 17489 push_adr('OTHER', 17490 member.get('street_secondary'), member.get('city_secondary'), member.get('state_secondary'), 17491 member.get('zip_secondary'), member.get('country_secondary')); 17492 17493 lines.push('END:VCARD'); 17494 return lines.join('\r\n'); 17495 } 17496 17497 function open_member_directory_profile(params) { 17498 let profile = store.components.member_directory_profile; 17499 profile.member = params.member || null; 17500 profile.all_members = params.all_members || []; 17501 profile.custom_fields = params.custom_fields || []; 17502 profile.custom_field_responses = params.custom_field_responses || []; 17503 profile.documents = params.documents || []; 17504 profile.photo_lightbox_url = null; 17505 profile.profile_field_config = params.member_portal_config || null; 17506 let boats_map = params.boats_map || null; 17507 if (!boats_map && params.boats && params.member) { 17508 let membership = params.member.get('membershipObject'); 17509 boats_map = {}; 17510 if (membership) { 17511 boats_map[membership.id] = params.boats; 17512 } 17513 } 17514 profile.boats_map = boats_map || {}; 17515 store.overlays.member_directory_profile = true; 17516 } 17517 17518 store.components["member_directory_profile"] = { 17519 member: null, 17520 all_members: [], 17521 boats_map: {}, 17522 custom_fields: [], 17523 custom_field_responses: [], 17524 documents: [], 17525 photo_lightbox_url: null, 17526 profile_field_config: null, 17527 }; 17528 17529 app_overlays.component("member-directory-profile", { 17530 template: "#template-member-directory-profile", 17531 data() { 17532 return store.components["member_directory_profile"]; 17533 }, 17534 props:{ 17535 member_portal_config: { 17536 type: Object, 17537 default: {} 17538 }, 17539 }, 17540 methods: { 17541 dismiss_overlay() { 17542 this.photo_lightbox_url = null; 17543 store.overlays.member_directory_profile = false; 17544 store.overlays.directory_vehicle_profile = false; 17545 }, 17546 open_photo(photo_url) { 17547 this.photo_lightbox_url = photo_url; 17548 }, 17549 close_photo() { 17550 this.photo_lightbox_url = null; 17551 }, 17552 open_member(member) { 17553 this.member = member; 17554 this.photo_lightbox_url = null; 17555 }, 17556 open_boat(boat) { 17557 let params = { 17558 vehicle: boat, 17559 vehicle_type: 'boat', 17560 all_members: this.all_members, 17561 boats_map: this.boats_map, 17562 documents: this.documents, 17563 }; 17564 open_directory_vehicle_profile(params); 17565 }, 17566 boat_field_is_visible(field_name) { 17567 let membership_settings = store.clubObject ? store.clubObject.get('membership_settings') : null; 17568 let boat_fields = membership_settings ? membership_settings.get('boat_data_collection_settings') : null; 17569 if (!boat_fields || boat_fields === true) { 17570 return true; 17571 } 17572 let field_settings = boat_fields[field_name]; 17573 if (!field_settings) { 17574 return false;
17575 } 17576 return field_settings.enabled && field_settings.member_visible; 17577 }, 17578 download_vcf() { 17579 let is_react_native = !!(typeof store !== 'undefined' 17580 && store 17581 && store.is_react_native_member_app); 17582 17583 if (is_react_native && typeof save_contact_to_native_device === 'function') { 17584 let m = this.member; 17585 if (!m) return; 17586 let first = m.get('firstName') || ''; 17587 let last = m.get('lastName') || ''; 17588 let nickname = m.get('nickname') || ''; 17589 let email = m.get('email') || ''; 17590 let mobile = m.get('mobile') || ''; 17591 let phone_home = m.get('phone_home') || ''; 17592 let emails = []; 17593 if (email && this.field_visible('email')) { 17594 emails.push({ label: 'work', email: email }); 17595 } 17596 let phoneNumbers = []; 17597 if (mobile && this.field_visible('mobile')) { 17598 phoneNumbers.push({ label: 'mobile', number: mobile }); 17599 } 17600 if (phone_home && this.field_visible('phone_home')) { 17601 phoneNumbers.push({ label: 'home', number: phone_home }); 17602 } 17603 17604 // Map directory address fields to native ContactAddressParam shape. 17605 // Primary address is treated as "home" (matches vCard fallback above); 17606 // secondary as "other". Both gated by the directory's per-field visibility. 17607 let addresses = []; 17608 let push_addr = (label, street, city, region, postal, country) => { 17609 if (!street && !city && !region && !postal && !country) return; 17610 addresses.push({ 17611 label: label, 17612 street: street || undefined, 17613 city: city || undefined, 17614 region: region || undefined, 17615 postalCode: postal || undefined, 17616 country: country || undefined 17617 }); 17618 }; 17619 if (this.field_visible('address')) { 17620 push_addr('home', 17621 m.get('street'), m.get('city'), m.get('state'), 17622 m.get('zip'), m.get('country')); 17623 } 17624 if (this.field_visible('address_secondary')) { 17625 push_addr('other', 17626 m.get('street_secondary'), m.get('city_secondary'), m.get('state_secondary'), 17627 m.get('zip_secondary'), m.get('country_secondary')); 17628 } 17629 17630 save_contact_to_native_device({ 17631 firstName: first, 17632 lastName: last, 17633 nickname: nickname, 17634 emails: emails, 17635 phoneNumbers: phoneNumbers, 17636 addresses: addresses 17637 }); 17638 return; 17639 } 17640 17641 let vcf = generate_member_vcf(this.member); 17642 let filename = (this.display_name || 'contact').replace(/[^a-z0-9 ]/gi, '_') + '.vcf'; 17643 let blob = new Blob([vcf], { type: 'text/vcard;charset=utf-8' }); 17644 let url = URL.createObjectURL(blob); 17645 let a = document.createElement('a'); 17646 a.href = url; 17647 a.download = filename; 17648 document.body.appendChild(a); 17649 a.click(); 17650 document.body.removeChild(a); 17651 URL.revokeObjectURL(url); 17652 }, 17653 return_name(m) { 17654 let first = (m.get('firstName') || ''); 17655 let nickname = m.get('nickname'); 17656 let last = (m.get('lastName') || ''); 17657 let name = first; 17658 if (nickname) name += ` "${nickname}"`; 17659 if (last) name += (name ? ' ' : '') + last; 17660 return name.trim() || 'â'; 17661 }, 17662 field_visible(key) { 17663 return this.visible_fields.indexOf(key) !== -1; 17664 }, 17665 format_date(date) { 17666 if (!date) return ''; 17667 let d = new Date(date); 17668 if (isNaN(d.getTime())) return ''; 17669 let months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; 17670 return months[d.getMonth()] + ' ' + d.getDate() + ', ' + d.getFullYear(); 17671 }, 17672 return_formatted_custom_field(custom_field) { 17673 if( !this.field_visible('custom_field:' + custom_field.id) ){ 17674 return null; 17675 } 17676 let response = this.current_custom_field_responses.find(custom_field_response => { 17677 return custom_field_response.get('custom_field').id === custom_field.id; 17678 }); 17679 if( !response ){ 17680 return null; 17681 } 17682 let formatted_response = this.format_custom_field_response(response); 17683 if( !formatted_response ){ 17684 return null; 17685 } 17686 return { 17687 id: custom_field.id, 17688 label: custom_field.get('name') || 'Custom field', 17689 value: formatted_response.value, 17690 file_url: formatted_response.file_url, 17691 }; 17692 }, 17693 format_custom_field_response(response) { 17694 let field_type = response.get('field_type'); 17695 if( ['text', 'textarea', 'number'].includes(field_type) ){ 17696 let value = response.get('response'); 17697 if( value === null || value === undefined || value === '' ){ 17698 return null; 17699 } 17700 return { 17701 value: value.toString(), 17702 file_url: null, 17703 }; 17704 } 17705 if( ['select', 'searchable_select', 'radio', 'checkbox'].includes(field_type) ){ 17706 let selected_options = response.get('selected_options') || []; 17707 let value = selected_options.map(option => { 17708 return option.option_name; 17709 }
17709).filter(Boolean).join(', '); 17710 if( !value ){ 17711 return null; 17712 } 17713 return { 17714 value: value, 17715 file_url: null, 17716 }; 17717 } 17718 if( field_type === 'file_upload' ){ 17719 let file_url = response.get('field_url'); 17720 if( !file_url ){ 17721 return null; 17722 } 17723 return { 17724 value: null, 17725 file_url: file_url, 17726 }; 17727 } 17728 if( ['date', 'pastDate', 'futureDate'].includes(field_type) ){ 17729 let date = response.get('date'); 17730 if( !date ){ 17731 return null; 17732 } 17733 let params = { 17734 utcDate: date, 17735 timezone: store.clubObject.get('timezone'), 17736 formatString: 'MMM D, YYYY', 17737 }; 17738 return { 17739 value: format_utc_date_v2(params), 17740 file_url: null, 17741 }; 17742 } 17743 return null; 17744 }, 17745 }, 17746 computed: { 17747 visible_fields() { 17748 let member_portal_config = this.profile_field_config || this.member_portal_config || {}; 17749 let fields = member_portal_config.member_directory_fields; 17750 if (!fields) { 17751 return ['member_number', 'photo', 'email', 'mobile', 'phone_home', 'category']; 17752 } 17753 return fields; 17754 }, 17755 current_membership_id() { 17756 if (!this.member) { 17757 return null; 17758 } 17759 let membership = this.member.get('membershipObject'); 17760 if (!membership) { 17761 return null; 17762 } 17763 return membership.id; 17764 }, 17765 membership_boats() { 17766 if (!this.current_membership_id) { 17767 return []; 17768 } 17769 return this.boats_map[this.current_membership_id] || []; 17770 }, 17771 membership_members() { 17772 if (!this.current_membership_id) { 17773 return []; 17774 } 17775 return this.all_members.filter(member => { 17776 let membership = member.get('membershipObject'); 17777 return membership && membership.id === this.current_membership_id; 17778 }); 17779 }, 17780 current_custom_field_responses() { 17781 if (!this.member) { 17782 return []; 17783 } 17784 return this.custom_field_responses.filter(response => { 17785 let response_member = response.get('member'); 17786 return response_member && response_member.id === this.member.id; 17787 }); 17788 }, 17789 boats_enabled() { 17790 let membership_settings = store.clubObject ? store.clubObject.get('membership_settings') : null; 17791 return (membership_settings && membership_settings.get('boats_enabled')) || false; 17792 }, 17793 formatted_boats() { 17794 let visible_field_names = ['class', 'sailNumber', 'model', 'builder', 'hullNumber'].filter(field_name => { 17795 return this.boat_field_is_visible(field_name); 17796 }); 17797 return this.membership_boats.map(boat => { 17798 let name_parts = [boat.get('boatName')].concat(visible_field_names.map(field_name => { 17799 return boat.get(field_name); 17800 })); 17801 return { 17802 id: boat.id, 17803 name: name_parts.filter(Boolean).join(' - '), 17804 boat: boat, 17805 }; 17806 }).filter(formatted_boat => { 17807 return formatted_boat.name; 17808 }); 17809 }, 17810 custom_field_sections() { 17811 return return_membership_custom_field_sections(this.custom_fields).map(section => { 17812 return { 17813 key: section.category_object ? section.category_object.id : 'uncategorized', 17814 //uncategorized fields keep the original heading instead of a category name 17815 name: section.category_object ? section.name : 'Additional information', 17816 custom_fields: section.custom_fields.map(custom_field => { 17817 return this.return_formatted_custom_field(custom_field); 17818 }
17818).filter(Boolean), 17819 }; 17820 }).filter(section => { 17821 return section.custom_fields.length > 0; 17822 }); 17823 }, 17824 prof_pic_url() { 17825 return this.member ? (this.member.get('profPicURL') || null) : null; 17826 }, 17827 display_name() { 17828 if (!this.member) return ''; 17829 return this.return_name(this.member); 17830 }, 17831 initials() { 17832 let first = (this.member ? (this.member.get('firstName') || '') : '').charAt(0); 17833 let last = (this.member ? (this.member.get('lastName') || '') : '').charAt(0); 17834 return (first + last).toUpperCase(); 17835 }, 17836 member_number() { 17837 let membership = this.member ? this.member.get('membershipObject') : null; 17838 if (!membership) return ''; 17839 return membership.get('memberNumber') || ''; 17840 }, 17841 category() { 17842 let membership = this.member ? this.member.get('membershipObject') : null; 17843 if (!membership) return ''; 17844 let tier = membership.get('tierObject'); 17845 return (tier && tier.get('name')) ? tier.get('name') : ''; 17846 }, 17847 plan() { 17848 let membership = this.member ? this.member.get('membershipObject') : null; 17849 if (!membership) return ''; 17850 let planObj = membership.get('planObject'); 17851 return (planObj && planObj.get('name')) ? planObj.get('name') : ''; 17852 }, 17853 family_members() { 17854 if (!this.member) { 17855 return []; 17856 } 17857 return this.membership_members.filter(member => { 17858 return member.id !== this.member.id; 17859 }); 17860 }, 17861 ghin_number() { 17862 return this.member ? (this.member.get('ghin_number') || '') : ''; 17863 }, 17864 start_date() { 17865 let membership = this.member ? this.member.get('membershipObject') : null; 17866 if (!membership || !membership.get('startDate')) return ''; 17867 return this.format_date(membership.get('startDate')); 17868 }, 17869 end_date() { 17870 let membership = this.member ? this.member.get('membershipObject') : null; 17871 if (!membership || !membership.get('endDate')) return ''; 17872 return this.format_date(membership.get('endDate')); 17873 }, 17874 renewed() { 17875 let membership = this.member ? this.member.get('membershipObject') : null; 17876 if (!membership || !membership.get('renewed')) return ''; 17877 return this.format_date(membership.get('renewed')); 17878 }, 17879 member_tag_list() { 17880 if (!this.member) return []; 17881 let tags = this.member.get('member_tags'); 17882 if (!tags || !Array.isArray(tags)) return []; 17883 return tags 17884 .filter(t => t.className === 'member_tags' && !t.get('archived') && !t.get('hidden') && t.get('name')) 17885 .map(t => ({ id: t.id, name: t.get('name'), hex: t.get('hex_value') || '' })); 17886 }, 17887 email() { 17888 return this.member ? (this.member.get('email') || null) : null; 17889 }, 17890 mobile() { 17891 if (!this.member || !this.member.get('mobile')) return null; 17892 return returnCleanMobileNumber(this.member.get('mobile')); 17893 }, 17894 formatted_mobile() { 17895 return this.mobile ? formatMobileNumber(this.mobile) : null; 17896 }, 17897 phone_home() { 17898 return this.member ? (this.member.get('phone_home') || null) : null; 17899 }, 17900 formatted_phone_home() { 17901 return this.phone_home ? formatMobileNumber(this.phone_home) : null; 17902 }, 17903 formatted_address() { 17904 if (!this.member) return ''; 17905 let parts = [ 17906 this.member.get('street'), 17907 [this.member.get('city'), this.member.get('state')].filter(
17907Boolean).join(', '), 17908 [this.member.get('zip'), this.member.get('country')].filter(Boolean).join(' '), 17909 ].filter(Boolean); 17910 return parts.join('\n'); 17911 }, 17912 formatted_address_secondary() { 17913 if (!this.member) return ''; 17914 let parts = [ 17915 this.member.get('street_secondary'), 17916 [this.member.get('city_secondary'), this.member.get('state_secondary')].filter(Boolean).join(', '), 17917 [this.member.get('zip_secondary'), this.member.get('country_secondary')].filter(Boolean).join(' '), 17918 ].filter(Boolean); 17919 return parts.join('\n'); 17920 }, 17921 has_contact_info() { 17922 return (this.email && this.field_visible('email')) || 17923 (this.mobile && this.field_visible('mobile')) || 17924 (this.phone_home && this.field_visible('phone_home')); 17925 }, 17926 show_right_column() { 17927 return this.has_contact_info || 17928 (this.formatted_address && this.field_visible('address')) || 17929 (this.formatted_address_secondary && this.field_visible('address_secondary')); 17930 }, 17931 }, 17932 }); 17933 17934</script>
17934 17935 17936<style> 17937 .mdp-wrap a { 17938 text-decoration: none !important; 17939 border-bottom: none !important; 17940 } 17941 .mdp-wrap .mdp-name { 17942 color: #081333 !important; 17943 font-weight: 500 !important; 17944 font-family: 'Roboto' !important; 17945 margin-top: 6px; 17946 } 17947 .mdp-wrap .mdp-background { 17948 height: 100px; 17949 background-color: #f2f2f3; 17950 } 17951 .mdp-wrap .mdp-background-inner { 17952 height: 100%; 17953 -webkit-filter: blur(70px); 17954 -moz-filter: blur(70px); 17955 -o-filter: blur(70px); 17956 -ms-filter: blur(70px); 17957 filter: blur(70px); 17958 background-repeat: no-repeat; 17959 background-size: 15%; 17960 background-position: right; 17961 } 17962 .mdp-wrap .mdp-avatar { 17963 width: 100px; 17964 height: 100px; 17965 border-radius: 100%; 17966 margin-top: -50px; 17967 text-align: center; 17968 margin-left: 20px; 17969 line-height: 100px; 17970 font-size: 50px; 17971 font-family: 'Roboto', sans-serif; 17972 font-weight: 500; 17973 background-size: cover; 17974 background-repeat: no-repeat; 17975 background-position: center; 17976 position: relative; 17977 background-color: var(--custom-mp-accent, #4a56f8); 17978 color: #fff; 17979 } 17980</style> 17981
17981<script type="text/html" id="template-directory-vehicle-profile"> 17982 17983 <bottom-sheet-overlay 17984 @dismiss_overlay="dismiss_overlay()" 17985 :narrow="true" 17986 > 17987 <template #header></template> 17988 17989 <template #body> 17990 <div class="standardCard noBoxShadow scrollable noPaddingTop noPaddingLeft noPaddingRight" style="padding-bottom: 80px !important;"> 17991 <div v-if="vehicle" class="mdp-wrap"> 17992 <div class="mdp-background overflowHidden"> 17993 <div 17994 class="mdp-background-inner hundop" 17995 style="height: 100%;" 17996 :style="{ backgroundImage: photo_url ? 'url(' + photo_url + ')' : '' }" 17997 ></div> 17998 </div> 17999 <div 18000 v-if="photo_url" 18001 class="mdp-avatar" 18002 style="cursor: pointer;" 18003 :style="{ backgroundImage: 'url(' + photo_url + ')' }" 18004 @click="open_photo(photo_url)" 18005 ></div> 18006 <div v-else class="mdp-avatar uppercase">{{ initials }}</div> 18007 <div class="flexWrapLeftAlign align-items-center smallerMarginTop" style="padding-left: 20px;"> 18008 <p class="noPadding mdp-name">{{ display_name }}</p> 18009 </div> 18010 <div class="flexWrapLeftAlign tinyPaddingTop smallPaddingBottom roboto"> 18011 <div class="minTwoHundo textLeft" style="padding-left: 20px; padding-right: 20px;"> 18012 <inline-pair-component 18013 v-for="field in detail_rows" 18014 :key="field.key" 18015 :title="field.label" 18016 :has_answer="true" 18017 :answer="field.value" 18018 data_type="text" 18019 ></inline-pair-component> 18020 <div v-if="vehicle_documents.length > 0" class="mdp-section tinyMarginTop tinyMarginBottom"> 18021 <h4 class="smallBlockLetters leftText smallMarginTop">Documents</h4> 18022 <p 18023 v-for="document_object in vehicle_documents" 18024 :key="document_object.id" 18025 class="flexGrowOne inline-value tinyPaddingTop" 18026 > 18027 <a :href="document_object.get('URL')" target="_blank">{{ document_object.get('name') }}</a> 18028 </p> 18029 </div> 18030 <div v-if="related_members.length > 0" class="mdp-section tinyMarginTop tinyMarginBottom"> 18031 <h4 class="smallBlockLetters leftText smallMarginTop">Related members</h4> 18032 <p 18033 v-for="related_member in related_members" 18034 :key="related_member.id" 18035 class="flexGrowOne inline-value tinyPaddingTop" 18036 > 18037 <a href="javascript:void(0);" @click="open_related_member(related_member)">{{ return_member_name(related_member) }}</a> 18038 </p> 18039 </div> 18040 <div v-if="related_vehicles.length > 0" class="mdp-section tinyMarginTop tinyMarginBottom"> 18041 <h4 class="smallBlockLetters leftText smallMarginTop">Related {{ vehicle_type_label }}</h4> 18042 <p 18043 v-for="related_vehicle in related_vehicles" 18044 :key="related_vehicle.id" 18045 class="flexGrowOne inline-value tinyPaddingTop" 18046 > 18047 <a href="javascript:void(0);" @click="open_related_vehicle(related_vehicle.vehicle)">{{ related_vehicle.name }}</a> 18048 </p> 18049 </div> 18050 </div> 18051 </div> 18052 </div> 18053 </div> 18054 </template> 18055 18056 </bottom-sheet-overlay> 18057 18058 <div 18059 v-if="photo_lightbox_url" 18060 @click="close_photo()" 18061 style="display: flex; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.85); z-index: 99999; cursor: pointer; align-items: center; justify-content: center;" 18062 > 18063 <img 18064 :src="photo_lightbox_url" 18065 alt="Vehicle photo" 18066 style="max-width: 90vw; max-height: 90vh; border-radius: 8px; object-fit: contain;" 18067 > 18068 </div> 18069</script>
18069 18070
18071<script> 18072 function open_directory_vehicle_profile(params) { 18073 let profile = store.components.directory_vehicle_profile; 18074 profile.vehicle = params.vehicle || null; 18075 profile.vehicle_type = params.vehicle_type || 'boat'; 18076 profile.all_members = params.all_members || []; 18077 profile.boats_map = params.boats_map || {}; 18078 profile.documents = params.documents || []; 18079 profile.photo_lightbox_url = null; 18080 store.overlays.directory_vehicle_profile = true; 18081 } 18082 18083 store.components["directory_vehicle_profile"] = { 18084 vehicle: null, 18085 vehicle_type: 'boat', 18086 all_members: [], 18087 boats_map: {}, 18088 documents: [], 18089 photo_lightbox_url: null, 18090 }; 18091 18092 app_overlays.component("directory-vehicle-profile", { 18093 template: "#template-directory-vehicle-profile", 18094 data() { 18095 return store.components["directory_vehicle_profile"]; 18096 }, 18097 methods: { 18098 dismiss_overlay() { 18099 this.photo_lightbox_url = null; 18100 this.vehicle = null; 18101 store.overlays.directory_vehicle_profile = false; 18102 }, 18103 open_photo(photo_url) { 18104 this.photo_lightbox_url = photo_url; 18105 }, 18106 close_photo() { 18107 this.photo_lightbox_url = null; 18108 }, 18109 open_related_vehicle(vehicle) { 18110 this.vehicle = vehicle; 18111 this.photo_lightbox_url = null; 18112 }, 18113 open_related_member(member) { 18114 let member_profile = store.components.member_directory_profile; 18115 let params = { 18116 member: member, 18117 all_members: this.all_members, 18118 boats_map: this.boats_map, 18119 documents: this.documents, 18120 custom_fields: member_profile ? member_profile.custom_fields : [], 18121 custom_field_responses: member_profile ? member_profile.custom_field_responses : [], 18122 member_portal_config: member_profile ? member_profile.profile_field_config : null, 18123 }; 18124 this.dismiss_overlay(); 18125 open_member_directory_profile(params); 18126 }, 18127 return_member_name(member) { 18128 let first = member.get('firstName') || ''; 18129 let nickname = member.get('nickname'); 18130 let last = member.get('lastName') || ''; 18131 let name = first; 18132 if (nickname) { 18133 name += ' "' + nickname + '"'; 18134 } 18135 if (last) { 18136 name += (name ? ' ' : '') + last; 18137 } 18138 return name.trim() || 'â'; 18139 }, 18140 field_is_visible(field_name) { 18141 if (this.vehicle_type !== 'boat') { 18142 return true; 18143 } 18144 let membership_settings = store.clubObject ? store.clubObject.get('membership_settings') : null; 18145 let boat_fields = membership_settings ? membership_settings.get('boat_data_collection_settings') : null; 18146 if (!boat_fields || boat_fields === true) { 18147 return true; 18148 } 18149 let field_settings = boat_fields[field_name]; 18150 if (!field_settings) { 18151 return false; 18152 } 18153 return field_settings.enabled && field_settings.member_visible; 18154 }, 18155 vehicle_name(vehicle) { 18156 if (this.vehicle_type === 'boat') { 18157 return vehicle.get('boatName') || ''; 18158 } 18159 return vehicle.get('name') || ''; 18160 }, 18161 }, 18162 computed: { 18163 field_definitions() { 18164 let definitions_by_type = { 18165 boat: [ 18166 { key: 'sailNumber', label: 'Sail #' }, 18167 { key: 'hullNumber', label: 'Hull #' }, 18168 { key: 'class', label: 'Class' }, 18169 { key: 'model', label: 'Model' }, 18170 { key: 'builder', label: 'Builder' }, 18171 { key: 'registration', label: 'Registration #' }, 18172 ], 18173 }; 18174 return definitions_by_type[this.vehicle_type] || []; 18175 }, 18176 detail_rows() { 18177 if (!this.vehicle) { 18178 return []; 18179 } 18180 return this.field_definitions.filter(field_definition => { 18181 return this.field_is_visible(field_definition.key) && this.vehicle.get(field_definition.key); 18182 }).map(field_definition => { 18183 return { 18184 key: field_definition.key, 18185 label: field_definition.label, 18186 value: this.vehicle.get(field_definition.key), 18187 }; 18188 }); 18189 }, 18190 photo_url() { 18191 if (!this.vehicle) { 18192 return null; 18193 } 18194 return this.vehicle.get('imageURL') || null; 18195 }, 18196 display_name() { 18197 if (!this.vehicle) { 18198 return ''; 18199 } 18200 let name = this.vehicle_name(this.vehicle); 18201 if (name) { 18202 return name; 18203 } 18204 if (this.vehicle_type === 'boat') { 18205 return 'Boat'; 18206 } 18207 return 'Vehicle'; 18208 }, 18209 initials() { 18210 return (this.display_name || '').charAt(0).toUpperCase(); 18211 }, 18212 vehicle_type_label() { 18213 if (this.vehicle_type === 'boat') { 18214 return 'boats'; 18215 } 18216 return 'vehicles'; 18217 }, 18218 membership_id() { 18219 if (!this.vehicle) { 18220 return null; 18221 } 18222 let membership = this.vehicle.get('membershipObject'); 18223 if (!membership) { 18224 return null; 18225 } 18226 return membership.id; 18227 }, 18228 membership_vehicles() { 18229 if (!this.membership_id || this.vehicle_type !== 'boat') { 18230 return []; 18231 } 18232 return this.boats_map[this.membership_id] || []; 18233 }, 18234 related_members() { 18235 if (!this.membership_id) { 18236 return []; 18237 } 18238 return this.all_members.filter(member => { 18239 let membership = member.get('membershipObject'); 18240 return membership && membership.id === this.membership_id; 18241 }); 18242 }, 18243 related_vehicles() { 18244 if (!this.vehicle) { 18245 return []; 18246 } 18247 let visible_field_names = ['class', 'sailNumber', 'model', 'builder', 'hullNumber'].filter(field_name => { 18248 return this.field_is_visible(field_name); 18249 }); 18250 return this.membership_vehicles.filter(vehicle => { 18251 return vehicle.id !== this.vehicle.id; 18252 }).map(vehicle => { 18253 let name_parts = [this.vehicle_name(vehicle)].concat(visible_field_names.map(field_name => { 18254 return vehicle.get(field_name); 18255 })); 18256 return { 18257 id: vehicle.id,
18258 name: name_parts.filter(Boolean).join(' - '), 18259 vehicle: vehicle, 18260 }; 18261 }).filter(related_vehicle => { 18262 return related_vehicle.name; 18263 }); 18264 }, 18265 vehicle_documents() { 18266 if (!this.vehicle || this.vehicle_type !== 'boat') { 18267 return []; 18268 } 18269 return this.documents.filter(document_object => { 18270 let boat_object = document_object.get('boatObject'); 18271 return boat_object && boat_object.id === this.vehicle.id && document_object.get('name') && document_object.get('URL'); 18272 }); 18273 }, 18274 }, 18275 }); 18276 18277</script>
18277 18278 18279<style> 18280 .mdp-wrap a { 18281 text-decoration: none !important; 18282 border-bottom: none !important; 18283 } 18284 .mdp-wrap .mdp-name { 18285 color: #081333 !important; 18286 font-weight: 500 !important; 18287 font-family: 'Roboto' !important; 18288 margin-top: 6px; 18289 } 18290 .mdp-wrap .mdp-background { 18291 height: 100px; 18292 background-color: #f2f2f3; 18293 } 18294 .mdp-wrap .mdp-background-inner { 18295 height: 100%; 18296 -webkit-filter: blur(70px); 18297 -moz-filter: blur(70px); 18298 -o-filter: blur(70px); 18299 -ms-filter: blur(70px); 18300 filter: blur(70px); 18301 background-repeat: no-repeat; 18302 background-size: 15%; 18303 background-position: right; 18304 } 18305 .mdp-wrap .mdp-avatar { 18306 width: 100px; 18307 height: 100px; 18308 border-radius: 100%; 18309 margin-top: -50px; 18310 text-align: center; 18311 margin-left: 20px; 18312 line-height: 100px; 18313 font-size: 50px; 18314 font-family: 'Roboto', sans-serif; 18315 font-weight: 500; 18316 background-size: cover; 18317 background-repeat: no-repeat; 18318 background-position: center; 18319 position: relative; 18320 background-color: var(--custom-mp-accent, #4a56f8); 18321 color: #fff; 18322 } 18323</style> 18324
18324<script type="text/html" id="template-show-event-details-in-member-cal"> 18325 <bottom-sheet-overlay 18326 @dismiss_overlay="dismiss_overlay()" 18327 :narrow="true" 18328 > 18329 <!-- header content --> 18330 <template #header></template> 18331 18332 <!-- body content --> 18333 <template #body> 18334 <div 18335 class="standardCardBody fff scrollable " 18336 > 18337 <h2 v-if="event_json.title" class="event-page-name centeredText marginTop">{{ event_json.title }}</h2> 18338 <div class="flexWrap smallMarginBottom tinyMarginTop"> 18339 <p v-if="date_string" class="hundop flexNoWrapLeftAlign"> 18340 <i class="fa-light fa-calendar-week" style="font-size:20px;"></i> 18341 <span class="leftText tinyMarginLeft">{{ date_string }}</span> 18342 </p> 18343 <p v-if="time_string" class="hundop flexNoWrapLeftAlign" style="margin-top: 12px;"> 18344 <i class="fa-light fa-clock" style="font-size:20px;"></i> 18345 <span class="leftText tinyMarginLeft">{{ time_string }}</span> 18346 </p> 18347 <!-- in-portal booking links (eg. group classes surfaced in the member 18348 portal booking hub) navigate within the SPA instead of opening the 18349 standalone page in a new tab --> 18350 <p v-if="event_json.in_portal_path" class="hundop flexNoWrapLeftAlign" style="margin-top: 12px;"> 18351 <i class="fa-light fa-link" style="font-size:20px;"></i> 18352 <span class="leftText tinyMarginLeft"> 18353 <a class="pointer" @click="tapped_in_portal_link()">Follow link</a> 18354 </span> 18355 </p> 18356 <p v-else-if="event_json.external_event_url" class="hundop flexNoWrapLeftAlign" style="margin-top: 12px;"> 18357 <i class="fa-light fa-link" style="font-size:20px;"></i> 18358 <span class="leftText tinyMarginLeft"> 18359 <a target="_blank" :href="event_json.external_event_url">Follow link</a> 18360 </span> 18361 </p> 18362 </div> 18363 18364 <div 18365 v-if="event_image_url" 18366 style="display:flex; width:100%; justify-content:center;" 18367 > 18368 <div 18369 v-if="image_crop_style == 'cover'" 18370 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;" 18371 > 18372 <img 18373 :src="event_image_url" 18374 style="width:100%; height:100%; display:block; object-fit:cover;" 18375 @error="console.log('event image failed to load:', event_image_url)" 18376 /> 18377 </div> 18378 18379 <div 18380 v-else 18381 style="width:100%; max-width:580px; height:400px; position:relative; overflow:hidden; border:1px solid #e4ebf1; border-radius:5px;" 18382 > 18383 <div 18384 :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)' }" 18385 > 18386 <div 18387 style="position:absolute; top:0; right:0; left:0; bottom:0; background-color: rgba(253,253,253,0.8);" 18388 ></div> 18389 </div> 18390 <div 18391 :style="{ backgroundImage: 'url(' + event_image_url + ')', position: 'absolute', top: '0', left: '0', right: '0', bottom: '0', backgroundRepeat: 'no-repeat', backgroundPosition: 'center', backgroundSize: 'contain' }" 18392 ></div> 18393 </div> 18394 </div> 18395 <div 18396 class="event-page-details flex-col" 18397 > 18398 <div class="event-page-details-wrapper maxSixFifty flex-col centeredText"> 18399 <p 18400 v-if="event_json.description" 18401 class="smallMarginTop tiptap-container marginBottom" 18402 style="line-height: 1.5;" 18403 v-html="event_json.description"> 18404 </p> 18405 </div> 18406 </div> 18407 </div> 18408 </template> 18409 18410 18411 </bottom-sheet-overlay> 18412</script>
18412 18413 18414
18415<script> 18416 18417 function follow_member_cal_event_link(event_json) { 18418 if (event_json.in_portal_path) { 18419 updateURL(event_json.in_portal_path); 18420 displayViewForUrlPath(); 18421 return true; 18422 } 18423 if (event_json.external_event_url) { 18424 window.open(event_json.external_event_url, '_blank'); 18425 return true; 18426 } 18427 return false; 18428 } 18429 18430 function show_event_details_in_member_cal(params) { 18431 store.components.show_event_details_in_member_cal.event_json = params.event_json; 18432 store.overlays.show_event_details_in_member_cal = true; 18433 } 18434 18435 store.components["show_event_details_in_member_cal"] = { 18436 event_json: null, 18437 } 18438 18439 app_overlays.component("show-event-details-in-member-cal-overlay", { 18440 template: "#template-show-event-details-in-member-cal", 18441 data() { 18442 return store.components["show_event_details_in_member_cal"]; 18443 }, 18444 methods: { 18445 dismiss_overlay(){ 18446 store.overlays.show_event_details_in_member_cal = false; 18447 }, 18448 tapped_in_portal_link(){ 18449 this.dismiss_overlay(); 18450 follow_member_cal_event_link(this.event_json); 18451 }, 18452 }, 18453 computed: { 18454 event_image_url(){ 18455 if(this.event_json && this.event_json.image && this.event_json.image.url){ 18456 return this.event_json.image.url; 18457 } 18458 return null; 18459 }, 18460 image_crop_style(){ 18461 if(this.event_json && this.event_json.image && this.event_json.image.crop){ 18462 return this.event_json.image.crop; 18463 } 18464 return null; 18465 }, 18466 date_string(){ 18467 var start_date_string = format_utc_date_v2({ 18468 utcDate: this.event_json.ts, 18469 formatString: 'MMMM D, YYYY', 18470 timezone: store.clubObject.get('timezone') 18471 }); 18472 if(this.event_json.isAllDay){ 18473 //it could span multiple days, check if we should show the end date 18474 var endDate = this.event_json.end_ts || null; 18475 if(endDate){ 18476 //compare the date/month/year only 18477 var startDateObj = new Date(this.event_json.ts); 18478 var endDateObj = new Date(endDate); 18479 if(startDateObj.getUTCFullYear() !== endDateObj.getUTCFullYear() ||startDateObj.getUTCMonth() !== endDateObj.getUTCMonth() || startDateObj.getUTCDate() !== endDateObj.getUTCDate()){ 18480 //different dates, show the end date 18481 start_date_string += ` - ${ format_utc_date_v2({ 18482 utcDate: endDate, 18483 formatString: 'MMMM D, YYYY', 18484 timezone: store.clubObject.get('timezone') 18485 })}`; 18486 } 18487 } 18488 } 18489 return start_date_string; 18490 }, 18491 time_string(){ 18492 if(this.event_json.isAllDay){ 18493 return ""; 18494 } 18495 var startDate = this.event_json.ts; 18496 var endDate = this.event_json.end_ts || null; 18497 var time_string = format_utc_date_v2({ 18498 utcDate: this.event_json.ts, 18499 formatString: 'h:mm A', 18500 timezone: store.clubObject.get('timezone') 18501 }); 18502 if(endDate && endDate !== startDate) { 18503 time_string += ` - ${ format_utc_date_v2({ 18504 utcDate: endDate, 18505 formatString: 'h:mm A', 18506 timezone: store.clubObject.get('timezone') 18507 })}`; 18508 } 18509 return time_string; 18510 } 18511 }, 18512 }); 18513</script>
18513 18514 18515 18516 <!-- define/register shared vue components --> 18517 18518
18518<script type="text/html" id="template-calendar-date-picker"> 18519 <vue-date-picker 18520 v-model="selected_date" 18521 :mode="mode" 18522 :modes="['year', 'month']" 18523 :monthPicker="true" 18524 :yearPicker="true" 18525 @update:modelValue="date_changed()" 18526 :clearable="false" 18527 format="MMM-d-yyyy" 18528 auto-apply 18529 :teleport="true" 18530 :month-change-on-scroll="false" 18531 menu-class-name="cs-calendar-date-picker-menu" 18532 input-class-name="cs-calendar-date-picker-input" 18533 > 18534 <template #input-icon> 18535 <i class="fa-regular fa-calendar-arrow-down"></i> 18536 </template> 18537 <template #calendar-icon> 18538 <i class="fa-regular fa-arrow-turn-down-left"></i> 18539 </template> 18540 <template #arrow-left> 18541 <i class="fa-regular fa-chevron-left"></i> 18542 </template> 18543 <template #arrow-right> 18544 <i class="fa-regular fa-chevron-right"></i> 18545 </template> 18546 </vue-date-picker> 18547 </script>
18547 18548 18549 <style> 18550 18551 .cs-calendar-date-picker-menu .dp__btn{ 18552 color: #000000 !important; 18553 background: none !important; 18554 font: inherit !important; 18555 line-height: normal !important; 18556 } 18557 18558 .cs-calendar-date-picker-input{ 18559 width: 0px; 18560 padding-left: 26px; 18561 padding-right: 22px; 18562 text-align: center; 18563 margin-right: 8px; 18564 overflow: hidden; 18565 padding-top: 7px; 18566 padding-bottom: 7px; 18567 border: 1px solid #e4ebf1; 18568 } 18569 18570 .cs-calendar-menu-wrap .dp__input_icon i{ 18571 font-size: 16px !important; 18572 transform: none; 18573 } 18574 18575 </style> 18576 18577
18577<script> 18578 18579 app_content.component("calendar-date-picker-component", { 18580 template: "#template-calendar-date-picker", 18581 data() { 18582 return { 18583 selected_date: new Date(), 18584 mode: 'month', 18585 } 18586 }, 18587 props: { 18588 handle_button_click: Function, 18589 }, 18590 methods: { 18591 date_changed(){ 18592 this.handle_button_click( 'date-picker', this.selected_date ); 18593 }, 18594 }, 18595 }); 18596 18597 </script>
18597 18598 18599
18599<script> 18600 app_content.component('VueDatePicker', VueDatePicker); 18601 </script>
18601 18602
18602<script type="text/html" id="template-calendar-grid"> 18603 <div class="centeredOnMobile smallMarginTop" :class="{'ninetyPercent': !is_membership_cal, 'standardCard': !is_membership_cal}"> 18604 <!-- main options header // stays consistent across all views --> 18605 <div class="standardCardBody noBorderTop calendar noPadding fff" style="overflow: none;"> 18606 <!-- options for non-membership cal --> 18607 <div v-if="filterOptions?.length >= 1 && !is_membership_cal" class="cal-filter-option-wrap scrollable paddingLeftRight tinyPaddingTop smallPaddingBottom"> 18608 <div class="flexNoWrap"> 18609 <template v-for="option in filterOptions"> 18610 <div 18611 class="cs-cal-filter-option" 18612 :class="[ 18613 'cs-cal-filter-option_' + option.data_option, 18614 { 'active': selectedFilterOptions.indexOf(option.data_option) !== -1 } 18615 ]" 18616 :data-option="option.data_option" 18617 @click="$emit('handleFilterSelection', option)" 18618 > 18619 {{ option.name }} 18620 </div> 18621 </template> 18622 </div> 18623 </div> 18624 <!-- main calendar header // stays consistent across all views --> 18625 <!-- options for membership cal --> 18626 <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"> 18627 <div v-if="filterOptions?.length >= 1 && is_membership_cal" class="scrollable posScrollbar lightBorderTop noPaddingBottom"> 18628 <div class="flexNoWrap" :style="generateWrapStyle('options')"> 18629 <template v-for="option in filterOptions"> 18630 <div 18631 class="cs-cal-filter-option" 18632 :class="{ 18633 ['cs-cal-filter-option_' + option.data_option]: true, 18634 'active': selectedFilterOptions.indexOf(option.data_option) !== -1 18635 }" 18636 :data-option="option.data_option" 18637 @click="$emit('handleFilterSelection', option)" 18638 > 18639 {{ option.name }} 18640 </div> 18641 </template> 18642 </div> 18643 </div> 18644 <div class="cs-calendar-menu-wrap flexNoWrap" :style="generateWrapStyle('cs-calendar-menu-wrap')"> 18645 <div class="flexWrapLeftAlign"> 18646 <div class="flexNoWrap"> 18647 <div class="cs-calendar-button roboto hideOnMobile" data-option="today" @click="handleButtonClick('today')">Today</div> 18648 <div class="cs-calendar-button cs-calendar-button-previous roboto" data-option="previous" @click="handleButtonClick('previous')"></div> 18649 <div class="cs-calendar-button cs-calendar-button-next roboto" data-option="next" @click="handleButtonClick('next')"></div> 18650 </div> 18651 <p v-if="calendarTitle" class="cs-calendar-date-range-title roboto">{{ calendarTitle }}</p> 18652 </div> 18653 <div class="flexGrowOne roboto"></div> 18654 <!-- calendar date picker --> 18655 <calendar-date-picker-component 18656 :handle_button_click="handleButtonClick" 18657 ></calendar-date-picker-component> 18658 <div class="vue-cs-calendar-select-wrap onWhite roboto"> 18659 <select 18660 class="vue-cs-calendar-view-select roboto" 18661 v-model="view_preference" 18662 @change="changeViewPreference" 18663 > 18664 <option value="Month">Month</option> 18665 <option value="Week">Week</option> 18666 <option value="Day">Day</option> 18667 </select> 18668 </div> 18669 </div> 18670 <!-- inject month view --> 18671 <div v-if="view_preference === 'Month' && !loading" class="flexGrid"> 18672 <!-- header -->
18673 <div class="daysOfWeek-grid"> 18674 <template v-for="day in daysOfWeek"> 18675 <calendar-cell-component 18676 class="grid-cell dayOfWeek" 18677 :class="{ 'borderClose': day === 'Sat' }" 18678 :day="day.toUpperCase()" 18679 cellType="dayOfWeek" 18680 :view_preference="view_preference" 18681 ></calendar-cell-component> 18682 </template> 18683 </div> 18684 <!-- events --> 18685 <div class="calendar-grid"> 18686 <template v-for="(calendarObj, i) in calendar"> 18687 <calendar-cell-component 18688 class="grid-cell" 18689 :client_id="client_id" 18690 :date="calendarObj.date" 18691 :class="{ 18692 'notCurrentMonth': calendarObj.class === 'prevMonthDate' || calendarObj.class === 'nextMonthDate', 18693 'currentMonth': calendarObj.class === 'currentMonthDate', 18694 'borderClose': calendarObj.date.day() === 6, 18695 'borderTopRightBottomClose': i >= 35 && i <= 40, 18696 'borderTopBottomClose': i === calendar.length - 1 18697 }" 18698 :month_class="calendarObj.class" 18699 :day="calendarObj.day" 18700 :events="calendarObj.events" 18701 cellType="calendarObj" 18702 :view_preference="view_preference" 18703 :defaultDate="calendarObj.defaultDate" 18704 :monthIndex="i" 18705 @createSchedule_emitted="createSchedule_emitted" 18706 @clickSchedule_emitted="clickSchedule_emitted" 18707 :is_membership_cal="is_membership_cal" 18708 ></calendar-cell-component> 18709 </template> 18710 </div> 18711 </div> 18712 <!-- inject week view --> 18713 <div v-if="view_preference === 'Week' && !loading" class="flexGrid"> 18714 <!-- header --> 18715 <div class="placeholder-daysOfWeek-grid"> 18716 <template v-for="day in placeholder_daysOfWeek"> 18717 <calendar-cell-component v-if="day.dayOfWeek === null" 18718 class="grid-cell-week dayOfWeek hide" 18719 :day="day" 18720 cellType="dayOfWeek" 18721 :view_preference="view_preference" 18722 ></calendar-cell-component> 18723 <calendar-cell-component v-if="day.dayOfWeek !== null" 18724 class="grid-cell-week dayOfWeek" 18725 :day="day" 18726 cellType="dayOfWeek" 18727 :view_preference="view_preference" 18728 :defaultDate="day.defaultDate" 18729 ></calendar-cell-component> 18730 </template> 18731 <!-- all day events --> 18732 <template v-for="allDay in placeholder_daysOfWeek"> 18733 <calendar-cell-component v-if="allDay.dayOfWeek === null" 18734 class="grid-cell-week allDay" 18735 :allDay="allDay" 18736 cellType="allDay_events" 18737 :view_preference="view_preference" 18738 :is_membership_cal="is_membership_cal" 18739 ></calendar-cell-component> 18740 <calendar-cell-component v-if="allDay.dayOfWeek !== null" 18741 class="grid-cell-week allDay" 18742 :allDay="allDay" 18743 cellType="allDay_events" 18744 :view_preference="view_preference" 18745 @createSchedule_emitted="createSchedule_emitted" 18746 @clickSchedule_emitted="clickSchedule_emitted" 18747 :is_membership_cal="is_membership_cal" 18748 ></calendar-cell-component> 18749 </template> 18750 </div> 18751 <!-- timestamps and events --> 18752 <div class="placeholder-calendar-grid"> 18753 <template v-for="(timestamp, i) in timestamps"> 18754 <template v-for="(calendarObj, j) in calendar"> 18755 <calendar-cell-component 18756 v-if="j === 0" 18757 class="grid-cell-week timestamp" 18758 :date="calendarObj.date" 18759 :timestamp="i % 4 === 0 ? formatShortTime( timestamp.customFormat ) : null" 18760 :events="[]" 18761 cellType="timestamp" 18762 :view_preference="view_preference" 18763 :is_membership_cal="is_membership_cal" 18764 :class="{ 'borderBottomClose': i === timestamps.length - 1 }" 18765 ></calendar-cell-component> 18766 <calendar-cell-component 18767 v-else 18768 class="grid-cell-week" 18769 :style="calendarObj.defaultDate ? { 'background-color': 'rgba( 175, 213, 240, 0.1)' } : null" 18770 :class="{ 18771 'showBorder': i % 4 === 0, 18772 'hideBorder': i === 0 || i % 4 !== 0 18773 }" 18774 :date="calendarObj.date" 18775 :events="generateEventForTimestamp( timestamp.customFormat, calendarObj.events, calendarObj.date )" 18776 cellType="calendarObj" 18777 :view_preference="view_preference" 18778 @createSchedule_emitted="createSchedule_emitted" 18779 @clickSchedule_emitted="clickSchedule_emitted" 18780 ></calendar-cell-component> 18781 </template> 18782 </template> 18783 </div> 18784 </div> 18785 <!-- inject day view --> 18786 <div v-if="view_preference === 'Day' && !loading" class="flexGrid"> 18787 <!-- header -->
18788 <div class="day-daysOfWeek-grid"> 18789 <template v-for="day in today_daysOfWeek"> 18790 <calendar-cell-component v-if="day.dayOfWeek === null" 18791 class="grid-cell-week dayOfWeek hide" 18792 :day="day" 18793 cellType="dayOfWeek" 18794 :view_preference="view_preference" 18795 ></calendar-cell-component> 18796 <calendar-cell-component v-if="day.dayOfWeek !== null" 18797 class="grid-cell-week day" 18798 :day="day" 18799 cellType="dayOfWeek" 18800 :view_preference="view_preference" 18801 :defaultDate="day.defaultDate" 18802 ></calendar-cell-component> 18803 </template> 18804 <!-- all day events --> 18805 <template v-for="allDay in today_daysOfWeek"> 18806 <calendar-cell-component v-if="allDay.dayOfWeek === null" 18807 class="grid-cell-week allDay" 18808 :allDay="allDay" 18809 cellType="allDay_events" 18810 :view_preference="view_preference" 18811 :is_membership_cal="is_membership_cal" 18812 ></calendar-cell-component> 18813 <calendar-cell-component v-if="allDay.dayOfWeek !== null" 18814 class="grid-cell-week allDay" 18815 :allDay="allDay" 18816 cellType="allDay_events" 18817 :view_preference="view_preference" 18818 @createSchedule_emitted="createSchedule_emitted" 18819 @clickSchedule_emitted="clickSchedule_emitted" 18820 :is_membership_cal="is_membership_cal" 18821 ></calendar-cell-component> 18822 </template> 18823 </div> 18824 <!-- timestamps and events --> 18825 <div class="day-calendar-grid"> 18826 <template v-for="(timestamp, i) in timestamps"> 18827 <template v-for="(calendarObj, j) in calendar"> 18828 <calendar-cell-component v-if="j === 0" 18829 class="grid-cell-week timestamp" 18830 :date="calendarObj.date" 18831 :timestamp="i % 4 === 0 ? formatShortTime( timestamp.customFormat ) : null" 18832 :events="[]" 18833 cellType="timestamp" 18834 :view_preference="view_preference" 18835 :is_membership_cal="is_membership_cal" 18836 :class="{ 'borderBottomClose': i === timestamps.length - 1 }" 18837 ></calendar-cell-component> 18838 <calendar-cell-component v-if="j !== 0" 18839 class="grid-cell-week" 18840 :style="calendarObj.defaultDate ? { 'background-color': 'rgba( 175, 213, 240, 0.1)' } : null" 18841 :class="{ 18842 'showBorder': i % 4 === 0, 18843 'hideBorder': i === 0 || i % 4 !== 0 18844 }" 18845 :date="calendarObj.date" 18846 :events="generateEventForTimestamp( timestamp.customFormat, calendarObj.events, calendarObj.date )" 18847 cellType="calendarObj" 18848 :view_preference="view_preference" 18849 @createSchedule_emitted="createSchedule_emitted" 18850 @clickSchedule_emitted="clickSchedule_emitted" 18851 ></calendar-cell-component> 18852 </template> 18853 </template> 18854 </div> 18855 </div> 18856 <!-- spinner while loading --> 18857 <div v-if="loading" class="cardBodySpinnerWrap relative active"> 18858 <div class="cardBodySpinner centeredBlock"></div> 18859 </div> 18860 </div> 18861 </div> 18862 </div> 18863</script>
vendor: 1 bytes, line 18863
18863
18864<script> 18865 /* ----- STORE ----- */ 18866 store.components["calendar_grid"] = { 18867 calendar: [], 18868 loading: true, 18869 view_preference: 'Month', 18870 dropdownOpen: false, 18871 daysOfWeek: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], 18872 placeholder_daysOfWeek: [], 18873 today_daysOfWeek: [], 18874 view_options: [ 'Month', 'Week', 'Day' ], 18875 calendarTitle: null, 18876 //dates and times 18877 currentDate: dayjs() || new Date(), 18878 defaultDate: dayjs() || new Date(), 18879 currentMonth: dayjs().format('MMMM'), 18880 startOfWeek: dayjs().startOf('week'), 18881 endOfWeek: dayjs().endOf('week'), 18882 currentYear: dayjs().year(), 18883 timestamps: [], 18884 currentTime: dayjs().format('HH:mm:ss'), 18885 }; 18886 18887 /* ----- COMPONENT ----- */ 18888 app_content.component("calendar-grid-component", { 18889 template: "#template-calendar-grid", 18890 data() { 18891 return store.components['calendar_grid'] 18892 }, 18893 created(){ 18894 if (this.timestamps.length === 0){ 18895 this.generateTimestamps(); 18896 } 18897 }, 18898 props: { 18899 client_id: String, 18900 class_string: String, 18901 style_string: String, 18902 filterOptions: Array, 18903 selectedFilterOptions: Array, 18904 schedules: Array, 18905 is_membership_cal: Boolean, 18906 load_cal: Function, 18907 handleFilterSelection: Function, 18908 createSchedule: Function, 18909 clickSchedule: Function, 18910 }, 18911 methods: { 18912 createSchedule_emitted( date ){ 18913 this.$emit('createSchedule', date) 18914 }, 18915 clickSchedule_emitted( event ){ 18916 this.$emit('clickSchedule', event ) 18917 }, 18918 formatShortTime( timeString ){ 18919 if (!timeString){ 18920 return '' // placeholder for 11:45-12:00 block 18921 } 18922 // if (timeString === '12:00 AM'){ 18923 // return Intl.DateTimeFormat().resolvedOptions().timeZone; 18924 // } 18925 const [hours, minutes] = timeString.split(':'); 18926 const AM_PM = minutes.split(' ')[1] 18927 return `${hours} ${AM_PM}`; 18928 }, 18929 generateWrapStyle( type ){ 18930 if (type === 'cs-calendar-wrap'){ 18931 return this.style_string === undefined ? {'width': '100%'} : this.style_string; 18932 } else if (type === 'options' && this.is_membership_cal){ 18933 return { 18934 'padding-top': '15px', 18935 'padding-right': '15px', 18936 'padding-left': '15px', 18937 'padding-bottom': '5px', 18938 'border-width': '0 1.5px 0 1.5px', 18939 'border-color': '#e2e8e8', 18940 'border-style': 'solid', 18941 'overflow': 'auto', 18942 } 18943 } else if (type === 'cs-calendar-menu-wrap' && this.is_membership_cal){ 18944 return { 18945 'border-width': '0 1.5px 0 1.5px', 18946 'border-color': '#e2e8e8', 18947 'border-style': 'solid' 18948 } 18949 } 18950 }, 18951 generateEventForTimestamp( timestamp, events, date ){ 18952 // do not want to display all day or multiple day events -- being handled separately 18953 let eventsArr = []; 18954 if (events.length > 0){ 18955 for (let i = 0; i < events.length; i++){ 18956 let event = events[i]; 18957 // normal event 18958 if ((event.hours_mins_start && event.hours_mins_end) && !event.isAllDay && event.calendarOnly) { 18959 let startTime = dayjs().set('hour', event.hours_mins_start.hours).set('minute', event.hours_mins_start.minutes); 18960 let endTime = dayjs().set('hour', event.hours_mins_end.hours).set('minute', event.hours_mins_end.minutes).subtract(15, 'minutes'); 18961 18962 let formattedStartTime = startTime.format('h:mm A'); 18963 let formattedEndTime = endTime.format('h:mm A'); 18964 18965 let timesArray = [formattedStartTime]; 18966 while (startTime.isBefore(endTime)) { 18967 startTime = startTime.add(15, 'minutes'); 18968 timesArray.push(startTime.format('h:mm A')); 18969 } 18970 if ( formattedStartTime === timestamp ){ 18971 eventsArr.push({ event, numOfTimeBlocks: timesArray.length }); 18972 } 18973 // recurring event 18974 }
18974 else if ( (event.start && event.end) && (event.start !== event.end) && !event.isAllDay && !event.isMultipleDay && event.calendarOnly ){ 18975 let startTime = dayjs(event.start); 18976 let endTime = dayjs(event.end).subtract(15, 'minutes'); 18977 18978 let formattedStartTime = startTime.format('h:mm A'); 18979 let formattedEndTime = endTime.format('h:mm A'); 18980 18981 let timesArray = [formattedStartTime]; 18982 18983 while (startTime.isBefore(endTime)) { 18984 startTime = startTime.add(15, 'minutes'); 18985 timesArray.push(startTime.format('h:mm A')); 18986 } 18987 if ( formattedStartTime === timestamp ){ 18988 eventsArr.push({ event, numOfTimeBlocks: timesArray.length }); 18989 } 18990 // non-calendarOnly events 18991 } else if ( (event.start && event.end) && (event.start !== event.end) && !event.calendarOnly ){ 18992 let startTime; 18993 let endTime; 18994 18995 if (dayjs(event.start).startOf('day').$d.toString() === dayjs(date).startOf('day').$d.toString()){ 18996 startTime = dayjs(event.start); 18997 endTime = dayjs(event.end).subtract(15, 'minutes'); 18998 } else if (dayjs(event.end).startOf('day').$d.toString() === dayjs(date).startOf('day').$d.toString()) { 18999 startTime = dayjs(event.end).startOf('day'); 19000 endTime = dayjs(event.end).subtract(15, 'minutes'); 19001 } else { 19002 startTime = dayjs().startOf('day'); 19003 endTime = dayjs().endOf('day'); 19004 } 19005 19006 let formattedStartTime = startTime.format('h:mm A'); 19007 let formattedEndTime = endTime.format('h:mm A'); 19008 19009 let timesArray = [formattedStartTime]; 19010 19011 while (startTime.isBefore(endTime)) { 19012 startTime = startTime.add(15, 'minutes'); 19013 timesArray.push(startTime.format('h:mm A')); 19014 } 19015 if ( formattedStartTime === timestamp ){ 19016 eventsArr.push({ event, numOfTimeBlocks: timesArray.length }); 19017 } 19018 } 19019 } 19020 } 19021 if (eventsArr.length > 0){ 19022 return eventsArr; 19023 } else { 19024 return []; 19025 } 19026 }, 19027 changeViewPreference(){ 19028 this.loading = true; 19029 //and re-load data 19030 this.$emit('load_cal', this.client_id); 19031 }, 19032 calculateTitleRange(){ 19033 if ( this.view_preference === 'Month' ){ 19034 this.calendarTitle = `${this.currentMonth} ${this.currentYear.toString()}`; 19035 } else if ( this.view_preference === 'Week' ){ 19036 this.calendarTitle = `${this.startOfWeek.format('MMMM')} ${this.startOfWeek.$D} - ${this.endOfWeek.format('MMMM')} ${this.endOfWeek.$D}`; 19037 } else if ( this.view_preference === 'Day' ){ 19038 this.calendarTitle = `${this.currentMonth} ${this.currentDate.$D}, ${this.currentYear}`; 19039 } 19040 }, 19041 handleButtonClick( option, selectedDate ){ 19042 this.loading = true; 19043 if (this.view_preference === 'Month'){ 19044 if( option === "today" ){ 19045 this.currentDate = dayjs(); 19046 this.currentMonth = this.currentDate.format('MMMM') 19047 this.currentYear = this.currentDate.$y 19048 this.startOfWeek = this.currentDate.startOf('week'); 19049 this.endOfWeek = this.currentDate.endOf('week'); 19050 }else if( option === "next" ){ 19051 this.currentDate = this.currentDate.add(1, 'month').startOf('month'); 19052 this.currentMonth = this.currentDate.format('MMMM') 19053 this.currentYear = this.currentDate.$y 19054 this.startOfWeek = this.currentDate.startOf('week'); 19055 this.endOfWeek = this.currentDate.endOf('week'); 19056 19057 }else if( option === "previous" ){ 19058 this.currentDate = this.currentDate.subtract(1, 'month').startOf('month'); 19059 this.currentMonth = this.currentDate.format('MMMM') 19060 this.currentYear = this.currentDate.$y 19061 this.startOfWeek = this.currentDate.startOf('week'); 19062 this.endOfWeek = this.currentDate.endOf('week'); 19063 }else if ( option === "date-picker" ){ 19064 let { month, year } = selectedDate; 19065 this.currentDate = dayjs(new Date(year, month, 1)); 19066 this.currentMonth = this.currentDate.format('MMMM') 19067 this.currentYear = this.currentDate.$y 19068 this.startOfWeek = this.currentDate.startOf('week'); 19069 this.endOfWeek = this.currentDate.endOf('week'); 19070 } 19071 } else if (this.view_preference === 'Week'){ 19072 if( option === "today" ){ 19073 this.currentDate = dayjs(); 19074 this.currentMonth = this.currentDate.format('MMMM') 19075 this.currentYear = this.currentDate.$y 19076 this.startOfWeek = this.currentDate.startOf('week'); 19077 this.endOfWeek = this.currentDate.endOf('week'); 19078 }else if( option === "next" ){ 19079 this.currentDate = this.currentDate.add(1, 'week'); 19080 this.currentMonth = this.currentDate.format('MMMM') 19081 this.currentYear = this.currentDate.$y 19082 this.startOfWeek = this.currentDate.startOf('week'); 19083 this.endOfWeek = this.currentDate.endOf('week'); 19084 }else if( option === "previous" ){ 19085 this.currentDate = this.currentDate.subtract(1, 'week'); 19086 this.currentMonth = this.currentDate.format('MMMM') 19087 this.currentYear = this.currentDate.$y 19088 this.startOfWeek = this.currentDate.startOf('week'); 19089 this.endOfWeek = this.currentDate.endOf('week'); 19090 }else if ( option === "date-picker" ){ 19091 let { month, year } = selectedDate; 19092 this.currentDate = dayjs(new Date(year, month, 1)); 19093 this.currentMonth = this.currentDate.format('MMMM') 19094 this.currentYear = this.currentDate.$y 19095 this.startOfWeek = this.currentDate.startOf('week'); 19096 this.endOfWeek = this.currentDate.endOf('week'); 19097 } 19098 } else if (this.view_preference === 'Day'){ 19099 if( option === "today" ){ 19100 this.currentDate = dayjs(); 19101 this.currentMonth = this.currentDate.format('MMMM') 19102 this.currentYear = this.currentDate.$y 19103 this.startOfWeek = this.currentDate.startOf('week'); 19104 this.endOfWeek = this.currentDate.endOf('week'); 19105 }else if( option === "next" ){ 19106 this.currentDate = this.currentDate.add(1, 'day'); 19107 this.currentMonth = this.currentDate.format('MMMM') 19108 this.currentYear = this.currentDate.$y 19109 this.startOfWeek = this.currentDate.startOf('week'); 19110 this.endOfWeek = this.currentDate.endOf('week'); 19111 }else if( option === "previous" ){ 19112 this.currentDate = this.currentDate.subtract(1, 'day'); 19113 this.currentMonth = this.currentDate.format('MMMM') 19114 this.currentYear = this.currentDate.$y 19115 this.startOfWeek = this.currentDate.startOf('week'); 19116 this.endOfWeek = this.currentDate.endOf('week'); 19117 }else if ( option === "date-picker" ){ 19118 let { month, year } = selectedDate; 19119 this.currentDate = dayjs(new Date(year, month, 1)); 19120 this.currentMonth = this.currentDate.format('MMMM') 19121 this.currentYear = this.currentDate.$y 19122 this.startOfWeek = this.currentDate.startOf('week'); 19123 this.endOfWeek = this.currentDate.endOf('week'); 19124 } 19125 } 19126 //and re-load data 19127 this.$emit('load_cal', this.client_id); 19128 }, 19129 /* ----- date methods ----- */ 19130 generateTimestamps(){ 19131 const startTime = dayjs().startOf('day'); 19132 const endTime = dayjs().endOf('day'); 19133 let currentTime = startTime; 19134 while (currentTime.isBefore(endTime)) { 19135 this.timestamps.push({ 19136 customFormat: currentTime.format('h:mm A'), 19137 militaryFormat: currentTime.format('HH:mm:ss'), 19138 }); 19139 currentTime = currentTime.add(15, 'minutes'); 19140 } 19141 this.timestamps.push({ 19142 customFormat: null, 19143 militaryFormat: null, 19144 }) 19145 }, 19146 generateCalendar( ){ 19147 console.log("generate calendar") 19148 this.loading = true; 19149 if ( this.view_preference === 'Month'){ 19150 // current month start date, end date, total days calculation 19151 const start = this.currentDate.startOf('month'); 19152 const end = start.endOf('month'); 19153 const totalDays = end.diff(start, 'day') + 1; 19154 // number of calendar days to add before and after current month days 19155 const spaceBefore = start.day(); 19156 const spaceAfter = 42 - (spaceBefore + totalDays); 19157 // generate number of days for grid cell of 42 19158 this.calendar = [...Array(spaceBefore)].map((_, i) => { 19159 const prevMonthDate = start.subtract(spaceBefore - i, 'day'); 19160 return { 19161 date: prevMonthDate, 19162 day: prevMonthDate.$D, 19163 class: 'prevMonthDate', 19164 events: [], 19165 defaultDate: false, 19166 } 19167 }).concat([...Array(totalDays)].map((_, i) => { 19168 const currentMonthDate = start.add(i, 'day'); 19169 if (this.defaultDate.startOf('day').$d.toString() === currentMonthDate.$d.toString()){ 19170 return { 19171 date: currentMonthDate, 19172 day: currentMonthDate.$D === 1 ? `${currentMonthDate.format('MMM')} ${currentMonthDate.$D}` : currentMonthDate.$D, 19173 class: 'currentMonthDate', 19174 events: [], 19175 defaultDate: true, 19176 } 19177 }else { 19178 return { 19179 date: currentMonthDate, 19180 day: currentMonthDate.$D === 1 ? `${currentMonthDate.format('MMM')} ${currentMonthDate.$D}` : currentMonthDate.$D, 19181 class: 'currentMonthDate', 19182 events: [], 19183 defaultDate: false, 19184 } 19185 } 19186 })).concat([...Array(spaceAfter)].map((_, i) => { 19187 const nextMonthDate = end.add(i + 1, 'day'); 19188 return { 19189 date: nextMonthDate, 19190 day: nextMonthDate.$D === 1 ? `${nextMonthDate.format('MMM')} ${nextMonthDate.$D}` : nextMonthDate.$D, 19191 class: 'nextMonthDate', 19192 events: [], 19193 defaultDate: false, 19194 } 19195 })) 19196 } else if ( this.view_preference === 'Week' ){ 19197 console.log("generate week A") 19198 // start and end of current week 19199 const startOfWeek = this.currentDate.startOf('week'); 19200 const endOfWeek = this.currentDate.endOf('week'); 19201 19202 const weekDays = [{ date: null, day: null, events: [] }]; 19203 let currentDay = startOfWeek; 19204 while (currentDay.isBefore(endOfWeek) || currentDay.isSame(endOfWeek, 'day')) { 19205 weekDays.push(currentDay); 19206 currentDay = currentDay.add(1, 'day'); 19207 } 19208 this.placeholder_daysOfWeek = [null, 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], 19209 this.placeholder_daysOfWeek = this.placeholder_daysOfWeek.map((dayOfTheWeek, i) => { 19210 if (JSON.stringify(this.defaultDate.startOf('day').$d) === JSON.stringify(weekDays[i].$d)){ 19211 return { 19212 dayOfWeek: dayOfTheWeek, 19213 day: weekDays[i].$D, 19214 date: weekDays[i], 19215 events: [], 19216 defaultDate: true, 19217 } 19218 } else { 19219 return { 19220 dayOfWeek: dayOfTheWeek, 19221 day: weekDays[i].$D, 19222 date: weekDays[i], 19223 events: [], 19224 defaultDate: false, 19225 } 19226 } 19227 }) 19228 console.log("generate week B") 19229 this.mapAllDayEvents(); 19230 this.calendar = weekDays.map(date => { 19231 if(JSON.stringify(this.defaultDate.startOf('day').$d) === JSON.stringify(date.$d)){ 19232 return { 19233 date: date, 19234 events: [], 19235 defaultDate: true, 19236 } 19237 } else { 19238 return { 19239 date: date, 19240 events: [], 19241 defaultDate: false, 19242 } 19243 } 19244 }) 19245 console.log("generate week C") 19246 } else if ( this.view_preference === 'Day' ){ 19247 this.today_daysOfWeek = [null, this.daysOfWeek[this.currentDate.day()]] 19248 this.today_daysOfWeek = this.today_daysOfWeek.map((dayOfTheWeek, i) => { 19249 if (i === 1 && this.defaultDate.startOf('day').$d.toString() === this.currentDate.startOf('day').$d.toString()){ 19250 return { 19251 dayOfWeek: dayOfTheWeek, 19252 day: dayOfTheWeek !== null ? this.currentDate.$D : null, 19253 date: dayOfTheWeek !== null ? this.currentDate : null, 19254 events: [], 19255 defaultDate: true, 19256 } 19257 } else { 19258 return { 19259 dayOfWeek: dayOfTheWeek,
19259 19260 day: dayOfTheWeek !== null ? this.currentDate.$D : null, 19261 date: dayOfTheWeek !== null ? this.currentDate : null, 19262 events: [], 19263 defaultDate: false, 19264 } 19265 } 19266 }) 19267 this.mapAllDayEvents(); 19268 this.calendar = [{ date: null, day: null, events: [] }, this.currentDate ].map((date, i) => { 19269 if(i === 1 && this.defaultDate.startOf('day').$d.toString() === date.startOf('day').$d.toString()){ 19270 return { 19271 date: date, 19272 events: [], 19273 defaultDate: true, 19274 } 19275 } else { 19276 return { 19277 date: date, 19278 events: [], 19279 defaultDate: false, 19280 } 19281 } 19282 }) 19283 } 19284 // map events onto calendar days 19285 this.mapCalendarEvents_v2(); 19286 }, 19287 // mapCalendarEvents(){ 19288 // let multipleDaySchedules = []; 19289 // this.calendar.map(day => { 19290 // let calendarDay; 19291 // if ( day.date !== null ){ 19292 // calendarDay = dayjs(day.date.$d).format('MMMM DD, YYYY'); 19293 // } 19294 // this.schedules.forEach(schedule => { 19295 // let scheduleStart = dayjs( schedule.start ).format('MMMM DD, YYYY'); 19296 // let scheduleEnd = dayjs( schedule.end ).format('MMMM DD, YYYY'); 19297 // let scheduleOnDay = scheduleStart === calendarDay && scheduleStart === scheduleEnd; 19298 // let scheduleOnDay_isAllDay = scheduleStart === calendarDay && schedule.isAllDay; 19299 // // check to see if there are any multiple event days 19300 // if ( multipleDaySchedules.length > 0 ){ 19301 // multipleDaySchedules.forEach((multipleDaySchedule, i) => { 19302 // if ( multipleDaySchedule.daysRemaining > 0 && calendarDay === multipleDaySchedule.scheduleStart.format('MMMM DD, YYYY')){ 19303 // day.events.push(multipleDaySchedule.schedule) 19304 // multipleDaySchedule.scheduleStart = multipleDaySchedule.scheduleStart.add(1, 'day') 19305 // multipleDaySchedule.daysRemaining--; 19306 // } else if ( multipleDaySchedule.daysRemaining === 0 ){ 19307 // multipleDaySchedules.splice(i, 1) 19308 // } 19309 // }) 19310 // } 19311 // // there is an event or all day event on this day 19312 // if ( scheduleOnDay || scheduleOnDay_isAllDay ){ 19313 // day.events.push(schedule) 19314 // // there is a multiple day event that starts on this day 19315 // } else if ( scheduleStart === calendarDay && scheduleStart !== scheduleEnd && !schedule.isAllDay ){ 19316 // multipleDaySchedules.push({ 19317 // schedule: schedule, 19318 // scheduleStart: dayjs(scheduleStart, { format: 'MMMM DD, YYYY' }).add(1, 'day'), 19319 // daysRemaining: dayjs(scheduleEnd, { format: 'MMMM DD, YYYY' }).diff(dayjs(scheduleStart, { format: 'MMMM DD, YYYY' }), 'day') - 1 19320 // }) 19321 // schedule.isMultipleDay = true; 19322 // day.events.push(schedule); 19323 // } 19324 // }) 19325 // return day; 19326 // }) 19327 // console.log('this is final mapping: ', this.calendar) 19328 // this.loading = false;
19329 // }, 19330 mapCalendarEvents_v2(){ 19331 console.log("map calendar events v2"); 19332 let previousDayEventOrder = []; 19333 this.calendar.map((day, dayIndex) => { 19334 let calendarDay; 19335 if ( day.date !== null ){ 19336 calendarDay = dayjs(day.date.$d) 19337 } 19338 this.schedules.forEach(schedule => { 19339 let scheduleStart = dayjs( schedule.start ) 19340 let scheduleEnd = dayjs( schedule.end ) 19341 let scheduleOnDay = scheduleStart.format('MMMM DD, YYYY') === calendarDay.format('MMMM DD, YYYY') && scheduleStart.format('MMMM DD, YYYY') === scheduleEnd.format('MMMM DD, YYYY'); 19342 let scheduleOnDay_isAllDay = scheduleStart.format('MMMM DD, YYYY') === calendarDay.format('MMMM DD, YYYY') && schedule.isAllDay; 19343 // event falls on current day OR it's an all day event 19344 // added scheduleOnDay && scheduleOnDay_isAllDay 1/25/24 for regatta subevents start start and end on the same day 19345 if ( scheduleOnDay && !scheduleOnDay_isAllDay || scheduleOnDay && scheduleOnDay_isAllDay ){ 19346 day.events.push(schedule) 19347 } 19348 // it's the first day of a multiple day event 19349 // these checks are not thorough enough 19350 else if (scheduleStart.format('MMMM DD, YYYY') === calendarDay.format('MMMM DD, YYYY') && scheduleStart.format('MMMM DD, YYYY') !== scheduleEnd.format('MMMM DD, YYYY')){ 19351 schedule.isMultipleDay = true; 19352 day.events.push(schedule) 19353 } 19354 // it's a multiple day event and the current date falls between the start and end date 19355 else if ( scheduleStart.format('MMMM DD, YYYY') !== calendarDay.format('MMMM DD, YYYY') && (calendarDay.isAfter(scheduleStart) && calendarDay.isBefore(scheduleEnd)) ){ 19356 schedule.isMultipleDay = true; 19357 day.events.push(schedule) 19358 } 19359 }) 19360 19361 /* ui stacking logic for month view */ 19362 if (this.view_preference === 'Month'){ 19363 if ( dayIndex !== 0 ){ 19364 let currentDayEventOrder; 19365 let currentDayIndexOrder = {}; 19366 let notMatchingEvents = []; 19367 let matchingEvents = []; 19368 for (let i = 0; i < day.events.length; i++){ 19369 let currentDayEventId = day.events[i].id; 19370 let previousDayIndex = previousDayEventOrder?.indexOf(currentDayEventId); 19371 if (previousDayIndex === -1){ 19372 notMatchingEvents.push(day.events[i]) 19373 } else if (previousDayIndex !== -1) { 19374 currentDayIndexOrder[previousDayIndex] = day.events[i]; 19375 matchingEvents.push(day.events[i]); 19376 } 19377 } 19378 19379 let missingIndexes = []; 19380 for (let j = 0; j < 3; j++ ){ 19381 if (Object.keys(currentDayIndexOrder).indexOf(j.toString()) === -1 ){ 19382 missingIndexes.push(j) 19383 } 19384 } 19385 19386 let placeholder = { 19387 bgColor: '', 19388 calendarId: '', 19389 calendarOnly: '', 19390 category: '', 19391 className: '', 19392 color: '', 19393 end: '', 19394 id: 'placeholder', 19395 raw: {dot_color: ''}, 19396 start: '', 19397 title: 'placeholder', 19398 }; 19399 19400 for (let k = 0; k < missingIndexes.length; k++){ 19401 let missingIndexValue = missingIndexes[k]; 19402 currentDayIndexOrder[missingIndexValue] = notMatchingEvents[k] || placeholder; 19403 } 19404 19405 let remainingIndexes = day.events.length - 3; 19406 let calculateRemainingEvents = matchingEvents.length > 3 ? matchingEvents.concat(notMatchingEvents).splice(3) : [...notMatchingEvents].splice(missingIndexes.length); 19407 19408 if (remainingIndexes > 0){ 19409 for (let l = 3; l < 3 + remainingIndexes; l++){ 19410 let targetIndex = matchingEvents.length > 3 ? l - 3 : l - 3; 19411 //added placeholder for white lake yacht club bugs 19412 currentDayIndexOrder[l] = calculateRemainingEvents[targetIndex] || placeholder; 19413 } 19414 } 19415 19416 const sortedObj = Object.fromEntries( 19417 Object.entries(currentDayIndexOrder).sort(([keyA], [keyB]) => parseInt(keyA) - parseInt(keyB)) 19418 ); 19419 currentDayEventOrder = Object.values(sortedObj); 19420 //set order of previous day events using event ids 19421 previousDayEventOrder = currentDayEventOrder.map(event =>
19421 { return event.id }); 19422 //set current day events to newly sorted order 19423 day.events = currentDayEventOrder.filter((event, i, self) => event.id === 'placeholder' || (event.id !== 'placeholder' && i === self.findIndex(e => e.id === event.id))); 19424 } 19425 } 19426 19427 return day; 19428 }) 19429 this.loading = false; 19430 }, 19431 mapAllDayEvents(){ 19432 let daysOfWeekArr; 19433 if (this.view_preference === 'Week'){ 19434 daysOfWeekArr = this.placeholder_daysOfWeek; 19435 } else if (this.view_preference === 'Day'){ 19436 daysOfWeekArr = this.today_daysOfWeek; 19437 } 19438 let multipleDaySchedules = []; 19439 daysOfWeekArr = daysOfWeekArr.map((day, i) => { 19440 this.schedules.forEach((schedule, i) => { 19441 if ( (i !== 0 && this.view_preference === 'Day' || i !== 0 && this.view_preference === 'Week') && ( schedule.calendarOnly || schedule.category === 'allday') ){ 19442 let calendarDay = dayjs(day.date) 19443 let scheduleStart = dayjs( schedule.start ) 19444 let scheduleEnd = dayjs( schedule.end ) 19445 let scheduleOnDay_isAllDay = scheduleStart.format('MMMM DD, YYYY') === calendarDay.format('MMMM DD, YYYY') && schedule.isAllDay; 19446 // it's an all day event 19447 if ( scheduleOnDay_isAllDay ){ 19448 day.events.push(schedule) 19449 } 19450 // it's the first day of a multiple day event 19451 else if (scheduleStart.format('MMMM DD, YYYY') === calendarDay.format('MMMM DD, YYYY') && scheduleStart.format('MMMM DD, YYYY') !== scheduleEnd.format('MMMM DD, YYYY')){ 19452 schedule.isMultipleDay = true; 19453 day.events.push(schedule) 19454 } 19455 // it's a multiple day event and the current date falls between the start and end date 19456 else if ( scheduleStart.format('MMMM DD, YYYY') !== calendarDay.format('MMMM DD, YYYY') && (calendarDay.isAfter(scheduleStart) && calendarDay.isBefore(scheduleEnd)) ){ 19457 schedule.isMultipleDay = true; 19458 day.events.push(schedule) 19459 } 19460 } 19461 }) 19462 return day; 19463 }) 19464 if (this.view_preference === 'Week'){ 19465 this.placeholder_daysOfWeek = daysOfWeekArr; 19466 } else if (this.view_preference === 'Day'){ 19467 this.today_daysOfWeek = daysOfWeekArr; 19468 } 19469 }, 19470 }, 19471 }) 19472</script>
19472
19472<script type="text/html" id="template-calendar-cell"> 19473 <div @click="$emit('createSchedule_emitted', date)"> 19474 <!-- cell for month view --> 19475 <template v-if="view_preference === 'Month'"> 19476 <div class="cell-content" :class="cellType"> 19477 <div v-if="day" class="cell-day" :class="cellType" :style="checkDefaultDate( day, defaultDate, 'month' )">{{ day }}</div> 19478 <!-- all events --> 19479 <div v-if="cellType === 'calendarObj' && events.length > 0" class="cell-events-month" ref="cell" @click.stop> 19480 <template v-for="(event, index) in events"> 19481 <span 19482 v-if="index <= 2" 19483 :class="{ 19484 'cell-event-month': event.id !== 'placeholder', 19485 'transparent-event': event.id === 'placeholder' 19486 }" 19487 :style="generateEventStyleForMonth( event, 'event_body', date, monthIndex )" 19488 @click="handleScheduleClickForMonth( event )" 19489 ><div :style="generateEventStyleForMonth( event, 'status_dot', '', '', month_class )"></div><div class="event-title" :style="generateFontSize('eventTitle', event)">{{ generateEventTitle( event ) }}</div></span> 19490 <span v-if="index >= 3 && index === events.length - 1" class="overflow-cell-events" ref="overflow-cell" @click="toggleOverflowView( date, events, $event )"> 19491 <div :style="[ { display: 'flex' }, generateFontSize('eventTitle') ]" class="event-title">{{ index - 2 }} more</div> 19492 </span> 19493 </template> 19494 </div> 19495 </div> 19496 </template> 19497 <!-- cell for week view --> 19498 <template v-if="view_preference === 'Week'"> 19499 <div class="cell-content-week" :class="cellType"> 19500 <div v-if="day" :class="{ 'week-cellDay-container' : day.dayOfWeek !== null }"> 19501 <div class="cell-day numberHeader" :class="cellType" :style="[{ 'font-size': '25px', 'font-weight': '400' }, checkDefaultDate(day, defaultDate, 'week_header')]">{{ day.day }}</div> 19502 <div class="cell-day dayHeader" :class="cellType">{{ day.dayOfWeek !== null ? day.dayOfWeek.toUpperCase() : null }}</div> 19503 </div> 19504 <div v-if="timestamp" class="cell-day" :class="cellType"> 19505 <div class="timestamp" :style="generateFontSize('timestamp')">{{ timestamp }}</div> 19506 </div> 19507 <!-- all day events --> 19508 <div v-if="allDay"> 19509 <div v-if="allDay.dayOfWeek === null" class="allDay_events" :class="cellType" :style="generateFontSize('allDay')">All Day</div> 19510 <div class="cell-events-month"> 19511 <template v-if="allDay.dayOfWeek !== null" v-for="event in allDay.events"> 19512 <span 19513 :style="generateEventStyleForWeek( event, 'allDay_event_body' )" 19514 class="allDay-cell-event" 19515 @click="$emit('clickSchedule_emitted', event)" 19516 >{{ generateEventTitle( event ) }}</span> 19517 </template> 19518 </div> 19519 </div> 19520 <!-- all events --> 19521 <div v-if="cellType === 'calendarObj' && events.length > 0" class="cell-events-week" @click.stop> 19522 <template v-for="(event, index) in events"> 19523 <span 19524 class="cell-event-week" 19525 :style="generateEventStyleForWeek( event, 'event_body' )" 19526 @click="$emit('clickSchedule_emitted', event.event)" 19527 ><div></div>{{ generateEventTitle( event.event ) }}</span> 19528 </template> 19529 </div> 19530 </div> 19531 </template> 19532 <!-- cell for day view --> 19533 <template v-if="view_preference === 'Day'"> 19534 <div class="cell-content-week" :class="cellType"> 19535 <div v-if="day" :class="{ 'day-cellDay-container' : day.dayOfWeek !== null }"> 19536 <div class="cell-day numberHeader" :class="cellType" :style="[{ 'font-size': '25px', 'font-weight': '400' }, checkDefaultDate(day, defaultDate, 'day_header')]">{{ day.day }}</div> 19537 <div class="cell-day dayHeader" :class="cellType">{{ day.dayOfWeek !== null ? day.dayOfWeek.toUpperCase() : null }}</div> 19538 </div> 19539 <div v-if="timestamp" class="cell-day" :class="cellType"> 19540 <div class="timestamp" :style="generateFontSize('timestamp')">{{ timestamp }}</div> 19541 </div> 19542 <!-- all day events --> 19543 <div v-if="allDay"> 19544 <div v-if="allDay.dayOfWeek === null" class="allDay_events" :class="cellType" :style="generateFontSize('allDay')">All Day</div> 19545 <div class="cell-events-month"> 19546 <template v-if="allDay.dayOfWeek !== null" v-for="event in allDay.events">
19547 <span 19548 :style="generateEventStyleForWeek( event, 'allDay_event_body' )" 19549 class="allDay-cell-event" 19550 @click="$emit('clickSchedule_emitted', event)" 19551 >{{ generateEventTitle( event ) }}</span> 19552 </template> 19553 </div> 19554 </div> 19555 <!-- all events --> 19556 <div v-if="cellType === 'calendarObj' && events.length > 0" class="cell-events-week" @click.stop> 19557 <template v-for="(event, index) in events"> 19558 <span 19559 class="cell-event-week" 19560 :style="generateEventStyleForWeek( event, 'event_body' )" 19561 @click="$emit('clickSchedule_emitted', event.event )" 19562 ><div></div>{{ generateEventTitle( event.event ) }}</span> 19563 </template> 19564 </div> 19565 </div> 19566 </template> 19567 </div> 19568</script>
19568 19569
19570<script> 19571 /* ----- STORE ----- */ 19572 store.components["calendar_cell"] = { 19573 cellWidth: this.getCellWidth, 19574 foo: null, 19575 }; 19576 /* ----- COMPONENT ----- */ 19577 app_content.component("calendar-cell-component", { 19578 template: "#template-calendar-cell", 19579 data() { 19580 return store.components['calendar_cell'] 19581 }, 19582 mounted(){ 19583 window.addEventListener('resize', this.handleResize); 19584 this.$nextTick(() => { 19585 this.handleResize(); 19586 }); 19587 }, 19588 beforeDestroy(){ 19589 window.removeEventListener('resize', this.handleResize); 19590 }, 19591 props: { 19592 client_id: String, 19593 date: Object, 19594 defaultDate: Boolean, 19595 day: { 19596 type: [String, Number, Object], 19597 required: false, 19598 }, 19599 allDay: Object, 19600 timestamp: String, 19601 currentTime: String, 19602 events: Array, 19603 cellType: String, 19604 view_preference: String, 19605 monthIndex: Number, 19606 month_class: String, 19607 is_membership_cal: Boolean, 19608 cell_emitted: Function, 19609 createSchedule_emitted: Function, 19610 clickSchedule_emitted: Function, 19611 }, 19612 methods: { 19613 handleResize(){ 19614 this.cellWidth = this.getCellWidth(); 19615 }, 19616 getCellWidth(){ 19617 const cell = this.$refs.cell; 19618 // Get the computed width of the cell 19619 if (cell){ 19620 this.cellWidth = cell.getBoundingClientRect().width; 19621 return cell.getBoundingClientRect().width; 19622 } 19623 }, 19624 checkDefaultDate( day, defaultDate, type ){ 19625 if( defaultDate ){ 19626 if ( type === 'month' ){ 19627 if ( day.length > 2){ 19628 return { 19629 'padding': '2.5px', 19630 'background-color': '#4a56f8', 19631 'color': '#fff', 19632 'min-width': 'fit-content', 19633 'max-width': 'fit-content', 19634 'min-height': 'fit-content', 19635 'max-height': 'fit-content', 19636 } 19637 } else { 19638 return { 19639 'background-color': '#4a56f8', 19640 'color': '#fff', 19641 'min-width': '22px', 19642 'max-width': '22px', 19643 'min-height': '22px', 19644 'max-height': '22px', 19645 } 19646 } 19647 } else if ( type === 'week_header' || type === 'day_header' ){ 19648 return { 19649 'font-weight': '575', 19650 // 'color': '#4a56f8', 19651 } 19652 } 19653 } else if ( !defaultDate ){ 19654 if ( type === 'month' ){ 19655 if ( day.length > 2){ 19656 return { 19657 'padding': '2.5px', 19658 'min-width': 'fit-content', 19659 'max-width': 'fit-content', 19660 'min-height': 'fit-content', 19661 'max-height': 'fit-content', 19662 } 19663 } else { 19664 return { 19665 'min-width': '22px', 19666 'max-width': '22px', 19667 'min-height': '22px', 19668 'max-height': '22px', 19669 } 19670 } 19671 } 19672 } 19673 }, 19674 generateEventTitle( eventObject ){ 19675 if ( !eventObject.isAllDay && !eventObject.isMultipleDay ) { 19676 return eventObject.raw.nice_time !== undefined ? `${eventObject.raw.nice_time} ${eventObject.title}` : `${eventObject.title}`; 19677 } else if ( eventObject.isAllDay || eventObject.isMultipleDay ) { 19678 return eventObject.title || 'Not Provided'; 19679 } 19680 }, 19681 generateFontSize( type, eventObject ){ 19682 if (this.is_membership_cal){ 19683 if (type === 'timestamp') { 19684 return { 19685 'font-size': '12px', 19686 'font-weight': '300', 19687 } 19688 } else if (type === 'allDay'){ 19689 return { 19690 'font-size': '13px', 19691 } 19692 } else if (type === 'eventTitle'){ 19693 if (eventObject?.id === 'placeholder'){ 19694 return { 19695 'color': 'transparent', 19696 'font-size': '13px', 19697 } 19698 } 19699 return { 19700 'font-size': '13px', 19701 } 19702 } 19703 } else { 19704 if (type === 'timestamp') { 19705 return { 19706 'font-size': '10px', 19707 'font-weight': '300', 19708 } 19709 } else if (type === 'allDay'){ 19710 return { 19711 'font-size': '11px', 19712 } 19713 }else if (type === 'eventTitle'){ 19714 if (eventObject?.id === 'placeholder'){ 19715 return { 19716 'color': 'transparent', 19717 'font-size': '12.5px', 19718 } 19719 } 19720 return { 19721 'font-size': '12.5px', 19722 } 19723 } 19724 } 19725 }, 19726 generateEventStyleForMonth( eventObject, type, date = null, monthIndex = null, month_class = null ){ 19727 let styleObject = {}; 19728 const dotColor = eventObject.raw.dot_color; 19729 const bgColor = eventObject.bgColor || '#e8edf1'; 19730 const color = /* eventObject.color || */ this.get_contrast_color( bgColor ) || 'inherit'; 19731 if ( type === 'status_dot' ){ 19732 if ( eventObject.id === 'placeholder' ){ 19733 styleObject = { 19734 'color': 'trasparent', 19735 } 19736 } else if ( !eventObject.isAllDay && !eventObject.isMultipleDay ) { 19737 if ( month_class === 'currentMonthDate' ){ 19738 styleObject = { 19739 'min-width': '2.2px', 19740 'max-width': '2.2px', 19741 'min-height': '2.2px', 19742 'max-height': '2.2px', 19743 'border': `2.2px solid ${dotColor || '#333'}`, 19744 'border-radius': '50%', 19745 'background-color': `${dotColor || '#333'}`, 19746 'margin': '0 5px 0 5px', 19747 } 19748 } else if ( month_class === 'prevMonthDate' || month_class === 'nextMonthDate' ){ 19749 styleObject = { 19750 'min-width': '2.2px', 19751 'max-width': '2.2px', 19752 'min-height': '2.2px', 19753 'max-height': '2.2px', 19754 'border': `2.2px solid rgb(232, 237, 241)`, 19755 'border-radius': '50%', 19756 'background-color': `rgb(232, 237, 241)`, 19757 'margin': '0 5px 0 5px', 19758 } 19759 } 19760 } else if ( eventObject.isAllDay || eventObject.isMultipleDay ){ 19761 styleObject = { 19762 'margin-right': '3.5px', 19763 } 19764 } 19765 } else if ( type === 'event_body' ){ 19766 if ( eventObject.isAllDay && !eventObject.isMultipleDay ) { 19767 styleObject = { 19768 'background-color': bgColor, 19769 'color': color, 19770 }; 19771 } else if ( eventObject.isMultipleDay ){ 19772 let calendarDay = dayjs(date.$d).format('MMMM DD, YYYY'); 19773 let scheduleStart = dayjs( eventObject.start ).format('MMMM DD, YYYY'); 19774 let scheduleEnd = dayjs( eventObject.end ).format('MMMM DD, YYYY'); 19775 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; 19776 let endsAtMidnight = dayjs( eventObject.end ).format('HH:mm:ss') === dayjs().startOf('day').format('HH:mm:ss'); 19777 if (calendarDay === scheduleStart || monthIndex % 7 === 0 ){ 19778 let remainingDays = dayjs(scheduleEnd, { format: 'MMMM DD, YYYY' }).diff(dayjs(calendarDay, { format: 'MMMM DD, YYYY' }), 'day'); 19779 //handle non-calendarOnly events since end time is different than calendarOnly events 19780 if (!eventObject.calendarOnly && !isOneDay && !endsAtMidnight ){ 19781 remainingDays += 1; 19782 } 19783 if (this.cellWidth === undefined){ 19784 styleObject = { 19785 'background-color': bgColor, 19786 'color': color, 19787 'z-index': '5', 19788 'width' : `${remainingDays*this.getCellWidth()}px` 19789 }; 19790 } else { 19791 styleObject = { 19792 'background-color': bgColor, 19793 'color': color, 19794 'z-index': '5', 19795 'width': `${remainingDays*this.cellWidth}px` 19796 }; 19797 } 19798 } 19799 } 19800 } 19801 return styleObject 19802 }, 19803 generateEventStyleForWeek( eventObject, type ){ 19804 const event = eventObject.event || eventObject; 19805 const numOfTimeBlocks = eventObject.numOfTimeBlocks; 19806 19807 let styleObject = {}; 19808 const dotColor = event?.raw.dot_color; 19809 const bgColor = event?.bgColor || '#e8edf1'; 19810 const color = /* event?.color */ this.get_contrast_color( bgColor ) || 'inherit'; 19811 19812 if ( type === 'event_body' ){ 19813 if ( event ) { 19814 styleObject = { 19815 'background-color': dotColor || bgColor, 19816 'color': color, 19817 'width': '50%', 19818 'border-width': '1px 1px 1px 1.5px', 19819 'border-left-color': 'black', 19820 'border-top-color':'white', 19821 'border-right-color':'transparent', 19822 'border-bottom-color': 'white', 19823 'border-style': 'solid', 19824 'border-radius': '2px', 19825 'opacity': '85%', 19826 'z-index': '5', 19827 'height': `${(numOfTimeBlocks*12) - 5}px`, /* (number of 15 min increments * .placeholder-calendar-grid > grid-auto-rows) - .cell-event-week > padding(top) */ 19828 }; 19829 } 19830 } else if ( type === 'allDay_event_body' ){ 19831 if ( event ) { 19832 styleObject = { 19833 'background-color': dotColor || bgColor, 19834 'color': color, 19835 }; 19836 } 19837 } 19838 return styleObject 19839 }, 19840 toggleOverflowView( date, eventsArr, event ){ 19841 if (store.overlays.overflow_overlay){
19842 store.overlays.overflow_overlay = false; 19843 } else { 19844 store.components.overflow_overlay.cellPosition = { 19845 top: (event.clientY + window.scrollY)-(230/2), //230 overlay height 19846 left: (event.clientX + window.scrollX)-(230/2), //230 overlay width 19847 } 19848 if (this.is_membership_cal){ 19849 store.components.overflow_overlay.is_membership_cal = true; 19850 } 19851 store.components.overflow_overlay.client_id = this.client_id; 19852 store.components.overflow_overlay.date = date; 19853 store.components.overflow_overlay.events = eventsArr; 19854 store.overlays.overflow_overlay = true; 19855 } 19856 }, 19857 handleScheduleClickForMonth( event ){ 19858 if (event.id !== 'placeholder') { 19859 this.$emit('clickSchedule_emitted', event); 19860 } else { 19861 this.$emit('createSchedule_emitted', this.date); 19862 } 19863 }, 19864 //methods to determine font color in contrast to bg color 19865 calculate_luminance(color) { 19866 const hex = color.substring(1); //remove # 19867 const rgb = parseInt(hex, 16); //convert to RGB 19868 const r = (rgb >> 16) & 0xff; //extract the red channel 19869 const g = (rgb >> 8) & 0xff; //extract the green channel 19870 const b = (rgb >> 0) & 0xff; //extract the blue channel 19871 //convert RGB to linear values 19872 const sR = r / 255; 19873 const sG = g / 255; 19874 const sB = b / 255; 19875 //calculate luminance 19876 const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; 19877 return luminance; 19878 }, 19879 get_contrast_color(background) { 19880 const backgroundLuminance = this.calculate_luminance(background); 19881 const fontColor = backgroundLuminance > 0.5 ? '#000000' : '#ffffff'; 19882 return fontColor; 19883 } 19884 }, 19885 }) 19886</script>
19886
19886<script type="text/html" id="template-overflow-overlay"> 19887 <div v-if="global.overlays.overflow_overlay" id="overlay_overflow_overlay" :style="generateStyle()" class="calendar-overlay active" @click.self="global.overlays.overflow_overlay=false;"> 19888 <div class="calendarOverlayCard"> 19889 <div class="overlay-events-contain"> 19890 <div v-if="me.date" class="overflow-date-contain"> 19891 <span class="overflow-dateOfWeek">{{ me.date.format('ddd') }}</span> 19892 <span class="overflow-day">{{ me.date.$D }}</span> 19893 </div> 19894 </div> 19895 <template v-for="event in me.events"> 19896 <template v-if="event.id !== 'placeholder'"> 19897 <span 19898 class="cell-event-overlay" 19899 :style="generateEventStyleForMonth( event, 'event_body' )" 19900 @click="clickSchedule( event )" 19901 ><div :style="generateEventStyleForMonth( event, 'status_dot' )"></div><div class="event-title">{{ generateEventTitle( event ) }}</div></span> 19902 </template> 19903 </template> 19904 </div> 19905 <div class="xButton-calendarOverlay" @click.self="global.overlays.overflow_overlay=false;"></div> 19906 </div> 19907 </div> 19908</script>
19908 19909
19910<script> 19911 //global store 19912 store.components["overflow_overlay"] = { 19913 client_id: null, 19914 date: null, 19915 events: [], 19916 cellPosition: {}, 19917 loading: false, 19918 is_membership_cal: false, 19919 }; 19920 //define the component 19921 app_overlays.component("overflow-overlay", { 19922 template: "#template-overflow-overlay", 19923 data() { 19924 return { 19925 global: store, 19926 me: store.components.overflow_overlay 19927 } 19928 }, 19929 methods: { 19930 generateStyle(){ 19931 const cellPosition = this.me.cellPosition; 19932 const viewportHeight = window.innerHeight; 19933 const viewportWidth = window.innerWidth; 19934 const overlayHeight = 230; 19935 const overlayWidth = 230; 19936 19937 let top = cellPosition.top; 19938 let left = cellPosition.left; 19939 19940 // Adjust top position if overlay goes off-screen 19941 // if ( top + overlayHeight > viewportHeight) { 19942 // } 19943 19944 // Adjust left position if overlay goes off-screen 19945 if ( left + overlayWidth > viewportWidth) { 19946 let difference = left + overlayWidth; 19947 left -= difference - viewportWidth; // 20px margin from right 19948 } 19949 return { 19950 'top': `${top}px`, 19951 'left': `${left}px` 19952 }; 19953 }, 19954 generateEventTitle( eventObject ){ 19955 if ( !eventObject.isAllDay && !eventObject.isMultipleDay ) { 19956 return eventObject.raw.nice_time !== undefined ? `${eventObject.raw.nice_time} ${eventObject.title}` : `${eventObject.title}`; 19957 } else if ( eventObject.isAllDay || eventObject.isMultipleDay ) { 19958 return eventObject.title || 'Not Provided'; 19959 } 19960 }, 19961 generateEventStyleForMonth( eventObject, type ){ 19962 let styleObject = {}; 19963 const dotColor = eventObject.raw.dot_color; 19964 const bgColor = eventObject.bgColor || '#e8edf1'; 19965 const color = /* eventObject.color */ this.get_contrast_color( bgColor ) || 'inherit'; 19966 if ( type === 'status_dot' ){ 19967 if ( !eventObject.isAllDay && !eventObject.isMultipleDay ) { 19968 styleObject = { 19969 'min-width': '2.2px', 19970 'max-width': '2.2px', 19971 'min-height': '2.2px', 19972 'max-height': '2.2px', 19973 'border': `2.2px solid ${dotColor || '#333'}`, 19974 'border-radius': '50%', 19975 'background-color': `${dotColor || '#333'}`, 19976 'margin': '0 5px 0 5px', 19977 } 19978 } else if ( eventObject.isAllDay || eventObject.isMultipleDay ){ 19979 styleObject = { 19980 'margin-right': '3.5px', 19981 } 19982 } 19983 } else if ( type === 'event_body' ){ 19984 if ( eventObject.isAllDay || eventObject.isMultipleDay ) { 19985 styleObject = { 19986 'background-color': bgColor, 19987 'color': color, 19988 'height': '22px', 19989 }; 19990 } 19991 } 19992 return styleObject 19993 }, 19994 clickSchedule(event){ 19995 if ( this.me.client_id === 'primary' ){ 19996 store.eventBus.$emit('clickSchedule_events_cal', event) 19997 store.overlays.overflow_overlay = false; 19998 } else if ( this.me.client_id === 'banquets' ){ 19999 store.eventBus.$emit('clickSchedule_banquets_cal', event) 20000 store.overlays.overflow_overlay = false; 20001 } else if ( this.me.client_id === 'bookings' ){ 20002 store.eventBus.$emit('clickSchedule_reservations_cal', event)
20003 store.overlays.overflow_overlay = false; 20004 } else if ( this.me.client_id === 'time_blocks' ){ 20005 store.eventBus.$emit('clickSchedule_time_blocks_cal', event) 20006 store.overlays.overflow_overlay = false; 20007 } else if (this.me.is_membership_cal ){ 20008 store.eventBus.$emit('clickSchedule_membership_cal', event) 20009 store.overlays.overflow_overlay = false; 20010 } 20011 }, 20012 //methods to determine font color in contrast to bg color 20013 calculate_luminance(color) { 20014 const hex = color.substring(1); //remove # 20015 const rgb = parseInt(hex, 16); //convert to RGB 20016 const r = (rgb >> 16) & 0xff; //extract the red channel 20017 const g = (rgb >> 8) & 0xff; //extract the green channel 20018 const b = (rgb >> 0) & 0xff; //extract the blue channel 20019 //convert RGB to linear values 20020 const sR = r / 255; 20021 const sG = g / 255; 20022 const sB = b / 255; 20023 //calculate luminance 20024 const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; 20025 return luminance; 20026 }, 20027 get_contrast_color(background) { 20028 const backgroundLuminance = this.calculate_luminance(background); 20029 const fontColor = backgroundLuminance > 0.5 ? '#000000' : '#ffffff'; 20030 return fontColor; 20031 } 20032 }, 20033 }); 20034 20035 20036</script>
20036
20036<script type="text/html" id="template-custom-field-wb"> 20037 <div class="custom-field-wrapper tinyMarginTop" :id="custom_field.id"> 20038 <div class="flex-row-start" style="width: 100%;"> 20039 <!-- question --> 20040 <div class="flex-column-start tinyMarginRight" style="width: 100%;"> 20041 <div class="custom-field flex-row-start"> 20042 <p v-if="custom_field && custom_field.name" class="default-font-body">{{ custom_field.name }}</p> 20043 <span v-if="custom_field && custom_field.required" 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 </div> 20048 </div> 20049 </div> 20050 <!-- response options --> 20051 <div style="width: 100%;"> 20052 <!-- short response --> 20053 <template v-if="custom_field && custom_field.type === 'text'"> 20054 <span class="flex-row-start tinyMarginBottom"> 20055 <input 20056 type="text" 20057 placeholder="Your answer" 20058 class="border-bottom-grey-2 default-font-body" 20059 style="width: 50%; line-height: 24px;" 20060 v-model="input_value" 20061 :disabled="is_admin" 20062 > 20063 </span> 20064 </template> 20065 <!-- paragraph --> 20066 <template v-if="custom_field && custom_field.type === 'textarea'"> 20067 <span class="flex-row-start tinyMarginBottom"> 20068 <input 20069 type="text" 20070 placeholder="Your answer" 20071 class="border-bottom-grey-2 default-font-body" 20072 style="width: 100%; line-height: 24px;" 20073 v-model="input_value" 20074 :disabled="is_admin" 20075 > 20076 </span> 20077 </template> 20078 <!-- multiple choice, checkbox --> 20079 <div v-if="custom_field.type === 'radio' || custom_field.type === 'checkbox'" class="flex-column-start"> 20080 <template v-for="(option, index) in custom_field_options" :id="index"> 20081 <span class="flex-row-start" style="position: relative; width: 100%; left: -5px;"> 20082 <!-- multiple choice --> 20083 <template v-if="custom_field.type === 'radio'"> 20084 <span v-if="!option.selected" @click="select_mc_option( true, option )" class="flex-row-start"> 20085 <i class="fa-light fa-circle options-icon"></i> 20086 <p v-if="option && option.name" class="default-font-body">{{ option.name }}</p> 20087 </span> 20088 <span v-if="option.selected" @click="select_mc_option( false, option )" class="flex-row-start"> 20089 <i class="fa-solid fa-circle-dot options-icon"></i> 20090 <p v-if="option && option.name" class="default-font-body">{{ option.name }}</p> 20091 </span> 20092 </template> 20093 <!-- checkbox --> 20094 <template v-if="custom_field.type === 'checkbox'"> 20095 <span v-if="!option.selected" @click="option.selected = true;" class="flex-row-start"> 20096 <i class="fa-light fa-square options-icon"></i> 20097 <p v-if="option && option.name" class="default-font-body">{{ option.name }}</p> 20098 </span> 20099 <span v-if="option.selected" @click="option.selected = false;" class="flex-row-start"> 20100 <i class="fa-solid fa-square-check options-icon"></i> 20101 <p v-if="option && option.name" class="default-font-body">{{ option.name }}</p> 20102 </span> 20103 </template> 20104 </span> 20105 </template> 20106 </div> 20107 <!-- dropdown --> 20108 <template v-if="custom_field.type === 'select' && custom_field_options"> 20109 <div id="response-dropdown" :style="{ 20110 borderBottomLeftRadius: dropdown_open ? '0' : '4px', 20111 borderBottomRightRadius: dropdown_open ? '0' : '4px' 20112 }"> 20113 <!-- dropdown closed --> 20114 <div id="selected-response" @click="dropdown_open = !dropdown_open;"> 20115 <p v-if="selected_dropdown_index < 0" class="response-option default-font-body">Choose</p> 20116 <p v-if="selected_dropdown_index >= 0 && custom_field_options && custom_field_options[selected_dropdown_index]" class="response-option default-font-body"> 20117 {{ custom_field_options[selected_dropdown_index].name }} 20118 </p> 20119 <i v-if="!dropdown_open" class="fa-solid fa-sort-down" style="color: rgb(95, 95, 95)"></i> 20120 <i v-if="dropdown_open" class="fa-solid fa-sort-up" style="color: rgba(95, 95, 95)"></i> 20121 </div> 20122 <!-- dropdown open --> 20123 <div v-if="dropdown_open" id="response-options"> 20124 <div 20125 class="response-option border-bottom-grey-2" 20126 @click="select_dropdown_option( null, -1 )" 20127 :style="[ return_selected_style( -1 ) ]" 20128 > 20129 <p class="default-font-body" style="color: rgb(150, 152, 154);">Choose</p> 20130 </div> 20131 <div 20132 v-for="(option, index) in custom_field_options" 20133 class="response-option" 20134 :key="option.value" 20135 @click="select_dropdown_option( option, index )" 20136 :style="[ return_selected_style( index ) ]" 20137 > 20138 <p class="default-font-body">{{ option.name }}</p> 20139 </div> 20140 </div> 20141 </div> 20142 </template> 20143 <!-- searchable dropdown (same option model as select) --> 20144 <template v-if="custom_field && custom_field.type === 'searchable_select' && custom_field_options"> 20145 <div class="tinyMarginBottom" style="width: 100%;"> 20146 <rich-select-dropdown
20147 :value="get_searchable_select_value()" 20148 :options="rich_wb_dropdown_options()" 20149 :placeholder="custom_field.name || 'Select...'" 20150 @input="set_searchable_select_from_rich($event)" 20151 :showFieldLabelWhenSelected="true" 20152 :searchable="true" 20153 :searchSubtext="false" 20154 ></rich-select-dropdown> 20155 </div> 20156 </template> 20157 <!-- numeric value --> 20158 <template v-if="custom_field && custom_field.type === 'number'"> 20159 <span class="flex-row-start tinyMarginBottom"> 20160 <input 20161 type="text" 20162 placeholder="Number" 20163 class="border-bottom-grey-2 inherit-font-css" 20164 style="width: 20%; line-height: 24px;" 20165 v-model="input_value" 20166 @input="check_input_is_num()" 20167 :disabled="is_admin" 20168 > 20169 <span v-if="display_num_warning" class="flex-row-start" @mouseover="display_popup = true;" @mouseleave="display_popup = false;"> 20170 <i class="fa-solid fa-triangle-exclamation" style="color: rgba(255, 0, 0, 0.885); font-size: 1em; cursor: pointer;"></i> 20171 </span> 20172 <div v-if="display_popup" class="display-popup" style="font-size: 12px;">This value must be a number.</div> 20173 </span> 20174 </template> 20175 <!-- file upload --> 20176 <template v-if="custom_field && custom_field.type === 'file_upload'"> 20177 <file-upload-component 20178 @file_selected="file_selected" 20179 :valid_file_types="valid_file_types" 20180 ></file-upload-component> 20181 </template> 20182 <!-- date value --> 20183 <template v-if="custom_field && custom_field.type === 'date'"> 20184 <span class="flex-row-start tinyMarginBottom"> 20185 <input 20186 type="date" 20187 placeholder="mm/dd/yyyy" 20188 class="border-bottom-grey-2 default-font-body" 20189 style="width: 20%; line-height: 24px;" 20190 v-model="input_value" 20191 :disabled="is_admin" 20192 > 20193 </span> 20194 </template> 20195 <!-- rating --> 20196 <template v-if="custom_field && custom_field.type === 'rating'"> 20197 <div class="flex-row-start tinyMarginBottom" style="gap: 12px; padding-top: 4px;"> 20198 <i 20199 v-for="star in 5" 20200 :key="star" 20201 :class="star <= input_value ? 'fa-solid fa-star' : 'fa-regular fa-star'" 20202 style="font-size: 28px; color: #4a56f8; cursor: pointer;" 20203 @click="set_rating( star )" 20204 ></i> 20205 </div> 20206 </template> 20207 </div> 20208 </div> 20209</script>
vendor: 1 bytes, line 20209
20209
20210<script> 20211 app_content.component("custom-field-component-wb", { 20212 template: "#template-custom-field-wb", 20213 data() { 20214 return { 20215 selected_dropdown_index: -1, //default 20216 dropdown_open: false, 20217 custom_field_name: '', 20218 custom_field_required: false, 20219 display_num_warning: false, 20220 display_popup: false, 20221 input_value: '', 20222 } 20223 }, 20224 props: { 20225 form: Object, 20226 is_admin: Boolean, 20227 custom_field: Object, 20228 last_update: Number, 20229 }, 20230 methods: { 20231 return_selected_style( index ){ 20232 if ( index === this.selected_dropdown_index ){ 20233 return { 20234 'background-color': 'rgba(26, 115, 232, .09)', 20235 } 20236 } 20237 }, 20238 clear_dropdown_index(){ 20239 this.selected_dropdown_index = -1; 20240 }, 20241 clear_field(){ 20242 this.input_value = ''; 20243 this.selected_dropdown_index = -1; 20244 this.display_num_warning = false; 20245 if ( this.custom_field_options ){ 20246 this.custom_field_options.forEach(option => { 20247 option.selected = false; 20248 }); 20249 } 20250 }, 20251 select_dropdown_option( option, index ){ 20252 this.selected_dropdown_index = index; 20253 this.dropdown_open = false; 20254 if ( option ){ 20255 option.selected = true; 20256 this.unselect_other_options( option, false ); 20257 } else if ( !option ){ 20258 this.unselect_other_options( option, true ); 20259 } 20260 }, 20261 select_mc_option( select, option ){ 20262 if ( select ){ 20263 option.selected = true; 20264 this.unselect_other_options( option, false ); 20265 } else if ( !select ){ 20266 option.selected = false; 20267 } 20268 }, 20269 get_searchable_select_value(){ 20270 if ( !this.custom_field_options || !this.custom_field_options.length ) return null; 20271 const selected = this.custom_field_options.find(o => o.selected); 20272 return selected ? selected.id : null; 20273 }, 20274 rich_wb_dropdown_options(){ 20275 if ( !this.custom_field_options ) return []; 20276 return this.custom_field_options.map(o => ({ id: o.id, name: o.name || '' })); 20277 }, 20278 set_searchable_select_from_rich(val){ 20279 let id = null; 20280 if ( val != null && typeof val === 'object' && val.id != null ){ 20281 id = val.id; 20282 } else { 20283 id = val; 20284 } 20285 if ( !id ){ 20286 this.select_dropdown_option(null, -1); 20287 return; 20288 } 20289 const idx = this.custom_field_options.findIndex(o => o.id === id); 20290 if ( idx === -1 ){ 20291 this.select_dropdown_option(null, -1); 20292 return; 20293 } 20294 this.select_dropdown_option(this.custom_field_options[idx], idx); 20295 }, 20296 unselect_other_options( option, clear_all = false ){ 20297 if ( clear_all ){ 20298 this.custom_field_options.forEach( custom_field_option => { 20299 custom_field_option.selected = false; 20300 }) 20301 } else if ( !clear_all ){ 20302 this.custom_field_options.forEach( custom_field_option => { 20303 if ( custom_field_option.id !== option.id ){ 20304 custom_field_option.selected = false; 20305 } 20306 }) 20307 } 20308 }, 20309 check_input_is_num(){ 20310 let number = Number(this.input_value); 20311 if ( isNaN(number) || !this.input_value ){ 20312 this.display_num_warning = true; 20313 } else { 20314 this.input_value = number; 20315 this.display_num_warning = false;
20316 } 20317 }, 20318 file_selected( data ){ 20319 let file = data.value; 20320 if (file) { 20321 this.input_value = file; 20322 } 20323 }, 20324 set_rating( star ){ 20325 if ( this.is_admin ){ 20326 return; 20327 } 20328 this.input_value = star; 20329 }, 20330 }, 20331 computed: { 20332 custom_field_options(){ 20333 if ( this.custom_field && this.custom_field.custom_field_options ){ 20334 return this.custom_field.custom_field_options; 20335 } 20336 return []; 20337 }, 20338 valid_file_types(){ 20339 return [ ]; 20340 }, 20341 }, 20342 watch: { 20343 input_value( newValue ){ 20344 this.$emit('field-updated', { 20345 id: this.custom_field.id, 20346 value: newValue 20347 }); 20348 } 20349 } 20350 }) 20351</script>
20351 20352 20353 20354<style scoped> 20355 :root { 20356 --custom-field-grey-1: rgba(180, 180, 180, 0.8); 20357 --custom-field-grey-2: rgba(195, 195, 195, 0.7); 20358 --custom-field-grey-3: rgb(154, 160, 166); 20359 --custom-field-primary: #202124; 20360 } 20361 .custom-field-wrapper{ 20362 width: 100%; 20363 border: 1px solid rgba(195, 195, 195, 0.7); 20364 border-radius: 8px; 20365 padding: 1.45em; 20366 padding-top: 10px; 20367 background-color: #fff; 20368 box-sizing: border-box; 20369 } 20370 .custom-field-wrapper .options-icon{ 20371 color: var(--custom-field-grey-3); 20372 margin-right: 8px; 20373 padding: 7.5px; 20374 cursor: pointer; 20375 border-radius: 50%; 20376 font-size: 1.1em; 20377 } 20378 .custom-field-wrapper .options-icon:hover{ 20379 background-color: rgba(150, 150, 150, 0.1); 20380 } 20381 .custom-field-wrapper .default-font-body{ 20382 font-family: Roboto, Arial, Helvetica, sans-serif; 20383 font-size: 15px; 20384 font-weight: 400; 20385 text-align: left; 20386 } 20387 .custom-field-wrapper .file-upload-wrapper{ 20388 height: 90px; 20389 width: 50%; 20390 border: 1px dashed var(--custom-field-grey-1); 20391 } 20392 .custom-field-wrapper .inherit-font-css{ 20393 font-family: inherit; 20394 font-size: inherit; 20395 font-weight: inherit; 20396 } 20397 .custom-field-wrapper .flex-row-center{ 20398 display: flex; 20399 flex-direction: row; 20400 align-items: center; 20401 justify-content: center; 20402 } 20403 .custom-field-wrapper .flex-column-center{ 20404 display: flex; 20405 flex-direction: column; 20406 align-items: center; 20407 justify-content: center; 20408 } 20409 .custom-field-wrapper .flex-row-start{ 20410 display: flex; 20411 flex-direction: row; 20412 align-items: center; 20413 justify-content: flex-start; 20414 } 20415 .custom-field-wrapper .flex-column-start{ 20416 display: flex; 20417 flex-direction: column; 20418 align-items: flex-start; 20419 justify-content: center; 20420 } 20421 .custom-field-wrapper .custom-field{ 20422 color: rgb(32, 33, 36); 20423 box-sizing: border-box; 20424 font-weight: 400; 20425 font-size: 14px; 20426 width: 100%; 20427 line-height: 2.5; 20428 letter-spacing: 0; 20429 } 20430 .custom-field-wrapper .custom-field > .custom-field-input{ 20431 font-family: inherit; 20432 font-size: inherit; 20433 font-weight: inherit; 20434 width: inherit; 20435 line-height: inherit; 20436 background-color: rgb(248, 249, 250); 20437 padding: 5px 0 5px 15px; 20438 } 20439 .custom-field-wrapper .border-bottom-grey-2, .custom-field-wrapper .input-is-focused{ 20440 border-bottom: 1.25px solid var(--custom-field-grey-2); 20441 } 20442 .custom-field-wrapper input { 20443 transition: border-color 0.2s; 20444 } 20445 #response-dropdown{ 20446 position: relative; 20447 display: flex; 20448 flex-direction: column; 20449 align-items: flex-start; 20450 justify-content: center; 20451 font-size: 14px; 20452 color: var(--custom-field-primary); 20453 background-color: #fff; 20454 width: 33%; 20455 padding: 12px; 20456 border: 1px solid var(--custom-field-grey-1); 20457 border-top-left-radius: 4px; 20458 border-top-right-radius: 4px; 20459 } 20460 #response-dropdown > #selected-response{ 20461 display: flex; 20462 flex-direction: row; 20463 align-items: center; 20464 justify-content: space-between; 20465 width: 100%; 20466 cursor: pointer; 20467 } 20468 #response-options{ 20469 position: absolute; 20470 top: 100%; 20471 left: -1px; 20472 z-index: 10; 20473 background-color: #fff; 20474 border: 1px solid var(--custom-field-grey-1); 20475 border-bottom-left-radius: 4px; 20476 border-bottom-right-radius: 4px; 20477 width: 100%; 20478 } 20479 #response-options > .response-option{ 20480 display: flex; 20481 flex-direction: row; 20482 align-items: center; 20483 justify-content: flex-start; 20484 padding: 12px; 20485 font-family: Roboto; 20486 cursor: pointer; 20487 } 20488 #response-options > .response-option:hover{ 20489 background-color: rgba(150, 150, 150, 0.2); 20490 } 20491 .display-popup{ 20492 background-color: rgb(245, 245, 245); 20493 padding: 5px; 20494 border-radius: 6px; 20495 position: relative; 20496 left: -100px; 20497 bottom: 30px; 20498 z-index: 10; 20499 width: fit-content; 20500 } 20501</style> 20502 20503
20503<script type="text/html" id="template-rich-select-dropdown"> 20504 <div class="relative" :class="{ inputWrapFull: floating_label_raised }"> 20505 <label 20506 v-if="uses_floating_label" 20507 class="inputLabel easeFast" 20508 style="pointer-events: none;" 20509 >{{ label || placeholder }}</label> 20510 <div 20511 class="rich-select-dropdown relative" ref="container" 20512 style="width: 100%;" 20513 > <!-- Closed state: box showing selected option(s) --> 20514 <div 20515 class="rich-select-trigger flexNoWrap align-items-center pointer" 20516 :class="[triggerClass, { 'rich-select-trigger-open': isOpen, 'rich-select-trigger-borderless': borderless, 'rich-select-trigger-disabled': disabled }]" 20517 @click="toggle()" 20518 > 20519 <slot name="indicator"></slot> 20520 <template v-if="multiple"> 20521 <template v-if="selectedOptionsForDisplay.length === 0"> 20522 <span v-if="!uses_floating_label" class="rich-select-placeholder">{{ label || placeholder }}</span> 20523 </template> 20524 <template v-else-if="selectedOptionsForDisplay.length === 1"> 20525 <div v-if="optionImage(selectedOptionsForDisplay[0])" class="rich-select-option-icon-wrap"> 20526 <img v-if="optionImageIsUrl(selectedOptionsForDisplay[0])" :src="optionImage(selectedOptionsForDisplay[0])" class="rich-select-option-icon" alt=""> 20527 <i v-else :class="optionImage(selectedOptionsForDisplay[0])" class="rich-select-option-icon fa"></i> 20528 </div> 20529 <div class="rich-select-trigger-text"> 20530 <span class="rich-select-trigger-title">{{ optionTitle(selectedOptionsForDisplay[0]) }}</span> 20531 <span v-if="optionSubtext(selectedOptionsForDisplay[0])" class="rich-select-trigger-subtext">{{ optionSubtext(selectedOptionsForDisplay[0]) }}</span> 20532 </div> 20533 </template> 20534 <template v-else> 20535 <span class="rich-select-trigger-title">{{ selectedOptionsForDisplay.length }} selected</span> 20536 </template> 20537 </template> 20538 <template v-else> 20539 <template v-if="selectedOptionSingle"> 20540 <div v-if="optionImage(selectedOptionSingle)" class="rich-select-option-icon-wrap"> 20541 <img v-if="optionImageIsUrl(selectedOptionSingle)" :src="optionImage(selectedOptionSingle)" class="rich-select-option-icon" alt=""> 20542 <i v-else :class="optionImage(selectedOptionSingle)" class="rich-select-option-icon fa"></i> 20543 </div> 20544 <div class="rich-select-trigger-text"> 20545 <span class="rich-select-trigger-title" :class="{ 'rich-select-trigger-title-no-subtext': !optionSubtext(selectedOptionSingle) }">{{ optionTitle(selectedOptionSingle) }}</span> 20546 <span v-if="optionSubtext(selectedOptionSingle)" class="rich-select-trigger-subtext">{{ optionSubtext(selectedOptionSingle) }}</span> 20547 </div> 20548 </template> 20549 <template v-else-if="disabled && disabledSubtext"> 20550 <div class="rich-select-trigger-text"> 20551 <span v-if="!uses_floating_label" class="rich-select-placeholder">{{ label || placeholder }}</span> 20552 <span class="rich-select-trigger-subtext">{{ disabledSubtext }}</span> 20553 </div> 20554 </template> 20555 <template v-else> 20556 <span v-if="!uses_floating_label" class="rich-select-placeholder">{{ label || placeholder }}</span> 20557 </template> 20558 </template> 20559 <div class="flexGrowOne"></div> 20560 <i class="fa-light fa-chevron-down rich-select-chevron" :class="{ 'rich-select-chevron-open': isOpen }" style="z-index: 10;"></i> 20561 </div> 20562 20563 <!-- Open state: dropdown panel (hovering above content) --> 20564 <div 20565 v-show="isOpen"
20566 class="rich-select-dropdown-panel boxShadowBottom moreBlur" 20567 :class="{ 'rich-select-dropdown-panel--fixed': uses_fixed_dropdown_panel && isOpen }" 20568 :style="fixedDropdownPanelInlineStyle" 20569 ref="dropdownPanel" 20570 @mouseover="isMouseOver = true" 20571 @mouseout="isMouseOver = false" 20572 tabindex="-1" 20573 @focusout="scheduleClose" 20574 > 20575 <div v-if="searchable" class="rich-select-search-wrap relative"> 20576 <input 20577 ref="searchInput" 20578 v-model="searchQuery" 20579 class="rich-select-search-input" 20580 :aria-label="search_placeholder || 'Search...'" 20581 @click.stop 20582 > 20583 <span 20584 v-if="!searchQuery" 20585 class="unclickable" 20586 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;" 20587 >{{ search_placeholder || 'Search...' }}</span> 20588 </div> 20589 <div class="rich-select-dropdown-list scrollable" ref="dropdownList"> 20590 <div v-if="loading" class="rich-select-option flexNoWrap align-items-center unclickable"> 20591 <div class="rich-select-option-text"> 20592 <span class="rich-select-option-subtext">Searching...</span> 20593 </div> 20594 </div> 20595 <div 20596 v-if="showNoneOption" 20597 class="rich-select-option flexNoWrap align-items-center" 20598 :class="{ 'rich-select-option-selected': value == null }" 20599 @click="selectOption(null)" 20600 > 20601 <div class="rich-select-option-text"> 20602 <span class="rich-select-option-title">None</span> 20603 </div> 20604 </div> 20605 <!-- Suggested section: only renders when the parent passes 20606 lookup-derived candidates AND the user hasn't typed a 20607 search query. Header + divider use inline styles per 20608 the no-new-classes rule for HTML files. --> 20609 <template v-if="visibleSuggestedOptions.length > 0"> 20610 <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> 20611 <div 20612 v-for="option in visibleSuggestedOptions" 20613 :key="'suggested-' + optionId(option)" 20614 class="rich-select-option flexNoWrap align-items-center" 20615 :class="{ 20616 'rich-select-option-selected': isSelected(option), 20617 'unclickable': option && (option.disabled || option.visually_disabled) 20618 }" 20619 @click="selectOption(option)" 20620 > 20621 <template v-if="multiple"> 20622 <i v-if="isSelected(option)" class="fa-solid fa-square-check rich-select-option-check"></i> 20623 <i v-else class="fa-light fa-square rich-select-option-check"></i> 20624 </template> 20625 <div v-if="optionImage(option)" class="rich-select-option-icon-wrap"> 20626 <img v-if="optionImageIsUrl(option)" :src="optionImage(option)" class="rich-select-option-icon" alt=""> 20627 <i v-else :class="optionImage(option)" class="rich-select-option-icon fa"></i> 20628 </div> 20629 <div class="rich-select-option-text">
20630 <span class="rich-select-option-title" :class="optionTitleClass(option)">{{ optionTitle(option) }}</span> 20631 <span v-if="optionSubtext(option)" class="rich-select-option-subtext">{{ optionSubtext(option) }}</span> 20632 </div> 20633 <slot name="option-action" :option="option"></slot> 20634 </div> 20635 <div style="height: 1px; background-color: #e4ebf1; margin: 4px 0;"></div> 20636 </template> 20637 <div 20638 v-for="option in nonSuggestedFilteredOptions" 20639 :key="optionId(option)" 20640 class="rich-select-option flexNoWrap align-items-center" 20641 :class="{ 20642 'rich-select-option-selected': isSelected(option), 20643 'unclickable': option && (option.disabled || option.visually_disabled) 20644 }" 20645 @click="selectOption(option)" 20646 > 20647 <template v-if="multiple"> 20648 <i v-if="isSelected(option)" class="fa-solid fa-square-check rich-select-option-check"></i> 20649 <i v-else class="fa-light fa-square rich-select-option-check"></i> 20650 </template> 20651 <div v-if="optionImage(option)" class="rich-select-option-icon-wrap"> 20652 <img v-if="optionImageIsUrl(option)" :src="optionImage(option)" class="rich-select-option-icon" alt=""> 20653 <i v-else :class="optionImage(option)" class="rich-select-option-icon fa"></i> 20654 </div> 20655 <div class="rich-select-option-text"> 20656 <span class="rich-select-option-title" :class="optionTitleClass(option)">{{ optionTitle(option) }}</span> 20657 <span v-if="optionSubtext(option)" class="rich-select-option-subtext">{{ optionSubtext(option) }}</span> 20658 </div> 20659 <slot name="option-action" :option="option"></slot> 20660 </div> 20661 <div v-if="!loading && nonSuggestedFilteredOptions.length == 0" class="rich-select-option flexNoWrap align-items-center unclickable"> 20662 <div class="rich-select-option-text"> 20663 <span class="rich-select-option-subtext">{{ empty_text || "No options found" }}</span> 20664 </div> 20665 </div> 20666 </div> 20667 <slot name="panel-footer"></slot> 20668 </div> 20669 </div> 20670 </div> 20671</script>
20671 20672
20673<script> 20674(function() { 20675 function optionId(option) { 20676 if (!option) return ''; 20677 return option.id != null ? option.id : (option.get ? option.get('id') : option); 20678 } 20679 function optionTitle(option) { 20680 if (!option) return ''; 20681 return option.title != null ? option.title : (option.name != null ? option.name : (option.get ? (option.get('title') || option.get('name')) : '')); 20682 } 20683 function optionSubtext(option) { 20684 if (!option) return ''; 20685 return option.subtext != null ? option.subtext : (option.get ? option.get('subtext') : ''); 20686 } 20687 function optionTitleClass(option) { 20688 if (!option) return ''; 20689 return option.title_class != null ? option.title_class : (option.get ? option.get('title_class') : ''); 20690 } 20691 function optionImage(option) { 20692 if (!option) return null; 20693 return option.image != null ? option.image : (option.icon != null ? option.icon : (option.get ? (option.get('image') || option.get('icon')) : null)); 20694 } 20695 function optionImageIsUrl(option) { 20696 var img = optionImage(option); 20697 return img && (typeof img === 'string' && (img.indexOf('http') === 0 || img.indexOf('/') === 0 || img.indexOf('data:') === 0)); 20698 } 20699 20700 var richSelectDropdownConfig = { 20701 template: "#template-rich-select-dropdown", 20702 emits: ['input', 'update:value', 'change', 'search'], 20703 data() { 20704 return { 20705 isOpen: false, 20706 isMouseOver: false, 20707 closeTimeout: null, 20708 searchQuery: '', 20709 fixedPanelPositionStyle: null, 20710 _fixedPanelScrollCleanup: null, 20711 auto_fix_dropdown_panel: false, 20712 }; 20713 }, 20714 mounted() { 20715 this.auto_fix_dropdown_panel = !!this.$refs.container.closest('.wb-inline-controls'); 20716 }, 20717 watch: { 20718 isOpen: function(is_open) { 20719 if (!this.uses_fixed_dropdown_panel) { 20720 return; 20721 } 20722 if (is_open) { 20723 this.scroll_trigger_into_safe_view_if_needed(); 20724 this.sync_fixed_dropdown_panel(); 20725 this.$nextTick(() => { 20726 this.sync_fixed_dropdown_panel(); 20727 this.teardown_fixed_dropdown_panel_listeners(); 20728 this._fixedPanelScrollCleanup = this.setup_fixed_dropdown_panel_listeners(); 20729 }); 20730 } else { 20731 this.fixedPanelPositionStyle = null; 20732 this.teardown_fixed_dropdown_panel_listeners(); 20733 } 20734 }, 20735 searchQuery: function(updated_query) { 20736 if (!this.searchable) { 20737 return; 20738 } 20739 this.$emit('search', updated_query); 20740 } 20741 }, 20742 props: { 20743 options: { 20744 type: Array, 20745 default: function() { return []; } 20746 }, 20747 value: { 20748 type: [Object, Array, String, Number], 20749 default: null 20750 }, 20751 multiple: { 20752 type: Boolean, 20753 default: false 20754 }, 20755 placeholder: { 20756 type: String, 20757 default: "Select..." 20758 }, 20759 label: { 20760 type: String, 20761 default: "" 20762 }, 20763 triggerClass: { 20764 type: String, 20765 default: "" 20766 }, 20767 /** If true, options may be Backbone models with .get('key') */ 20768 parse_objects: { 20769 type: Boolean, 20770 default: false 20771 }, 20772 showFieldLabelWhenSelected: { 20773 type: Boolean, 20774 default: false 20775 }, 20776 searchable: { 20777 type: Boolean, 20778 default: false 20779 }, 20780 search_placeholder: { 20781 type: String, 20782 default: "" 20783 }, 20784 remote_search: { 20785 type: Boolean, 20786 default: false 20787 }, 20788 loading: { 20789 type: Boolean, 20790 default: false 20791 }, 20792 empty_text: { 20793 type: String, 20794 default: '' 20795 }, 20796 /** When true, the closed trigger renders without its border/box so it can sit flush inside a table cell. */ 20797 borderless: { 20798 type: Boolean, 20799 default: false 20800 }, 20801 searchSubtext: { 20802 type: Boolean, 20803 default: true 20804 }, 20805 /** When true, the open panel uses position:fixed so it escapes overflow:auto
20805scroll parents (e.g. lodging step). */ 20806 dropdownPanelFixed: { 20807 type: Boolean, 20808 default: false 20809 }, 20810 /** When true, a "None" option is shown at the top of the list to clear the selection. */ 20811 showNoneOption: { 20812 type: Boolean, 20813 default: false 20814 }, 20815 /** 20816 * Optional, ranked list of options to surface at the top of the 20817 * dropdown panel under a "Suggested" header. Items must use the 20818 * same shape as `options` (id + title/name). The component 20819 * doesn't decide what's "suggested" â that's the parent's job 20820 * (e.g. the add-product modal feeds in the top N candidates from 20821 * its similar-products lookup). Hidden once the user starts 20822 * typing in the search input so search results aren't fragmented 20823 * across two sections. 20824 */ 20825 suggested_options: { 20826 type: Array, 20827 default: function() { return []; } 20828 }, 20829 /** Header label rendered above suggested_options. */ 20830 suggested_label: { 20831 type: String, 20832 default: 'Suggested' 20833 }, 20834 /** When true, the trigger can't be opened and renders in a muted state. */ 20835 disabled: { 20836 type: Boolean, 20837 default: false 20838 }, 20839 /** Explanatory line rendered under the placeholder when disabled (e.g. "No products found"). */ 20840 disabledSubtext: { 20841 type: String, 20842 default: null 20843 } 20844 }, 20845 computed: { 20846 uses_fixed_dropdown_panel() { 20847 return this.dropdownPanelFixed || this.auto_fix_dropdown_panel; 20848 }, 20849 fixedDropdownPanelInlineStyle() { 20850 if (!this.uses_fixed_dropdown_panel || !this.isOpen || !this.fixedPanelPositionStyle) { 20851 return null; 20852 } 20853 return this.fixedPanelPositionStyle; 20854 }, 20855 displayOptions() { 20856 return this.options || []; 20857 }, 20858 filteredOptions() { 20859 if (this.remote_search) { 20860 return this.displayOptions; 20861 } 20862 if (!this.searchable || !this.searchQuery.trim()) { 20863 return this.displayOptions; 20864 } 20865 var normalized_search_query = this.searchQuery.trim().toLowerCase(); 20866 var searchSubtext = this.searchSubtext; 20867 return this.displayOptions.filter(function(option) { 20868 var title = (optionTitle(option) || '').toLowerCase(); 20869 if (title.indexOf(normalized_search_query) !== -1) { 20870 return true; 20871 } 20872 if (searchSubtext) { 20873 var option_subtext = (optionSubtext(option) || '').toLowerCase(); 20874 if (option_subtext.indexOf(normalized_search_query) !== -1) { 20875 return true; 20876 } 20877 } 20878 return false; 20879 }); 20880 }, 20881 // Suggested-section visibility. Hidden once the user types so the 20882 // search hit list doesn't get split across two sections. 20883 visibleSuggestedOptions() { 20884 if (!Array.isArray(this.suggested_options) || this.suggested_options.length === 0) { 20885 return []; 20886 } 20887 if (this.searchable && this.searchQuery && this.searchQuery.trim()) { 20888 return []; 20889 } 20890 return this.suggested_options; 20891 }, 20892 // Filtered options minus anything already rendered in the 20893 // suggested section, so suggested items don't appear twice in 20894 // the panel. 20895 nonSuggestedFilteredOptions() { 20896 var suggested = this.visibleSuggestedOptions; 20897 if (!suggested.length) { 20898 return this.filteredOptions; 20899 } 20900 var suggestedIds = {}; 20901 suggested.forEach(function(option) { 20902 suggestedIds[optionId(option)] = true; 20903 }); 20904 return this.filteredOptions.filter(function(option) { 20905 return !suggestedIds[optionId(option)]; 20906 }); 20907 }, 20908 selectedOptionSingle() { 20909 if (this.multiple || !this.options.length) return null; 20910 var v = this.value; 20911 if (v == null) return null; 20912 var id = typeof v === 'object' && v !== null && (v.id != null || (v.get && v.get('id') != null)) 20913 ? (v.id != null ? v.id : v.get('id')) 20914 : v; 20915 return this.options.find(function(o) { return optionId(o) == id; }) || null; 20916 }, 20917 selectedOptionsForDisplay() { 20918 if (!this.multiple || !Array.isArray(this.value)) return []; 20919 var ids = this.value; 20920 return this.options.filter(function(o) { return ids.some(function(id) { return id == optionId(o); }); }); 20921 }, 20922 has_selected_value() { 20923 if (this.multiple) { 20924 return this.selectedOptionsForDisplay.length > 0; 20925 } 20926 return !!this.selectedOptionSingle; 20927 }, 20928 uses_floating_label() {
20929 if (!this.showFieldLabelWhenSelected) { 20930 return false; 20931 } 20932 if (this.label) { 20933 return true; 20934 } 20935 if (this.placeholder) { 20936 return true; 20937 } 20938 return false; 20939 }, 20940 floating_label_raised() { 20941 if (!this.uses_floating_label) { 20942 return false; 20943 } 20944 if (this.has_selected_value) { 20945 return true; 20946 } 20947 if (this.isOpen) { 20948 return true; 20949 } 20950 return false; 20951 } 20952 }, 20953 methods: { 20954 optionId: function(o) { return optionId(o); }, 20955 optionTitle: function(o) { return optionTitle(o); }, 20956 optionSubtext: function(o) { return optionSubtext(o); }, 20957 optionTitleClass: function(option) { return optionTitleClass(option); }, 20958 optionImage: function(o) { return optionImage(o); }, 20959 optionImageIsUrl: function(o) { return optionImageIsUrl(o); }, 20960 isSelected(option) { 20961 var id = optionId(option); 20962 if (this.multiple && Array.isArray(this.value)) 20963 return this.value.some(function(v) { return v == id; }); 20964 var single = this.value; 20965 if (single == null) return false; 20966 var currentId = typeof single === 'object' && single !== null 20967 ? (single.id != null ? single.id : (single.get ? single.get('id') : null)) 20968 : single; 20969 return currentId == id; 20970 }, 20971 toggle() { 20972 if (this.disabled) { 20973 return; 20974 } 20975 if (this.closeTimeout) { 20976 clearTimeout(this.closeTimeout); 20977 this.closeTimeout = null; 20978 } 20979 this.isOpen = !this.isOpen; 20980 if (this.isOpen) { 20981 this.searchQuery = ''; 20982 this.$nextTick(() => { 20983 if (this.$refs.dropdownList) { 20984 this.$refs.dropdownList.scrollTop = 0; 20985 } 20986 if (this.uses_fixed_dropdown_panel && this.searchable) { 20987 requestAnimationFrame(() => { 20988 requestAnimationFrame(() => { 20989 this.focus_open_panel(); 20990 }); 20991 }); 20992 } else { 20993 this.focus_open_panel(); 20994 } 20995 }); 20996 document.addEventListener('click', this.handleClickOutside); 20997 } else { 20998 this.searchQuery = ''; 20999 document.removeEventListener('click', this.handleClickOutside); 21000 } 21001 }, 21002 focus_open_panel() { 21003 if (!this.isOpen) { 21004 return; 21005 } 21006 if (this.searchable && this.$refs.searchInput) { 21007 this.$refs.searchInput.focus(); 21008 } else if (this.$refs.dropdownPanel) { 21009 this.$refs.dropdownPanel.focus(); 21010 } 21011 }, 21012 scheduleClose() { 21013 var self = this; 21014 this.closeTimeout = setTimeout(function() { 21015 if (!self.isMouseOver) { 21016 self.isOpen = false; 21017 document.removeEventListener('click', self.handleClickOutside); 21018 } 21019 }, 150); 21020 }, 21021 handleClickOutside(e) { 21022 var panel = this.$refs.dropdownPanel; 21023 var inPanel = panel && panel.contains(e.target); 21024 var inContainer = this.$refs.container && this.$refs.container.contains(e.target); 21025 if (!inContainer && !inPanel) { 21026 this.isOpen = false;
21027 document.removeEventListener('click', this.handleClickOutside); 21028 } 21029 }, 21030 return_visual_viewport() { 21031 let view = window.visualViewport; 21032 if (view) { 21033 return { 21034 height: view.height, 21035 width: view.width, 21036 offset_top: view.offsetTop || 0, 21037 offset_left: view.offsetLeft || 0, 21038 }; 21039 } 21040 return { 21041 height: window.innerHeight, 21042 width: window.innerWidth, 21043 offset_top: 0, 21044 offset_left: 0, 21045 }; 21046 }, 21047 scroll_trigger_into_safe_view_if_needed() { 21048 if (!this.searchable || !this.$refs.container) { 21049 return; 21050 } 21051 let view = this.return_visual_viewport(); 21052 let trigger_rect = this.$refs.container.getBoundingClientRect(); 21053 if (view.height - trigger_rect.bottom >= 200) { 21054 return; 21055 } 21056 let node = this.$refs.container.parentElement; 21057 while (node && node !== document.documentElement) { 21058 let style = window.getComputedStyle(node); 21059 let overflow_y = style.overflowY; 21060 let overflow = style.overflow; 21061 if (overflow_y === 'auto' || overflow_y === 'scroll' || overflow === 'auto' || overflow === 'scroll') { 21062 let parent_rect = node.getBoundingClientRect(); 21063 let delta = trigger_rect.top - parent_rect.top - 8; 21064 if (delta > 0) { 21065 node.scrollTop = node.scrollTop + delta; 21066 } 21067 return; 21068 } 21069 node = node.parentElement; 21070 } 21071 }, 21072 sync_fixed_dropdown_panel() { 21073 if (!this.uses_fixed_dropdown_panel || !this.isOpen || !this.$refs.container) { 21074 return; 21075 } 21076 let trigger_rect = this.$refs.container.getBoundingClientRect(); 21077 let view = this.return_visual_viewport(); 21078 let edge = 8; 21079 let gap = 4; 21080 let desired_height = 280; 21081 let min_usable = 160; 21082 let space_below = view.height - trigger_rect.bottom - edge; 21083 let space_above = trigger_rect.top - edge; 21084 let visual_top = trigger_rect.bottom + gap; 21085 let max_height = Math.min(desired_height, space_below); 21086 21087 if (space_below < min_usable && space_above > space_below) { 21088 max_height = Math.min(desired_height, space_above); 21089 visual_top = trigger_rect.top - gap - max_height; 21090 } else if (space_below < 80 && space_above >= 80) { 21091 max_height = Math.min(desired_height, space_above); 21092 visual_top = trigger_rect.top - gap - max_height; 21093 } else if (space_below < 80 && space_above < 80) { 21094 visual_top = edge; 21095 max_height = view.height - edge * 2; 21096 } else if (space_below < min_usable) { 21097 max_height = Math.max(space_below, 0); 21098 visual_top = trigger_rect.bottom + gap; 21099 } 21100 21101 if (visual_top < edge) { 21102 max_height = max_height - (edge - visual_top); 21103 visual_top = edge; 21104 } 21105 if (visual_top + max_height > view.height - edge) { 21106 max_height = view.height - edge - visual_top; 21107 } 21108 if (max_height < 80) { 21109 max_height = Math.min(80, Math.max(0, view.height - edge * 2)); 21110 visual_top = Math.min(visual_top, view.height - edge - max_height); 21111 visual_top = Math.max(edge, visual_top); 21112 } 21113 21114 //keyboard pan: getBoundingClientRect is visual, position:fixed is layout 21115 this.fixedPanelPositionStyle = { 21116 position: 'fixed', 21117 top: (visual_top + view.offset_top) + 'px', 21118 left: (trigger_rect.left + view.offset_left) + 'px', 21119 width: trigger_rect.width + 'px', 21120 minWidth: trigger_rect.width + 'px', 21121 maxHeight: max_height + 'px', 21122 zIndex: 10050, 21123 }; 21124 }, 21125 setup_fixed_dropdown_panel_listeners() { 21126 let sync_panel = () => { 21127 this.sync_fixed_dropdown_panel(); 21128 }; 21129 let scroll_nodes = []; 21130 let node = this.$refs.container; 21131 while (node && node !== document.documentElement) { 21132 let style = window.getComputedStyle(node); 21133 let overflow_y = style.overflowY; 21134 let overflow_x = style.overflowX; 21135 let overflow = style.overflow; 21136 if (overflow_y === 'auto' || overflow_y === 'scroll' || overflow_x === 'auto' || overflow_x === 'scroll' || overflow === 'auto' || overflow === 'scroll') { 21137 node.addEventListener('scroll', sync_panel, { passive: true }); 21138 scroll_nodes.push(node); 21139 } 21140 node = node.parentElement; 21141 } 21142 window.addEventListener('resize', sync_panel); 21143 window.addEventListener('scroll', sync_panel, true);
21144 let visual_viewport = window.visualViewport; 21145 if (visual_viewport) { 21146 visual_viewport.addEventListener('resize', sync_panel); 21147 visual_viewport.addEventListener('scroll', sync_panel); 21148 } 21149 return () => { 21150 scroll_nodes.forEach((scroll_node) => { 21151 scroll_node.removeEventListener('scroll', sync_panel); 21152 }); 21153 window.removeEventListener('resize', sync_panel); 21154 window.removeEventListener('scroll', sync_panel, true); 21155 if (visual_viewport) { 21156 visual_viewport.removeEventListener('resize', sync_panel); 21157 visual_viewport.removeEventListener('scroll', sync_panel); 21158 } 21159 }; 21160 }, 21161 teardown_fixed_dropdown_panel_listeners() { 21162 if (this._fixedPanelScrollCleanup) { 21163 this._fixedPanelScrollCleanup(); 21164 this._fixedPanelScrollCleanup = null; 21165 } 21166 }, 21167 selectOption(option) { 21168 if (option && option.disabled) { 21169 return; 21170 } 21171 var id = optionId(option); 21172 if (this.multiple) { 21173 var arr = Array.isArray(this.value) ? this.value.slice() : []; 21174 var idx = arr.indexOf(id); 21175 if (idx >= 0) { 21176 arr.splice(idx, 1); 21177 } else { 21178 arr.push(id); 21179 } 21180 this.$emit('input', arr); 21181 this.$emit('update:value', arr); 21182 this.$emit('change', arr); 21183 } else { 21184 this.$emit('input', option); 21185 this.$emit('update:value', option); 21186 this.$emit('change', option); 21187 this.isOpen = false; 21188 document.removeEventListener('click', this.handleClickOutside); 21189 } 21190 } 21191 }, 21192 beforeDestroy() { 21193 document.removeEventListener('click', this.handleClickOutside); 21194 if (this.closeTimeout) { 21195 clearTimeout(this.closeTimeout); 21196 } 21197 this.teardown_fixed_dropdown_panel_listeners(); 21198 } 21199 }; 21200 app_content.component("rich-select-dropdown", richSelectDropdownConfig); 21201 app_overlays.component("rich-select-dropdown", richSelectDropdownConfig); 21202})(); 21203</script>
21203 21204 21205<style> 21206.rich-select-dropdown { 21207 display: inline-block; 21208 min-width: 180px; 21209} 21210 21211.rich-select-trigger { 21212 border: 1px solid #e4ebf1; 21213 border-radius: 8px; 21214 padding: 10px 12px; 21215 min-height: 32px; 21216 -webkit-user-select: none; 21217 user-select: none; 21218 transition: border-color 0.15s ease, box-shadow 0.15s ease; 21219} 21220 21221.rich-select-trigger:hover { 21222 border-color: #c5cdd6; 21223} 21224 21225.rich-select-trigger.rich-select-trigger-borderless, 21226.rich-select-trigger.rich-select-trigger-borderless:hover, 21227.rich-select-trigger.rich-select-trigger-borderless.rich-select-trigger-open { 21228 border-color: transparent; 21229 box-shadow: none; 21230 padding-left: 2px; 21231 padding-right: 2px; 21232} 21233 21234.rich-select-trigger.rich-select-trigger-disabled, 21235.rich-select-trigger.rich-select-trigger-disabled:hover { 21236 background-color: #fbfcfe; 21237 border-color: #e4ebf1; 21238 cursor: default; 21239} 21240 21241.rich-select-trigger.rich-select-trigger-disabled .rich-select-chevron { 21242 opacity: 0.4; 21243} 21244 21245.rich-select-trigger-open, 21246.rich-select-trigger.rich-select-trigger-open { 21247 /* border-color: #2e59d6; */ 21248 /* box-shadow: 0 0 0 1px #2e59d6; */ 21249 border-color: #e4ebf1; 21250 box-shadow: none; 21251 outline: none; 21252} 21253 21254.rich-select-dropdown:focus-within .rich-select-trigger { 21255 border-color: #e4ebf1; 21256 box-shadow: none; 21257 outline: none; 21258} 21259 21260.rich-select-trigger:focus, 21261.rich-select-trigger:focus-visible, 21262.rich-select-dropdown-panel:focus, 21263.rich-select-dropdown-panel:focus-visible { 21264 border-color: #e4ebf1; 21265 box-shadow: none; 21266 outline: none; 21267} 21268 21269.rich-select-trigger-text { 21270 /* flex: 1; */ 21271 min-width: 0; 21272 display: flex; 21273 flex-direction: column; 21274 gap: 2px; 21275} 21276 21277.rich-select-trigger-title { 21278 font-size: 15px; 21279 color: #081333; 21280 font-family: 'roboto', sans-serif; 21281} 21282 21283.rich-select-trigger-title-no-subtext { 21284 font-size: 18px; 21285 font-weight: 100; 21286 color: #081333; 21287 font-family: 'roboto', sans-serif; 21288} 21289 21290.rich-select-trigger-subtext { 21291 font-size: 13px; 21292 color: #83909d; 21293} 21294 21295.rich-select-placeholder { 21296 font-size: 15px; 21297 color: #83909d; 21298} 21299 21300.rich-select-option-icon-wrap { 21301 width: 32px; 21302 height: 32px; 21303 min-width: 32px; 21304 border-radius: 50%; 21305 overflow: hidden; 21306 background-color: #e8eaf1; 21307 display: flex; 21308 align-items: center; 21309 justify-content: center; 21310 margin-right: 10px; 21311} 21312 21313.rich-select-option-icon { 21314 width: 100%; 21315 height: 100%; 21316 object-fit: cover; 21317} 21318 21319.rich-select-option-icon.fa { 21320 width: auto; 21321 height: auto; 21322 font-size: 16px; 21323 color: #52596f; 21324 background: none !important; 21325} 21326.rich-select-option-icon.fa::before { 21327 background: none !important; 21328} 21329 21330.rich-select-chevron { 21331 margin-left: 8px; 21332 font-size: 12px; 21333 color: #83909d; 21334 transition: transform 0.2s ease; 21335} 21336 21337.rich-select-chevron.rich-select-chevron-open { 21338 transform: rotate(180deg); 21339} 21340 21341/* Dropdown panel: floats below trigger, above surrounding content */ 21342.rich-select-dropdown-panel { 21343 position: absolute; 21344 top: 100%; 21345 left: 0; 21346 margin-top: 4px; 21347 z-index: 1001; 21348 background-color: #fff; 21349 border: 1px solid #e4ebf1; 21350 border-radius: 5px; 21351 overflow: hidden; 21352 padding: 6px 0; 21353 max-height: 280px; 21354 min-width: 100%; 21355 display: flex; 21356 flex-direction: column; 21357 transform-origin: top center; 21358 animation: rich-select-dropdown-panel-open 0.11s ease-out; 21359} 21360 21361/* v-show flips display, which restarts the animation on every open */ 21362@keyframes rich-select-dropdown-panel-open { 21363 from { 21364 opacity: 0; 21365 transform: translateY(-5px) scaleY(0.96); 21366 } 21367 to { 21368 opacity: 1; 21369 transform: translateY(0) scaleY(1); 21370 } 21371} 21372 21373/* Fixed panel: escapes overflow:auto ancestors (position + size from inline style) */ 21374.rich-select-dropdown-panel--fixed { 21375 position: fixed; 21376 top: auto; 21377 left: auto; 21378 margin-top: 0; 21379 min-width: 0; 21380 max-height: none; 21381} 21382 21383.rich-select-search-wrap { 21384 padding: 6px 8px; 21385 border-bottom: 1px solid #e4ebf1; 21386 flex-shrink: 0; 21387} 21388 21389.rich-select-search-input { 21390 width: 100%; 21391 border: 1px solid #e4ebf1; 21392 border-radius: 5px; 21393 padding: 8px 10px; 21394 font-size: 14px; 21395 font-family: 'roboto', sans-serif; 21396 outline: none; 21397 box-sizing: border-box; 21398} 21399 21400.rich-select-search-input:focus { 21401 /* border-color: #2e59d6; */ 21402 /* box-shadow: 0 0 0 1px #2e59d6; */ 21403 border-color: #e4ebf1; 21404 box-shadow: none; 21405 outline: none; 21406} 21407 21408.rich-select-dropdown-list { 21409 flex: 1 1 auto; 21410 overflow-y: auto; 21411 overflow-x: hidden; 21412} 21413 21414.rich-select-option { 21415 padding: 10px 14px; 21416 cursor: pointer; 21417 transition: background-color 0.1s ease; 21418} 21419 21420.rich-select-option:hover { 21421 background-color: #f6f8f9; 21422} 21423 21424.rich-select-option-selected { 21425 background-color: #f0f4ff; 21426} 21427 21428.rich-select-option-check { 21429 margin-right: 10px; 21430 font-size: 18px; 21431 color: #52596f; 21432} 21433 21434.rich-select-option-selected .rich-select-option-check { 21435 color: var(--custom-mp-accent, #2e59d6); 21436} 21437 21438.rich-select-option-text { 21439 flex: 1; 21440 min-width: 0; 21441 display: flex; 21442 flex-direction: column; 21443 gap: 2px; 21444} 21445 21446.rich-select-option-title { 21447 font-size: 15px; 21448 color: #081333; 21449 font-family: 'roboto', sans-serif; 21450} 21451 21452.rich-select-option-subtext { 21453 font-size: 13px; 21454 color: #83909d; 21455} 21456</style> 21457
21457<script type="text/html" id="template-default-field-wb"> 21458 <div class="default-field-wrapper tinyMarginTop"> 21459 <span class="flex-row-start"> 21460 <p style="color: rgba(255, 0, 0, 0.885); margin: 0 5.5px 0 3px;">*</p> 21461 <p class="default-font-body" style="font-size: 14px; color: rgb(150, 152, 154); font-style: italic;">(required)</p> 21462 </span> 21463 <!-- default fields --> 21464 <div class="flex-row-start" style="width: 100%;"> 21465 <div class="flex-column-start" style="width: 100%;"> 21466 <template v-if="default_configuration === 'list'" v-for="(field, index) in default_fields" :id="index"> 21467 <div class="flex-row-start smallMarginTop" style="width: 100%;"> 21468 <input 21469 type="text" 21470 :placeholder="field.label" 21471 class="default-field-input border-bottom-grey-2 default-font-body" 21472 style="width: 100%;" 21473 v-model="field.input_value" 21474 :disabled="is_admin" 21475 > 21476 </div> 21477 </template> 21478 <template v-if="default_configuration === 'grid'" v-for="(row, i) in default_fields" :id="i"> 21479 <div class="flex-row-start smallMarginTop" style="width: 100%;"> 21480 <template v-for="(field, j) in row" :id="j"> 21481 <input 21482 type="text" 21483 :placeholder="field.label" 21484 class="default-field-input border-bottom-grey-2 default-font-body marginRight" 21485 style="width: 100%;" 21486 v-model="field.input_value" 21487 :disabled="is_admin" 21488 > 21489 </template> 21490 </div> 21491 </template> 21492 </div> 21493 </div> 21494 </div> 21495</script>
21495 21496 21497<style scoped> 21498 :root { 21499 --default-field-grey-1: rgba(180, 180, 180, 0.8); 21500 --default-field-grey-2: rgba(195, 195, 195, 0.7); 21501 --default-field-grey-3: rgb(154, 160, 166); 21502 --default-field-primary: #202124; 21503 } 21504 .default-field-wrapper{ 21505 width: 100%; 21506 border: 1px solid rgba(195, 195, 195, 0.7); 21507 border-radius: 8px; 21508 padding: 1.45em; 21509 background-color: #fff; 21510 box-sizing: border-box; 21511 } 21512 .default-field-wrapper .default-font-body{ 21513 font-family: Roboto, Arial, Helvetica, sans-serif; 21514 font-size: 15px; 21515 font-weight: 400; 21516 } 21517 .default-field-wrapper .configuration-icon{ 21518 font-size: 1em; 21519 cursor: pointer; 21520 padding: 5px; 21521 border-radius: 50%; 21522 color: rgba(95, 95, 95); 21523 background-color: #fff; 21524 transition: color 0.2s, background-color 0.2s; 21525 } 21526 .default-field-wrapper .configuration-icon:hover{ 21527 color: var(--default-field-primary); 21528 background-color: rgba(180, 180, 180, 0.15); 21529 } 21530 .default-field-wrapper .file-upload-wrapper{ 21531 height: 90px; 21532 width: 50%; 21533 border: 1px dashed var(--default-field-grey-1); 21534 } 21535 .default-field-wrapper .inherit-font-css{ 21536 font-family: inherit; 21537 font-size: inherit; 21538 font-weight: inherit; 21539 } 21540 .default-field-wrapper .flex-row-center{ 21541 display: flex; 21542 flex-direction: row; 21543 align-items: center; 21544 justify-content: center; 21545 } 21546 .default-field-wrapper .flex-column-center{ 21547 display: flex; 21548 flex-direction: column; 21549 align-items: center; 21550 justify-content: center; 21551 } 21552 .default-field-wrapper .flex-row-start{ 21553 display: flex; 21554 flex-direction: row; 21555 align-items: center; 21556 justify-content: flex-start; 21557 } 21558 .default-field-wrapper .flex-row-end{ 21559 display: flex; 21560 flex-direction: row; 21561 align-items: center; 21562 justify-content: flex-end; 21563 } 21564 .default-field-wrapper .flex-column-start{ 21565 display: flex; 21566 flex-direction: column; 21567 align-items: flex-start; 21568 justify-content: center; 21569 } 21570 .default-field-wrapper .default-field{ 21571 color: rgb(32, 33, 36); 21572 box-sizing: border-box; 21573 font-weight: 400; 21574 font-size: 14px; 21575 width: 100%; 21576 line-height: 2.5; 21577 letter-spacing: 0; 21578 } 21579 .default-field-wrapper .default-field > .default-field-input{ 21580 font-family: inherit; 21581 font-size: inherit; 21582 font-weight: inherit; 21583 width: inherit; 21584 line-height: inherit; 21585 background-color: rgb(248, 249, 250); 21586 padding: 5px 0 5px 15px; 21587 } 21588 .default-field-wrapper .border-bottom-grey-2, .default-field-wrapper .input-is-focused{ 21589 border-bottom: 1.25px solid var(--default-field-grey-2); 21590 } 21591 .default-field-wrapper input { 21592 transition: border-color 0.2s; 21593 } 21594</style> 21595
21596<script> 21597 app_content.component("default-field-component-wb", { 21598 template: "#template-default-field-wb", 21599 data() { 21600 return { 21601 21602 } 21603 }, 21604 props: { 21605 form: Object, //json 21606 is_admin: Boolean, 21607 default_fields: Array, 21608 rerender_key: String, 21609 }, 21610 mounted() { 21611 21612 }, 21613 methods: { 21614 21615 }, 21616 computed: { 21617 default_configuration(){ 21618 if ( this.form && this.form.default_configuration ){ 21619 return this.form.default_configuration; 21620 } 21621 return 'list'; 21622 }, 21623 } 21624 }) 21625</script>
21625 21626
21626<script type="text/html" id="template-file-upload-component"> 21627 <div> 21628 <p v-if="label" class="tinyPaddingBottom subtext evenSmaller">{{ label }}</p> 21629 <div 21630 class="file-upload-container lightBorderAllAround overflowHidden" 21631 :class="{ 'file-selected': hasFiles, 'upload-loading': loading }" 21632 @dragover.prevent="!loading && (isDragging = true)" 21633 @dragleave.prevent="!loading && (isDragging = false)" 21634 @drop.prevent="handle_drop" 21635 > 21636 <div 21637 class="file-upload-inner relative flexWrap align-items-center flexGrowOne" 21638 > 21639 <input 21640 type="file" 21641 id="fileInput" 21642 ref="fileInput" 21643 class="absoluteInput" 21644 @change="handle_file_upload" 21645 :multiple="multiple" 21646 :accept="acceptAttribute" 21647 :disabled="loading" 21648 /> 21649 <div v-if="loading" class="file-upload-loading-overlay"> 21650 <div class="cardBodySpinner small-spinner centeredBlock active"></div> 21651 </div> 21652 <div 21653 class="upload-placeholder flexNoWrapCenter align-items-center" 21654 :style="{ opacity: loading ? 0.55 : 1 }" 21655 > 21656 <div> 21657 <i :class="icon ? icon : 'fa-light fa-file-arrow-up'" class="upload-icon tinyMarginBottom"></i> 21658 <p class="upload-text">Drag and drop here or click to upload</p> 21659 <p class="upload-subtext">{{ fileTypeMessage }}</p> 21660 </div> 21661 </div> 21662 </div> 21663 21664 <div 21665 class="selected-file-wrap flexNoWrap lightBorderTop align-items-center" 21666 v-if="hasFiles && !loading" 21667 > 21668 <template v-if="multiple"> 21669 <div class="flexColumn flexGrowOne selected-file-list"> 21670 <div 21671 v-for="(file, index) in selectedFiles" 21672 :key="file.name + index" 21673 class="flexNoWrap align-items-center" 21674 :class="{ 'smallMarginBottom': index !== selectedFiles.length - 1 }" 21675 > 21676 <img 21677 v-if="imagePreviewURLs[return_file_key(file, index)]" 21678 :src="imagePreviewURLs[return_file_key(file, index)]" 21679 class="file-preview-thumb" 21680 alt="Image preview" 21681 > 21682 <p class="selected-file-name flexGrowOne"> 21683 <i class="fa-solid fa-square-check" style="padding-right:3px;"></i> {{ file.name }} 21684 </p> 21685 <div 21686 class="remove-file-wrap pointer" 21687 :class="{ unclickable: loading }" 21688 @click="remove_file(index)" 21689 > 21690 <i class="fa-light fa-trash-can"></i> 21691 </div> 21692 </div> 21693 </div> 21694 </template> 21695 <template v-else> 21696 <div class="flexNoWrap align-items-center flexGrowOne"> 21697 <img 21698 v-if="selectedFiles[0] && imagePreviewURLs[return_file_key(selectedFiles[0], 0)]" 21699 :src="imagePreviewURLs[return_file_key(selectedFiles[0], 0)]" 21700 class="file-preview-thumb" 21701 alt="Image preview" 21702 > 21703 <p class="selected-file-name flexGrowOne"><i class="fa-solid fa-square-check" style="padding-right:3px;"></i> {{ selectedFiles[0].name }}</p> 21704 </div> 21705 <div 21706 class="remove-file-wrap pointer" 21707 :class="{ unclickable: loading }" 21708 @click="remove_file" 21709 > 21710 <i 21711 class="fa-light fa-trash-can" 21712 ></i> 21713 </div> 21714 </template> 21715 </div> 21716 21717 </div> 21718 </div> 21719 21720</script>
21720 21721
21722<script> 21723 21724 var config = { 21725 template: "#template-file-upload-component", 21726 data() { 21727 return { 21728 selectedFiles: [], 21729 imagePreviewURLs: {}, 21730 isDragging: false, 21731 //file type mappings 21732 fileTypeMappings: { 21733 csv: [".csv", "text/csv", "application/csv", "application/vnd.ms-excel"], 21734 pdf: [".pdf", "application/pdf"], 21735 image: [".jpg", ".jpeg", ".png", "image/jpeg", "image/png"], 21736 docx: [".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"], 21737 video: [ 21738 ".mp4", ".webm", ".avi", ".mov", ".mkv", ".flv", ".wmv", 21739 "video/mp4", "video/webm", "video/x-msvideo", "video/quicktime", 21740 "video/x-matroska", "video/x-flv", "video/x-ms-wmv" 21741 ] 21742 } 21743 } 21744 }, 21745 props: { 21746 icon: String, 21747 label: String, 21748 valid_file_types: { 21749 type: Array, 21750 default: () => [] 21751 }, 21752 multiple: { 21753 type: Boolean, 21754 default: false 21755 }, 21756 loading: { 21757 type: Boolean, 21758 default: false, 21759 }, 21760 }, 21761 mounted(){ 21762 21763 }, 21764 beforeUnmount(){ 21765 this.revoke_image_previews(); 21766 }, 21767 beforeDestroy(){ 21768 this.revoke_image_previews(); 21769 }, 21770 methods: { 21771 open_file_picker() { 21772 if( this.loading ) { 21773 return; 21774 } 21775 this.$refs.fileInput.click(); 21776 }, 21777 handle_file_upload(event) { 21778 if( this.loading ) return; 21779 const files = Array.from(event.target.files || []); 21780 this.set_files_from_input(files); 21781 }, 21782 handle_drop(event) { 21783 if( this.loading ) return; 21784 event.preventDefault(); 21785 const files = Array.from(event.dataTransfer.files || []); 21786 this.set_files_from_input(files); 21787 this.isDragging = false; 21788 }, 21789 set_files_from_input(files) { 21790 if (!files.length) { 21791 this.selectedFiles = []; 21792 return; 21793 } 21794 const validFiles = files.filter(file => this.is_valid_file_type(file)); 21795 if (!validFiles.length) { 21796 alert("Invalid file type. Please upload a valid file."); 21797 this.selectedFiles = []; 21798 this.$refs.fileInput.value = ""; 21799 return; 21800 } 21801 if (validFiles.length !== files.length) { 21802 alert("Some files were skipped because they are not allowed."); 21803 } 21804 if (this.multiple) { 21805 // Append rather than replace so re-dropping or "add 21806 // another" grows the batch. Dedupe on name/size/mtime so 21807 // picking the same file twice doesn't create a duplicate. 21808 const existingKeys = new Set( 21809 this.selectedFiles.map(existingFile => `${existingFile.name}_${existingFile.size}_${existingFile.lastModified}`) 21810 ); 21811 const filesToAdd = validFiles.filter(file => { 21812 return !existingKeys.has(`${file.name}_${file.size}_${file.lastModified}`); 21813 }); 21814 this.selectedFiles = this.selectedFiles.concat(filesToAdd); 21815 } else { 21816 this.selectedFiles = [validFiles[0]]; 21817 } 21818 }, 21819 is_valid_file_type(file) { 21820 const fileExtension = `.${file.name.split('.').pop().toLowerCase()}`; 21821 const fileType = (file.type || "").toLowerCase(); 21822 if (!this.valid_file_types.length){ 21823 // If no restrictions, allow all , but limit to standard types for security 21824 const defaultAllowedTypes = this.default_file_types; 21825 return defaultAllowedTypes.includes(fileExtension) || defaultAllowedTypes.includes(fileType); 21826 } 21827 21828 return this.resolvedFileTypes.includes(fileExtension) || this.resolvedFileTypes.includes(fileType); 21829 }, 21830 clear_files() { 21831 this.revoke_image_previews(); 21832 this.selectedFiles = []; 21833 this.$refs.fileInput.value = ""; 21834 }, 21835 return_file_key(file, index = 0){ 21836 if(!file){ 21837 return ''; 21838 } 21839 return `${file.name}_${file.size}_${file.lastModified}_${index}`; 21840 }, 21841 is_image_file(file){ 21842 if(!file || !file.name){ 21843 return false;
21844 } 21845 const fileType = (file.type || "").toLowerCase(); 21846 if(fileType.startsWith("image/")){ 21847 return true; 21848 } 21849 const fileExtension = `.${file.name.split('.').pop().toLowerCase()}`; 21850 return [".jpg", ".jpeg", ".png", ".gif", ".webp"].includes(fileExtension); 21851 }, 21852 refresh_image_previews(files){ 21853 this.revoke_image_previews(); 21854 const nextPreviewURLs = {}; 21855 (files || []).forEach((file, index) => { 21856 if(this.is_image_file(file)){ 21857 const key = this.return_file_key(file, index); 21858 nextPreviewURLs[key] = URL.createObjectURL(file); 21859 } 21860 }); 21861 this.imagePreviewURLs = nextPreviewURLs; 21862 }, 21863 revoke_image_previews(){ 21864 Object.values(this.imagePreviewURLs || {}).forEach((url) => { 21865 try { 21866 URL.revokeObjectURL(url); 21867 } catch (e) { 21868 // no-op 21869 } 21870 }); 21871 this.imagePreviewURLs = {}; 21872 }, 21873 remove_file(index) { 21874 if( this.loading ) return; 21875 if (this.multiple && typeof index === "number") { 21876 this.selectedFiles.splice(index, 1); 21877 if (!this.selectedFiles.length) { 21878 this.$refs.fileInput.value = ""; 21879 } 21880 this.$emit("file_selected", { 21881 value: this.multiple ? this.selectedFiles : this.selectedFiles[0] 21882 }); 21883 } else { 21884 this.clear_files(); 21885 } 21886 } 21887 }, 21888 computed: { 21889 hasFiles() { 21890 return this.selectedFiles && this.selectedFiles.length > 0; 21891 }, 21892 resolvedFileTypes() { 21893 if(this.valid_file_types && this.valid_file_types.length > 0){ 21894 return this.valid_file_types.flatMap(type => this.fileTypeMappings[type] || [type]); 21895 }else{ 21896 return this.default_file_types; 21897 } 21898 }, 21899 acceptAttribute() { 21900 return this.resolvedFileTypes.join(","); 21901 }, 21902 fileTypeMessage() { 21903 if (!this.valid_file_types.length) { 21904 // No restrictions 21905 return ""; 21906 } 21907 21908 // Get only extensions and remove dots for display 21909 const displayTypes = this.valid_file_types 21910 .flatMap(type => this.fileTypeMappings[type] || [type]) 21911 .filter(type => type.startsWith(".")) // Show only file extensions 21912 .map(type => type.replace(".", "").toUpperCase()) // Remove dot and uppercase 21913 21914 return `You may upload ${displayTypes.join(", ")} files`; 21915 }, 21916 default_file_types(){ 21917 return [ 21918 // Documents 21919 ".pdf", "application/pdf", 21920 ".doc", ".docx", 21921 "application/msword", 21922 "application/vnd.openxmlformats-officedocument.wordprocessingml.document", 21923 ".rtf", "application/rtf", 21924 ".txt", "text/plain", 21925 21926 // Spreadsheets 21927 ".xls", ".xlsx", ".csv", 21928 "application/vnd.ms-excel", 21929 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", 21930 "text/csv", "application/csv", "application/vnd.ms-excel", 21931 21932 // Presentations 21933 ".ppt", ".pptx", 21934 "application/vnd.ms-powerpoint", 21935 "application/vnd.openxmlformats-officedocument.presentationml.presentation", 21936 21937 // Images 21938 ".jpg", ".jpeg", ".png", ".gif", ".webp", 21939 "image/jpeg", "image/png", "image/gif", "image/webp", 21940 // Videos 21941 ".mp4", ".webm", ".avi", ".mov", ".mkv", ".flv", ".wmv", 21942 "video/mp4", "video/webm", 21943 ]; 21944 } 21945 }, 21946 watch: { 21947 selectedFiles(newVal, oldVal){ 21948 this.refresh_image_previews(newVal); 21949 this.$emit("file_selected", { 21950 value: this.multiple ? newVal : newVal[0] 21951 }); 21952 } 21953 } 21954 } 21955 21956 //define the component 21957 app_content.component("file-upload-component", config); 21958 app_overlays.component("file-upload-component", config); 21959 21960 21961</script>
21961 21962 21963<style scoped> 21964 21965 .remove-file-wrap{ 21966 padding-left: 4px; 21967 padding-right: 4px; 21968 } 21969 21970 .selected-file-wrap{ 21971 padding: 12px; 21972 background-color: #fff; 21973 } 21974 21975 /* Ensure both children take full width but account for padding */ 21976 .file-upload-inner, .selected-file-wrap { 21977 width: 100%; 21978 flex: 1; /* Make sure they stretch */ 21979 box-sizing: border-box; /* Includes padding in width calculation */ 21980 padding: 12px; /* Add your desired padding */ 21981 } 21982 21983 .selected-file-name{ 21984 color: #292e4a; 21985 font-size: 16px; 21986 line-height: 20px; 21987 } 21988 .file-preview-thumb{ 21989 width: 42px; 21990 height: 42px; 21991 object-fit: cover; 21992 border-radius: 6px; 21993 border: 1px solid #e4ebf1; 21994 margin-right: 10px; 21995 flex-shrink: 0; 21996 } 21997 21998 .file-upload-inner{ 21999 min-height: 160px; 22000 } 22001 .file-upload-container { 22002 display: flex; 22003 flex-direction: column; 22004 align-items: center; 22005 justify-content: center; 22006 width: 100%; 22007 border-radius: 8px; 22008 background-color: #f8f9fb; 22009 cursor: pointer; 22010 transition: all 0.3s ease; 22011 } 22012 .file-upload-container.upload-loading { 22013 cursor: not-allowed; 22014 } 22015 .file-upload-loading-overlay { 22016 position: absolute; 22017 top: 0; 22018 right: 0; 22019 bottom: 0; 22020 left: 0; 22021 z-index: 3; 22022 display: flex; 22023 justify-content: center; 22024 align-items: center; 22025 background: rgba(255, 255, 255, 0.6); 22026 } 22027 .file-upload-container.upload-loading .upload-text, 22028 .file-upload-container.upload-loading .upload-subtext, 22029 .file-upload-container.upload-loading .selected-file-name { 22030 color: transparent !important; 22031 } 22032 .file-upload-container.upload-loading .upload-icon { 22033 color: transparent !important; 22034 } 22035 .file-upload-container:hover:not(:has(.selected-file-wrap:hover)), 22036 .file-upload-container.dragging:not(:has(.selected-file-wrap:hover)) { 22037 border-color: var(--custom-mp-accent, #4a56f8); 22038 } 22039 .file-upload-container:hover .upload-icon:not(:has(.selected-file-wrap:hover)), 22040 .file-upload-container.dragging .upload-icon:not(:has(.selected-file-wrap:hover)){ 22041 color: var(--custom-mp-accent, #4a56f8); 22042 } 22043 22044 .upload-placeholder { 22045 text-align: center; 22046 padding: 20px; 22047 } 22048 22049 .upload-icon{ 22050 color: #788097; 22051 font-size: 28px; 22052 padding-right: 16px; 22053 } 22054 .upload-text { 22055 font-size: 16px; 22056 font-weight: 500; 22057 color: #081333; 22058 } 22059 .upload-subtext { 22060 font-size: 14px; 22061 color: #8890a3; 22062 padding-top: 4px; 22063 line-height: 18px; 22064 } 22065</style> 22066
22066<script type="text/html" id="template-no-objects-found"> 22067 22068 <div 22069 class="marginTopBottom" 22070 style="display: flex; flex-direction: column; justify-content: center; align-items: center;" 22071 > 22072 <i 22073 :class="icon_class" 22074 class="no-objects-icon" 22075 ></i> 22076 <p class="no-objects-subtext">{{ subtext }}</p> 22077 <p v-if="call_to_action" class="no-objects-subtext"><a @click="call_to_action.fn">{{ call_to_action.text }}</a></p> 22078 </div> 22079 22080</script>
22080 22081
22082<script> 22083 22084/* ----- COMPONENT ----- */ 22085 22086let no_objects_config = { 22087 template: "#template-no-objects-found", 22088 data() { 22089 return {} 22090 }, 22091 props: { 22092 icon_class: { 22093 type: String, 22094 required: true, 22095 default: "fa-regular fa-magnifying-glass", 22096 }, 22097 subtext: { 22098 type: String, 22099 required: true, 22100 default: "No items found.", 22101 }, 22102 call_to_action: { 22103 type: Object, 22104 required: false, 22105 default: null, 22106 } 22107 }, 22108 mounted() { 22109 22110 22111 }, 22112 methods: { 22113 22114 22115 } 22116 22117} 22118app_content.component("no-objects-found", no_objects_config); 22119app_overlays.component("no-objects-found", no_objects_config); 22120if (typeof app_ai_mcp !== 'undefined') { 22121 app_ai_mcp.component("no-objects-found", no_objects_config); 22122} 22123 22124</script>
22124 22125 22126<style> 22127 .no-objects-icon{ 22128 font-size: 18px; 22129 padding: 15px; 22130 background-color: rgba(230, 232, 237, 0.667); 22131 border-radius: 50%; 22132 color: #6a7482; 22133 height: 20px; 22134 width: 20px; 22135 text-align: center; 22136 } 22137 .no-objects-subtext{ 22138 font-size: 16px; 22139 padding: 2px; 22140 margin-top: 5px; 22141 color: #9ca4b7; 22142 text-align: center; 22143 } 22144</style> 22145 22146 22147 22148
22148<script type="text/html" id="template-icon-button"> 22149 <button 22150 class="card-header-button-v3 easeFast flexNoWrap align-items-center relative icon-button centeredText flexGrowOne" 22151 :class="[ 22152 {'unclickable': disabled || loading}, 22153 {'full-width': full_width}, 22154 {'warning': warning}, 22155 {'text-hides-on-mobile': hide_text_on_mobile} 22156 ]" 22157 :disabled="disabled || loading" 22158 @click="tapped_button()" 22159 style="width: auto; max-width: fit-content;" 22160 :style="height ? {'height': height + 'px', 'line-height': height + 'px', 'padding': '0 8px'} : {}" 22161 :style="no_margin_right ? {'margin-right': '0'} : {}" 22162 > 22163 <i 22164 v-if="corrected_icon_position == 'left' && ! image_url" 22165 :class="icon_class" 22166 :style="{ opacity: loading ? 0 : 1 }" 22167 ></i> 22168 <div 22169 v-else-if="corrected_icon_position == 'left' && image_url" 22170 class="icon-button-avatar" 22171 :style="{ backgroundImage: 'url(' + image_url + ')', opacity: loading ? 0 : 1 }" 22172 ></div> 22173 <span :style="{ opacity: loading ? 0 : 1 }" :class="{ 'hideOnMobile': hide_text_on_mobile }">{{ label }}</span> 22174 <i 22175 v-if="corrected_icon_position == 'right' && ! image_url" 22176 :class="icon_class" 22177 style="padding-left: 8px;" 22178 :style="{ opacity: loading ? 0 : 1 }" 22179 ></i> 22180 <div 22181 v-else-if="corrected_icon_position == 'right' && image_url" 22182 class="icon-button-avatar icon-button-avatar-right" 22183 :style="{ backgroundImage: 'url(' + image_url + ')', opacity: loading ? 0 : 1 }" 22184 ></div> 22185 <div v-if="loading" class="icon-button-spinner-wrap"> 22186 <div class="card-header-button-v3-spinner easeFast"></div> 22187 </div> 22188 <a v-if="href" :href="href" :target="target" class="absoluteA"></a> 22189 </button> 22190</script>
vendor: 1 bytes, line 22190
22190
22191<script> 22192 var config = { 22193 template: "#template-icon-button", 22194 props: { 22195 disabled: { 22196 type: Boolean, 22197 default: false, 22198 }, 22199 label: { 22200 type: String, 22201 default: 'Done', 22202 }, 22203 icon_class: String, 22204 image_url: { 22205 type: String, 22206 default: null, 22207 }, 22208 icon_position: { 22209 type: String, 22210 default: 'left', 22211 }, 22212 loading: { 22213 type: Boolean, 22214 default: false, 22215 }, 22216 href: { 22217 type: String, 22218 default: null, 22219 }, 22220 target: { 22221 type: String, 22222 default: '', 22223 }, 22224 full_width: { 22225 type: Boolean, 22226 default: false, 22227 }, 22228 warning: { 22229 type: Boolean, 22230 default: false, 22231 }, 22232 no_margin_right: { 22233 type: Boolean, 22234 default: false, 22235 }, 22236 hide_text_on_mobile: { 22237 type: Boolean, 22238 default: false, 22239 } 22240 }, 22241 methods: { 22242 tapped_button() { 22243 if (this.loading || this.disabled) return; 22244 this.$emit('clicked'); 22245 }, 22246 }, 22247 computed: { 22248 corrected_icon_position(){ 22249 return this.icon_position || 'left'; 22250 }, 22251 }, 22252 } 22253 app_content.component("icon-button", config); 22254 if (typeof app_overlays !== "undefined") { 22255 app_overlays.component("icon-button", config); 22256 } 22257 22258</script>
22258 22259 22260<style> 22261 .icon-button.full-width{ 22262 width: 100% !important; 22263 max-width: unset !important; 22264 justify-content: center !important; 22265 text-align: center !important; 22266 line-height: 30px; 22267 font-size: 15px; 22268 } 22269 .icon-button.taller { 22270 padding-top: 12px; 22271 padding-bottom: 12px; 22272 } 22273 .icon-button.warning { 22274 color: #9b1818 !important; 22275 } 22276 .icon-button-spinner-wrap { 22277 position: absolute; 22278 left: 0; 22279 right: 0; 22280 top: 0; 22281 bottom: 0; 22282 display: flex; 22283 justify-content: center; 22284 align-items: center; 22285 } 22286 .icon-button-avatar { 22287 width: 18px; 22288 height: 18px; 22289 border-radius: 50%; 22290 background-size: cover; 22291 background-position: center; 22292 background-repeat: no-repeat; 22293 flex-shrink: 0; 22294 margin-right: 4px; 22295 } 22296 .icon-button-avatar-right { 22297 margin-right: 0; 22298 margin-left: 4px; 22299 } 22300</style> 22301 <!-- new calendar --> 22302 <template id="template-booking-calendar"> 22303 <div 22304 class="centeredOnMobile smallMarginTop" 22305 :class="{'standardCard ninetyPercent': calendar_type !== 'web_component'}" 22306 > 22307 <div 22308 class="standardCardBody noBorderTop calendar noPadding fff native-tab-bar-padding-bottom" 22309 > 22310 <div class="flexNoWrapLeftAlign hundop" style="display: flex; width: 100%;" :style="[{ height: height || '100vh' }]"> 22311 <div 22312 style="flex: 1; min-width: 0;" 22313 :class="{ scrollable: calendar_type !== 'web_component' }" 22314 > 22315 <div 22316 class="cs-calendar-wrap noBorder" 22317 :style="generateWrapStyle('cs-calendar-wrap')" 22318 > 22319 <!-- NAVIGATION MENU --> 22320 <div 22321 class="cs-calendar-menu-wrap flexNoWrap lightBorderBottom" 22322 :style="generateWrapStyle('cs-calendar-menu-wrap')" 22323 > 22324 <div class="flexWrapLeftAlign flexGrowOne smallMarginRight align-items-center"> 22325 <div class="flexNoWrap"> 22326 <div class="card-header-button-v3 roboto hideOnMobile" data-option="today" @click="handleButtonClick('today')">Today</div> 22327 <div class="card-header-button-v3 cs-calendar-button-previous roboto" data-option="previous" @click="handleButtonClick('previous')"></div> 22328 <div class="card-header-button-v3 cs-calendar-button-next roboto" data-option="next" @click="handleButtonClick('next')"></div> 22329 </div> 22330 <p 22331 v-if="calendarTitle" 22332 class="cs-calendar-date-range-title roboto textLeft noMarginLeft hideOnMobile" 22333 >{{ calendarTitle }}</p> 22334 <p 22335 v-if="calendarTitle_noYear" 22336 class="cs-calendar-date-range-title roboto textLeft noMarginLeft showOnMobile" 22337 >{{ calendarTitle_noYear }}</p> 22338 <div class="flexGrowOne hideOnMobile" style="max-width: fit-content;"> 22339 <refresh-component 22340 @refresh-table="refresh_calendar" 22341 style="margin-left: 2px;" 22342 ></refresh-component> 22343 </div> 22344 </div> 22345 22346 <!-- nav butttons --> 22347 <div class="flexWrapLeftAlign align-items-center"> 22348 <div class="flexGrowOne"></div> 22349 <div class="flexWrap align-items-center calendar-menu-v2"> 22350 <calendar-date-picker-component 22351 :handle_button_click="handleButtonClick" 22352 ></calendar-date-picker-component> 22353 </div> 22354 22355 <div 22356 class="hideOnMobile card-header-button-v3 pointer noWrap centeredText relative" 22357 > 22358 {{ view_preference }} 22359 <i class="fa-light fa-chevron-down" style="padding-left: 3px;"></i> 22360 <select 22361 v-model="v_model_view_preference" 22362 @change="changeViewPreference" 22363 class="pointer" 22364 style="position:absolute;
22364left:0px;top:0px;width:100%;height:100%;opacity:0;" 22365 > 22366 <option value="Month">Month</option> 22367 <option value="Week">Week</option> 22368 <option value="Day">Day</option> 22369 </select> 22370 </div> 22371 <div 22372 v-if="!show_calendar_tags && !disable_filtering" 22373 class="flexWrap align-items-center calendar-menu-v2 tinyMarginTop" 22374 > 22375 <h3 22376 class="calendar-list menu-icon" 22377 > 22378 <i 22379 class="fa-regular fa-bars pointer" 22380 @click="show_calendar_tags = !show_calendar_tags" 22381 ></i> 22382 </h3> 22383 </div> 22384 </div> 22385 22386 </div> 22387 22388 <div 22389 class="relative" 22390 > 22391 <!-- Progress Bar --> 22392 <div v-if="loading_parent.calendar || loading_parent.ui_refresh || loading_parent.calendar_tags || loading_parent.filter" class="progress-bar"></div> 22393 </div> 22394 22395 <!-- CALENDAR VIEWS --> 22396 22397 <!-- month view --> 22398 <template v-if="view_preference == 'Month'"> 22399 <!-- days of week header --> 22400 <div 22401 class="daysOfWeek-grid relative" 22402 > 22403 <div v-for="(day, index) in daysOfWeek" :key="index" class="grid-cell dayOfWeek noBorderTop"> 22404 {{ day }} 22405 </div> 22406 </div> 22407 22408 <!-- calendar grid --> 22409 <div 22410 class="calendar-grid month-view" 22411 :style="{ '--calendar-row-height': calendarRowHeight() }" 22412 > 22413 <template v-for="(day, index) in days_array" :key="index"> 22414 <booking-calendar-cell 22415 :date="day.date" 22416 :day="day.day" 22417 :date_components="day.date_components" 22418 :day_of_week="day.day_of_week" 22419 :events="day.events" 22420 :slots="day.slots" 22421 :timezone="timezone" 22422 :days_array="days_array" 22423 :is_current_month="day.date.month() === current_date.month()" 22424 @clicked_event="clicked_event" 22425 @clicked_show_more_events="clicked_show_more_events" 22426 @add_event="add_event" 22427 @zoom_in_to_date="zoom_in_to_date" 22428 /> 22429 </template> 22430 </div> 22431 22432 </template> 22433 22434 <!-- week view --> 22435 <template v-else-if="view_preference === 'Week'" > 22436 22437 <!-- multi/all day events section --> 22438 <div class="flexNoWrap"> 22439 <div style="width:60px;" class="flexWrap align-items-center"> 22440 <div class="time-label">All day</div> 22441 </div> 22442 <div class="flexGrowOne"> 22443 <!-- days of week header --> 22444 <div 22445 class="daysOfWeek-grid relative" 22446 > 22447 <div v-for="(day, index) in daysOfWeek" :key="index" class="grid-cell dayOfWeek noBorderTop"> 22448 {{ day }} 22449 <br>
22450 <span class="day-date-label">{{ current_date.clone().startOf("week").add(index, "day").date() }}</span> 22451 </div> 22452 </div> 22453 <div 22454 class="all-day-week-wrap" 22455 > 22456 <div v-for="(day, index) in days_array" :key="index" class="grid-cell noBorderTop"> 22457 <div class="events"> 22458 <!-- handle all day events --> 22459 <!-- render content for each slot --> 22460 <template 22461 v-for="(slot, slot_index) in day.slots" 22462 :key="slot_index" 22463 > 22464 <!-- ok, we have an event for this slot--> 22465 <template v-if="slot.event"> 22466 <div 22467 class="event" 22468 :class="[{'all_day': slot.event.isAllDay}, {'partial-day': ! slot.event.isAllDay}]" 22469 @click.stop="clicked_event( slot.event )" 22470 :style="return_multi_day_event_style( slot.event, day.date )" 22471 style="position:relative;z-index:1;" 22472 > 22473 <div 22474 class="event-title-v2 flexNoWrap align-items-center" 22475 style="font-size:12.5px;" 22476 > 22477 <!-- event title --> 22478 {{ slot.event.title }} 22479 </div> 22480 </div> 22481 </template> 22482 <template v-else-if="should_hold_slot(slot, slot_index, day)"> 22483 <div 22484 class="event" 22485 > 22486 <!-- add a placeholder if there's an event in this slot but it doesn't start on this day --> 22487 <div 22488 class="event-title-v2 flexNoWrap align-items-center easeSuperFast" 22489 style="font-size:12.5px; visibility: hidden;" 22490 > </div><!-- adds a hidden, non-breaking space --> 22491 </div> 22492 </template> 22493 22494 <!-- no event for this slot --> 22495 <template v-else> 22496 <div 22497 class="event partial-day" 22498 ></div> 22499 </template> 22500 22501 </template> 22502 22503 </div> 22504 </div> 22505 </div> 22506 </div> 22507 </div> 22508 22509 <!-- main content --> 22510 <div 22511 class="flexNoWrap" 22512 > 22513 <!-- times --> 22514 <div 22515 class="time-label-wrap" 22516 style="width: 60px;" 22517 > 22518 <div v-for="hour in 24" :key="hour" class="time-slot"> 22519 <div 22520 class="time-label" 22521 >{{ formatHour( hour - 1 ) }}</div> 22522 </div> 22523 </div> 22524 22525 <!-- main content --> 22526 <div class="flexGrowOne"> 22527 22528 <!-- day columns --> 22529 <div class="flexNoWrap"> 22530 <template 22531 v-for="(day, index) in days_array" 22532 :key="index" 22533 > 22534 <booking-calendar-day-column 22535 :events="day.events" 22536 :date_components="day.date_components" 22537 :timezone="timezone" 22538 :view_preference="view_preference" 22539 @clicked_event="clicked_event" 22540 @add_event="add_event" 22541 ></booking-calendar-day-column> 22542 </template> 22543 </div> 22544 22545 </div> 22546 </div> 22547 </template> 22548 22549 <!-- day view --> 22550 <template v-else-if="view_preference === 'Day'"> 22551 <div class="showOnMobile"> 22552 <icon-button 22553 :label="'Back to month'" 22554 icon_class="fa-regular fa-chevron-left" 22555 @clicked="back_to_month" 22556 :style="[{ marginLeft: '8px' }, calendar_type !== 'web_component' ? { maxHeight: '28px' } : {}]" 22557 ></icon-button> 22558 </div> 22559 <div 22560 class="flexNoWrap tinyPaddingTop" 22561 > 22562 <booking-calendar-day-column 22563 :events="days_array[0].events" 22564 :date_components="days_array[0].date_components" 22565 :view_preference="view_preference" 22566 :timezone="timezone" 22567 @clicked_event="clicked_event" 22568 @add_event="add_event" 22569 /> 22570 </div> 22571 </template> 22572 </div> 22573 </div> 22574 <!-- start of new component --> 22575 <booking-calendar-filters 22576 v-if="!disable_filtering"
22577 :show_calendar_tags="show_calendar_tags" 22578 :default_calendar_tags="default_calendar_tags" 22579 :default_calendar_tags_secondary="default_calendar_tags_secondary" 22580 :show_catch_all_custom_tags_option="show_catch_all_custom_tags_option" 22581 :catch_all_custom_tag_selected="catch_all_custom_tag_selected" 22582 :ui_catch_all_custom_tag_selected="ui_catch_all_custom_tag_selected" 22583 :ui_tag_ids_default_secondary="ui_tag_ids_default_secondary" 22584 :view_preference="view_preference" 22585 :calendar_tags="calendar_tags" 22586 :selected_tags="selected_tags" 22587 :ui_tag_ids_default="ui_tag_ids_default" 22588 :ui_tag_ids_custom="ui_tag_ids_custom" 22589 :collapsible_menus_open="collapsible_menus_open" 22590 :loading_parent="loading_parent" 22591 :calendar_type="calendar_type" 22592 :view_only="view_only" 22593 @clicked_default_tag="clicked_default_tag" 22594 @clicked_default_tag_secondary="clicked_default_tag_secondary" 22595 @clicked_calendar_tag="clicked_calendar_tag" 22596 @clicked_catch_all_custom_tag="clicked_catch_all_custom_tag" 22597 @clicked_add_calendar_tag="clicked_add_calendar_tag" 22598 @clicked_remove_tag="clicked_remove_tag" 22599 @isolate_default_tag="isolate_default_tag" 22600 @isolate_default_tag_secondary="isolate_default_tag_secondary" 22601 @select_all_default_tags="select_all_default_tags" 22602 @select_all_default_tags_secondary="select_all_default_tags_secondary" 22603 @select_visible_default_tags_secondary="select_visible_default_tags_secondary" 22604 @clear_all_default_tags="clear_all_default_tags" 22605 @clear_all_default_tags_secondary="clear_all_default_tags_secondary" 22606 @clear_visible_default_tags_secondary="clear_visible_default_tags_secondary" 22607 @select_visible_default_tags="select_visible_default_tags" 22608 @clear_visible_default_tags="clear_visible_default_tags" 22609 @isolate_calendar_tag="isolate_calendar_tag" 22610 @isolate_catch_all_custom="isolate_catch_all_custom" 22611 @select_all_custom_tags="select_all_custom_tags" 22612 @clear_all_custom_tags="clear_all_custom_tags" 22613 @select_visible_custom_tags="select_visible_custom_tags" 22614 @clear_visible_custom_tags="clear_visible_custom_tags" 22615 @add_event="add_event" 22616 @change_view_preference="changeViewPreference" 22617 @change_show_calendar_tags="change_show_calendar_tags" 22618 @close="show_calendar_tags = false" 22619 ></booking-calendar-filters> 22620 </div> 22621 </div> 22622 </div> 22623 </template> 22624 22625 <style scoped> 22626 .cs-calendar-menu-wrap{ 22627 position: sticky; 22628 top: 0px; 22629 background-color: #fff; 22630 z-index: 3; 22631 } 22632 .calendar-list.menu-icon{ 22633 min-width: 38px !important; 22634 max-width: 38px !important; 22635 width: 38px !important; 22636 min-height: 38px !important; 22637 max-height: 38px !important; 22638 height: 38px !important; 22639 padding: 0; 22640 display: flex; 22641 justify-content: center; 22642 align-items: center; 22643 cursor: pointer; 22644 } 22645 22646 .day-date-label{ 22647 font-size: 20px; 22648 color: #081333; 22649 } 22650 22651 .cs-calendar-date-range-title { 22652 font-size: 18px !important; 22653 } 22654 22655 .calendar-menu-v2 .cs-calendar-date-picker-input{ 22656 border-radius: 30px; 22657 overflow: hidden !important; 22658 padding-bottom: 8px; 22659 padding-top: 8px; 22660 } 22661 22662 .cs-calendar-button-next, 22663 .cs-calendar-button-previous{ 22664 min-width: 38px !important; 22665 max-width: 38px !important; 22666 width: 38px !important; 22667 min-height: 38px !important; 22668 max-height: 38px !important; 22669 height: 38px !important; 22670 } 22671
22672 .daysOfWeek-grid{ 22673 grid-auto-rows: initial; 22674 } 22675 22676 .all-day-week-wrap{ 22677 display: grid; 22678 grid-template-columns: repeat(7, minmax(10%, 1fr)); 22679 border-bottom: 1px solid #e7ecf0; 22680 } 22681 22682 .progress-bar { 22683 position: absolute; 22684 top: 0; 22685 left: 0; 22686 height: 2px; /* Thin progress bar */ 22687 background-color: #5774f5; /* Primary color */ 22688 z-index: 10; /* Ensure it appears above the calendar */ 22689 animation: progress-bar-animation 0.5s linear infinite; /* Animation */ 22690 } 22691 22692 /* Animation Keyframes */ 22693 @keyframes progress-bar-animation { 22694 0% { 22695 left: -100%; /* Start outside of the viewport */ 22696 width: 30%; /* Start with a small bar */ 22697 } 22698 50% { 22699 left: 25%; /* Move towards the center */ 22700 width: 50%; /* Expand the bar */ 22701 } 22702 100% { 22703 left: 100%; /* End outside of the viewport */ 22704 width: 30%; /* Shrink the bar */ 22705 } 22706 } 22707 22708 .calendar-grid.month-view{ 22709 grid-auto-rows: var(--calendar-row-height, 130px); 22710 } 22711 22712 .grid-cell.dayOfWeek{ 22713 border-radius: 5px; 22714 } 22715 22716 .time-content { 22717 position: relative; 22718 height: 100%; /* Full height of the hour */ 22719 flex-grow: 1; 22720 border-left: 1px solid #e7ecf0; 22721 } 22722 22723 /* Day Header */ 22724 .day-header { 22725 background-color: white; 22726 padding: 16px; 22727 border-bottom: 1px solid #e7ecf0; 22728 } 22729 22730 .day-title { 22731 margin: 0; 22732 font-size: 24px; 22733 font-weight: bold; 22734 color: #202124; 22735 } 22736 22737 /* All Day Section */ 22738 .all-day-section { 22739 padding: 12px; 22740 border-bottom: 1px solid #e7ecf0; 22741 width: 100%; 22742 } 22743 22744 .all-day-section h3 { 22745 font-size: 16px; 22746 font-weight: bold; 22747 color: #5f6368; 22748 margin-bottom: 8px; 22749 } 22750 22751 .all-day-events { 22752 display: flex; 22753 flex-wrap: wrap; 22754 gap: 8px; 22755 padding-left: 12px; 22756 padding-right: 12px; 22757 } 22758 22759 .no-events { 22760 color: #9e9e9e; 22761 font-size: 12px; 22762 } 22763 22764 /* Time Slots */ 22765 .time-slots { 22766 flex-grow: 1; 22767 overflow-y: auto; 22768 background-color: white; 22769 width:100%; 22770 } 22771 22772 .time-slot { 22773 display: flex; 22774 border-bottom: 1px solid #e7ecf0; 22775 height: 60px; /* One slot per hour */ 22776 } 22777 22778 .time-label { 22779 width: 60px; 22780 text-align: center; 22781 font-size: 12px; 22782 padding-top: 8px; 22783 color: #5f6368; 22784 } 22785 22786 </style> 22787 22788
22788<script> 22789 app_content.component("calendar-booking-component", { 22790 template: "#template-booking-calendar", 22791 data() { 22792 return { 22793 show_calendar_tags: true, 22794 v_model_view_preference: this.view_preference, 22795 daysOfWeek: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], 22796 loading: { 22797 calendar_tags: true, 22798 }, 22799 catch_all_custom_tag_selected: true, 22800 ui_catch_all_custom_tag_selected: true, 22801 collapsible_menus_open: { 22802 default_primary: true, 22803 default_secondary: true, 22804 custom: true, 22805 }, 22806 }; 22807 }, 22808 mounted(){ 22809 this.check_if_mobile(); 22810 }, 22811 props: { 22812 filter_logic: { 22813 type: String, 22814 default: "or" //and vs. or 22815 }, 22816 height: String, //optional 22817 timezone: { 22818 type: String, 22819 default: moment.tz.guess(), 22820 }, 22821 loading_parent: Object, //object with several loading attributes 22822 view_preference: String, 22823 current_date: Date, 22824 events: Array, 22825 is_membership_cal: Boolean, 22826 calendar_type: String, 22827 default_calendar_tags: Object, 22828 default_calendar_tags_secondary: Object, 22829 calendar_tags: Array, 22830 selected_tags: Object, 22831 ui_tag_ids_default: Object, 22832 ui_tag_ids_default_secondary: Object, 22833 ui_tag_ids_custom: Array, 22834 is_admin: Boolean, 22835 view_only: { 22836 type: Boolean, 22837 default: false 22838 }, 22839 disable_filtering: { 22840 type: Boolean, 22841 default: false 22842 } 22843 }, 22844 methods: { 22845 back_to_month(){ 22846 this.$emit("change_view_preference", "Month"); 22847 }, 22848 check_if_mobile(){ 22849 if(this.disable_filtering){ 22850 this.show_calendar_tags = false; 22851 return; 22852 } 22853 var is_mobile = window.innerWidth < 720; 22854 if(is_mobile || this.calendar_type === 'web_component'){ 22855 this.show_calendar_tags = false; 22856 }else{ 22857 this.show_calendar_tags = true; 22858 } 22859 }, 22860 clicked_default_tag(tag) { 22861 this.$emit('clicked_default_tag', tag); 22862 }, 22863 clicked_default_tag_secondary(tag) { 22864 this.$emit('clicked_default_tag_secondary', tag); 22865 }, 22866 isolate_default_tag_secondary(tag) { 22867 this.$emit('isolate_default_tag_secondary', tag); 22868 }, 22869 select_all_default_tags_secondary() { 22870 this.$emit('select_all_default_tags_secondary'); 22871 }, 22872 select_visible_default_tags_secondary(visibleIds) { 22873 this.$emit('select_visible_default_tags_secondary', visibleIds); 22874 }, 22875 clear_all_default_tags_secondary() { 22876 this.$emit('clear_all_default_tags_secondary'); 22877 }, 22878 clear_visible_default_tags_secondary(visibleIds) { 22879 this.$emit('clear_visible_default_tags_secondary', visibleIds); 22880 }, 22881 clicked_calendar_tag(tag) { 22882 this.$emit('clicked_calendar_tag', tag); 22883 }, 22884 clicked_catch_all_custom_tag(){ 22885 this.ui_catch_all_custom_tag_selected = !this.ui_catch_all_custom_tag_selected; 22886 requestAnimationFrame(() => { 22887 // second RAF helps ensure weâre past layout/paint on Chrome/Safari 22888 requestAnimationFrame(() => { 22889 this.catch_all_custom_tag_selected = !this.catch_all_custom_tag_selected; 22890 }); 22891 }); 22892 }, 22893 remaining_days_in_event(event, date) { 22894 const cellDate = moment.tz(date, this.timezone).startOf("day"); 22895 const eventEnd = moment.tz(event.end_ts, this.timezone).startOf("day"); 22896 if (cellDate.isAfter(eventEnd)) { 22897 //already ended 22898 return 0; 22899 } 22900 // Calculate the remaining days, including the current day 22901 return eventEnd.diff(cellDate, "days") + 1; 22902 }, 22903 remaining_visible_days_in_event(event, date) { 22904 const cellDate = moment.tz(date, this.timezone).startOf("day"); 22905 const eventEnd = moment.tz(event.end_ts, this.timezone).endOf("day"); 22906 const eventStart = moment.tz(event.ts, this.timezone).startOf("day"); 22907 const startOfWeek = this.current_date.clone().startOf("week"); 22908 const endOfWeek = this.current_date.clone().endOf("week"); 22909 22910 // Determine the latest starting point between cellDate and eventStart 22911 const visibleStart = moment.max(cellDate, eventStart, startOfWeek); 22912 22913 // Determine the earliest end point between eventEnd and endOfWeek 22914 const visibleEnd = moment.min(eventEnd, endOfWeek); 22915 22916 // If the visible start is after the visible end, the event isn't visible 22917 if (visibleStart.isAfter(visibleEnd)) { 22918 return 0; 22919 } 22920 22921 // Calculate number of visible days the event should span, inclusive 22922 return visibleEnd.diff(visibleStart, "days") + 1; 22923 }, 22924 return_multi_day_event_style( event, date ){ 22925 if( ! this.is_multi_day_event( event ) ){ 22926 return { 22927 "background": event.bgColors && event.bgColors.length > 0 22928 ? `repeating-linear-gradient( 22929 -45deg, 22930 ${event.bgColors.map((color, i) => `${color} ${i * 20}px, ${color} ${(i + 1) * 20}px`).join(",")} 22931 )` 22932 : event.bgColor || "", 22933 }; 22934 } 22935 let day_of_week = moment.tz(date, this.timezone).day(); 22936 let days_until_end_of_week = 7 - day_of_week; 22937 let number_of_days = this.remaining_visible_days_in_event(event, date); 22938 let is_first_day = this.is_first_day_of_event( event, date ); 22939 if( is_first_day ){ 22940 //figure out how many days it spans 22941 let days_to_span = number_of_days; 22942 if( days_to_span > days_until_end_of_week ){ 22943 days_to_span = days_until_end_of_week; 22944 } 22945 return {
22946 "width": `calc(${days_to_span * 100}% - 4px)`, 22947 "zIndex": 1, 22948 "background": event.bgColors && event.bgColors.length > 0 22949 ? `repeating-linear-gradient( 22950 -45deg, 22951 ${event.bgColors.map((color, i) => `${color} ${i * 20}px, ${color} ${(i + 1) * 20}px`).join(",")} 22952 )` 22953 : event.bgColor || "", 22954 } 22955 }else if( day_of_week == 0 ){ 22956 //not the first day of the event 22957 //.. but see it's the first day of a new week 22958 //... figure out which day of the event this is, and how many days are left (including this day) 22959 let remaining_days_in_event = this.remaining_days_in_event( event, date ); 22960 let days_to_span = remaining_days_in_event; 22961 if( days_to_span > days_until_end_of_week ){ 22962 days_to_span = days_until_end_of_week; 22963 } 22964 return { 22965 "width": `calc(${days_to_span * 100}% - 4px)`, 22966 "zIndex": 1, 22967 "background": event.bgColors && event.bgColors.length > 0 22968 ? `repeating-linear-gradient( 22969 -45deg, 22970 ${event.bgColors.map((color, i) => `${color} ${i * 20}px, ${color} ${(i + 1) * 20}px`).join(",")} 22971 )` 22972 : event.bgColor || "", 22973 } 22974 }else{ 22975 return { 22976 "visibility":"hidden" 22977 } 22978 } 22979 }, 22980 zoom_in_to_date( date ){ 22981 this.$emit("zoom_in_to_date", date); 22982 }, 22983 clicked_event( event ){ 22984 this.$emit('clicked_event', event); 22985 }, 22986 clicked_show_more_events( date, day_of_week, events ){ 22987 this.$emit('clicked_show_more_events', date, day_of_week, events); 22988 }, 22989 add_event( date_components ){ 22990 this.$emit('add_event', date_components); 22991 }, 22992 formatHour(hour) { 22993 const baseDate = moment().startOf('day').hour(hour); 22994 return baseDate.format("h:mm A"); 22995 }, 22996 changeViewPreference( event ){ 22997 this.$emit("change_view_preference", event.target.value); 22998 }, 22999 return_number_of_days_for_event( event ){ 23000 let start = moment.tz(event.ts, this.timezone); 23001 let end = moment.tz(event.end_ts, this.timezone); 23002 //continue 23003 return end.diff(start, 'days') + 1; 23004 }, 23005 is_first_day_of_event( event, date ) { 23006 // Compare the event start date with the cell's date 23007 const eventStart = moment.tz(event.ts, this.timezone).startOf("day"); 23008 const cellDate = moment.tz(date, this.timezone).startOf("day"); 23009 return eventStart.isSame(cellDate, "day"); 23010 }, 23011 sort_events_for_day(events){ 23012 //multi day and all day events first, then by start time 23013 return events.sort((a, b) => { 23014 const aAllDay = a.isAllDay; 23015 const bAllDay = b.isAllDay; 23016 23017 const aMulti = this.is_multi_day_event(a); 23018 const bMulti = this.is_multi_day_event(b); 23019 23020 // Sort all-day events first 23021 if (aAllDay && !bAllDay) return -1; 23022 if (!aAllDay && bAllDay) return 1; 23023 23024 // Then multi-day events 23025 if (aMulti && !bMulti) return -1; 23026 if (!aMulti && bMulti) return 1; 23027 23028 // Otherwise, by start time 23029 return moment.tz(a.ts, this.timezone).isBefore(moment.tz(b.ts, this.timezone)) ? -1 : 1; 23030 }); 23031 }, 23032 is_multi_day_event( event ){ 23033 return moment.tz(event.ts, this.timezone).isBefore(moment.tz(event.end_ts, this.timezone), 'day'); 23034 }, 23035 handleButtonClick(option, new_date) { 23036 if (option === "today") { 23037 //vReplace with a new moment object 23038 var new_date = moment.tz( this.timezone ).startOf("day"); 23039 } else if (option === "next") { 23040 // Add time and replace with a new object 23041 var new_date = moment(this.current_date).add(1, this.view_preference.toLowerCase()); 23042 } else if (option === "previous") { 23043 // Subtract time and replace with a new object 23044 var new_date = moment(this.current_date).subtract(1, this.view_preference.toLowerCase()); 23045 }else if( option === 'date-picker' ){ 23046 var new_date = moment.tz( new_date, this.timezone ).startOf("day"); 23047 } 23048 if( new_date ){ 23049 this.$emit("change_date", new_date); 23050 } 23051 }, 23052 generateWrapStyle( type ){ 23053 if (type === 'cs-calendar-wrap'){ 23054 return {'width': '100%'} 23055 } else if (type === 'options' && this.is_membership_cal){ 23056 return { 23057 'padding-top': '15px', 23058 'padding-right': '15px', 23059 'padding-left': '15px', 23060 'padding-bottom': '5px', 23061 'border-width': '0 1.5px 0 1.5px', 23062 'border-color': '#e2e8e8', 23063 'border-style': 'solid', 23064 'overflow': 'auto', 23065 } 23066 } else if (type === 'cs-calendar-menu-wrap' && this.is_membership_cal){ 23067 return { 23068 'border-width': '0 1.5px 0 1.5px', 23069 'border-color': '#e2e8e8', 23070 'border-style': 'solid' 23071 } 23072 } 23073 }, 23074 // tapped_add_to_cal(){ 23075 // var params = {}; 23076 // params["title"] = "Create calendar item"; 23077 // params["hide_on_selection"] = true; 23078 // params["retrieve_sections_fn"] = this.return_sections_for_handle_common_grid; 23079 // handle_common_grid( params ); 23080 // }, 23081 // return_sections_for_handle_common_grid(){ 23082 // var sections = []; 23083 // var options = [ 23084 // { 23085 // text: "Event", 23086 // fn: this.add_event, 23087 // icon_class: "fa-regular fa-calendar-plus", 23088 // }, 23089 // { 23090 // text: "Calendar tag", 23091 // fn: this.clicked_add_calendar_tag, 23092 // icon_class: "fa-regular fa-tags", 23093 // }, 23094 // ] 23095 // sections.push({ 23096 // options: options 23097 // }); 23098 // return sections; 23099 // }, 23100 clicked_add_calendar_tag(){ 23101 store.components.tag_event.type_text = "this calendar"; 23102 store.components.tag_event.handle_selected_event_tag = this.handle_selected_event_tag; 23103 store.components.tag_event.event_tag_input = ''; 23104 store.components.tag_event.event_source = this.calendar_type; 23105 store.overlays.tag_event = true; 23106 }, 23107 handle_selected_event_tag(tag_data, event_tags){ 23108 //emit 23109 this.$emit('added_calendar_tag', tag_data, event_tags); 23110 },
23111 refresh_calendar(){ 23112 this.$emit('refresh_calendar'); 23113 }, 23114 clicked_remove_tag(tag){ 23115 this.$emit('remove_calendar_tag', tag); 23116 }, 23117 isolate_calendar_tag(tag){ 23118 this.ui_catch_all_custom_tag_selected = false; 23119 this.$emit('isolate_calendar_tag', tag); 23120 }, 23121 isolate_default_tag(tag){ 23122 this.$emit('isolate_default_tag', tag); 23123 }, 23124 select_all_custom_tags(){ 23125 this.ui_catch_all_custom_tag_selected = true; 23126 this.$emit('select_all_custom_tags'); 23127 }, 23128 select_all_default_tags(){ 23129 this.$emit('select_all_default_tags'); 23130 }, 23131 clear_all_default_tags(){ 23132 this.$emit('clear_all_default_tags'); 23133 }, 23134 select_visible_default_tags(visibleIds){ 23135 this.$emit('select_visible_default_tags', visibleIds); 23136 }, 23137 clear_visible_default_tags(visibleIds){ 23138 this.$emit('clear_visible_default_tags', visibleIds); 23139 }, 23140 clear_all_custom_tags(){ 23141 this.$emit('clear_all_custom_tags'); 23142 }, 23143 select_visible_custom_tags(visibleIds){ 23144 this.$emit('select_visible_custom_tags', visibleIds); 23145 }, 23146 clear_visible_custom_tags(visibleIds){ 23147 this.$emit('clear_visible_custom_tags', visibleIds); 23148 }, 23149 isolate_catch_all_custom(){ 23150 this.$emit('unselect_all_custom_tags'); 23151 this.ui_catch_all_custom_tag_selected = true; 23152 }, 23153 calendarRowHeight() { 23154 let maxSlots = 0; 23155 this.days_array.forEach((d) => { 23156 var used_slots = 0; 23157 if (Array.isArray(d.slots)) { 23158 used_slots = d.slots.filter(slot => slot.event).length; 23159 } 23160 maxSlots = Math.max(maxSlots, used_slots); 23161 }); 23162 const baseHeight = 100; // Base height for the calendar row 23163 var slotHeight = 10; // Height for each slot 23164 if(maxSlots >= 6){ 23165 slotHeight = 20; //extra height for more slots 23166 } 23167 return `${baseHeight + (maxSlots * slotHeight)}px`; 23168 }, 23169 should_hold_slot(slot, index, day) { 23170 var date = day.date; 23171 const usedSlots = this.return_slots_in_use_for_week(slot, index, date); 23172 return usedSlots.includes(index); 23173 }, 23174 return_slots_in_use_for_week(slot, index, date) { 23175 const startOfWeek = moment.tz(date, this.timezone).startOf("week"); 23176 const endOfWeek = moment.tz(date, this.timezone).endOf("week"); 23177 23178 const slotsUsed = new Set(); 23179 23180 this.days_array.forEach(day => { 23181 const dayMoment = moment.tz(day.date, this.timezone).startOf("day"); 23182 23183 // Only consider days in the current week 23184 if (dayMoment.isBetween(startOfWeek, endOfWeek, null, '[]')) { 23185 if (Array.isArray(day.slots)) { 23186 day.slots.forEach(slotObj => { 23187 if (slotObj.event) { 23188 slotsUsed.add(slotObj.slot); 23189 } 23190 }); 23191 } 23192 } 23193 }); 23194 23195 return Array.from(slotsUsed); 23196 }, 23197 change_show_calendar_tags( ){ 23198 this.show_calendar_tags = !this.show_calendar_tags; 23199 }, 23200 // default_tag_selected(tag) { 23201 // return this.ui_tag_ids_default && this.ui_tag_ids_default.includes(tag.id); 23202 // }, 23203 }, 23204 computed: { 23205 show_catch_all_custom_tags_option() { 23206 //if we have AND logic, we want to show an option that's a catch-all for all custom tags 23207 return this.filter_logic == 'and'; 23208 }, 23209 selected_tags_set() { 23210 let selected_tags_set = new Set( this.selected_tags.custom ); 23211 return selected_tags_set; 23212 }, 23213 selected_filter_set() { 23214 let selected_filter_set = new Set( this.selected_tags.default ); 23215 return selected_filter_set; 23216 }, 23217 selected_filter_set_secondary() { 23218 return new Set( this.selected_tags.default_secondary || [] ); 23219 }, 23220 dates_for_days_array(){ 23221 //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 23222 //create an object with the following structure for every day in the given date range 23223 /** 23224 { 23225 "date": "2025-07-27T07:00:00.000Z", 23226 "date_components": { 23227 "year": 2025, 23228 "month": 6, 23229 "day": 27 23230 }, 23231 "day": 27, 23232 "day_of_week": "Sunday", 23233 "slots": [ 23234 { 23235 "slot": 0, 23236 "event": null 23237 }, 23238 { 23239 "slot": 1, 23240 "event": null 23241 }, 23242 { 23243 "slot": 2, 23244 "event": null 23245 }, 23246 { 23247 "slot": 3, 23248 "event": null 23249 }, 23250 { 23251 "slot": 4, 23252 "event": null 23253 }, 23254 { 23255 "slot": 5, 23256 "event": null 23257 }, 23258 { 23259 "slot": 6, 23260 "event": null 23261 }, 23262 { 23263 "slot": 7, 23264 "event": null 23265 }, 23266 { 23267 "slot": 8, 23268 "event": null 23269 }, 23270 { 23271 "slot": 9, 23272 "event": null 23273 } 23274 ], 23275 "events": [] 23276 } **/ 23277 23278 const start = this.current_date.clone().startOf("month").startOf("week"); 23279 const end = this.current_date.clone().endOf("month").endOf("week"); 23280 let current = start.clone(); 23281 let days_array = []; 23282 while (current.isBefore(end) || current.isSame(end, "day")) { 23283 //create the day object 23284 let day = { 23285 date: current.clone(), 23286 date_components: { 23287 year: current.year(), 23288 month: current.month(), 23289 day: current.date(), 23290 }, 23291 day: current.date(), 23292 day_of_week: current.format("dddd"), 23293 slots: Array.from({ length: 10 }, (_, i) => ({ 23294 slot: i, 23295 event: null, // Initially no event 23296 })), 23297 events: [] 23298 }; 23299 //add the day object to the array 23300 days_array.push(day); 23301 current.add(1, "day"); 23302 } 23303 return days_array; 23304 }, 23305 days_array() { 23306 //leverage days_array_dates 23307 //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 23308 if (this.view_preference === "Month" || this.view_preference == "Week") { 23309 //always start at the beginning/end of the month, EVEN IN WEEK VIEW 23310 //.. so that we can generate slots effectively 23311 //... Then, we only push the day to the array in week view if it's in the current week 23312 23313 const start = this.current_date.clone().startOf("month").startOf("week"); 23314 const end = this.current_date.clone().endOf("month").endOf("week"); 23315 23316 23317 let days_array = []; 23318 let visible_slots = 10; 23319 if( this.view_preference == "Week" ){ 23320 visible_slots = 100; 23321 } 23322 let reserved_slots = {}; 23323 let highest_reserved_slot_in_week = 0; 23324 /* 23325 //example of reserved_slots 23326 { 23327 0: { 23328 event: Object, 23329 days_remaining: 3 23330 }, 23331 1: { 23332 event: Object, 23333 days_remaining: 1 23334 }, 23335 2: { 23336 event: Object, 23337 days_remaining: 4 23338 }, 23339 } 23340 */ 23341 23342 var dates = this.dates_for_days_array;
23343 dates.forEach(day => { 23344 let current = day.date.clone(); 23345 let day_is_in_week = current.isSame( this.current_date, "week" ); 23346 let events_for_day = this.visible_events_by_day[current.format("YYYY-MM-DD")] || []; 23347 let handled_events = []; //events we have already managed to slot in 23348 //generate slots for the day 23349 let slots = []; 23350 for (let i = 0; i <= visible_slots - 1; i++) { 23351 //check for a reserved slot already 23352 if( reserved_slots[ i ] ){ 23353 //slot is reserved 23354 slots.push({ 23355 slot: i, 23356 event: reserved_slots[ i ].event 23357 }) 23358 //update it 23359 reserved_slots[ i ].days_remaining -= 1; 23360 }else{ 23361 //slot is open 23362 //.. check for an event 23363 //.. if there is an event, reserve the slot 23364 let event_to_use = null; 23365 for (let e = 0; e < events_for_day.length; e++) { 23366 const this_event = events_for_day[ e ]; 23367 if( this.view_preference == "Week" && ! this_event.isAllDay ){ 23368 //we only slot all day events for the week view 23369 continue; 23370 } 23371 //keep going 23372 if( ! handled_events.includes( this_event.id ) ){ 23373 //haven't used it yet 23374 if( ! this.is_multi_day_event( this_event ) ){ 23375 //it's a single-day event 23376 //.. slot it right in! 23377 event_to_use = this_event; 23378 break; 23379 }else{ 23380 //check if it's the first visible day of the month calendar 23381 var is_first_of_month = current.isSame(this.current_date.clone().startOf("month").startOf("week"), 'day'); 23382 //ok, it's a multi-day event 23383 //.. see if it's the first day of a new event 23384 var remaining_days = this.remaining_days_in_event( this_event, current ); 23385 if(is_first_of_month){ 23386 //check for events that started before the month started but are still ongoing 23387 //.. and slot them 23388 if( remaining_days > 0 ){ 23389 //it's an ongoing event that started before the month started 23390 event_to_use = this_event; 23391 reserved_slots[ i ] = { 23392 event: this_event, 23393 days_remaining: Math.max(0, this.remaining_days_in_event(this_event, current) - 1) 23394 } 23395 break; 23396 } 23397 }else if( this.is_first_day_of_event( this_event, current ) ){ 23398 //it's the first day of a multi-day event 23399 //.. reserve the slot 23400 event_to_use = this_event; 23401 reserved_slots[ i ] = { 23402 event: this_event, 23403 days_remaining: Math.max(0, this.remaining_days_in_event(this_event, current) - 1) 23404 } 23405 break; 23406 } 23407 } 23408 } 23409 } 23410 if( event_to_use ){ 23411 slots.push({ 23412 slot: i, 23413 event: event_to_use 23414 }) 23415 handled_events.push( event_to_use.id ); 23416 if( day_is_in_week && i > highest_reserved_slot_in_week ){
23417 highest_reserved_slot_in_week = i; 23418 } 23419 }else{ 23420 //push it, even if we don't have an event 23421 slots.push({ 23422 slot: i, 23423 event: null 23424 }) 23425 } 23426 } 23427 if( reserved_slots[ i ] && reserved_slots[ i ].days_remaining === 0 ){ 23428 //remove the slot 23429 delete reserved_slots[ i ]; 23430 } 23431 } 23432 //end slots 23433 day.slots = slots; //visible events for the day 23434 day.events = events_for_day; //all events for the day 23435 if( this.view_preference == "Month" || day_is_in_week){ 23436 days_array.push(day); 23437 } 23438 }); 23439 23440 /** 23441 let current = start.clone(); 23442 while (current.isBefore(end) || current.isSame(end, "day")) { 23443 let day_is_in_week = current.isSame( this.current_date, "week" ); 23444 //find all events for this day 23445 let events_for_day = this.visible_events_by_day[current.format("YYYY-MM-DD")] || []; 23446 let handled_events = []; //events we have already managed to slot in 23447 //generate slots for the day 23448 let slots = []; 23449 for (let i = 0; i <= visible_slots - 1; i++) { 23450 //check for a reserved slot already 23451 if( reserved_slots[ i ] ){ 23452 //slot is reserved 23453 slots.push({ 23454 slot: i, 23455 event: reserved_slots[ i ].event 23456 }) 23457 //update it 23458 reserved_slots[ i ].days_remaining -= 1; 23459 }else{ 23460 //slot is open 23461 //.. check for an event 23462 //.. if there is an event, reserve the slot 23463 let event_to_use = null; 23464 for (let e = 0; e < events_for_day.length; e++) { 23465 const this_event = events_for_day[ e ]; 23466 if( this.view_preference == "Week" && ! this_event.isAllDay ){ 23467 //we only slot all day events for the week view 23468 continue; 23469 } 23470 //keep going 23471 if( ! handled_events.includes( this_event.id ) ){ 23472 //haven't used it yet 23473 if( ! this.is_multi_day_event( this_event ) ){ 23474 //it's a single-day event 23475 //.. slot it right in! 23476 event_to_use = this_event; 23477 break; 23478 }else{ 23479 //check if it's the first visible day of the month calendar 23480 var is_first_of_month = current.isSame(this.current_date.clone().startOf("month").startOf("week"), 'day'); 23481 //ok, it's a multi-day event 23482 //.. see if it's the first day of a new event 23483 var remaining_days = this.remaining_days_in_event( this_event, current ); 23484 if(is_first_of_month){ 23485 //check for events that started before the month
23485started but are still ongoing 23486 //.. and slot them 23487 if( remaining_days > 0 ){ 23488 //it's an ongoing event that started before the month started 23489 event_to_use = this_event; 23490 reserved_slots[ i ] = { 23491 event: this_event, 23492 days_remaining: remaining_days 23493 } 23494 break; 23495 } 23496 }else if( this.is_first_day_of_event( this_event, current ) ){ 23497 //it's the first day of a multi-day event 23498 //.. reserve the slot 23499 event_to_use = this_event; 23500 reserved_slots[ i ] = { 23501 event: this_event, 23502 days_remaining: this.return_number_of_days_for_event( this_event ) 23503 } 23504 break; 23505 } 23506 } 23507 } 23508 } 23509 if( event_to_use ){ 23510 slots.push({ 23511 slot: i, 23512 event: event_to_use 23513 }) 23514 handled_events.push( event_to_use.id ); 23515 if( day_is_in_week && i > highest_reserved_slot_in_week ){ 23516 highest_reserved_slot_in_week = i; 23517 } 23518 }else{ 23519 //push it, even if we don't have an event 23520 slots.push({ 23521 slot: i, 23522 event: null 23523 }) 23524 } 23525 } 23526 if( reserved_slots[ i ] && reserved_slots[ i ].days_remaining === 0 ){ 23527 //remove the slot 23528 delete reserved_slots[ i ]; 23529 } 23530 } 23531 //end slots 23532 //see if we should push to the days array 23533 if( this.view_preference == "Month" || day_is_in_week){ 23534 days_array.push({ 23535 date: current.clone(), 23536 date_components: { 23537 year: current.year(), 23538 month: current.month(), 23539 day: current.date(), 23540 }, 23541 day: current.date(), 23542 day_of_week: current.format("dddd"), 23543 slots: slots, //visible events for the day 23544 events: events_for_day, //all events for the day 23545 }); 23546 } 23547 //increment it 23548 current.add(1, "day"); 23549 } 23550 23551 */ 23552 //remove slots that are greater than highest actually used slot 23553 if (this.view_preference === "Week") { 23554 let highest_used_slot_index = 0; 23555 days_array.forEach(day => {
23556 day.slots.forEach(slot => { 23557 if (slot.event && slot.slot > highest_used_slot_index) { 23558 highest_used_slot_index = slot.slot; 23559 } 23560 }); 23561 }); 23562 days_array.forEach(day => { 23563 day.slots = day.slots.filter(slot => { 23564 return slot.slot <= highest_used_slot_index; 23565 }); 23566 }); 23567 } 23568 return days_array; 23569 } else if (this.view_preference === "Day") { 23570 // Generate only the current day 23571 return [ 23572 { 23573 date: this.current_date.clone(), 23574 date_components: { 23575 year: this.current_date.year(), 23576 month: this.current_date.month(), 23577 day: this.current_date.date(), 23578 }, 23579 day: this.current_date.date(), 23580 day_of_week: this.current_date.format("dddd"), 23581 events: this.visible_events_by_day[this.current_date.format("YYYY-MM-DD")] || [], 23582 }, 23583 ]; 23584 } 23585 return []; 23586 }, 23587 calendarTitle() { 23588 if (this.view_preference === "Month") { 23589 return this.current_date.format("MMMM YYYY"); 23590 } else if (this.view_preference === "Week") { 23591 // Set the title to the range of the week 23592 const start = this.current_date.clone().startOf("week"); 23593 const end = this.current_date.clone().endOf("week"); 23594 return `${start.format("MMM D")} - ${end.format("MMM D")}`; 23595 } else if (this.view_preference === "Day") { 23596 return this.current_date.format("MMMM D, YYYY"); 23597 } 23598 return null; 23599 }, 23600 calendarTitle_noYear(){ 23601 if (this.view_preference === "Month") { 23602 return this.current_date.format("MMMM"); 23603 } else if (this.view_preference === "Week") { 23604 // Set the title to the range of the week 23605 const start = this.current_date.clone().startOf("week"); 23606 const end = this.current_date.clone().endOf("week"); 23607 return `${start.format("MMM D")} - ${end.format("MMM D")}`; 23608 } else if (this.view_preference === "Day") { 23609 return this.current_date.format("MMMM D"); 23610 } 23611 return null; 23612 }, 23613 events_by_day(){ 23614 //create an object with keys as days and values as arrays of events 23615 let events_by_day = {}; 23616 this.events.forEach(event => { 23617 //get the start and end dates of the event 23618 const start = moment.tz(event.ts, this.timezone).startOf("day"); 23619 const end = moment.tz(event.end_ts, this.timezone).endOf("day"); 23620 //iterate through the days from start to end 23621 let current = start.clone(); 23622 while (current.isSameOrBefore(end)) { 23623 const date_key = current.format("YYYY-MM-DD"); 23624 if (!events_by_day[date_key]) { 23625 events_by_day[date_key] = []; 23626 } 23627 events_by_day[date_key].push(event); 23628 current.add(1, "day"); 23629 } 23630 }); 23631 //sort array for each day 23632 for (let date in events_by_day) { 23633 if(date && events_by_day[date] && events_by_day[date].length > 0){ 23634 //sort the events for the day 23635 events_by_day[date] = this.sort_events_for_day(events_by_day[date]); 23636 } 23637 } 23638 return events_by_day; 23639 }, 23640 visible_events_by_day() { 23641 const visible_events_by_day = {}; 23642 const defaultFilterIDs = this.selected_tags?.default || []; 23643 const selectedCalendarTagIDs = this.selected_tags?.custom || []; 23644 23645 for (const [dayKey, events] of Object.entries(this.events_by_day)) { 23646 visible_events_by_day[dayKey] = events.filter(event => { 23647 if(event.type !== 'booking'){ 23648 23649 var hasMatchingDefault = false;
23650 var hasMatchingDefaultSecondary = true; 23651 // Two default groups (e.g. lodging requests: room type + room with AND) 23652 if (this.default_calendar_tags_secondary) { 23653 var room_type_ids = event.room_type_ids || []; 23654 var room_ids = event.room_ids || []; 23655 if (room_type_ids.length || room_ids.length) { 23656 var is_day_trip_request_filter = this.calendar_type === 'lodging_requests' && room_type_ids.indexOf('day_trip') !== -1; 23657 if (this.default_calendar_tags && room_type_ids.length) { 23658 for (var ri = 0; ri < room_type_ids.length; ri++) { 23659 if (this.selected_filter_set.has(room_type_ids[ri])) { 23660 hasMatchingDefault = true; 23661 break; 23662 } 23663 } 23664 } else if (!this.default_calendar_tags || (this.selected_filter_set && this.selected_filter_set.size === 0)) { 23665 hasMatchingDefault = true; 23666 } 23667 if (is_day_trip_request_filter) { 23668 hasMatchingDefaultSecondary = true; 23669 } else { 23670 var secSet = this.selected_filter_set_secondary; 23671 if (secSet && secSet.size > 0) { 23672 hasMatchingDefaultSecondary = false; 23673 if (secSet.has('no_room') && room_ids.length === 0) { 23674 hasMatchingDefaultSecondary = true; 23675 } 23676 for (var rj = 0; rj < room_ids.length; rj++) { 23677 if (secSet.has(room_ids[rj])) { 23678 hasMatchingDefaultSecondary = true; 23679 break; 23680 } 23681 } 23682 } 23683 } 23684 } else { 23685 hasMatchingDefault = true; 23686 hasMatchingDefaultSecondary = true; 23687 } 23688 } else if (this.default_calendar_tags) { 23689 // 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) 23690 if ( 23691 (this.calendar_type === 'lodging' || (this.calendar_type === 'reservations' && event.type === "lodging_reservation")) && 23692 event.filter_ids && 23693 event.filter_ids.length > 0 23694 ) { 23695 const filter_ids = event.filter_ids || []; 23696 for (const filter of filter_ids) { 23697 if (this.selected_filter_set.has(filter)) { 23698 hasMatchingDefault = true; 23699 if( this.filter_logic == "or" && hasMatchingDefault ){ 23700 return true; 23701 } 23702 } 23703 } 23704 } else { 23705 const filter_ids = [event.filter_id]; 23706 for (const filter of filter_ids) { 23707 if (this.selected_filter_set.has(filter)) { 23708 hasMatchingDefault = true; 23709 if( this.filter_logic == "or" && hasMatchingDefault ){ 23710 return true; 23711 } 23712 } 23713 } 23714 } 23715 } 23716 if (this.default_calendar_tags_secondary && (!hasMatchingDefault || !hasMatchingDefaultSecondary)) { 23717 return false;
23718 } 23719 //see if we should exit early, based on default 23720 if( this.filter_logic == "or" && hasMatchingDefault ){ 23721 return true; 23722 } 23723 //now check if one of the events tags are selected 23724 var hasMatchingCalendar = false; 23725 const event_tag_ids = event.event_tag_ids || []; 23726 if( 23727 this.show_catch_all_custom_tags_option && 23728 this.catch_all_custom_tag_selected && 23729 event_tag_ids.length === 0 23730 ){ 23731 //ok, they have the default (no tags) option selected 23732 //.. AND this event isn't tagged 23733 //... it's a match 23734 hasMatchingCalendar = true; 23735 }else{ 23736 //ok, do a full check on the tags 23737 if( this.calendar_tags && this.calendar_tags.length >= 1 ){ 23738 //ok, we have tags 23739 //.. it actually needs to match one of the tags 23740 if( selectedCalendarTagIDs.length > 0 && selectedCalendarTagIDs.some(tagId => event_tag_ids.includes(tagId)) ){ 23741 hasMatchingCalendar = true; 23742 } 23743 }else if( this.filter_logic == "and" ){ 23744 //if no tags, it's effectively a match 23745 hasMatchingCalendar = true; 23746 } 23747 } 23748 //check logic 23749 if( this.filter_logic == "and" ){ 23750 //AND logic 23751 return hasMatchingDefault && hasMatchingCalendar; 23752 }else{ 23753 //OR logic 23754 return hasMatchingDefault || hasMatchingCalendar; 23755 } 23756 } else if (this.calendar_tags && this.calendar_tags.length > 0) { 23757 23758 const event_tag_ids = event.event_tag_ids || []; 23759 for (const tag_id of event_tag_ids) { 23760 if (this.selected_tags_set.has(tag_id)) { 23761 return true; // exit early 23762 } 23763 } 23764 return false; 23765 23766 } 23767 return false; 23768 }); 23769 } 23770 return visible_events_by_day; 23771 }, 23772 23773 }, 23774 watch: { 23775 view_preference(newVal) { 23776 this.v_model_view_preference = newVal; 23777 }, 23778 } 23779 }); 23780 </script>
23780
23780<script type="text/html" id="template-booking-calendar-filters"> 23781 <!-- DESKTOP VERSION --> 23782 <div 23783 v-if="show_calendar_tags" 23784 class="hideOnMobile calendar-tag-picker lightBorderLeft scrollable native-tab-bar-padding-bottom" 23785 style="width: 240px;" 23786 :class="{'flexible-height-version scrollable': calendar_type === 'web_component'}" 23787 > 23788 <div class="relative flexNoWrapLeftAlign" style="align-items: baseline;"> 23789 23790 <div style="position: sticky;top: 0;background-color: #fff;z-index: 10;" > 23791 <div v-if="can_edit_tags" class="flexWrapLeftAlign align-items-center tinyPaddingBottom" style="padding-top: 10px;padding-bottom: 6px; margin-right: 10px;"> 23792 23793 <div class="smallPaddingLeft"> 23794 <icon-button 23795 label="Add event" 23796 icon_class="fa-regular fa-plus" 23797 @clicked="add_event()" 23798 style="max-height: fit-content;" 23799 ></icon-button> 23800 </div> 23801 <div class="flexGrowOne"></div> 23802 <div class="flexWrap align-items-center calendar-menu-v2 tinyMarginTop"> 23803 <h3 23804 class="calendar-list menu-icon pointer" 23805 @click="change_show_calendar_tags()" 23806 > 23807 <i 23808 class="fa-regular fa-bars pointer" 23809 ></i> 23810 </h3> 23811 </div> 23812 </div> 23813 <h3 23814 class="flexWrapLeftAlign align-items-center" 23815 :class="{'lightBorderBottom': calendar_type !== 'web_component'}" 23816 style="padding-top: 10px;"> 23817 Calendars 23818 <template v-if="!can_edit_tags"> 23819 <div class="flexGrowOne"></div> 23820 <div class="flexWrap align-items-center calendar-menu-v2 tinyMarginTop"> 23821 <h3 23822 class="calendar-list menu-icon pointer" 23823 style="margin-right: 10px;" 23824 @click="change_show_calendar_tags()" 23825 > 23826 <i 23827 class="fa-regular fa-bars pointer" 23828 ></i> 23829 </h3> 23830 </div> 23831 </template> 23832 </h3> 23833 </div> 23834 23835 <div class="calendar-tag-picker-input bigPaddingLeftRight scrollable paddingBottom"> 23836 <div v-if="default_calendar_tags && default_calendar_tags.title" class="smallMarginTop"> 23837 <h4 class="smallBlockLetters leftText tinyMarginBottom pointer" @click="collapsible_menus_open.default_primary = !collapsible_menus_open.default_primary"> 23838 {{ default_calendar_tags.title }} 23839 <i 23840 class="fa fa-chevron-down rotate-chevron" 23841 :class="{ down: collapsible_menus_open.default_primary }" 23842 style="margin-left: 6px;" 23843 ></i> 23844 </h4> 23845 <div v-if="collapsible_menus_open.default_primary && loading_parent.filter" class="coming-soon">xxx</div> 23846 <div v-if="collapsible_menus_open.default_primary && !loading_parent.filter" class="calendar-filter-search-wrap tinyMarginBottom"> 23847 <div 23848 class="calendar-filter-search-container relative" 23849 :class="{ 'active': filter_search_default_focused }" 23850 > 23851 <i class="fa-light fa-magnifying-glass calendar-filter-search-icon" style="z-index: 8;"></i> 23852 <input 23853 class="calendar-filter-search-input" 23854 :placeholder="default_search_placeholder" 23855 v-model="filter_search_default" 23856 @focus="filter_search_default_focused = true" 23857 @blur="filter_search_default_focused = false" 23858 autocomplete="off" 23859 autocorrect="off" 23860 autocapitalize="none" 23861 spellcheck="false" 23862 > 23863 </div> 23864 </div> 23865 <div class="flexWrapLeftAlign align-items-center"> 23866 <p 23867 v-if="collapsible_menus_open.default_primary && !loading_parent.filter" 23868 @click="select_default_tags()" 23869 class="smallBlockLetters tinyMarginBottom" 23870 style="margin-right: 12px; font-size: 12px;" 23871 > 23872 <a> 23873 <i class="fa-regular fa-check" style="margin-right: 4px;"></i>{{ default_select_all_label }} 23874 </a> 23875 </p> 23876 <p 23877 v-if="collapsible_menus_open.default_primary && (has_default_search ? true : has_any_default_filters_selected)" 23878 @click="clear_default_tags()" 23879 class="smallBlockLetters tinyMarginBottom" 23880 style="margin-right: 12px; font-size: 12px;" 23881 > 23882 <a> 23883 <i class="fa-regular fa-xmark" style="margin-right: 4px;"></i>{{ default_clear_label }} 23884 </a> 23885 </p> 23886 </div> 23887 <template 23888 v-if="collapsible_menus_open.default_primary && filtered_default_options?.length > 0" 23889 v-for="tag in filtered_default_options" 23890 :key="tag.id" 23891 > 23892 <div 23893 @click="clicked_default_tag(tag)" 23894 class="calendar-tag-item flexWrapLeftAlign align-items-center tinyPaddingTop relative pointer" 23895 style="display: flex;" 23896 > 23897 <label class="calendar-tag-item flexWrapLeftAlign align-items-center" style="display: flex; align-items: center; flex: 1;"> 23898 <div 23899 class="custom-checkbox" 23900 :style="{ 23901 backgroundColor: ui_tag_ids_default && ui_tag_ids_default[tag.id] 23902 ? (tag.color || '#5774f5') 23903 : '', 23904 border: '2px solid ' + (tag.color || '#5774f5') 23905 }" 23906 > 23907 <i class="fa-regular fa-check" 23908 style="color: white; position: absolute; top: 2px; left: 2px; font-size: 12px;" 23909 ></i> 23910 </div>
23911 <span class="calendar-tag-name" style="flex: 1 1 auto; word-break: break-word;"> 23912 {{ tag.label }} 23913 </span> 23914 </label> 23915 <!-- Default tag context menu trigger --> 23916 <div 23917 class="calendar-tag-remove-icon" 23918 style="margin-left: 4px; flex-shrink: 0; padding-left: 4px; padding-right: 4px; z-index: 3;" 23919 @click.stop="toggleTagMenu(tag, $event)" 23920 > 23921 <i class="fa-regular fa-ellipsis-vertical"></i> 23922 </div> 23923 <!-- Default tag context menu dropdown (only isolate option) --> 23924 <div 23925 v-if="open_menu_for_tag_id === tag.id" 23926 class="tag-context-menu" 23927 @click.stop 23928 > 23929 <button 23930 type="button" 23931 class="tag-context-menu-item" 23932 @click.stop="closeTagMenu(); isolate_default_tag(tag)" 23933 > 23934 Display this only 23935 </button> 23936 </div> 23937 </div> 23938 </template> 23939 </div> 23940 <!-- Secondary default group (e.g. Room for lodging requests: room type + room with AND) --> 23941 <div v-if="default_calendar_tags_secondary && default_calendar_tags_secondary.title" class="smallMarginTop"> 23942 <h4 class="smallBlockLetters leftText tinyMarginBottom pointer" @click="collapsible_menus_open.default_secondary = !collapsible_menus_open.default_secondary"> 23943 {{ default_calendar_tags_secondary.title }} 23944 <i class="fa fa-chevron-down rotate-chevron" :class="{ down: collapsible_menus_open.default_secondary }" style="margin-left: 6px;"></i> 23945 </h4> 23946 <div v-if="collapsible_menus_open.default_secondary" class="calendar-filter-search-wrap tinyMarginBottom"> 23947 <div class="calendar-filter-search-container relative" :class="{ 'active': filter_search_default_secondary_focused }"> 23948 <i class="fa-light fa-magnifying-glass calendar-filter-search-icon" style="z-index: 8;"></i> 23949 <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"> 23950 </div> 23951 </div> 23952 <div class="flexWrapLeftAlign align-items-center"> 23953 <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> 23954 <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> 23955 <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> 23956 </div> 23957 <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"> 23958 <div @click="clicked_default_tag_secondary(tag)" class="calendar-tag-item flexWrapLeftAlign align-items-center tinyPaddingTop relative pointer" style="display: flex;"> 23959 <label class="calendar-tag-item flexWrapLeftAlign align-items-center" style="display: flex; align-items: center; flex: 1;"> 23960 <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') }"> 23961 <i class="fa-regular fa-check" style="color: white; position: absolute; top: 2px; left: 2px; font-size: 12px;"></i> 23962 </div>
23963 <span class="calendar-tag-name" style="flex: 1 1 auto; word-break: break-word;">{{ tag.label }}</span> 23964 </label> 23965 <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)"> 23966 <i class="fa-regular fa-ellipsis-vertical"></i> 23967 </div> 23968 <div v-if="open_menu_for_tag_id === tag.id" class="tag-context-menu" @click.stop> 23969 <button type="button" class="tag-context-menu-item" @click.stop="closeTagMenu(); isolate_default_tag_secondary(tag)">Display this only</button> 23970 </div> 23971 </div> 23972 </template> 23973 </div> 23974 <!-- Other custom calendars (event tags) --> 23975 <div v-if="show_other_filters" class="marginTop"> 23976 <h4 class="smallBlockLetters leftText tinyMarginBottom pointer noWrap" @click="collapsible_menus_open.custom = !collapsible_menus_open.custom"> 23977 {{ calendar_tag_label }} 23978 <i 23979 v-if="can_edit_tags" 23980 class="fa-regular fa-plus pointer" 23981 style="margin-left: 6px; margin-right: 3px;" 23982 @click.stop="clicked_add_calendar_tag()" 23983 ></i> 23984 <i 23985 class="fa fa-chevron-down rotate-chevron" 23986 :class="{ down: collapsible_menus_open.custom }" 23987 style="margin-left: 6px;" 23988 ></i> 23989 </h4> 23990 <div v-if="collapsible_menus_open.custom && loading_parent.calendar_tags" class="coming-soon">xxx</div> 23991 <div v-if="collapsible_menus_open.custom && !loading_parent.calendar_tags" class="calendar-filter-search-wrap tinyMarginBottom"> 23992 <div 23993 class="calendar-filter-search-container relative" 23994 :class="{ 'active': filter_search_custom_focused }" 23995 > 23996 <i class="fa-light fa-magnifying-glass calendar-filter-search-icon" style="z-index: 8;"></i> 23997 <input 23998 class="calendar-filter-search-input" 23999 :placeholder="'Search tags'" 24000 v-model="filter_search_custom" 24001 @focus="filter_search_custom_focused = true" 24002 @blur="filter_search_custom_focused = false" 24003 autocomplete="off" 24004 autocorrect="off" 24005 autocapitalize="none" 24006 spellcheck="false" 24007 > 24008 </div> 24009 </div> 24010 <div v-if="collapsible_menus_open.custom && !loading_parent.calendar_tags" class="flexWrapLeftAlign align-items-center tinyMarginBottom"> 24011 <p @click="select_custom_tags()" class="smallBlockLetters tinyMarginBottom" style="margin-right: 12px; font-size: 12px;"> 24012 <a><i class="fa-regular fa-check" style="margin-right: 4px;"></i>{{ custom_select_all_label }}</a> 24013 </p> 24014 <p 24015 v-if="has_custom_search || has_any_custom_filters_selected" 24016 @click="clear_custom_tags()" 24017 class="smallBlockLetters tinyMarginBottom" 24018 style="margin-right: 12px; font-size: 12px;" 24019 > 24020 <a><i class="fa-regular fa-xmark" style="margin-right: 4px;"></i>{{ custom_clear_label }}</a> 24021 </p> 24022 </div> 24023 24024 <!-- default (no tags) option --> 24025 <div 24026 v-if="show_catch_all_custom_tags_option && collapsible_menus_open.custom && show_no_tags_in_custom" 24027 @click="clicked_catch_all_custom_tag()" 24028 class="calendar-tag-item flexWrapLeftAlign align-items-center tinyPaddingTop relative pointer" 24029 style="display: flex;" 24030 > 24031 <label class="calendar-tag-item flexWrapLeftAlign align-items-center" style="display: flex; align-items: center; flex: 1;"> 24032 <div 24033 class="custom-checkbox" 24034 :style="{ 24035 backgroundColor: ui_catch_all_custom_tag_selected 24036 ? '#5774f5' 24037 : '', 24038 border: '2px solid #5774f5' 24039 }" 24040 > 24041 <i class="fa-regular fa-check" 24042 v-if="ui_catch_all_custom_tag_selected" 24043 style="color: white; position: absolute; top: 2px; left: 2px; font-size: 12px;" 24044 ></i> 24045 </div>
24046 <span class="calendar-tag-name" style="flex: 1 1 auto; word-break: break-word;"> 24047 No tags 24048 </span> 24049 </label> 24050 <!-- Tag context menu trigger --> 24051 <div 24052 class="calendar-tag-remove-icon" 24053 style="margin-left: 4px; flex-shrink: 0; padding-left: 4px; padding-right: 4px; z-index: 3;" 24054 @click.stop="toggleTagMenu({id: 'catch_all_custom'}, $event)" 24055 > 24056 <i class="fa-regular fa-ellipsis-vertical"></i> 24057 </div> 24058 <!-- Tag context menu dropdown --> 24059 <div 24060 v-if="open_menu_for_tag_id === 'catch_all_custom'" 24061 class="tag-context-menu" 24062 @click.stop 24063 > 24064 <button 24065 type="button" 24066 class="tag-context-menu-item" 24067 @click.stop="closeTagMenu(); isolate_catch_all_custom()" 24068 > 24069 Display this only 24070 </button> 24071 </div> 24072 </div> 24073 <!-- end default (no tags) option --> 24074 24075 <template 24076 v-if="collapsible_menus_open.custom && !loading_parent.calendar_tags && filtered_custom_tags && filtered_custom_tags.length > 0" 24077 v-for="tag in filtered_custom_tags" 24078 :key="tag.id" 24079 > 24080 <div 24081 v-if="tag" 24082 @click="clicked_calendar_tag(tag)" 24083 class="calendar-tag-item flexWrapLeftAlign align-items-center tinyPaddingTop relative pointer" 24084 style="display: flex;" 24085 > 24086 <label class="calendar-tag-item flexWrapLeftAlign align-items-center" style="display: flex; align-items: center; flex: 1;"> 24087 <div 24088 class="custom-checkbox" 24089 :style="{ 24090 backgroundColor: ui_tag_ids_custom && ui_tag_ids_custom[tag.id] 24091 ? (tag.get('hex_value') || '#5774f5') 24092 : '', 24093 border: '2px solid ' + (tag.get('hex_value') || '#5774f5') 24094 }" 24095 > 24096 <i class="fa-regular fa-check" 24097 v-if="ui_tag_ids_custom && ui_tag_ids_custom[tag.id]" 24098 style="color: white; position: absolute; top: 2px; left: 2px; font-size: 12px;" 24099 ></i> 24100 </div> 24101 <span class="calendar-tag-name" style="flex: 1 1 auto; word-break: break-word;"> 24102 {{ tag.get('name') }} 24103 </span> 24104 </label> 24105 <!-- Tag context menu trigger --> 24106 <div 24107 class="calendar-tag-remove-icon" 24108 style="margin-left: 4px; flex-shrink: 0; padding-left: 4px; padding-right: 4px; z-index: 3;" 24109 @click.stop="toggleTagMenu(tag, $event)" 24110 > 24111 <i class="fa-regular fa-ellipsis-vertical"></i> 24112 </div> 24113 <!-- Tag context menu dropdown --> 24114 <div 24115 v-if="open_menu_for_tag_id === tag.id" 24116 class="tag-context-menu" 24117 :class="{'tag-context-menu--above': isLastTag(tag)}" 24118 @click.stop 24119 > 24120 <button 24121 type="button" 24122 class="tag-context-menu-item" 24123 @click.stop="closeTagMenu(); isolate_calendar_tag(tag)" 24124 > 24125 Display this only 24126 </button> 24127 <button 24128 v-if="can_edit_tags" 24129 type="button" 24130 class="tag-context-menu-item tag-context-menu-item-danger" 24131 @click.stop="closeTagMenu(); clicked_remove_tag(tag)" 24132 > 24133 Remove tag 24134 </button> 24135 </div> 24136 </div> 24137 </template> 24138 </div> 24139 </div> 24140 </div> 24141 </div> 24142 <!-- MOBILE VERSION --> 24143 <!-- Teleport the modal to body --> 24144 <teleport to="#v-overlays"> 24145 <div v-if="show_calendar_tags" class="showOnMobile"> 24146 <transition name="slide-in-right">
24147 <aside class="mobile-filter-overlay scrollable" role="dialog" aria-modal="true" aria-labelledby="calendar-filters-title"> 24148 <div class="calendar-tag-picker marginBottom"> 24149 <div class="relative flexNoWrapLeftAlign" style="align-items: baseline;"> 24150 <div 24151 style="position: sticky;top: 0;background-color: #fff;z-index: 10;" 24152 class="lightBorderBottom smallPaddingTop smallPaddingBottom bigPaddingLeftRight" 24153 > 24154 <div 24155 v-if="can_edit_tags" 24156 class="tinyPaddingBottom" 24157 > 24158 <icon-button 24159 label="Add event" 24160 icon_class="fa-regular fa-plus" 24161 @clicked="add_event()" 24162 style="max-height: fit-content;" 24163 ></icon-button> 24164 </div> 24165 <div 24166 class="card-header-button-v3 pointer noWrap centeredText relative" 24167 > 24168 {{ v_model_view_preference }} 24169 <i class="fa-light fa-chevron-down" style="padding-left: 3px;"></i> 24170 <select 24171 v-model="v_model_view_preference" 24172 @change="changeViewPreference($event)" 24173 class="pointer" 24174 style="position:absolute;left:0px;top:0px;width:100%;height:100%;opacity:0;" 24175 > 24176 <option value="Month">Month</option> 24177 <option value="Week">Week</option> 24178 <option value="Day">Day</option> 24179 </select> 24180 </div> 24181 <h3 id="calendar-filters-title" class="flexWrapLeftAlign align-items-center smallMarginTop" style="padding-top: 0;"> 24182 Calendars 24183 <div class="flexGrowOne"></div> 24184 <!-- <i class="fa-regular fa-xmark pointer" @click="$emit('close')" style="margin-right: 10px;"></i> --> 24185 </h3> 24186 </div> 24187 <div class="calendar-tag-picker-input bigPaddingLeftRight scrollable paddingBottom"> 24188 <div v-if="default_calendar_tags && default_calendar_tags.title" class="smallMarginTop"> 24189 <h4 class="smallBlockLetters leftText tinyMarginBottom pointer" @click="collapsible_menus_open.default_primary = !collapsible_menus_open.default_primary"> 24190 {{ default_calendar_tags.title }} 24191 <i 24192 class="fa fa-chevron-down rotate-chevron" 24193 :class="{ down: collapsible_menus_open.default_primary }" 24194 style="margin-left: 6px;" 24195 ></i> 24196 </h4> 24197 <div v-if="collapsible_menus_open.default_primary && loading_parent.filter" class="coming-soon">xxx</div> 24198 <div v-if="collapsible_menus_open.default_primary && !loading_parent.filter" class="calendar-filter-search-wrap tinyMarginBottom"> 24199 <div 24200 class="calendar-filter-search-container relative" 24201 :class="{ 'active': filter_search_default_focused }" 24202 > 24203 <i class="fa-light fa-magnifying-glass calendar-filter-search-icon" style="z-index: 10;"></i> 24204 <input 24205 class="calendar-filter-search-input" 24206 :placeholder="default_search_placeholder" 24207 v-model="filter_search_default" 24208 @focus="filter_search_default_focused = true" 24209 @blur="filter_search_default_focused = false" 24210 autocomplete="off" 24211 autocorrect="off" 24212 autocapitalize="none" 24213 spellcheck="false" 24214 > 24215 </div> 24216 </div> 24217 <div class="flexWrapLeftAlign align-items-center"> 24218 <p 24219 v-if="collapsible_menus_open.default_primary" 24220 @click="select_default_tags()" 24221 class="smallBlockLetters tinyMarginBottom" 24222 style="margin-right: 12px; font-size: 12px;" 24223 > 24224 <a> 24225 <i class="fa-regular fa-check" style="margin-right: 4px;"></i>{{ default_select_all_label }} 24226 </a> 24227 </p> 24228 <p 24229 v-if="collapsible_menus_open.default_primary && (has_default_search ? true : has_any_default_filters_selected)" 24230 @click="clear_default_tags()" 24231 class="smallBlockLetters tinyMarginBottom" 24232 style="margin-right: 12px; font-size: 12px;" 24233 > 24234 <a> 24235 <i class="fa-regular fa-xmark" style="margin-right: 4px;"></i>{{ default_clear_label }} 24236 </a> 24237 </p> 24238 </div> 24239 <template 24240 v-if="collapsible_menus_open.default_primary && filtered_default_options?.length > 0" 24241 v-for="tag in filtered_default_options" 24242 :key="tag.id" 24243 > 24244 <div 24245 @click="clicked_default_tag(tag)" 24246 class="calendar-tag-item flexWrapLeftAlign align-items-center relative pointer" 24247 style="display: flex; padding-top: 16px;" 24248 > 24249 <label class="calendar-tag-item flexWrapLeftAlign align-items-center" style="display: flex; align-items: center; flex: 1;"> 24250 <div 24251 class="custom-checkbox" 24252 :style="{ 24253 backgroundColor: ui_tag_ids_default && ui_tag_ids_default[tag.id] 24254 ? (tag.color || '#5774f5') 24255 : '', 24256 border: '2px solid ' + (tag.color || '#5774f5') 24257 }" 24258 > 24259 <i class="fa-regular fa-check" 24260 style="color: white; position: absolute; top: 2px; left: 2px; font-size: 12px;" 24261 ></i> 24262 </div>
24263 <span class="calendar-tag-name" style="flex: 1 1 auto; word-break: break-word;"> 24264 {{ tag.label }} 24265 </span> 24266 </label> 24267 <!-- Default tag context menu trigger (mobile) --> 24268 <div 24269 class="calendar-tag-more mobile-version" 24270 style="margin-left: 4px; flex-shrink: 0; padding-left: 4px; padding-right: 4px; z-index: 3;" 24271 @click.stop="toggleTagMenu(tag, $event)" 24272 > 24273 <i class="fa-regular fa-ellipsis-vertical"></i> 24274 </div> 24275 <!-- Default tag context menu dropdown (only isolate option) --> 24276 <div 24277 v-if="open_menu_for_tag_id === tag.id" 24278 class="tag-context-menu" 24279 @click.stop 24280 > 24281 <button 24282 type="button" 24283 class="tag-context-menu-item" 24284 @click.stop="closeTagMenu(); isolate_default_tag(tag)" 24285 > 24286 Display this only 24287 </button> 24288 </div> 24289 </div> 24290 </template> 24291 </div> 24292 <!-- Secondary default group (mobile) --> 24293 <div v-if="default_calendar_tags_secondary && default_calendar_tags_secondary.title" class="marginTop"> 24294 <h4 class="smallBlockLetters leftText tinyMarginBottom pointer" @click="collapsible_menus_open.default_secondary = !collapsible_menus_open.default_secondary"> 24295 {{ default_calendar_tags_secondary.title }} 24296 <i class="fa fa-chevron-down rotate-chevron" :class="{ down: collapsible_menus_open.default_secondary }" style="margin-left: 6px;"></i> 24297 </h4> 24298 <div v-if="collapsible_menus_open.default_secondary" class="flexWrapLeftAlign align-items-center tinyMarginBottom"> 24299 <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> 24300 <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> 24301 </div> 24302 <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"> 24303 <div @click="clicked_default_tag_secondary(tag)" class="calendar-tag-item flexWrapLeftAlign align-items-center relative pointer" style="display: flex; padding-top: 16px;"> 24304 <label class="calendar-tag-item flexWrapLeftAlign align-items-center" style="display: flex; align-items: center; flex: 1;"> 24305 <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') }"> 24306 <i class="fa-regular fa-check" style="color: white; position: absolute; top: 2px; left: 2px; font-size: 12px;"></i> 24307 </div>
24308 <span class="calendar-tag-name" style="flex: 1 1 auto; word-break: break-word;">{{ tag.label }}</span> 24309 </label> 24310 <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)"> 24311 <i class="fa-regular fa-ellipsis-vertical"></i> 24312 </div> 24313 <div v-if="open_menu_for_tag_id === tag.id" class="tag-context-menu" @click.stop> 24314 <button type="button" class="tag-context-menu-item" @click.stop="closeTagMenu(); isolate_default_tag_secondary(tag)">Display this only</button> 24315 </div> 24316 </div> 24317 </template> 24318 </div> 24319 <!-- Other custom calendars (event tags) --> 24320 <div v-if="has_other_calendar_filters" class="marginTop"> 24321 <h4 class="smallBlockLetters leftText tinyMarginBottom pointer noWrap" @click="collapsible_menus_open.custom = !collapsible_menus_open.custom"> 24322 {{ calendar_tag_label }} 24323 <i 24324 v-if="can_edit_tags" 24325 class="fa-regular fa-plus pointer" 24326 style="margin-left: 6px; margin-right: 3px;" 24327 @click.stop="clicked_add_calendar_tag()" 24328 ></i> 24329 <i 24330 class="fa fa-chevron-down rotate-chevron" 24331 :class="{ down: collapsible_menus_open.custom }" 24332 style="margin-left: 6px;" 24333 ></i> 24334 </h4> 24335 <div v-if="collapsible_menus_open.custom && loading_parent.calendar_tags" class="coming-soon">xxx</div> 24336 <div v-if="collapsible_menus_open.custom && !loading_parent.calendar_tags" class="calendar-filter-search-wrap tinyMarginBottom"> 24337 <div 24338 class="calendar-filter-search-container relative" 24339 :class="{ 'active': filter_search_custom_focused }" 24340 > 24341 <i class="fa-light fa-magnifying-glass calendar-filter-search-icon" style="z-index: 10;"></i> 24342 <input 24343 class="calendar-filter-search-input" 24344 :placeholder="'Search tags'" 24345 v-model="filter_search_custom" 24346 @focus="filter_search_custom_focused = true" 24347 @blur="filter_search_custom_focused = false" 24348 autocomplete="off" 24349 autocorrect="off" 24350 autocapitalize="none" 24351 spellcheck="false" 24352 > 24353 </div> 24354 </div> 24355 <div v-if="collapsible_menus_open.custom && !loading_parent.calendar_tags" class="flexWrapLeftAlign align-items-center tinyMarginBottom"> 24356 <p @click="select_custom_tags()" class="smallBlockLetters tinyMarginBottom" style="margin-right: 12px; font-size: 12px;"> 24357 <a><i class="fa-regular fa-check" style="margin-right: 4px;"></i>{{ custom_select_all_label }}</a> 24358 </p> 24359 <p 24360 v-if="has_custom_search || has_any_custom_filters_selected" 24361 @click="clear_custom_tags()" 24362 class="smallBlockLetters tinyMarginBottom" 24363 style="margin-right: 12px; font-size: 12px;" 24364 > 24365 <a><i class="fa-regular fa-xmark" style="margin-right: 4px;"></i>{{ custom_clear_label }}</a> 24366 </p> 24367 </div> 24368 24369 <!-- default (no tags) option --> 24370 <div 24371 v-if="show_catch_all_custom_tags_option && collapsible_menus_open.custom && show_no_tags_in_custom" 24372 @click="clicked_catch_all_custom_tag()" 24373 class="calendar-tag-item flexWrapLeftAlign align-items-center tinyPaddingTop relative pointer" 24374 style="display: flex;" 24375 > 24376 <label class="calendar-tag-item flexWrapLeftAlign align-items-center" style="display: flex; align-items: center; flex: 1;"> 24377 <div 24378 class="custom-checkbox" 24379 :style="{ 24380 backgroundColor: ui_catch_all_custom_tag_selected 24381 ? '#5774f5' 24382 : '', 24383 border: '2px solid #5774f5' 24384 }" 24385 > 24386 <i class="fa-regular fa-check" 24387 v-if="ui_catch_all_custom_tag_selected" 24388 style="color: white; position: absolute; top: 2px; left: 2px; font-size: 12px;" 24389 ></i> 24390 </div>
24391 <span class="calendar-tag-name" style="flex: 1 1 auto; word-break: break-word;"> 24392 No tags 24393 </span> 24394 </label> 24395 <!-- Tag context menu trigger --> 24396 <div 24397 class="calendar-tag-catch-all mobile-version" 24398 style="margin-left: 4px; flex-shrink: 0; padding-left: 4px; padding-right: 4px; z-index: 3;" 24399 @click.stop="toggleTagMenu({id: 'catch_all_custom'}, $event)" 24400 > 24401 <i class="fa-regular fa-ellipsis-vertical"></i> 24402 </div> 24403 <!-- Tag context menu dropdown --> 24404 <div 24405 v-if="open_menu_for_tag_id === 'catch_all_custom'" 24406 class="tag-context-menu" 24407 @click.stop 24408 > 24409 <button 24410 type="button" 24411 class="tag-context-menu-item" 24412 @click.stop="closeTagMenu(); isolate_catch_all_custom()" 24413 > 24414 Display this only 24415 </button> 24416 </div> 24417 </div> 24418 <!-- end default (no tags) option --> 24419 24420 <template 24421 v-if="collapsible_menus_open.custom && !loading_parent.calendar_tags && filtered_custom_tags && filtered_custom_tags.length > 0" 24422 v-for="tag in filtered_custom_tags" 24423 :key="tag.id" 24424 > 24425 <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"> 24426 <label class="calendar-tag-item flexWrapLeftAlign align-items-center" style="display: flex; align-items: center; flex: 1;"> 24427 <div 24428 class="custom-checkbox" 24429 :style="{ 24430 backgroundColor: ui_tag_ids_custom && ui_tag_ids_custom[tag.id] 24431 ? (tag.get('hex_value') || '#5774f5') 24432 : '', 24433 border: '2px solid ' + (tag.get('hex_value') || '#5774f5') 24434 }" 24435 > 24436 <i class="fa-regular fa-check" 24437 v-if="ui_tag_ids_custom && ui_tag_ids_custom[tag.id]" 24438 style="color: white; position: absolute; top: 2px; left: 2px; font-size: 12px;" 24439 ></i> 24440 </div>
24441 <span class="calendar-tag-name" style="flex: 1 1 auto; word-break: break-word;"> 24442 {{ tag.get('name') }} 24443 </span> 24444 </label> 24445 <!-- Tag context menu trigger (mobile)--> 24446 <div 24447 class="calendar-tag-more mobile-version" 24448 style="margin-left: 4px; flex-shrink: 0; padding-left: 4px; padding-right: 4px; z-index: 3;" 24449 @click.stop="toggleTagMenu(tag, $event)" 24450 > 24451 <i class="fa-regular fa-ellipsis-vertical"></i> 24452 </div> 24453 <!-- Tag context menu dropdown --> 24454 <div 24455 v-if="open_menu_for_tag_id === tag.id" 24456 class="tag-context-menu" 24457 :class="{'tag-context-menu--above': isLastTag(tag)}" 24458 @click.stop 24459 > 24460 <button 24461 type="button" 24462 class="tag-context-menu-item" 24463 @click.stop="closeTagMenu(); isolate_calendar_tag(tag)" 24464 > 24465 Display this only 24466 </button> 24467 <button 24468 v-if="can_edit_tags" 24469 type="button" 24470 class="tag-context-menu-item tag-context-menu-item-danger" 24471 @click.stop="closeTagMenu(); clicked_remove_tag(tag)" 24472 > 24473 Remove tag 24474 </button> 24475 </div> 24476 </div> 24477 </template> 24478 24479 </div> 24480 </div> 24481 </div> 24482 </div> 24483 </aside> 24484 </transition> 24485 24486 <div v-if="show_calendar_tags" class="overlay-backdrop showOnMobile" @click="$emit('close')"></div> 24487 </div> 24488 </teleport> 24489</script>
24489 24490
24491<script> 24492 app_content.component("booking-calendar-filters", { 24493 template: "#template-booking-calendar-filters", 24494 data(){ 24495 return { 24496 collapsible_menus_open: { 24497 default_primary: true, 24498 default_secondary: true, 24499 custom: true, 24500 }, 24501 v_model_view_preference: this.view_preference || 'Month', 24502 open_menu_for_tag_id: null, 24503 filter_search_default: '', 24504 filter_search_custom: '', 24505 filter_search_default_secondary: '', 24506 filter_search_default_focused: false, 24507 filter_search_custom_focused: false, 24508 filter_search_default_secondary_focused: false, 24509 } 24510 }, 24511 props: { 24512 show_calendar_tags: Boolean, 24513 default_calendar_tags: Object, 24514 calendar_tags: Array, 24515 selected_tags: Object, 24516 catch_all_custom_tag_selected: Boolean, 24517 ui_tag_ids_default: Object, 24518 ui_tag_ids_custom: Array, 24519 ui_catch_all_custom_tag_selected: Boolean, 24520 show_catch_all_custom_tags_option: { 24521 type: Boolean, 24522 default: false, 24523 }, 24524 default_calendar_tags_secondary: Object, 24525 ui_tag_ids_default_secondary: Object, 24526 collapsible_menus_open: Object, 24527 loading_parent: Object, 24528 calendar_type: String, 24529 view_only: Boolean, 24530 view_preference: String, 24531 }, 24532 mounted(){ 24533 this.v_model_view_preference = this.view_preference || 'Month'; 24534 document.addEventListener('click', this.handleClickOutside); 24535 }, 24536 beforeDestroy(){ 24537 document.removeEventListener('click', this.handleClickOutside); 24538 }, 24539 methods: { 24540 add_event(){ 24541 this.$emit('add_event'); 24542 }, 24543 isLastTag(tag){ 24544 if (!this.calendar_tags || this.calendar_tags.length === 0 || !tag) { 24545 return false; 24546 } 24547 const len = this.calendar_tags.length; 24548 const last = this.calendar_tags[len - 1]; 24549 const secondLast = this.calendar_tags[len - 2]; 24550 return (last && last.id === tag.id) || (secondLast && secondLast.id === tag.id); 24551 }, 24552 toggleTagMenu(tag, event){ 24553 if (event && event.stopPropagation) { 24554 event.stopPropagation(); 24555 } 24556 // Toggle the menu for this tag; close if it's already open 24557 this.open_menu_for_tag_id = this.open_menu_for_tag_id === tag.id ? null : tag.id; 24558 }, 24559 closeTagMenu(){ 24560 this.open_menu_for_tag_id = null; 24561 }, 24562 handleClickOutside(){ 24563 // Close any open menu when clicking outside 24564 if (this.open_menu_for_tag_id !== null) { 24565 this.open_menu_for_tag_id = null; 24566 } 24567 }, 24568 clicked_default_tag(tag) { 24569 this.$emit('clicked_default_tag', tag); 24570 }, 24571 clicked_catch_all_custom_tag(){ 24572 this.$emit('clicked_catch_all_custom_tag'); 24573 }, 24574 clicked_calendar_tag(tag) { 24575 this.$emit('clicked_calendar_tag', tag); 24576 }, 24577 clicked_add_calendar_tag(){ 24578 this.$emit('clicked_add_calendar_tag'); 24579 }, 24580 clicked_remove_tag(tag) { 24581 this.$emit('clicked_remove_tag', tag); 24582 }, 24583 changeViewPreference(event){ 24584 this.$emit('change_view_preference', event ); 24585 }, 24586 change_show_calendar_tags(){ 24587 this.$emit('change_show_calendar_tags'); 24588 }, 24589 isolate_default_tag(tag){ 24590 this.$emit('isolate_default_tag', tag); 24591 }, 24592 clicked_default_tag_secondary(tag) { 24593 this.$emit('clicked_default_tag_secondary', tag); 24594 }, 24595 select_default_tags_secondary() { 24596 var q = (this.filter_search_default_secondary || '').trim(); 24597 if (q && this.filtered_default_secondary_options && this.filtered_default_secondary_options.length) { 24598 this.$emit('select_visible_default_tags_secondary', this.filtered_default_secondary_options.map((t)=>{ return t.id; })); 24599 } else { 24600 this.$emit('select_all_default_tags_secondary'); 24601 } 24602 }, 24603 clear_default_tags_secondary() { 24604 var q = (this.filter_search_default_secondary || '').trim(); 24605 if (q && this.filtered_default_secondary_options && this.filtered_default_secondary_options.length) { 24606 this.$emit('clear_visible_default_tags_secondary', this.filtered_default_secondary_options.map((t)=>{ return t.id; })); 24607 } else { 24608 this.$emit('clear_all_default_tags_secondary'); 24609 } 24610 }, 24611 clear_visible_default_tags_secondary() { 24612 var ids = (this.filtered_default_secondary_options || []).map((t)=>{ return t.id; }); 24613 this.$emit('clear_visible_default_tags_secondary', ids); 24614 }, 24615 isolate_default_tag_secondary(tag) { 24616 this.$emit('isolate_default_tag_secondary', tag); 24617 }, 24618 isolate_calendar_tag(tag){ 24619 this.$emit('isolate_calendar_tag', tag); 24620 }, 24621 select_all_custom_tags(){ 24622 this.$emit('select_all_custom_tags'); 24623 }, 24624 select_default_tags(){ 24625 var q = (this.filter_search_default || '').trim(); 24626 if (q && this.filtered_default_options && this.filtered_default_options.length) { 24627 var visibleIds = this.filtered_default_options.map((t)=>{ return t.id; }); 24628 this.$emit('select_visible_default_tags', visibleIds); 24629 } else { 24630 this.$emit('select_all_default_tags'); 24631 } 24632 }, 24633 clear_default_tags(){ 24634 var q = (this.filter_search_default || '').trim(); 24635 if (q && this.filtered_default_options && this.filtered_default_options.length) { 24636 var visibleIds = this.filtered_default_options.map((t)=>{ return t.id; }); 24637 this.$emit('clear_visible_default_tags', visibleIds); 24638 } else { 24639 this.$emit('clear_all_default_tags'); 24640 } 24641 }, 24642 select_custom_tags(){ 24643 var q = (this.filter_search_custom || '').trim(); 24644 if (q) { 24645 var visibleIds = (this.filtered_custom_tags || []).map((t)=>{ return t.id; }); 24646 if (this.show_no_tags_in_custom) { visibleIds.push('catch_all_custom'); } 24647 this.$emit('select_visible_custom_tags', visibleIds); 24648 } else { 24649 this.$emit('select_all_custom_tags'); 24650 } 24651 }, 24652 clear_custom_tags(){ 24653 var q = (this.filter_search_custom || '').trim(); 24654 if (q) { 24655 var visibleIds = (this.filtered_custom_tags || []).map((t)=>{ return t.id; }); 24656 if (this.show_no_tags_in_custom) { visibleIds.push('catch_all_custom'); } 24657 this.$emit('clear_visible_custom_tags', visibleIds); 24658 } else { 24659 this.$emit('clear_all_custom_tags'); 24660 } 24661 }, 24662 select_all_default_tags(){ 24663 this.$emit('select_all_default_tags'); 24664 }, 24665 clear_all_default_tags(){ 24666 this.$emit('clear_all_default_tags'); 24667 }, 24668 isolate_catch_all_custom(){ 24669 this.$emit('isolate_catch_all_custom'); 24670 } 24671 }, 24672 computed: { 24673 show_other_filters(){ 24674 // member facing calendars only show if we have tags 24675 if(this.calendar_type && (this.calendar_type == 'member_portal' || this.calendar_type == 'web_component' )){ 24676 //only show if we have tags
24677 return this.has_other_calendar_filters; 24678 } 24679 //admin calendars always show 24680 //...since they need to be able to add tags 24681 return true; 24682 }, 24683 has_other_calendar_filters(){ 24684 if(!this.calendar_tags || this.calendar_tags.length == 0){ 24685 return false; 24686 } 24687 return true; 24688 }, 24689 can_edit_tags(){ 24690 if(this.view_only){ 24691 return false; 24692 } 24693 if(this.calendar_type && ( this.calendar_type == 'member_portal' || this.calendar_type == 'web_component' || this.calendar_type == 'events_primary' )){ 24694 //member facing calendars 24695 // also the primary events calendar since it shows ALL event tags 24696 return false; 24697 } 24698 return true; 24699 }, 24700 calendar_tag_label(){ 24701 if(this.calendar_type && (this.calendar_type == 'member_portal' || this.calendar_type == 'web_component' || this.calendar_type == 'events_primary' )){ 24702 return "Event tags"; 24703 } 24704 return "Other calendars"; 24705 }, 24706 default_search_placeholder(){ 24707 var title = this.default_calendar_tags?.title || 'event types'; 24708 if(title.toLowerCase().trim() === 'type'){ 24709 return 'Search event types'; 24710 }else{ 24711 return 'Search ' + title.toLowerCase(); 24712 } 24713 }, 24714 has_any_default_filters_selected(){ 24715 var defaultSelected = (this.ui_tag_ids_default && typeof this.ui_tag_ids_default === 'object') ? Object.keys(this.ui_tag_ids_default).length : 0; 24716 return defaultSelected > 0; 24717 }, 24718 has_any_custom_filters_selected(){ 24719 var customSelected = (this.ui_tag_ids_custom && typeof this.ui_tag_ids_custom === 'object') ? Object.keys(this.ui_tag_ids_custom).length : 0; 24720 if (customSelected > 0) { return true; } 24721 if (this.ui_catch_all_custom_tag_selected) { return true; } 24722 return false; 24723 }, 24724 has_default_search(){ 24725 return (this.filter_search_default || '').trim().length > 0; 24726 }, 24727 has_custom_search(){ 24728 return (this.filter_search_custom || '').trim().length > 0; 24729 }, 24730 filtered_default_options(){ 24731 var options = this.default_calendar_tags?.options || []; 24732 var q = (this.filter_search_default || '').trim().toLowerCase(); 24733 if (!q) { return options; } 24734 if (typeof fuzzysort !== 'undefined') { 24735 var matches = fuzzysort.go(q, options, { keys: ['label'], threshold: -1000, limit: 100, allowTypo: false }); 24736 return matches ? matches.map((m)=>{ return m.obj; }) : []; 24737 } 24738 return options.filter((opt)=>{ return (opt.label || '').toLowerCase().indexOf(q) !== -1; }); 24739 }, 24740 filtered_default_secondary_options(){ 24741 var options = this.default_calendar_tags_secondary?.options || []; 24742 var q = (this.filter_search_default_secondary || '').trim().toLowerCase(); 24743 if (!q) { return options; } 24744 if (typeof fuzzysort !== 'undefined') { 24745 var matches = fuzzysort.go(q, options, { keys: ['label'], threshold: -1000, limit: 100, allowTypo: false }); 24746 return matches ? matches.map((m)=>{ return m.obj; }) : []; 24747 } 24748 return options.filter((opt)=>{ return (opt.label || '').toLowerCase().indexOf(q) !== -1; }); 24749 }, 24750 has_any_default_secondary_filters_selected(){ 24751 var o = this.ui_tag_ids_default_secondary && typeof this.ui_tag_ids_default_secondary === 'object' ? this.ui_tag_ids_default_secondary : {}; 24752 return Object.keys(o).filter((k)=>{ return o[k]; }).length > 0; 24753 }, 24754 default_select_all_label(){ 24755 if (!this.has_default_search || !this.filtered_default_options) { return 'Select all'; } 24756 var n = this.filtered_default_options.length; 24757 return n ? 'Select all (' + n + ')' : 'Select all'; 24758 }, 24759 default_clear_label(){ 24760 if (!this.has_default_search || !this.filtered_default_options) { return 'Clear'; } 24761 var n = this.filtered_default_options.length; 24762 return n ? 'Clear (' + n + ')' : 'Clear'; 24763 }, 24764 _custom_searchables(){ 24765 var list = []; 24766 if (this.calendar_tags && this.calendar_tags.length) {
24767 this.calendar_tags.forEach((tag)=>{ 24768 list.push({ name: (tag.get ? tag.get('name') : (tag.name || '') || ''), tag: tag }); 24769 }); 24770 } 24771 if (this.show_catch_all_custom_tags_option) { 24772 list.push({ name: 'No tags', tag: null }); 24773 } 24774 return list; 24775 }, 24776 filtered_custom_tags(){ 24777 var searchables = this._custom_searchables; 24778 var q = (this.filter_search_custom || '').trim().toLowerCase(); 24779 if (!q) { 24780 return this.calendar_tags || []; 24781 } 24782 if (typeof fuzzysort !== 'undefined') { 24783 var matches = fuzzysort.go(q, searchables, { keys: ['name'], threshold: -1000, limit: 100, allowTypo: false }); 24784 if (!matches || !matches.length) { return []; } 24785 return matches.map((m)=>{ return m.obj.tag; }).filter(Boolean); 24786 } 24787 return (this.calendar_tags || []).filter((tag)=>{ 24788 var name = (tag.get ? tag.get('name') : (tag.name || '')) || '' || ''; 24789 return name.toLowerCase().indexOf(q) !== -1; 24790 }); 24791 }, 24792 filtered_custom_display_count(){ 24793 var n = (this.filtered_custom_tags || []).length; 24794 if (this.show_no_tags_in_custom) { n += 1; } 24795 return n; 24796 }, 24797 custom_select_all_label(){ 24798 if (!this.has_custom_search) { return 'Select all'; } 24799 var n = this.filtered_custom_display_count; 24800 return n ? 'Select all (' + n + ')' : 'Select all'; 24801 }, 24802 custom_clear_label(){ 24803 if (!this.has_custom_search) { return 'Clear'; } 24804 var n = this.filtered_custom_display_count; 24805 return n ? 'Clear (' + n + ')' : 'Clear'; 24806 }, 24807 show_no_tags_in_custom(){ 24808 if (!this.show_catch_all_custom_tags_option) { return false; } 24809 var q = (this.filter_search_custom || '').trim().toLowerCase(); 24810 if (!q) { return true; } 24811 if (typeof fuzzysort !== 'undefined') { 24812 var matches = fuzzysort.go(q, [{ name: 'No tags', tag: null }], { keys: ['name'], threshold: -1000, allowTypo: false }); 24813 return matches && matches.length > 0; 24814 } 24815 return 'no tags'.indexOf(q) !== -1 || q === 'no'; 24816 }, 24817 }, 24818 watch: { 24819 view_preference(newVal) { 24820 this.v_model_view_preference = newVal; 24821 24822 } 24823 } 24824 24825 24826 }); 24827</script>
24827 24828 24829<style scoped> 24830 /* Rotating chevrons */ 24831 .rotate-chevron.down { 24832 transform: rotate(180deg); 24833 } 24834 24835 .calendar-filter-search-wrap { 24836 margin-top: 6px; 24837 } 24838 .calendar-filter-search-container { 24839 display: flex; 24840 align-items: center; 24841 border-radius: 5px; 24842 border: 1px solid #e5eaec; 24843 overflow: hidden; 24844 margin-bottom: 2px; 24845 } 24846 .calendar-filter-search-container.active { 24847 border: 1px solid #4a8be6; 24848 } 24849 .calendar-filter-search-input { 24850 flex: 1; 24851 padding: 8px 8px 8px 30px; 24852 border: none; 24853 outline: none; 24854 } 24855 .calendar-filter-search-icon { 24856 font-size: 12px; 24857 position: absolute; 24858 left: 10px; 24859 top: 50%; 24860 transform: translateY(-50%); 24861 color: #52596f; 24862 } 24863 24864 .calendar-tag-picker.flexible-height-version{ 24865 max-height: 100vh; 24866 } 24867 24868 .custom-checkbox { 24869 width: 18px; 24870 height: 18px; 24871 display: inline-block; 24872 position: relative; 24873 align-items: center; 24874 border-radius: 4px; 24875 margin-right: 8px; 24876 box-sizing: border-box; 24877 vertical-align: middle; 24878 cursor: pointer; 24879 } 24880 24881 /* Improved calendar tag item for wrapping and left alignment */ 24882 .calendar-tag-item { 24883 /* align-items: flex-start !important; */ 24884 flex-direction: row; 24885 flex-wrap: wrap; 24886 white-space: normal; 24887 text-align: left; 24888 line-height: 1.4; 24889 } 24890 24891 .calendar-tag-name { 24892 display: inline-block; 24893 word-break: break-word; 24894 max-width: 80%; 24895 } 24896 24897 .calendar-tag-item:hover .calendar-tag-remove-icon { 24898 visibility: visible; 24899 } 24900 24901 .calendar-tag-remove-icon { 24902 visibility: hidden; 24903 cursor: pointer; 24904 color:#788097; 24905 } 24906 24907 /* MOBILE */ 24908 .mobile-filter-overlay{ 24909 position: fixed; 24910 top: 0; 24911 right: 0; 24912 height: 100vh; 24913 width: 65vw; 24914 min-width: 250px; 24915 max-width: 480px; 24916 background: #fff; 24917 z-index: 1002; 24918 box-shadow: 0 0 20px rgba(0,0,0,0.2); 24919 display: flex; 24920 flex-direction: column; 24921 border-radius: 5px 0 0 5px; 24922 } 24923 .overlay-backdrop{ 24924 position: fixed; 24925 inset: 0; 24926 background: rgba(0,0,0,0.4); 24927 z-index: 1001; 24928 } 24929 /* Slide-in-from-right transition */ 24930 .slide-in-right-enter-active, .slide-in-right-leave-active{ 24931 transition: transform 200ms ease-out; 24932 } 24933 .slide-in-right-enter-from, .slide-in-right-leave-to{ 24934 transform: translateX(100%); 24935 } 24936 24937 .tag-context-menu{ 24938 position: absolute; 24939 top: 100%; 24940 right: 0; 24941 margin-top: 4px; 24942 background: #fff; 24943 border: 1px solid #e0e4ee; 24944 border-radius: 6px; 24945 box-shadow: 0 4px 12px rgba(0,0,0,0.08); 24946 min-width: 160px; 24947 z-index: 10; 24948 padding: 4px 0; 24949 } 24950 24951 .tag-context-menu--above{ 24952 top: auto; 24953 bottom: 100%; 24954 margin-top: 0; 24955 margin-bottom: -5px; 24956 } 24957 24958 .tag-context-menu-item{ 24959 width: 100%; 24960 padding: 6px 10px; 24961 background: transparent; 24962 border: none; 24963 text-align: left; 24964 font-size: 16px; 24965 cursor: pointer; 24966 color: #333; 24967 white-space: nowrap; 24968 } 24969 24970 .tag-context-menu-item:hover{ 24971 background: #f3f5fb; 24972 } 24973 24974 .tag-context-menu-item-danger{ 24975 color: #c0392b; 24976 } 24977</style> 24978
24978<script type="text/html" id="template-booking-calendar-cell"> 24979 <div 24980 class="calendar-cell grid-cell pointer booking-calendar" 24981 :class="{'notCurrentMonth': ! is_current_month, 'today': is_today}" 24982 @click="add_event" 24983 > 24984 <div class="day-info flexWrap"> 24985 <div @click.stop="zoom_in_to_date"> 24986 <div class="cell-day calendarObj">{{ day }}</div> 24987 <div class="day-name">{{ dayOfWeek }}</div> 24988 </div> 24989 </div> 24990 <div class="events"> 24991 <!-- render content for each slot --> 24992 <template 24993 v-for="(slot, index) in slots" 24994 :key="index" 24995 > 24996 <!-- ok, we have an event for this slot--> 24997 <template v-if="slot.event"> 24998 <div 24999 class="event" 25000 :class="[{'all_day': slot.event.isAllDay}, {'partial-day': ! slot.event.isAllDay}]" 25001 @click.stop="clicked_event( slot.event )" 25002 :style="return_multi_day_event_style( slot.event )" 25003 > 25004 <div 25005 class="event-title-v2 flexNoWrap align-items-center easeSuperFast" 25006 style="font-size:12.5px;" 25007 > 25008 <template 25009 v-if="! slot.event.isAllDay" 25010 > 25011 <!-- dot --> 25012 <i 25013 style="padding-right:6px;padding-left:2px;font-size:8px;" 25014 class="fa-solid fa-circle hideOnMobile" 25015 :style="{color: slot.event.color}" 25016 ></i> 25017 <!-- start time --> 25018 <span v-if="! slot.event.isAllDay" class="event-time-v2 hideOnMobile">{{ formatEventTime( slot.event.ts ) }}</span> 25019 </template> 25020 <!-- event title --> 25021 {{ slot.event.title }} 25022 </div> 25023 </div> 25024 </template> 25025 <template v-else-if="should_hold_slot(slot, index)"> 25026 <!-- Leave it blank if this slot is used later in the week --> 25027 <div 25028 class="event placeholder" 25029 > 25030 <!-- add a placeholder if there's an event in this slot but it doesn't start on this day --> 25031 <div 25032 class="event-title-v2 flexNoWrap align-items-center easeSuperFast" 25033 style="font-size:12.5px; visibility: hidden;" 25034 > </div> 25035 <!-- adds a hidden, non-breaking space --> 25036 </div> 25037 </template> 25038 25039 <!-- no event for this slot --> 25040 <template v-else> 25041 <div 25042 class="event partial-day" 25043 ></div> 25044 </template> 25045 25046 </template> 25047 25048 <!-- "+ x more" Label for Overflow --> 25049 <div 25050 v-if="overflowCount > 0" 25051 class="more-events-label" 25052 @click.stop="clicked_show_more_events" 25053 > 25054 + {{ overflowCount }} more 25055 </div> 25056 </div> 25057 </div> 25058</script>
25058 25059 25060<style scoped> 25061 25062 .event-time-v2{ 25063 font-size: 11px; 25064 padding-right: 4px;; 25065 } 25066 25067 .more-events-label { 25068 font-size: 12px; 25069 cursor: pointer; 25070 padding-left: 6px; 25071 padding-right: 6px; 25072 text-align: left; 25073 padding-top: 2px; 25074 padding-bottom: 2px; 25075 cursor: pointer; 25076 border-radius: 5px; 25077 } 25078 25079 .event-title-v2{ 25080 font-size: 12.5px; 25081 white-space: nowrap; 25082 padding-top: 2px; 25083 padding-bottom: 2px;; 25084 padding-left: 6px; 25085 padding-right: 6px; 25086 cursor: pointer; 25087 border-radius: 5px; 25088 } 25089 25090 .more-events-label:hover, 25091 .partial-day .event-title-v2:hover{ 25092 background-color: #e4ebf1; 25093 } 25094 .booking-calendar .event.partial-day { 25095 /* color: #fff; */ 25096 border-radius: 8px; 25097 } 25098 25099 .cell-day.calendarObj{ 25100 min-width: 22px; 25101 max-width: 22px; 25102 min-height: 22px; 25103 max-height: 22px; 25104 margin: 6px !important; 25105 font-size: 12px; 25106 } 25107 .grid-cell.today .cell-day{ 25108 background-color: var(--custom-mp-accent, #4a56f8); 25109 color: rgb(255, 255, 255); 25110 } 25111 25112 25113 /* Event List Styling */ 25114 .events { 25115 display: flex; 25116 flex-direction: column; 25117 gap: 4px; /* Space between events */ 25118 } 25119 25120 .event{ 25121 font-size: 12px; 25122 font-weight: 400; 25123 overflow: hidden; 25124 white-space: nowrap; 25125 text-align: left; 25126 } 25127 25128 /* Individual Event Styling */ 25129 .event.all_day { 25130 background-color: var(--custom-mp-accent, #4a56f8); 25131 color: #fff; 25132 border-radius: 8px; 25133 width: calc(100% - 4px); 25134 } 25135 25136</style> 25137
25138<script> 25139 25140 /* ----- COMPONENT ----- */ 25141 app_content.component("booking-calendar-cell", { 25142 template: "#template-booking-calendar-cell", 25143 data() { 25144 return {} 25145 }, 25146 props: { 25147 date: Object, 25148 day: Number, 25149 day_of_week: String, 25150 date_components: Object, 25151 events: Array, 25152 slots: Array, 25153 timezone: String, 25154 is_current_month: Boolean, 25155 days_array: Array, 25156 calendar_type: String 25157 }, 25158 mounted(){ 25159 }, 25160 methods: { 25161 add_event(){ 25162 var date_components = return_date_components_v2({ 25163 date: this.date, 25164 timezone: this.timezone 25165 }) 25166 this.$emit("add_event", date_components); 25167 }, 25168 zoom_in_to_date(){ 25169 this.$emit("zoom_in_to_date", this.date); 25170 }, 25171 formatEventTime(timestamp) { 25172 const time = moment.tz(timestamp, this.timezone); 25173 if (time.minute() === 0) { 25174 return time.format("ha"); 25175 } 25176 return time.format("h:mma"); 25177 }, 25178 is_multi_day_event( event ){ 25179 return moment.tz(event.ts, this.timezone).isBefore(moment.tz(event.end_ts, this.timezone), 'day'); 25180 }, 25181 // Updated to fix last day rendering & timezone issues for multi-day events 25182 return_multi_day_event_style(event) { 25183 const cellDate = moment.tz(this.date, this.timezone).startOf('day'); 25184 const eventStart = moment.tz(event.ts, this.timezone).startOf('day'); 25185 const eventEnd = moment.tz(event.end_ts, this.timezone).startOf('day'); 25186 25187 // Skip styling if the event doesn't span this day 25188 if (cellDate.isBefore(eventStart) || cellDate.isAfter(eventEnd)) { 25189 return { display: 'none' }; 25190 } 25191 25192 const isFirstDay = cellDate.isSame(eventStart, 'day'); 25193 const isStartOfWeek = cellDate.day() === 0; 25194 const textColor = this.adaptiveTextColorFromColors(event.bgColors); 25195 25196 if (isFirstDay || isStartOfWeek) { 25197 const remainingDays = eventEnd.diff(cellDate, 'days') + 1; 25198 const span = Math.min(remainingDays, 7 - cellDate.day()); 25199 25200 return { 25201 width: `calc(${span * 100}% - 4px)`, 25202 zIndex: 1, 25203 color: textColor, 25204 background: event.bgColors?.length 25205 ? `repeating-linear-gradient( 25206 -45deg, 25207 ${event.bgColors.map((color, i) => `${color} ${i * 20}px, ${color} ${(i + 1) * 20}px`).join(",")} 25208 )` 25209 : event.bgColor || "#4a56f8" 25210 }; 25211 } 25212 25213 return { 25214 width: 'calc(100% - 4px)', 25215 background: 'transparent', 25216 visibility: 'hidden' 25217 }; 25218 }, 25219 remaining_days_in_event(event) { 25220 const cellDate = moment.tz(this.date, this.timezone).startOf("day"); 25221 const eventEnd = moment.tz(event.end_ts, this.timezone).startOf("day"); 25222 if (cellDate.isAfter(eventEnd)) { 25223 return 0; 25224 } 25225 return eventEnd.diff(cellDate, "days") + 1; 25226 }, 25227 return_number_of_days_for_event( event ){ 25228 let start = moment.tz(event.ts, this.timezone); 25229 let end = moment.tz(event.end_ts, this.timezone); 25230 //continue 25231 return end.diff(start, 'days') + 1; 25232 }, 25233 is_first_day_of_event(event) { 25234 // Compare the event start date with the cell's date 25235 const eventStart = moment.tz(event.ts, this.timezone).startOf("day"); 25236 const cellDate = moment.tz(this.date, this.timezone).startOf("day"); 25237 return eventStart.isSame(cellDate, "day"); 25238 }, 25239 clicked_event(event) { 25240 // Handle event click 25241 this.$emit("clicked_event", event); 25242 }, 25243 clicked_show_more_events() { 25244 // Handle "+ x more" click (e.g., show a modal or expand view
25244) 25245 this.$emit("clicked_show_more_events", this.date, this.day_of_week, this.events); 25246 }, 25247 should_hold_slot(slot, index) { 25248 const usedSlots = this.return_slots_in_use_for_week(slot, index); 25249 return usedSlots.includes(index); 25250 }, 25251 return_slots_in_use_for_week(slot, index) { 25252 const startOfWeek = moment.tz(this.date, this.timezone).startOf("week"); 25253 const endOfWeek = moment.tz(this.date, this.timezone).endOf("week"); 25254 25255 const slotsUsed = new Set(); 25256 25257 this.days_array.forEach(day => { 25258 const dayMoment = moment.tz(day.date, this.timezone).startOf("day"); 25259 25260 // Only consider days in the current week 25261 if (dayMoment.isBetween(startOfWeek, endOfWeek, null, '[]')) { 25262 if (Array.isArray(day.slots)) { 25263 day.slots.forEach(slotObj => { 25264 if (slotObj.event) { 25265 slotsUsed.add(slotObj.slot); 25266 } 25267 }); 25268 } 25269 } 25270 }); 25271 return Array.from(slotsUsed); 25272 }, 25273 // --- text color helpers --- 25274 computeLuminance(hex) { 25275 if (!hex) return 1; // treat as light 25276 const m = String(hex).trim(); 25277 let r, g, b; 25278 if (/^#?[0-9a-fA-F]{3}$/.test(m)) { 25279 const s = m.replace('#',''); 25280 r = parseInt(s[0] + s[0], 16); 25281 g = parseInt(s[1] + s[1], 16); 25282 b = parseInt(s[2] + s[2], 16); 25283 } else { 25284 const s = m.replace('#','').slice(0,6); 25285 r = parseInt(s.substr(0,2), 16); 25286 g = parseInt(s.substr(2,2), 16); 25287 b = parseInt(s.substr(4,2), 16); 25288 } 25289 const toLin = (c) => { 25290 c /= 255; 25291 return c <= 0.03928 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4); 25292 }; 25293 return 0.2126*toLin(r) + 0.7152*toLin(g) + 0.0722*toLin(b); // 0=dark, 1=light 25294 }, 25295 adaptiveTextColorFromColors(colors, lightText = '#ffffff', darkText = '#2c2e30') { 25296 const arr = (colors && colors.length) ? colors : []; 25297 if (!arr.length) { 25298 const defaultBg = '#4a56f8'; 25299 return this.computeLuminance(defaultBg) < 0.5 ? lightText : darkText; 25300 } 25301 let darkCount = 0; 25302 for (let i = 0; i < arr.length; i++) { 25303 const L = this.computeLuminance(arr[i]); 25304 if (!isNaN(L) && L < 0.5) darkCount++; 25305 } 25306 const mostlyDark = darkCount >= Math.ceil(arr.length / 2); 25307 return mostlyDark ? lightText : darkText; 25308 }, 25309 }, 25310 computed:{ 25311 is_today(){ 25312 return moment.tz(new Date(), this.timezone).isSame(moment.tz(this.date, this.timezone), 'day'); 25313 }, 25314 overflowCount() { 25315 // Calculate the number of hidden events 25316 let visible_events = 0; 25317 this.slots.forEach(slot => { 25318 if( slot.event ){ 25319 visible_events ++; 25320 } 25321 }); 25322 return this.events.length - visible_events; 25323 }, 25324 days_until_end_of_week(){ 25325 let start = moment.tz(this.date, this.timezone); 25326 let days_until_end_of_week = 7 - start.day(); 25327 return days_until_end_of_week; 25328 }, 25329 is_first_day_of_week(){ 25330 return moment.tz(this.date, this.timezone).day() === 0; 25331 }, 25332 }, 25333 }) 25334</script>
25334
25334<script type="text/html" id="template-booking-calendar-day-column"> 25335 25336 <div class="flexGrowOne"> 25337 <!-- Time Slots Section --> 25338 <div class="time-slots"> 25339 <!-- all day --> 25340 <div 25341 v-if="view_preference == 'Day'" 25342 class="time-slot" 25343 style="height:auto;" 25344 > 25345 <div class="time-label">All day</div> 25346 <div 25347 class="time-content" 25348 style="display: flex;flex-direction: column;gap: 4px; padding: 8px;" 25349 > 25350 <!-- Filter and position events for this hour --> 25351 25352 <div v-for="(event, index) in all_day_events" :key="index" class="event all_day"> 25353 <div 25354 class="event-title-v2 flexNoWrap align-items-center easeSuperFast" 25355 :style="all_day_event_style(event)" 25356 @click="clicked_event(event)" 25357 > 25358 {{ event.title }} 25359 </div> 25360 </div> 25361 25362 </div> 25363 </div> 25364 <!-- hours --> 25365 <div 25366 v-for="hour in 24" 25367 :key="hour" 25368 class="time-slot pointer" 25369 @click="add_event( hour - 1 )" 25370 > 25371 <div 25372 class="time-label" 25373 v-if="view_preference == 'Day'" 25374 >{{ formatHour( hour - 1 ) }}</div> 25375 <div class="time-content"> 25376 <!-- Filter and position events for this hour --> 25377 <div 25378 v-for="(event, index) in filterEventsForHour(hour - 1)" 25379 :key="index" 25380 class="event time-slot-event" 25381 :style="calculateEventStyle(event, index)" 25382 @click.stop="clicked_event( event )" 25383 > 25384 <div class="event-title-v2" style="white-space: initial;">{{ event.title }}</div> 25385 </div> 25386 </div> 25387 </div> 25388 </div> 25389 </div> 25390 25391</script>
25391 25392 25393<style scoped> 25394 25395 .event.time-slot-event { 25396 position: absolute; 25397 left: 5px; /* Align slightly inside the slot */ 25398 right: 5px; 25399 color: white; 25400 border-radius: 4px; 25401 font-size: 12px; 25402 font-weight: 500; 25403 overflow: hidden; 25404 white-space: nowrap; 25405 text-overflow: ellipsis; 25406 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); 25407 z-index: 1; 25408 padding-top: 0px; 25409 padding-bottom: 0px; 25410 padding-left: 8px; 25411 padding-right: 8px; 25412 cursor: pointer; 25413 background-color: #5774f5; /* Primary color */ 25414 } 25415 25416</style> 25417
25418<script> 25419 25420 /* ----- COMPONENT ----- */ 25421 app_content.component("booking-calendar-day-column", { 25422 template: "#template-booking-calendar-day-column", 25423 data() { 25424 return {} 25425 }, 25426 props: { 25427 date_components: Object, 25428 events: Array, 25429 timezone: String, 25430 view_preference: String 25431 }, 25432 mounted(){ 25433 }, 25434 methods: { 25435 add_event( hour ){ 25436 let date_components = {... this.date_components}; 25437 date_components.hour = hour; 25438 date_components.date = this.date_components.day; 25439 this.$emit('add_event', date_components); 25440 }, 25441 clicked_event( event ){ 25442 this.$emit('clicked_event', event); 25443 }, 25444 formatHour(hour) { 25445 const baseDate = moment().startOf('day').hour(hour); 25446 return baseDate.format("h:mm A"); 25447 }, 25448 filterEventsForHour(hour) { 25449 const filtered = this.events.filter((event) => { 25450 if (event.isAllDay) return false; 25451 const eventStart = moment(event.ts).tz(this.timezone); 25452 return eventStart.hour() === hour; 25453 }); 25454 const count = filtered.length; 25455 return filtered.map(event => ({ 25456 ...event, 25457 countForHour: count 25458 })); 25459 }, 25460 all_day_event_style(event){ 25461 return { 25462 "background": event.bgColors && event.bgColors.length > 0 25463 ? `repeating-linear-gradient( 25464 -45deg, 25465 ${event.bgColors.map((color, i) => `${color} ${i * 20}px, ${color} ${(i + 1) * 20}px`).join(",")} 25466 )` 25467 : event.bgColor || "", 25468 }; 25469 }, 25470 calculateEventStyle(event, i) { 25471 const eventStart = moment( event.ts ).tz(this.timezone); 25472 const eventEnd = moment(event.end_ts).tz(this.timezone); 25473 const count = event.countForHour; 25474 25475 // Calculate start and end as a percentage of the hour 25476 const startMinutes = eventStart.minutes(); 25477 const endMinutes = eventEnd.minutes(); 25478 const duration = eventEnd.diff(eventStart, "minutes"); 25479 25480 return { 25481 top: `${(startMinutes / 60) * 100}%`, // Start position 25482 height: `calc(${(duration / 60) * 100}% - 3px)`, // Subtracts 3px for padding 25483 width: `calc(${(1/count) * 100}% - 3px)`, // Subtracts 3px for padding 25484 'margin-left': `calc(${(i/count) * 100}%)`, 25485 'padding-left': '0px', 25486 'padding-right': '0px', 25487 "background": event.bgColors && event.bgColors.length > 0 25488 ? `repeating-linear-gradient( 25489 -45deg, 25490 ${event.bgColors.map((color, i) => `${color} ${i * 20}px, ${color} ${(i + 1) * 20}px`).join(",")} 25491 )` 25492 : event.bgColor || "", 25493 }; 25494 }, 25495 }, 25496 computed:{ 25497 all_day_events(){ 25498 let all_day = this.events.filter( event => event.isAllDay === true ); 25499 return all_day; 25500 }, 25501 }, 25502 }) 25503</script>
25503 25504 25505 25506 25507 25508 25509 25510 <!-- native tab bar --> 25511 <!DOCTYPE html> 25512<html lang="en"> 25513
25513<script type="text/html" id="template-bottom-sheet-overlay"> 25514 25515 <div 25516 class="v-overlay scrollable hugePaddingBottom active" 25517 @touchstart.self="touched_overlay()" 25518 @mousedown.self="touched_overlay()" 25519 @click.self="clicked_overlay()" 25520 :class="[{'noPointer': prevent_close}, {'bottom-sheet-blur': blur_background}]" 25521 > 25522 <div 25523 class="standardOverlayCard centeredBlock bottom-sheet" 25524 :class="[ 25525 {'short': height == 'short' || is_short}, 25526 {'narrow': narrow}, 25527 {'noMaxWidth': full_width} 25528 ]" 25529 > 25530 25531 <!-- generic header content --> 25532 <div class="bottom-sheet-overlay-header"> 25533 25534 <div 25535 v-if="! prevent_close" 25536 class="relative" 25537 > 25538 <div 25539 class="overlay-header-handle-wrap centeredBlock" 25540 @click.stop="toggle_height()" 25541 > 25542 <div class="overlay-header-handle"></div> 25543 </div> 25544 <i 25545 class="overlay-x-button fa-regular fa-xmark pointer" 25546 @click.stop="dismiss_overlay()" 25547 ></i> 25548 </div> 25549 25550 <!-- header content injected here --> 25551 <slot name="header"></slot> 25552 25553 </div> 25554 25555 <!-- body content injected here --> 25556 <slot name="body"></slot> 25557 25558 <!-- footer content injected here --> 25559 <slot name="footer"></slot> 25560 25561 </div> 25562 </div> 25563 25564</script>
25564 25565 25566<style> 25567 25568 .standardOverlayCard.bottom-sheet.narrow{ 25569 max-width: 600px; 25570 } 25571 25572 .standardOverlayCard.bottom-sheet.medium{ 25573 max-width: 800px; 25574 } 25575 25576 .standardOverlayCard.bottom-sheet{ 25577 position: fixed; 25578 bottom: 0px; 25579 top: 50px; 25580 left: 0px; 25581 right: 0px; 25582 z-index: 1001; 25583 background-color: #fff; 25584 box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); 25585 transition: bottom 0.3s cubic-bezier(0.25, 0.1, 0.25, 1); 25586 border-top-left-radius: 10px; 25587 border-top-right-radius: 10px; 25588 padding-bottom: 10px; 25589 padding-top: 10px; 25590 max-width: 1100px; 25591 margin-top: 0px; 25592 border-bottom-left-radius: 0px; 25593 border-bottom-right-radius: 0px; 25594 } 25595 25596 .standardOverlayCard.bottom-sheet.short{ 25597 top: 50%; 25598 } 25599 25600 @media (max-height: 600px) { 25601 25602 .standardOverlayCard.bottom-sheet.short{ 25603 top: 150px; 25604 } 25605 25606 } 25607 25608 .standardOverlayCard.bottom-sheet h3{ 25609 padding-top:0px; 25610 } 25611 25612 .standardOverlayCard.bottom-sheet{ 25613 display: flex; 25614 flex-direction: column; 25615 padding-bottom:0px; 25616 padding-top: 0px; 25617 } 25618 25619 .standardOverlayCard.bottom-sheet .bottom-sheet-overlay-card-header 25620 { 25621 flex: 0 0 auto; /* Fixed height, doesn't grow or shrink */ 25622 } 25623 25624 .standardOverlayCard.bottom-sheet .standardCardFooter{ 25625 position: absolute; 25626 bottom: 0px; 25627 padding-bottom: var(--cs-safe-area-bottom, 10px); 25628 left: 0px; 25629 right: 0px; 25630 background-color: #fff; 25631 border-top: 1px solid #e4ebf1; 25632 z-index: 10; 25633 } 25634 25635 .standardOverlayCard.bottom-sheet .standardCardBody{ 25636 flex: 1 1 auto; /* Flexible height, can grow and shrink */ 25637 padding-bottom: 90px !important; 25638 } 25639 25640 .overlay-header-handle-wrap{ 25641 padding: 10px; 25642 cursor: pointer; 25643 max-width: 120px; 25644 } 25645 .overlay-header-handle{ 25646 width: 70px; 25647 height: 3px; 25648 border-radius: 10px; 25649 background-color: #acb3ba; 25650 margin-left: auto; 25651 margin-right: auto; 25652 margin-top: 8px; 25653 margin-bottom: 8px; 25654 } 25655 .overlay-x-button{ 25656 position: absolute; 25657 right: 14px; 25658 color: #535e6c; 25659 font-size: 20px; 25660 top: 12px; 25661 } 25662</style> 25663
25664<script> 25665 25666 //define the component 25667 app_overlays.component("bottom-sheet-overlay", { 25668 template: "#template-bottom-sheet-overlay", 25669 data() { 25670 return { 25671 height: "tall" 25672 } 25673 }, 25674 props: { 25675 blur_background: { 25676 type: Boolean, 25677 default: false 25678 }, 25679 narrow: { 25680 type: Boolean, 25681 default: false 25682 }, 25683 prevent_close: { 25684 type: Boolean, 25685 default: false 25686 }, 25687 full_width: { 25688 type: Boolean, 25689 default: false 25690 }, 25691 is_short: { 25692 type: Boolean, 25693 default: false 25694 } 25695 }, 25696 mounted(){ 25697 const universalEditOverlay = document.getElementById('overlay_universal-edit'); 25698 if (universalEditOverlay) { 25699 universalEditOverlay.style.zIndex = "1000000"; 25700 } 25701 const membershipValidationOverlay = document.getElementById('overlay_verifyMembership'); 25702 if (membershipValidationOverlay) { 25703 membershipValidationOverlay.style.zIndex = "1000000"; 25704 } 25705 }, 25706 beforeUnmount() { 25707 //reset the z-index 25708 const universalEditOverlay = document.getElementById('overlay_universal-edit'); 25709 if (universalEditOverlay) { 25710 universalEditOverlay.style.zIndex = ""; // Revert to default 25711 } 25712 const membershipValidationOverlay = document.getElementById('overlay_verifyMembership'); 25713 if (membershipValidationOverlay) { 25714 membershipValidationOverlay.style.zIndex = ""; // Revert to default 25715 } 25716 }, 25717 methods: { 25718 dismiss_overlay(){ 25719 this.$emit('dismiss_overlay'); 25720 }, 25721 toggle_height(){ 25722 if( this.height === "tall" ){ 25723 this.height = "short"; 25724 }else{ 25725 this.height = "tall"; 25726 } 25727 }, 25728 clicked_overlay(){ 25729 if( this.prevent_close ){ 25730 return; 25731 } 25732 if (should_close_overlay(this.last_touch)) { 25733 this.dismiss_overlay(); 25734 } 25735 }, 25736 touched_overlay(){ 25737 this.last_touch = new Date().getTime(); 25738 }, 25739 }, 25740 }); 25741 25742 25743</script>
25743 25744 25745<style> 25746 .bottom-sheet-blur{ 25747 backdrop-filter: blur(6px); 25748 } 25749 25750 @keyframes bottom-sheet-fade-in { 25751 from { opacity: 0; } 25752 to { opacity: 1; } 25753 } 25754</style>
25754<script type="text/html" id="template-show-push-notification"> 25755 25756 <bottom-sheet-overlay 25757 @dismiss_overlay="dismiss_overlay()" 25758 > 25759 25760 <!-- header content --> 25761 <template #header> 25762 <div class="flexNoWrap"> 25763 <h3 25764 class="flexGrowOne" 25765 style="padding-bottom:0px;" 25766 > 25767 <p>{{ title }}</p> 25768 <template v-if="has_date"> 25769 <p 25770 class="card-header-subtext pointer" 25771 >{{ date_string }}</p> 25772 </template> 25773 </h3> 25774 </div> 25775 </template> 25776 25777 <!-- body content --> 25778 <template #body> 25779 <div 25780 class="standardCardBody fff scrollable" 25781 > 25782 <p class="p-1" style="margin: 0px 8px;">{{ notification.get('data').alert }}</p> 25783 </div> 25784 </template> 25785 25786 <!-- footer content --> 25787 <template 25788 #footer 25789 > 25790 <div 25791 class="standardCardFooter flexNoWrap" 25792 > 25793 <div class="flexGrowOne"></div> 25794 <button class="marketing-header-button blue easeSuperFast flexWrapLeftAlign align-items-center" @click="dismiss_overlay()" >Done</button> 25795 </div> 25796 </template> 25797 25798 </bottom-sheet-overlay> 25799 25800</script>
25800 25801
25802<script> 25803 25804 function show_push_notification( params ){ 25805 store.components.show_push_notification.notification = params.notification; 25806 store.overlays.show_push_notification = true; 25807 } 25808 25809 //add a value to our global store, specific to this component 25810 store.components["show_push_notification"] = { 25811 notification: null 25812 } 25813 25814 //define the component 25815 app_overlays.component("show-push-notification", { 25816 template: "#template-show-push-notification", 25817 data() { 25818 return store.components["show_push_notification"] 25819 }, 25820 methods: { 25821 dismiss_overlay(){ 25822 store.overlays.show_push_notification = false; 25823 } 25824 }, 25825 computed: { 25826 title(){ 25827 let title = this.notification.get('data').title; 25828 if (title && title.length > 0){ 25829 return title; 25830 } 25831 return "Notification"; 25832 }, 25833 has_date(){ 25834 return this.notification.createdAt !== undefined; 25835 }, 25836 date_string(){ 25837 const createdAt = this.notification.createdAt; 25838 25839 if (! createdAt){ 25840 return createdAt; 25841 } 25842 25843 if ( is_utc_date_today( createdAt ) ){ 25844 return format_utc_date_v2({ 25845 "utcDate": createdAt, 25846 "formatString": "[Today at] h:mm A" 25847 }); 25848 } else { 25849 return format_utc_date_v2({ 25850 "utcDate": createdAt, 25851 "formatString": "ddd M/D h:mm A" 25852 }); 25853 } 25854 } 25855 } 25856 }); 25857 25858</script>
25858
25858<script type="text/html" id="template-native-tab-bar"> 25859 25860 <!-- first, make sure we're in the react native member app --> 25861 <template v-if="is_member_portal || is_react_native_member_app || is_admin_preview"> 25862 25863 <!-- ââ Floating glass tab bar âââââââââââ --> 25864 <div 25865 v-if="show_tab_bar && use_floating_tab_bar" 25866 class="mp-float-tab-bar" 25867 :class="{ 25868 'mp-float-tab-bar--preview': is_admin_preview, 25869 'mp-float-tab-bar--settings-preview': settings_preview_mode 25870 }" 25871 > 25872 <!-- Single sliding pill â moves between tabs via CSS transition --> 25873 <div class="mp-float-tab-pill" :style="float_pill_style"></div> 25874 25875 <template v-if="visible_tabs"> 25876 <div 25877 v-for="tab in visible_tabs" 25878 :key="tab.key" 25879 class="mp-float-tab-item" 25880 :class="{ 'mp-float-tab-item--active': tab_is_active(tab) }" 25881 @touchstart.prevent="tapped_tab_bar_item(tab)" 25882 @click="tapped_tab_bar_item(tab)" 25883 > 25884 <template v-if="show_member_details_avatar(tab)"> 25885 <span 25886 class="mp-float-tab-avatar" 25887 :class="{ 'mp-float-tab-avatar--active': tab_is_active(tab) }" 25888 :style="{ backgroundImage: 'url(' + member_tab_avatar_url + ')' }" 25889 ></span> 25890 </template> 25891 <template v-else> 25892 <i class="mp-float-tab-icon" :class="float_icon_class(tab, tab_is_active(tab))"></i> 25893 </template> 25894 </div> 25895 <!-- More item --> 25896 <div 25897 class="mp-float-tab-item" 25898 :class="{ 'mp-float-tab-item--active': more_is_active }" 25899 @touchstart.prevent="tapped_more()" 25900 @click="tapped_more()" 25901 > 25902 <i :class="(more_is_active ? 'fa-solid' : 'fa-regular') + ' fa-ellipsis mp-float-tab-icon'"></i> 25903 </div> 25904 </template> 25905 </div> 25906 25907 <!-- Classic tab bar âââââââââââââââ --> 25908 <div 25909 v-else-if="show_tab_bar" 25910 class="native-tab-bar-outer flexNoWrap" 25911 :style="[{ 25912 backgroundColor: tab_bar_colors.background || 'rgba(249, 251, 251, 1)' 25913 }, { 25914 backdropFilter: tab_bar_colors.blur ? 'blur(' + tab_bar_colors.blur + ')' : 'none' 25915 }]" 25916 :class="{ 'noBorder': tab_bar_colors.background, 'preview': is_admin_preview }" 25917 > 25918 <template v-if="visible_tabs"> 25919 <div 25920 v-for="tab in visible_tabs" 25921 :key="tab.key" 25922 class="native-tab-bar-item" 25923 :class="{ active : tab_is_active( tab ) }" 25924 :style=" tab_is_active( tab ) ? { color: tab_bar_colors.active || '#4a56f8' } : { color: tab_bar_colors.text || '#697684' } " 25925 @touchstart.prevent="tapped_tab_bar_item( tab )" 25926 @click="tapped_tab_bar_item( tab )" 25927 > 25928 <div class="centeredText centeredBlock"> 25929 <p> 25930 <template v-if="show_member_details_avatar(tab)"> 25931 <span 25932 class="native-tab-bar-avatar" 25933 :style="{ backgroundImage: 'url(' + member_tab_avatar_url + ')' }" 25934 ></span> 25935 </template> 25936 <template v-else> 25937 <i class="native-tab-bar-icon" :class="tab.icon_class"></i> 25938 </template> 25939 </p> 25940 <p class="native-tab-bar-label">{{ tab.label }}</p> 25941 </div> 25942 </div> 25943 <div 25944 class="native-tab-bar-item" 25945 @touchstart.prevent="tapped_more()" 25946 @click="tapped_more()" 25947 :class="{ 'active': more_is_active }" 25948 :style=" more_is_active ? { color: tab_bar_colors.active || '#4a56f8' } : { color: tab_bar_colors.text || '#697684' } " 25949 > 25950 <div class="centeredText centeredBlock"> 25951 <p><i class="fa-regular fa-ellipsis native-tab-bar-icon"></i></p> 25952 <p class="native-tab-bar-label">More</p> 25953 </div> 25954 </div> 25955 </template> 25956 </div> 25957 25958 <!-- compact more-section tab bar (floating) --> 25959 <div 25960 v-else-if="show_more_section_tab_bar && use_floating_tab_bar" 25961 class="mp-float-tab-bar" 25962 > 25963 <div class="mp-float-tab-pill" :style="compact_float_pill_style"></div> 25964 <div 25965 class="mp-float-tab-item"
25966 @touchstart.prevent="tapped_back_immediate()" 25967 @click="tapped_back_immediate()" 25968 > 25969 <i class="fa-regular fa-chevron-left mp-float-tab-icon"></i> 25970 </div> 25971 <div 25972 class="mp-float-tab-item" 25973 @touchstart.prevent="tapped_home()" 25974 @click="tapped_home()" 25975 > 25976 <i class="mp-float-tab-icon" :class="compact_home_icon_class"></i> 25977 </div> 25978 <div 25979 class="mp-float-tab-item mp-float-tab-item--active" 25980 @touchstart.prevent="tapped_tab_bar_item(compact_selected_tab)" 25981 @click="tapped_tab_bar_item(compact_selected_tab)" 25982 > 25983 <template v-if="show_member_details_avatar(compact_selected_tab)"> 25984 <span 25985 class="mp-float-tab-avatar mp-float-tab-avatar--active" 25986 :style="{ backgroundImage: 'url(' + member_tab_avatar_url + ')' }" 25987 ></span> 25988 </template> 25989 <template v-else> 25990 <i class="mp-float-tab-icon" :class="compact_selected_tab_icon_class"></i> 25991 </template> 25992 </div> 25993 <div 25994 class="mp-float-tab-item" 25995 @touchstart.prevent="tapped_more()" 25996 @click="tapped_more()" 25997 > 25998 <i class="fa-regular fa-ellipsis mp-float-tab-icon"></i> 25999 </div> 26000 </div> 26001 26002 <!-- compact more-section tab bar (classic) --> 26003 <div 26004 v-else-if="show_more_section_tab_bar" 26005 class="native-tab-bar-outer flexNoWrap" 26006 :style="[{ 26007 backgroundColor: tab_bar_colors.background || 'rgba(249, 251, 251, 1)' 26008 }, { 26009 backdropFilter: tab_bar_colors.blur ? 'blur(' + tab_bar_colors.blur + ')' : 'none' 26010 }]" 26011 :class="{ 'noBorder': tab_bar_colors.background, 'preview': is_admin_preview }" 26012 > 26013 <div 26014 class="native-tab-bar-item" 26015 :style="{ color: tab_bar_colors.text || '#697684' }" 26016 @touchstart.prevent="tapped_back_immediate()" 26017 @click="tapped_back_immediate()" 26018 > 26019 <div class="centeredText centeredBlock"> 26020 <p><i class="fa-regular fa-chevron-left native-tab-bar-icon"></i></p> 26021 <p class="native-tab-bar-label">Back</p> 26022 </div> 26023 </div> 26024 <div 26025 class="native-tab-bar-item" 26026 :style="{ color: tab_bar_colors.text || '#697684' }" 26027 @touchstart.prevent="tapped_home()" 26028 @click="tapped_home()" 26029 > 26030 <div class="centeredText centeredBlock"> 26031 <p><i class="native-tab-bar-icon" :class="compact_home_icon_class"></i></p> 26032 <p class="native-tab-bar-label">{{ compact_home_tab_label }}</p> 26033 </div> 26034 </div> 26035 <div 26036 class="native-tab-bar-item active" 26037 :style="{ color: tab_bar_colors.active || '#4a56f8' }" 26038 @touchstart.prevent="tapped_tab_bar_item(compact_selected_tab)" 26039 @click="tapped_tab_bar_item(compact_selected_tab)" 26040 > 26041 <div class="centeredText centeredBlock"> 26042 <p> 26043 <template v-if="show_member_details_avatar(compact_selected_tab)"> 26044 <span 26045 class="native-tab-bar-avatar" 26046 :style="{ backgroundImage: 'url(' + member_tab_avatar_url + ')' }" 26047 ></span> 26048 </template> 26049 <template v-else> 26050 <i class="native-tab-bar-icon" :class="compact_selected_tab_icon_class"></i> 26051 </template> 26052 </p> 26053 <p class="native-tab-bar-label">{{ compact_selected_tab.label }}</p> 26054 </div> 26055 </div> 26056 <div 26057 class="native-tab-bar-item" 26058 :style="{ color: tab_bar_colors.text || '#697684' }" 26059 @touchstart.prevent="tapped_more()" 26060 @click="tapped_more()" 26061 > 26062 <div class="centeredText centeredBlock"> 26063 <p><i class="fa-regular fa-ellipsis native-tab-bar-icon"></i></p> 26064 <p class="native-tab-bar-label">More</p> 26065 </div> 26066 </div> 26067 </div> 26068 26069 <!-- back button (global) --> 26070 <div v-else-if="show_back_button"> 26071 26072 <!-- overlay --> 26073 <div 26074 class="native-tab-bar-back-button-overlay pointer" 26075 @touchstart.self.prevent="back_button_expanded = false" 26076 @click.self="back_button_expanded = false" 26077 :class="{ 'active': back_button_expanded }" 26078 ></div> 26079 26080 <!-- home button --> 26081 <div 26082 class="native-tab-bar-floating-home-button boxShadowBottom moreBlur pointer easeFast" 26083 @click="tapped_home()" 26084 :class="{ 'active': back_button_expanded }" 26085 > 26086 <div class="centeredText centeredBlock"> 26087 <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> 26088 </div> 26089 </div> 26090 26091 <!-- back button --> 26092 <div 26093 class="native-tab-bar-floating-back-button boxShadowBottom moreBlur pointer easeFast" 26094 @click="tapped_back()" 26095 :class="{ 'expanded': back_button_expanded }" 26096 > 26097 <div class="centeredText centeredBlock"> 26098 <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> 26099 </div> 26100 </div> 26101 26102 </div> 26103 26104 </template> 26105 26106</script>
26106 26107 26108 26109<style> 26110 26111 .native-tab-bar-outer{ 26112 position: fixed; 26113 bottom: 0px; 26114 padding-bottom: max(0px, calc(var(--cs-safe-area-bottom, 0px) - 10px)); 26115 left: 0px; 26116 right: 0px; 26117 background-color: rgba(249, 251, 251, 1); 26118 border-top: 1px solid #e4ebf1; 26119 z-index: 1000; 26120 transition: 0.15s cubic-bezier(0.25, 0.1, 0.25, 1); 26121 } 26122 26123 .native-tab-bar-padding-bottom { 26124 padding-bottom: var(--native-tab-bar-padding-bottom, 0px); 26125 } 26126 26127 /* In admin preview mode this is just a sample, so keep z-index low and let real UI layers sit above it. */ 26128 .native-tab-bar-outer.preview{ 26129 position: relative; 26130 z-index: 1; 26131 } 26132 26133 .native-tab-bar-item{ 26134 padding: 16px 11px; 26135 display: flex; 26136 flex: 1 1 0; /* flex-grow: 1; flex-shrink: 1; flex-basis: 0; */ 26137 color: #697684; 26138 cursor: pointer; 26139 } 26140 .native-tab-bar-item.active{ 26141 color: #4a56f8; 26142 } 26143 .native-tab-bar-label{ 26144 font-size: 12px; 26145 padding-top: 4px; 26146 white-space: nowrap; 26147 } 26148 .native-tab-bar-icon{ 26149 font-size: 22px; 26150 } 26151 .native-tab-bar-avatar{ 26152 width: 23px; 26153 height: 23px; 26154 border-radius: 100%; 26155 background-size: cover; 26156 background-position: center center; 26157 background-repeat: no-repeat; 26158 display: inline-block; 26159 vertical-align: middle; 26160 } 26161 26162 /*------ back button -------*/ 26163 26164 .native-tab-bar-back-button-overlay{ 26165 position: fixed; 26166 top: 0; 26167 left: 0; 26168 width: 100%; 26169 height: 100%; 26170 /* 26171 background-color: rgba(111,117,122,0.5); 26172 */ 26173 background-color: rgba(249, 251, 251, 0.8); 26174 opacity: 0; 26175 visibility: hidden; 26176 transition: opacity 0.15s ease; 26177 z-index: 999; 26178 } 26179 26180 .native-tab-bar-back-button-overlay.active{ 26181 opacity: 1; 26182 visibility: visible; 26183 } 26184 26185 .native-tab-bar-floating-back-button{ 26186 position: fixed; 26187 bottom: 22px; 26188 left: 22px; 26189 right: auto; 26190 z-index: 1000; 26191 transition: 0.15s cubic-bezier(0.25, 0.1, 0.25, 1); 26192 border-radius: 100%; 26193 width: 50px; 26194 height: 50px; 26195 background-color: rgba(255, 255, 255, 0.28); 26196 backdrop-filter: blur(15px); 26197 -webkit-backdrop-filter: blur(15px); 26198 line-height: 50px; 26199 font-size: 16px; 26200 white-space: nowrap; 26201 min-width: 50px; 26202 } 26203 26204 .native-tab-bar-floating-back-button.expanded{ 26205 border-radius: 25px; 26206 padding-left: 16px; 26207 padding-right: 16px; 26208 width: auto; 26209 } 26210 26211 /*------ home button -------*/ 26212 26213 .native-tab-bar-floating-home-button.active{ 26214 border-radius: 25px; 26215 padding-left: 16px; 26216 padding-right: 16px; 26217 width: auto; 26218 visibility: visible; 26219 opacity: 1; 26220 animation: expand 0.15s cubic-bezier(0.25, 0.1, 0.25, 1) forwards; 26221 background-color: #4a56f8; 26222 color: #fff; 26223 } 26224 26225 .native-tab-bar-floating-home-button{ 26226 position: fixed; 26227 bottom: 82px; 26228 left: 22px; 26229 right: auto; 26230 visibility: hidden; 26231 opacity: 0; 26232 background-color: rgba(249, 251, 251, 1); 26233 /* border-top: 1px solid #e4ebf1; */ 26234 z-index: 1000; 26235 transition: 0.15s cubic-bezier(0.25, 0.1, 0.25, 1); 26236 border-radius: 20px; 26237 width: auto; 26238 height: 50px; 26239 background-color: #fff; 26240 line-height: 50px; 26241 font-size: 16px; 26242 white-space: nowrap; 26243 26244 /* Initial state for animation */ 26245 transform: scale(0); 26246 animation: none; 26247 } 26248 26249 /* Keyframes for the expansion effect */ 26250 @keyframes expand { 26251 from { 26252 transform: scale(0); 26253 opacity: 0; 26254 } 26255 to { 26256 transform: scale(1); 26257 opacity: 1; 26258 } 26259 } 26260 26261 /* ââ Floating glass tab bar (club XVgOrNASDY only) âââââââââââââââââââ */ 26262 26263 .mp-float-tab-bar { 26264 position: fixed; 26265 /* sit above the safe-area bottom edge with a small gap */ 26266 bottom: max(12px, var(--cs-safe-area-bottom, 12px)); 26267 /* center the pill horizontally; max-width keeps it compact on tablets */ 26268 left: 0; 26269 right: 0; 26270 margin-left: auto; 26271 margin-right: auto; 26272 max-width: 360px; 26273 width: calc(100% - 48px); 26274 /* light frosted glass pill */ 26275 border-radius: 30px; 26276 background: rgba(255, 255, 255, 0.28); 26277 backdrop-filter: blur(15px); 26278 -webkit-backdrop-filter: blur(15px); 26279 border: 1px solid rgba(0, 0, 0, 0.06); 26280 box-shadow: 0 4px 28px rgba(0, 0, 0, 0.12); 26281 /* layout */ 26282 display: flex; 26283 align-items: center; 26284 z-index: 1000; 26285 overflow: hidden; 26286 } 26287 26288 .mp-float-tab-bar--preview { 26289 position: relative; 26290 bottom: auto; 26291 margin: 12px auto; 26292 /* In preview mode we do not want the production-level z-index to overlap the left menu peek. */ 26293 z-index: 1; 26294 } 26295 26296 .mp-float-tab-bar--settings-preview { 26297 margin: 0; 26298 } 26299 26300 .mp-float-tab-item { 26301 flex: 1 1 0; 26302 display: flex; 26303 align-items: center; 26304 justify-content: center; 26305 position: relative; 26306 padding: 18px 0; 26307 cursor: pointer; 26308 /* tap highlight off */ 26309 -webkit-tap-highlight-color: transparent; 26310 } 26311 26312 /* Single sliding active pill â left and width are driven by float_pill_style computed */ 26313 .mp-float-tab-pill { 26314 position: absolute; 26315 top: 7px; 26316 bottom: 7px; 26317 /* left + width set inline; transition slides the pill between tabs */ 26318 border-radius: 22px; 26319 background: rgba(0, 0, 0, 0.08); 26320 pointer-events: none; 26321 transition: left 0.28s cubic-bezier(0.25, 0.1, 0.25, 1), 26322 width 0.28s cubic-bezier(0.25, 0.1, 0.25, 1); 26323 } 26324 26325 .mp-float-tab-icon { 26326 font-size: 22px; 26327 color: rgba(0, 0, 0, 0.7); 26328 position: relative; /* above the pill */ 26329 z-index: 1; 26330 } 26331 26332 .mp-float-tab-avatar { 26333 width: 26px;
26334 height: 26px; 26335 border-radius: 50%; 26336 background-size: cover; 26337 background-position: center; 26338 background-repeat: no-repeat; 26339 display: inline-block; 26340 position: relative; 26341 z-index: 1; 26342 opacity: 1; 26343 box-shadow: 0 0 0 2px transparent; 26344 transition: box-shadow 0.2s ease; 26345 } 26346 26347 .mp-float-tab-avatar--active { 26348 box-shadow: 0 0 0 2px #fff; 26349 } 26350 26351</style> 26352
26353<script> 26354 26355 let native_tab_bar_config = { 26356 template: "#template-native-tab-bar", 26357 data() { 26358 return { 26359 back_button_expanded: false 26360 } 26361 }, 26362 props: { 26363 primary_view: String, 26364 tabs: Array, 26365 hide_back_button: Boolean, 26366 is_admin_preview: { 26367 type: Boolean, 26368 default: false 26369 }, 26370 preview_colors: { 26371 type: Object, 26372 default: null 26373 }, 26374 preview_tab_bar_style: { 26375 type: String, 26376 default: null 26377 }, 26378 member_portal_config: { 26379 type: Object, 26380 default: null 26381 }, 26382 settings_preview_mode: { 26383 type: Boolean, 26384 default: false 26385 }, 26386 is_member_portal: { 26387 type: Boolean, 26388 default: false 26389 } 26390 }, 26391 mounted(){ 26392 console.log("Mounted native tab bar"); 26393 }, 26394 methods: { 26395 tab_is_active( tab ){ 26396 if( this.primary_view ){ 26397 return tab.key === this.primary_view; 26398 } 26399 return false; 26400 }, 26401 tapped_home(){ 26402 if(this.is_admin_preview){ 26403 return; 26404 } 26405 if( this.is_member_portal ){ 26406 this.back_button_expanded = false; 26407 updateURL('home'); 26408 displayViewForUrlPath(); 26409 }else{ 26410 window.location.href = '/account/home'; 26411 } 26412 }, 26413 tapped_more(){ 26414 if(this.is_admin_preview){ 26415 return; 26416 } 26417 toggle_left_menu(); 26418 }, 26419 tapped_tab_bar_item( tab ){ 26420 if(this.is_admin_preview){ 26421 return; 26422 } 26423 if( tab && tab.type === 'custom' && tab.link ){ 26424 window.location.href = tab.link; 26425 return; 26426 } 26427 updateURL( tab.key ); 26428 displayViewForUrlPath(); 26429 }, 26430 tapped_back(){ 26431 if(this.is_admin_preview){ 26432 return; 26433 } 26434 if( this.back_button_expanded ){ 26435 this.back_button_expanded = false; 26436 window.history.back(); 26437 } else { 26438 this.back_button_expanded = true; 26439 } 26440 }, 26441 tapped_back_immediate(){ 26442 if(this.is_admin_preview){ 26443 return; 26444 } 26445 window.history.back(); 26446 }, 26447 float_icon_class( tab, is_active ){ 26448 if (!tab || !tab.icon_class) { 26449 return ''; 26450 } 26451 if (is_active) { 26452 return tab.icon_class.replace('fa-regular', 'fa-solid'); 26453 } 26454 return tab.icon_class; 26455 }, 26456 show_member_details_avatar( tab ){ 26457 if( ! tab ){ 26458 return false; 26459 } 26460 //only show if its the account tab AND we have an avatar url 26461 return tab.key === "settings" && !!this.member_tab_avatar_url; 26462 }, 26463 }, 26464 computed: { 26465 member_tab_avatar_url(){ 26466 const current_user = Parse.User.current(); 26467 if( ! current_user ){ 26468 return null; 26469 } 26470 const member_object = current_user.get('memberObject'); 26471 if( member_object && member_object.get('profPicURL') ){ 26472 return member_object.get('profPicURL'); 26473 } 26474 if( current_user.get('profPicURL') ){ 26475 return current_user.get('profPicURL'); 26476 } 26477 return null; 26478 }, 26479 tab_bar_colors(){ 26480 if( this.preview_colors ){ 26481 return this.preview_colors; 26482 } 26483 //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) 26484 if( ! store.native_app_props || ! store.native_app_props.safeAreaInsets ){ 26485 if(!this.is_admin_preview){ 26486 return {}; 26487 } 26488 } 26489 //ok, it's a newer version of the app! 26490 if( store.clubObject && store.clubObject.get('web_settings').get('tab_bar_colors') ){ 26491 //validate the config 26492 let tab_bar_colors = store.clubObject.get('web_settings').get('tab_bar_colors'); 26493 if( 26494 tab_bar_colors.background && 26495 tab_bar_colors.text && 26496 tab_bar_colors.active 26497 ){ 26498 return tab_bar_colors; 26499 } 26500 } 26501 return {}; 26502 }, 26503 //we only show it when we're on one of the relevant primary views (contained in the tab bar) 26504 //.. otherwise we should display the back button bar 26505 tab_bar_keys(){ 26506 26507 /* 26508 var keys = []; 26509 if( this.visible_tabs ){
26510 this.visible_tabs.forEach(tab => { 26511 keys.push( tab.key ); 26512 }); 26513 } 26514 return keys; 26515 */ 26516 26517 let keys = []; 26518 if( this.tabs ){ 26519 this.tabs.forEach(tab => { 26520 keys.push( tab.key ); 26521 }); 26522 } 26523 return keys; 26524 26525 }, 26526 visible_tab_keys(){ 26527 let keys = []; 26528 if( this.visible_tabs ){ 26529 this.visible_tabs.forEach(tab => { 26530 keys.push( tab.key ); 26531 }); 26532 } 26533 return keys; 26534 }, 26535 more_is_active(){ 26536 if( ! this.tab_bar_keys.includes( this.primary_view ) ){ 26537 return false; 26538 } 26539 //it's active if not in the visible tabs 26540 if( this.visible_tab_keys.includes( this.primary_view ) ){ 26541 return false; 26542 } 26543 return true; 26544 }, 26545 show_more_section_tab_bar(){ 26546 if( ! this.is_member_portal ){ 26547 return false; 26548 } 26549 if( this.primary_view === 'booking' && this.is_inside_booking_flow ){ 26550 return false; 26551 } 26552 if( ! this.tab_bar_keys.includes( this.primary_view ) ){ 26553 return false; 26554 } 26555 if( this.visible_tab_keys.includes( this.primary_view ) ){ 26556 return false; 26557 } 26558 return true; 26559 }, 26560 show_tab_bar(){ 26561 //1) hide the tab bar if a member has drilled into one ofthe in-portal booking flows (golf, courts, simulators, 26562 if( this.primary_view === 'booking' && this.is_inside_booking_flow ){ 26563 return false; 26564 } 26565 //2) see if it's a visible tab, else don't show 26566 if( this.visible_tab_keys.includes( this.primary_view ) ){ 26567 return true; 26568 } 26569 return false; 26570 26571 26572 /* 26573 // hide the tab bar once a member has drilled into one of 26574 // the in-portal booking flows (golf, courts, simulators, 26575 // group-classes, dining, staff-services, lodging). The hub 26576 // landing page itself has secondary_view == 'default', so 26577 // we keep the tab bar visible there. 26578 if( this.primary_view === 'booking' && this.is_inside_booking_flow ){ 26579 return false; 26580 } 26581 for (let t = 0; t < this.tab_bar_keys.length; t++) { 26582 if( this.tab_bar_keys[t] === this.primary_view ){ 26583 return true 26584 } 26585 } 26586 return false; 26587 */ 26588 }, 26589 is_inside_booking_flow(){ 26590 let secondary = store.secondary_view; 26591 if( ! secondary || secondary === 'default' ){ 26592 return false; 26593 } 26594 return true; 26595 }, 26596 visible_tabs(){ 26597 if( this.tabs ){ 26598 return this.tabs.slice(0,4) 26599 } 26600 return []; 26601 }, 26602 show_back_button(){ 26603 if( this.hide_back_button ){ 26604 return false; 26605 } 26606 if( this.show_more_section_tab_bar ){ 26607 return false; 26608 } 26609 //if we're on the home screen, show the back button 26610 if( ! this.show_tab_bar && window.history.length >= 1 ){ 26611 return true; 26612 } 26613 return false; 26614 }, 26615 is_react_native_member_app(){ 26616 return store.is_react_native_member_app; 26617 }, 26618 use_floating_tab_bar(){ 26619 // In the admin preview, respect the dropdown selection immediately. 26620 if( this.is_admin_preview ){ 26621 return this.preview_tab_bar_style === 'modern'; 26622 } 26623 //end preview case 26624 if( this.member_portal_config ){ 26625 return this.member_portal_config.tab_bar_style === 'modern'; 26626 } 26627 return false;
26628 }, 26629 float_active_tab_index(){ 26630 if (!this.visible_tabs) { 26631 return 0; 26632 } 26633 for (let idx = 0; idx < this.visible_tabs.length; idx++) { 26634 if (this.tab_is_active(this.visible_tabs[idx])) { 26635 return idx; 26636 } 26637 } 26638 // "More" is the last slot 26639 return this.visible_tabs.length; 26640 }, 26641 float_pill_style(){ 26642 let total = this.visible_tabs ? this.visible_tabs.length + 1 : 5; 26643 let idx = this.float_active_tab_index; 26644 // Each slot is (100% / total) wide. The pill is inset 8px on each side. 26645 return { 26646 left: 'calc(' + idx + ' * (100% / ' + total + ') + 8px)', 26647 width: 'calc(100% / ' + total + ' - 16px)', 26648 }; 26649 }, 26650 compact_float_pill_style(){ 26651 // compact more-section bar is always 4 slots and selected tab is slot index 2 26652 let total = 4; 26653 let selected_tab_index = 2; 26654 return { 26655 left: 'calc(' + selected_tab_index + ' * (100% / ' + total + ') + 8px)', 26656 width: 'calc(100% / ' + total + ' - 16px)', 26657 }; 26658 }, 26659 compact_home_icon_class(){ 26660 let home_tab = this.tabs.find(tab => tab.key === 'home'); 26661 if( home_tab && home_tab.icon_class ){ 26662 return home_tab.icon_class; 26663 } 26664 return 'fa-regular fa-house'; 26665 }, 26666 compact_home_tab_label(){ 26667 let home_tab = this.tabs.find(tab => tab.key === 'home'); 26668 if( home_tab && home_tab.label ){ 26669 return home_tab.label; 26670 } 26671 return 'Home'; 26672 }, 26673 compact_selected_tab(){ 26674 let current_tab = this.tabs.find(tab => tab.key === this.primary_view); 26675 if( current_tab ){ 26676 return current_tab; 26677 } 26678 return { 26679 icon_class: 'fa-regular fa-circle', 26680 label: 'Tab', 26681 }; 26682 }, 26683 compact_selected_tab_icon_class(){ 26684 if( this.use_floating_tab_bar ){ 26685 return this.float_icon_class(this.compact_selected_tab, true); 26686 } 26687 if( this.compact_selected_tab && this.compact_selected_tab.icon_class ){ 26688 return this.compact_selected_tab.icon_class; 26689 } 26690 return 'fa-regular fa-circle'; 26691 }, 26692 }, 26693 } 26694 app_content.component("native-tab-bar-component", native_tab_bar_config ); 26695 app_overlays.component("native-tab-bar-component", native_tab_bar_config ); 26696 26697</script>
26697</html> 26698
26698<script> 26699 //after we've included all the relevant views, mount the vue apps 26700 app_content.mount('#v-content'); 26701 app_overlays.mount('#v-overlays'); 26702 26703 </script>
26703 26704 26705 26706 26707 26708</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.