PageSourceSearch

https://llsc.com/

html llsc.com collected 2026-10-08 20:11:16 UTC 1,233,354 bytes, 26,708 lines download raw bytes

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="'{&#34;section_height&#34;:&#34;67&#34;,&#34;section_width&#34;:&#34;70&#34;,&#34;align_items&#34;:&#34;center&#34;,&#34;background_size&#34;:&#34;cover&#34;,&#34;overlay_opacity&#34;:&#34;0&#34;}'"
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="'[&#34;https://d282wvk2qi4wzk.cloudfront.net/1675124290994_6795&#34;,&#34;https://d282wvk2qi4wzk.cloudfront.net/AaQbUtDZKrgG_2S4MFL7tHNz_1675123838_1_video_1774712288862&#34;,&#34;https://d282wvk2qi4wzk.cloudfront.net/1675124306798_4534&#34;,&#34;https://d282wvk2qi4wzk.cloudfront.net/1675124359559_7962&#34;,&#34;https://d282wvk2qi4wzk.cloudfront.net/fmsmClsJnRPW_2S4MFL7tHNz_1675123838_4_image_1774712155990&#34;]'"
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="'{&#34;section_height&#34;:&#34;1&#34;,&#34;section_width&#34;:&#34;100&#34;,&#34;align_items&#34;:&#34;flex-start&#34;,&#34;card_spacing&#34;:&#34;22&#34;}'"
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&amp;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 &amp; Services</p>
1546                                                        
1547                                                        
1548                                                        
1549                                                            <a class="absoluteA" href="/facilities-&amp;-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 &amp; 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&amp;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 &amp; Services
1981                                                            <a class="absoluteA" href="/facilities-&amp;-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 &amp; 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, '&quot;') : '';
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                                                        >&nbsp;</div><!-- &nbsp; 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                        >&nbsp;</div>
25035                        <!-- &nbsp; 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.